1
0
forked from erp-dev/erp

feat: statement for supplier and customer

This commit is contained in:
2025-12-01 22:55:52 +08:00
parent fae33809ac
commit d522c0a9b0
15 changed files with 1085 additions and 52 deletions

View File

@@ -3,6 +3,7 @@ import shutil
import tempfile
from pathlib import Path
from unittest.mock import patch
from decimal import Decimal
from django.test import TestCase, override_settings
from django.contrib.auth.models import User, Permission
@@ -24,6 +25,7 @@ from basic_info.models import (
)
from business import models as business_models, services
from stock import models as stock_models
from stock import services as stock_services
from api_v1 import tasks
@@ -689,6 +691,81 @@ class SalesReturnOrderAPITestCase(TestCase):
self.assertEqual(order.status, business_models.SalesReturnStatusEnum.PENDING)
@override_settings(
CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPAGATES=True,
)
class StockChangeOffsetAPITestCase(TestCase):
def setUp(self):
self.merchant = Merchant.objects.create(name='库存商户', type=MerchantTypeEnum.FACTORY)
self.user = User.objects.create_user(username='stock_user', password='pass123')
self.employee = Employee.objects.create(
merchant=self.merchant,
sys_user=self.user,
name='库存员',
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.warehouse = WareHouse.objects.create(
merchant=self.merchant,
name='库存仓',
mode=WareHouseModeEnum.RESTRICT_IN,
)
category = ProductCategory.objects.create(
merchant=self.merchant,
name='库存品类',
product_prefix='STK',
)
self.product = Product.objects.create(
merchant=self.merchant,
category=category,
name='库存产品',
human_id='STK-001',
unit=ProductUnitEnum.METER,
)
self.stock_record, _, _ = stock_services.create_stock_change_record_with_details(
merchant=self.merchant,
created_by=None,
type=stock_models.StockChangeTypeEnum.ADD,
warehouse_id=self.warehouse.id,
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
source_id=5001,
products=[{'product': self.product.id, 'quantity': [Decimal('12')]}],
)
stock_services.make_stock_change_completed(self.stock_record)
def test_offset_stock_change_success(self):
response = self.client.post(
f'/api/v1/stock-change/{self.stock_record.id}/offset/',
{'reason': '数据修正'},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data['status'], 'success')
self.assertEqual(
stock_models.StockSnapshot.objects.filter(
stock_change_record=self.stock_record,
cancelled=True,
).count(),
1,
)
def test_offset_stock_change_duplicate(self):
self.client.post(
f'/api/v1/stock-change/{self.stock_record.id}/offset/',
{'reason': '首次'},
format='json',
)
response = self.client.post(
f'/api/v1/stock-change/{self.stock_record.id}/offset/',
{'reason': '再次'},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('已执行红冲', response.data['error'])
class PaymentOrderAPITestCase(TestCase):
def setUp(self):
self.merchant = Merchant.objects.create(name='付款商户', type=MerchantTypeEnum.FACTORY)
@@ -879,6 +956,26 @@ class CustomerBalanceAPITestCase(TestCase):
)
response = self.client.get(f'/api/v1/customers/{self.customer.id}/balance/')
self.assertEqual(response.data['balance'], '30.00')
class SupplierBalanceAPITestCase(TestCase):
def setUp(self):
self.merchant = Merchant.objects.create(name='供应商余额商户', type=MerchantTypeEnum.FACTORY)
self.supplier = Supplier.objects.create(merchant=self.merchant, name='供应商余额')
self.user = User.objects.create_user(username='supplier-balance', password='pass123')
self.employee = Employee.objects.create(
merchant=self.merchant,
sys_user=self.user,
name='供应商财务',
status=EmployeeStatusEnum.ACTIVE,
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
def test_supplier_balance_api(self):
response = self.client.get(f'/api/v1/suppliers/{self.supplier.id}/balance/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['supplier'], self.supplier.id)
@override_settings(
CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPAGATES=True,

View File

@@ -17,6 +17,7 @@ from .views.business.receipt import views as receipt_views
from .views.business.balance import views as balance_views
from .views.business.purchase_return import views as purchase_return_views
from .views.business.sales_return import views as sales_return_views
from .views.business.statements import views as statement_views
from .views.stock_change_views.snapshot import StockSnapshotListView
from .views.printing.views import PrintingOrderViewSet, PrintingJobViewSet, PlateOrderViewSet
from .views.upload import UploadFileViewSet
@@ -77,6 +78,9 @@ urlpatterns = [
path('receipt-orders/', receipt_views.ReceiptOrderView.as_view(), name='receipt_orders'),
path('receipt-orders/<int:pk>/review/', receipt_views.ReceiptOrderReviewView.as_view(), name='receipt_order_review'),
path('customers/<int:customer_id>/balance/', balance_views.CustomerBalanceView.as_view(), name='customer_balance'),
path('suppliers/<int:supplier_id>/balance/', balance_views.SupplierBalanceView.as_view(), name='supplier_balance'),
path('customers/<int:customer_id>/statements/', statement_views.CustomerStatementView.as_view(), name='customer_statements'),
path('suppliers/<int:supplier_id>/statements/', statement_views.SupplierStatementView.as_view(), name='supplier_statements'),
path('health/', healthy.HealthCheckView.as_view(), name='health_check'),
path('print-count/delta/', print_count.adjust_print_count, name='print_count_delta'),

View File

@@ -34,3 +34,29 @@ class CustomerBalanceView(StockChangeViewMixin, views.APIView):
}
)
class SupplierBalanceView(StockChangeViewMixin, views.APIView):
permission_classes = [IsAuthenticated]
def get(self, request, supplier_id: int):
if not self.check_employee_permission(request):
return self.permission_error_response('无权限访问')
merchant = request.user.employee.merchant
try:
supplier = basic_models.Supplier.objects.get(id=supplier_id, merchant=merchant)
except basic_models.Supplier.DoesNotExist:
return self.not_found_response('供应商不存在')
balance: Decimal = business_services.BalanceService.get_supplier_balance(
merchant=merchant,
supplier=supplier,
)
return Response(
{
'supplier': supplier.id,
'supplier_name': supplier.name,
'balance': str(balance),
}
)

View File

@@ -0,0 +1,7 @@
"""
对账单statement相关的 API 视图。
该子包目前提供客户/供应商的对账单查询接口。
"""

View File

@@ -0,0 +1,37 @@
from rest_framework import serializers
class StatementRecordSerializer(serializers.Serializer):
source_type = serializers.CharField()
source_label = serializers.CharField()
source_id = serializers.IntegerField()
occurred_at = serializers.DateField()
recorded_at = serializers.DateTimeField()
status = serializers.IntegerField()
status_label = serializers.CharField()
counterparty = serializers.IntegerField()
counterparty_name = serializers.CharField()
positive_amount = serializers.DecimalField(max_digits=15, decimal_places=2)
negative_amount = serializers.DecimalField(max_digits=15, decimal_places=2)
extra = serializers.DictField(required=False)
class StatementResponseSerializer(serializers.Serializer):
counterparty = serializers.IntegerField()
counterparty_name = serializers.CharField()
records = StatementRecordSerializer(many=True)
summary = serializers.SerializerMethodField()
def get_summary(self, obj):
summary_builder = self.context.get('summary_builder')
if callable(summary_builder):
return summary_builder(obj)
return self.context.get('summary')
def to_representation(self, instance):
data = super().to_representation(instance)
if data.get('summary') is None:
data.pop('summary', None)
return data

View File

@@ -0,0 +1,325 @@
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from typing import Iterable, List
from rest_framework import views
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from basic_info import models as basic_models
from business import models as business_models
from api_v1.views.stock_change_views.mixins import StockChangeViewMixin
from . import serializers as statement_serializers
TWO_PLACES = Decimal('0.01')
ZERO = Decimal('0')
class StatementViewBase(StockChangeViewMixin, views.APIView):
"""
提供对账单视图的公共实现:权限校验、序列化上下文及记录构建方法。
"""
permission_classes = [IsAuthenticated]
serializer_class = statement_serializers.StatementResponseSerializer
def get_serializer_context(self):
return {
'request': self.request,
'view': self,
'summary_builder': self._build_summary,
}
def _build_response(self, counterparty, records: List[dict]):
serializer = self.serializer_class(
instance={
'counterparty': counterparty.id,
'counterparty_name': counterparty.name,
'records': records,
},
context=self.get_serializer_context(),
)
return Response(serializer.data)
def _build_record(
self,
*,
counterparty_id: int,
counterparty_name: str,
source_type: str,
source_label: str,
source_id: int,
occurred_at,
recorded_at,
status: int,
status_label: str,
positive_amount,
negative_amount,
extra: dict | None = None,
) -> dict:
record = {
'counterparty': counterparty_id,
'counterparty_name': counterparty_name,
'source_type': source_type,
'source_label': source_label,
'source_id': source_id,
'occurred_at': occurred_at,
'recorded_at': recorded_at,
'status': status,
'status_label': status_label,
'positive_amount': self._normalize_amount(positive_amount),
'negative_amount': self._normalize_amount(negative_amount),
}
if extra:
record['extra'] = extra
return record
def _sort_records(self, records: Iterable[dict]) -> List[dict]:
return sorted(
records,
key=lambda item: (item['occurred_at'], item['recorded_at'], item['source_id']),
reverse=True,
)
def _normalize_amount(self, value) -> Decimal:
if isinstance(value, Decimal):
decimal_value = value
else:
decimal_value = Decimal(str(value))
return decimal_value.quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
def _decimal_to_string(self, value: Decimal) -> str:
normalized = self._normalize_amount(value)
return format(normalized, 'f')
def _build_summary(self, payload: dict):
records = payload.get('records', [])
total_positive = sum((record.get('positive_amount', ZERO) for record in records), ZERO)
total_negative = sum((record.get('negative_amount', ZERO) for record in records), ZERO)
return {
'positive_total': self._decimal_to_string(total_positive),
'negative_total': self._decimal_to_string(total_negative),
}
class CustomerStatementView(StatementViewBase):
"""
返回指定客户的业务单据对账单,包含销售单、销售退货单与收款单。
"""
def get(self, request, customer_id: int):
if not self.check_employee_permission(request):
return self.permission_error_response('无权限访问')
merchant = request.user.employee.merchant
try:
customer = basic_models.Customer.objects.get(id=customer_id, merchant=merchant)
except basic_models.Customer.DoesNotExist:
return self.not_found_response('客户不存在')
records = self._collect_customer_records(merchant, customer)
return self._build_response(customer, records)
def _collect_customer_records(self, merchant, customer) -> List[dict]:
records: List[dict] = []
records.extend(self._build_sales_records(merchant, customer))
records.extend(self._build_sales_return_records(merchant, customer))
records.extend(self._build_receipt_records(merchant, customer))
return self._sort_records(records)
def _build_sales_records(self, merchant, customer) -> List[dict]:
qs = (
business_models.SalesOrder.objects.filter(
merchant=merchant,
customer=customer,
status=business_models.SalesOrderStatusEnum.APPROVED,
)
.select_related('customer')
)
records = []
for order in qs:
records.append(
self._build_record(
counterparty_id=order.customer_id,
counterparty_name=order.customer.name,
source_type='sales_order',
source_label='销售单',
source_id=order.id,
occurred_at=order.sales_date,
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
positive_amount=order.get_total_amount(),
negative_amount=ZERO,
)
)
return records
def _build_sales_return_records(self, merchant, customer) -> List[dict]:
qs = (
business_models.SalesReturnOrder.objects.filter(
merchant=merchant,
customer=customer,
status=business_models.SalesReturnStatusEnum.APPROVED,
)
.select_related('customer')
)
records = []
for order in qs:
records.append(
self._build_record(
counterparty_id=order.customer_id,
counterparty_name=order.customer.name,
source_type='sales_return_order',
source_label='销售退货单',
source_id=order.id,
occurred_at=order.return_date,
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
positive_amount=ZERO,
negative_amount=order.get_total_amount(),
)
)
return records
def _build_receipt_records(self, merchant, customer) -> List[dict]:
qs = (
business_models.ReceiptOrder.objects.filter(
merchant=merchant,
customer=customer,
status=business_models.ReceiptOrderStatusEnum.APPROVED,
)
.select_related('customer')
)
records = []
for order in qs:
records.append(
self._build_record(
counterparty_id=order.customer_id,
counterparty_name=order.customer.name,
source_type='receipt_order',
source_label='收款单',
source_id=order.id,
occurred_at=order.receipt_date,
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
positive_amount=ZERO,
negative_amount=order.get_total_amount(),
)
)
return records
class SupplierStatementView(StatementViewBase):
"""
返回指定供应商的业务单据对账单,包含采购单、采购退货单与付款单。
"""
def get(self, request, supplier_id: int):
if not self.check_employee_permission(request):
return self.permission_error_response('无权限访问')
merchant = request.user.employee.merchant
try:
supplier = basic_models.Supplier.objects.get(id=supplier_id, merchant=merchant)
except basic_models.Supplier.DoesNotExist:
return self.not_found_response('供应商不存在')
records = self._collect_supplier_records(merchant, supplier)
return self._build_response(supplier, records)
def _collect_supplier_records(self, merchant, supplier) -> List[dict]:
records: List[dict] = []
records.extend(self._build_purchase_records(merchant, supplier))
records.extend(self._build_purchase_return_records(merchant, supplier))
records.extend(self._build_payment_records(merchant, supplier))
return self._sort_records(records)
def _build_purchase_records(self, merchant, supplier) -> List[dict]:
qs = (
business_models.PurchaseOrder.objects.filter(
merchant=merchant,
supplier=supplier,
status=business_models.PurchaseOrderStatusEnum.APPROVED,
)
.select_related('supplier')
)
records = []
for order in qs:
records.append(
self._build_record(
counterparty_id=order.supplier_id,
counterparty_name=order.supplier.name,
source_type='purchase_order',
source_label='采购单',
source_id=order.id,
occurred_at=order.purchase_date,
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
positive_amount=order.get_total_amount(),
negative_amount=ZERO,
)
)
return records
def _build_purchase_return_records(self, merchant, supplier) -> List[dict]:
qs = (
business_models.PurchaseReturnOrder.objects.filter(
merchant=merchant,
supplier=supplier,
status=business_models.PurchaseReturnStatusEnum.APPROVED,
)
.select_related('supplier')
)
records = []
for order in qs:
records.append(
self._build_record(
counterparty_id=order.supplier_id,
counterparty_name=order.supplier.name,
source_type='purchase_return_order',
source_label='采购退货单',
source_id=order.id,
occurred_at=order.return_date,
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
positive_amount=ZERO,
negative_amount=order.get_total_amount(),
)
)
return records
def _build_payment_records(self, merchant, supplier) -> List[dict]:
qs = (
business_models.PaymentOrder.objects.filter(
merchant=merchant,
supplier=supplier,
status=business_models.PaymentOrderStatusEnum.APPROVED,
)
.select_related('supplier')
)
records = []
for order in qs:
records.append(
self._build_record(
counterparty_id=order.supplier_id,
counterparty_name=order.supplier.name,
source_type='payment_order',
source_label='付款单',
source_id=order.id,
occurred_at=order.payment_date,
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
positive_amount=ZERO,
negative_amount=order.get_total_amount(),
)
)
return records

View File

@@ -32,20 +32,14 @@ class OffsetStockChangeView(StockChangeViewMixin, views.APIView):
@extend_schema(
tags=['库存红冲'],
summary='发起库存红冲(占位)',
description=(
'对指定库存变动记录创建红冲请求。当前仅提供占位实现,'
'会返回 501 Not Implemented待库存红冲逻辑完善后启用。'
),
summary='发起库存红冲',
description='对指定库存变动记录创建反向库存记录,并标记原库存快照已被红冲。',
request=StockChangeOffsetSerializer,
responses={
201: OpenApiResponse(
description='红冲成功(未来实现后返回)'
),
201: OpenApiResponse(description='红冲成功'),
400: OpenApiResponse(description='参数或业务校验失败'),
403: OpenApiResponse(description='无权访问对应出入库记录'),
404: OpenApiResponse(description='库存变动记录不存在'),
501: OpenApiResponse(description='功能占位,尚未实现'),
},
)
def post(self, request, record_id: int):
@@ -81,15 +75,6 @@ class OffsetStockChangeView(StockChangeViewMixin, views.APIView):
request_id=payload.get('request_id'),
extra_meta=payload.get('extra_meta'),
)
except NotImplementedError as exc:
return Response(
{
'error': 'stock_offset_not_ready',
'message': str(exc),
'record_id': stock_change_record.id,
},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
except ValueError as exc:
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)