From 9006a530d14e9b2d4ba7854d93cd522d39996aaa Mon Sep 17 00:00:00 2001 From: colaftc Date: Sun, 30 Nov 2025 21:52:36 +0800 Subject: [PATCH] feat: payment order and receipt order --- .coverage | Bin 53248 -> 53248 bytes api_v1/business/README.md | 36 ++++- api_v1/business/payment/views.py | 133 +++++++++++++++++ api_v1/business/receipt/views.py | 133 +++++++++++++++++ api_v1/tests.py | 119 +++++++++++++++ api_v1/urls.py | 6 + business/ARCHITECTURE.md | 23 +-- .../0011_paymentorder_receiptorder.py | 56 +++++++ business/models.py | 104 +++++++++++++ business/services.py | 137 ++++++++++++++++++ business/tests.py | 129 +++++++++++++++++ docs/payment_receipt_workflow.md | 36 +++++ 12 files changed, 900 insertions(+), 12 deletions(-) create mode 100644 api_v1/business/payment/views.py create mode 100644 api_v1/business/receipt/views.py create mode 100644 business/migrations/0011_paymentorder_receiptorder.py create mode 100644 docs/payment_receipt_workflow.md diff --git a/.coverage b/.coverage index 33ceb9c6cf97e3585f82b8963d769b9113ce700e..cef02288950b0fc767c1168a0f1d070c96ee2f48 100644 GIT binary patch literal 53248 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 E1*{4oX8-^I delta 1436 zcmbV~Z%i9y9LKMHu79q*dmfyK&Jl~gkgeOG8=`E>sQSj4IXh)Ywq?21_P|rxE7vQm zGTVYNF)pleb-_tkiLZ>Apf=s-;jU>wnG5lu#YAtSzEm4WS=ZatreLM#rG#pIGc z`99z0^Za_BiwuCs0Qi`Gyqx766uN}Ypl-AuZH8(11MGknF3G*cITCl3RCC&GY|Quc zTAg2!T(88k&#coom}#nOV{C4BS^Z*I?GamKry@#Tf3?kKtK}C{*Go=si{KU%r`XE7 z@Ls?p@Xeyo>h@Rpg7qfMh&_1VUh8Uztp!B0EC{a(xcvfy+gr$uPp(#fgm<=Jk-=p&eYMvU1^F!t3jHM}f%q;`Q9z3qZmk>nun5xt@(;NjZ>POl;= zK@6!h0IHEN?k+GC!LN{iRI3M68_6HIjm=}CsPH^)S#>doV{KWX;uqw0(It@6mGnxg z(~Ymj`Fa4TCPFP;qDpR|l^l3j3#bONfeIT=pO3^=4dHJoWJULyD2igay%f5P4xo>? zz1(l`2&~|a!3=sGRl$o`wT+8O`|tn9vyRZ*swJIiZA8gnoUhK zwv5|Ptk%9$%S`y#X|l14|#2XIU|*1Pw()EE(2X!9j+IFPKU!@Ff-HEbOLG7R{k^=sollvcqrS z7jOu^58s8|SnZ22T*}fK#>&syTV%#;~Y0znyu69Examr#z&CLy~V^MqlM)s5Bjhd^0f5##o{MxatjmoS^MR1_}B2u>Y4snRh7l( zo%qS`C&v55u}$`?&GA#OrKeI~{hSKrZXMU_@fc9Ac}_>Bcu1FzWv44Ar>7<6e9xvU zfqYe$CNe&HB|S7WBTrWKjtp%5Fns9qxAW)Ok+SQKQF~w5^yftB=$>#c^GE7L$EVrk z?n@OjA4HRXjrNw)n7y4o{YD&|`?@Z2>`mWD4;V2YKCJ&eLczxX-t~dd9Dy+E%eBWthgG&IA&m>SiU lvuKb)IkbRg(N&a0ljuh@j=n`-qR-GEwsagN2IJ4${sv#pF3kV{ diff --git a/api_v1/business/README.md b/api_v1/business/README.md index ad1308f..f41b45d 100644 --- a/api_v1/business/README.md +++ b/api_v1/business/README.md @@ -104,7 +104,41 @@ --- -## 4. 错误示例 +## 4. 付款单(PaymentOrder) + +### 4.1 列表 +- **GET** `/api/v1/payment-orders/` +- 返回字段:`supplier_name`、`payment_date`、`amount`、`status` 等。 + +### 4.2 创建 +- **POST** `/api/v1/payment-orders/` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `supplier` | int | 供应商 ID | +| `payment_date` | str (`YYYY-MM-DD`) | 付款日期 | +| `amount` | decimal | 付款金额(必须 > 0) | +| `remarks` | str | 可选 | + +### 4.3 审批 / 作废 +- **POST** `/api/v1/payment-orders//review/` +- `{"action": "approve"}` 或 `{"action": "cancel"}` + +--- + +## 5. 收款单(ReceiptOrder) + +接口与付款单类似,只是主体为 `customer`: + +- **GET** `/api/v1/receipt-orders/` +- **POST** `/api/v1/receipt-orders/`:需要 `customer`、`receipt_date`、`amount` +- **POST** `/api/v1/receipt-orders//review/` + +审批通过表示“确认收款”,作废则恢复为初始状态。 + +--- + +## 6. 错误示例 | 场景 | HTTP | 返回体 | |------|------|--------| diff --git a/api_v1/business/payment/views.py b/api_v1/business/payment/views.py new file mode 100644 index 0000000..8772f18 --- /dev/null +++ b/api_v1/business/payment/views.py @@ -0,0 +1,133 @@ +from rest_framework import status, views, serializers, pagination +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from basic_info import models as basic_models +from business import services as business_services +from business import models as business_models +from api_v1.views.stock_change_views.mixins import StockChangeViewMixin + + +class PaymentOrderSerializer(serializers.ModelSerializer): + supplier_name = serializers.CharField(source='supplier.name', read_only=True) + operator_name = serializers.CharField(source='operator.name', read_only=True) + + class Meta: + model = business_models.PaymentOrder + fields = [ + 'id', 'supplier', 'supplier_name', 'payment_date', + 'amount', 'operator', 'operator_name', 'status', + 'remarks', 'created_at', 'updated_at', + ] + read_only_fields = ['id', 'supplier_name', 'operator_name', 'status', 'created_at', 'updated_at'] + + +class PaymentOrderPagination(pagination.LimitOffsetPagination): + default_limit = 20 + max_limit = 100 + + +class PaymentOrderView(StockChangeViewMixin, views.APIView): + permission_classes = [IsAuthenticated] + pagination_class = PaymentOrderPagination + + def get(self, request): + if not self.check_employee_permission(request): + return self.permission_error_response('无权限访问') + merchant = request.user.employee.merchant + queryset = business_models.PaymentOrder.objects.filter(merchant=merchant).select_related( + 'supplier', 'operator' + ).order_by('-created_at') + paginator = self.pagination_class() + page = paginator.paginate_queryset(queryset, request, view=self) + serializer = PaymentOrderSerializer(page, many=True) + return paginator.get_paginated_response(serializer.data) + + def post(self, request): + if not self.check_employee_permission(request): + return self.permission_error_response('无权限访问') + merchant = request.user.employee.merchant + data = request.data or {} + supplier_id = data.get('supplier') + payment_date = data.get('payment_date') + amount = data.get('amount') + remarks = data.get('remarks', '') + + if not supplier_id: + return Response({'error': '缺少供应商 ID'}, status=status.HTTP_400_BAD_REQUEST) + if not payment_date: + return Response({'error': '缺少 payment_date'}, status=status.HTTP_400_BAD_REQUEST) + if amount is None: + return Response({'error': '缺少 amount'}, status=status.HTTP_400_BAD_REQUEST) + + try: + supplier = basic_models.Supplier.objects.get(id=supplier_id, merchant=merchant) + except basic_models.Supplier.DoesNotExist: + return Response({'error': f'供应商 {supplier_id} 不存在'}, status=status.HTTP_400_BAD_REQUEST) + + operator = request.user.employee + + try: + payment_order = business_services.create_payment_order( + merchant=merchant, + supplier=supplier, + payment_date=payment_date, + amount=amount, + operator=operator, + remarks=remarks, + ) + except ValueError as exc: + return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + return Response( + { + 'id': payment_order.id, + 'status': payment_order.status, + 'message': '付款单创建成功,等待审批', + }, + status=status.HTTP_201_CREATED, + ) + + +class PaymentOrderReviewSerializer(serializers.Serializer): + action = serializers.ChoiceField(choices=[('approve', '审批通过'), ('cancel', '作废')]) + + +class PaymentOrderReviewView(StockChangeViewMixin, views.APIView): + permission_classes = [IsAuthenticated] + ACTION_STATUS_MAP = { + 'approve': business_models.PaymentOrderStatusEnum.APPROVED, + 'cancel': business_models.PaymentOrderStatusEnum.CANCELLED, + } + + def post(self, request, pk: int): + if not self.check_employee_permission(request): + return self.permission_error_response('无权限访问') + + merchant = request.user.employee.merchant + try: + payment_order = business_models.PaymentOrder.objects.select_related( + 'supplier', 'operator' + ).get(id=pk, merchant=merchant) + except business_models.PaymentOrder.DoesNotExist: + return self.not_found_response('付款单不存在') + + serializer = PaymentOrderReviewSerializer(data=request.data or {}) + serializer.is_valid(raise_exception=True) + action = serializer.validated_data['action'] + target_status = self.ACTION_STATUS_MAP[action] + + try: + business_services.review_payment_order( + payment_order=payment_order, + target_status=target_status, + reviewed_by=request.user, + ) + except ValueError as exc: + return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + refreshed = business_models.PaymentOrder.objects.select_related( + 'supplier', 'operator' + ).get(id=payment_order.id) + return Response(PaymentOrderSerializer(refreshed).data, status=status.HTTP_200_OK) + diff --git a/api_v1/business/receipt/views.py b/api_v1/business/receipt/views.py new file mode 100644 index 0000000..d9c527b --- /dev/null +++ b/api_v1/business/receipt/views.py @@ -0,0 +1,133 @@ +from rest_framework import status, views, serializers, pagination +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from basic_info import models as basic_models +from business import services as business_services +from business import models as business_models +from api_v1.views.stock_change_views.mixins import StockChangeViewMixin + + +class ReceiptOrderSerializer(serializers.ModelSerializer): + customer_name = serializers.CharField(source='customer.name', read_only=True) + operator_name = serializers.CharField(source='operator.name', read_only=True) + + class Meta: + model = business_models.ReceiptOrder + fields = [ + 'id', 'customer', 'customer_name', 'receipt_date', + 'amount', 'operator', 'operator_name', 'status', + 'remarks', 'created_at', 'updated_at', + ] + read_only_fields = ['id', 'customer_name', 'operator_name', 'status', 'created_at', 'updated_at'] + + +class ReceiptOrderPagination(pagination.LimitOffsetPagination): + default_limit = 20 + max_limit = 100 + + +class ReceiptOrderView(StockChangeViewMixin, views.APIView): + permission_classes = [IsAuthenticated] + pagination_class = ReceiptOrderPagination + + def get(self, request): + if not self.check_employee_permission(request): + return self.permission_error_response('无权限访问') + merchant = request.user.employee.merchant + queryset = business_models.ReceiptOrder.objects.filter(merchant=merchant).select_related( + 'customer', 'operator' + ).order_by('-created_at') + paginator = self.pagination_class() + page = paginator.paginate_queryset(queryset, request, view=self) + serializer = ReceiptOrderSerializer(page, many=True) + return paginator.get_paginated_response(serializer.data) + + def post(self, request): + if not self.check_employee_permission(request): + return self.permission_error_response('无权限访问') + merchant = request.user.employee.merchant + data = request.data or {} + customer_id = data.get('customer') + receipt_date = data.get('receipt_date') + amount = data.get('amount') + remarks = data.get('remarks', '') + + if not customer_id: + return Response({'error': '缺少客户 ID'}, status=status.HTTP_400_BAD_REQUEST) + if not receipt_date: + return Response({'error': '缺少 receipt_date'}, status=status.HTTP_400_BAD_REQUEST) + if amount is None: + return Response({'error': '缺少 amount'}, status=status.HTTP_400_BAD_REQUEST) + + try: + customer = basic_models.Customer.objects.get(id=customer_id, merchant=merchant) + except basic_models.Customer.DoesNotExist: + return Response({'error': f'客户 {customer_id} 不存在'}, status=status.HTTP_400_BAD_REQUEST) + + operator = request.user.employee + + try: + receipt_order = business_services.create_receipt_order( + merchant=merchant, + customer=customer, + receipt_date=receipt_date, + amount=amount, + operator=operator, + remarks=remarks, + ) + except ValueError as exc: + return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + return Response( + { + 'id': receipt_order.id, + 'status': receipt_order.status, + 'message': '收款单创建成功,等待审批', + }, + status=status.HTTP_201_CREATED, + ) + + +class ReceiptOrderReviewSerializer(serializers.Serializer): + action = serializers.ChoiceField(choices=[('approve', '审批通过'), ('cancel', '作废')]) + + +class ReceiptOrderReviewView(StockChangeViewMixin, views.APIView): + permission_classes = [IsAuthenticated] + ACTION_STATUS_MAP = { + 'approve': business_models.ReceiptOrderStatusEnum.APPROVED, + 'cancel': business_models.ReceiptOrderStatusEnum.CANCELLED, + } + + def post(self, request, pk: int): + if not self.check_employee_permission(request): + return self.permission_error_response('无权限访问') + + merchant = request.user.employee.merchant + try: + receipt_order = business_models.ReceiptOrder.objects.select_related( + 'customer', 'operator' + ).get(id=pk, merchant=merchant) + except business_models.ReceiptOrder.DoesNotExist: + return self.not_found_response('收款单不存在') + + serializer = ReceiptOrderReviewSerializer(data=request.data or {}) + serializer.is_valid(raise_exception=True) + action = serializer.validated_data['action'] + target_status = self.ACTION_STATUS_MAP[action] + + try: + business_services.review_receipt_order( + receipt_order=receipt_order, + target_status=target_status, + reviewed_by=request.user, + ) + except ValueError as exc: + return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + refreshed = business_models.ReceiptOrder.objects.select_related( + 'customer', 'operator' + ).get(id=receipt_order.id) + return Response(ReceiptOrderSerializer(refreshed).data, status=status.HTTP_200_OK) + diff --git a/api_v1/tests.py b/api_v1/tests.py index 3461c4d..a0b027c 100644 --- a/api_v1/tests.py +++ b/api_v1/tests.py @@ -11,6 +11,7 @@ from rest_framework import status from basic_info.models import ( Customer, Employee, + EmployeeStatusEnum, Merchant, MerchantTypeEnum, Product, @@ -450,6 +451,124 @@ class SalesOrderAPITestCase(TestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) order = business_models.SalesOrder.objects.get(id=order_id) self.assertEqual(order.status, business_models.SalesOrderStatusEnum.PENDING) + + +class PaymentOrderAPITestCase(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='pay_user', 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) + self.payload = { + 'supplier': self.supplier.id, + 'payment_date': '2025-11-26', + 'amount': '120.5', + 'remarks': '付款备注', + } + + def test_create_payment_order_success(self): + response = self.client.post('/api/v1/payment-orders/', self.payload, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIn('id', response.data) + + def test_create_payment_order_invalid_supplier(self): + payload = {**self.payload, 'supplier': 999} + response = self.client.post('/api/v1/payment-orders/', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_review_payment_order(self): + resp = self.client.post('/api/v1/payment-orders/', self.payload, format='json') + order_id = resp.data['id'] + response = self.client.post(f'/api/v1/payment-orders/{order_id}/review/', {'action': 'approve'}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], business_models.PaymentOrderStatusEnum.APPROVED) + response_cancel = self.client.post( + f'/api/v1/payment-orders/{order_id}/review/', + {'action': 'cancel'}, + format='json', + ) + self.assertEqual(response_cancel.status_code, status.HTTP_200_OK) + self.assertEqual(response_cancel.data['status'], business_models.PaymentOrderStatusEnum.CANCELLED) + + def test_payment_order_requires_amount(self): + payload = {**self.payload} + payload.pop('amount') + response = self.client.post('/api/v1/payment-orders/', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('缺少 amount', response.data['error']) + + def test_payment_order_amount_must_be_positive(self): + payload = {**self.payload, 'amount': '0'} + response = self.client.post('/api/v1/payment-orders/', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('amount 必须大于 0', response.data['error']) + + +class ReceiptOrderAPITestCase(TestCase): + def setUp(self): + self.merchant = Merchant.objects.create(name='收款商户', type=MerchantTypeEnum.FACTORY) + self.customer = Customer.objects.create( + merchant=self.merchant, + name='客户C', + mobile='13812345678', + created_by=None, + ) + self.user = User.objects.create_user(username='receipt_user', 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) + self.payload = { + 'customer': self.customer.id, + 'receipt_date': '2025-11-26', + 'amount': '88.00', + 'remarks': '收款备注', + } + + def test_create_receipt_order_success(self): + response = self.client.post('/api/v1/receipt-orders/', self.payload, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIn('id', response.data) + + def test_create_receipt_order_invalid_customer(self): + payload = {**self.payload, 'customer': 999} + response = self.client.post('/api/v1/receipt-orders/', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_review_receipt_order(self): + resp = self.client.post('/api/v1/receipt-orders/', self.payload, format='json') + order_id = resp.data['id'] + response = self.client.post( + f'/api/v1/receipt-orders/{order_id}/review/', + {'action': 'cancel'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], business_models.ReceiptOrderStatusEnum.CANCELLED) + + def test_receipt_order_requires_amount(self): + payload = {**self.payload} + payload.pop('amount') + response = self.client.post('/api/v1/receipt-orders/', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('缺少 amount', response.data['error']) + + def test_receipt_order_amount_must_be_positive(self): + payload = {**self.payload, 'amount': '0'} + response = self.client.post('/api/v1/receipt-orders/', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('amount 必须大于 0', response.data['error']) @override_settings( CELERY_TASK_ALWAYS_EAGER=True, CELERY_TASK_EAGER_PROPAGATES=True, diff --git a/api_v1/urls.py b/api_v1/urls.py index aacefe4..abc275b 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -12,6 +12,8 @@ from .views import ( ) from .business.purchase import views as purchase_views from .business.sales import views as sales_views +from .business.payment import views as payment_views +from .business.receipt import views as receipt_views from .views.stock_change_views.snapshot import StockSnapshotListView from .views.printing.views import PrintingOrderViewSet, PrintingJobViewSet, PlateOrderViewSet from .views.upload import UploadFileViewSet @@ -63,6 +65,10 @@ urlpatterns = [ path('purchase-orders//review/', purchase_views.PurchaseOrderReviewView.as_view(), name='purchase_order_review'), path('sales-orders/', sales_views.SalesOrderView.as_view(), name='sales_orders'), path('sales-orders//review/', sales_views.SalesOrderReviewView.as_view(), name='sales_order_review'), + path('payment-orders/', payment_views.PaymentOrderView.as_view(), name='payment_orders'), + path('payment-orders//review/', payment_views.PaymentOrderReviewView.as_view(), name='payment_order_review'), + path('receipt-orders/', receipt_views.ReceiptOrderView.as_view(), name='receipt_orders'), + path('receipt-orders//review/', receipt_views.ReceiptOrderReviewView.as_view(), name='receipt_order_review'), path('health/', healthy.HealthCheckView.as_view(), name='health_check'), path('print-count/delta/', print_count.adjust_print_count, name='print_count_delta'), diff --git a/business/ARCHITECTURE.md b/business/ARCHITECTURE.md index feb73ed..0e74187 100644 --- a/business/ARCHITECTURE.md +++ b/business/ARCHITECTURE.md @@ -32,17 +32,18 @@ ### 2.3 OrderCounterpartyMixin 对外暴露统一的 `get_counterparty()` 接口,通过 `get_counterparty_field_name()`(或 `counterparty_field_name` 属性)确定具体业务主体字段。这样采购单/销售单分别返回 `Supplier` 与 `Customer`,但调用方只需面对一个接口。 -### 2.4 PurchaseOrder / SalesOrder -两类订单模型均继承上述三个 Mixin: +### 2.4 订单类型 +当前业务对象覆盖: -- 采购单:`get_direction()` 返回 `1`,`get_counterparty_field_name()` 返回 `supplier` -- 销售单:`get_direction()` 返回 `-1`,`get_counterparty_field_name()` 返回 `customer` -- 其余字段(`merchant/warehouse/operator/status/items`)保持一致,便于服务、序列化与统计逻辑复用 +- **PurchaseOrder**(采购单):`direction=1`,`counterparty=supplier`,含库存明细。 +- **SalesOrder**(销售单):`direction=-1`,`counterparty=customer`,含库存明细;严进严出模式需记录 `consume_detail_ids`。 +- **PaymentOrder**(付款单):`direction=-1`,`counterparty=supplier`,仅金额字段,不触发库存。 +- **ReceiptOrder**(收款单):`direction=-1`,`counterparty=customer`,仅金额字段,不触发库存。 -通过 mixin,两个模型天然具备: -- 金额聚合与带方向金额计算 -- 统一的业务主体读取接口 -- 与库存/财务交互时一致的 `StockFlowService` payload +通过 mixin,所有单据都具备: +- 统一的金额聚合与方向计算(资金/库存可共用 `get_signed_total_amount()`) +- 一致的业务主体接口(供应商/客户) +- 可扩展的服务与序列化模式(有无库存由具体模型决定) ## 3. 服务层约定 @@ -52,10 +53,10 @@ ## 4. 未来演进建议 -1. **新增单据**:若未来出现调拨单、退货单等,优先继承 `OrderItemsAggregationMixin + OrderDirectionMixin + OrderCounterpartyMixin`,仅通过 `get_direction()` / `get_counterparty_field_name()` 区别方向与主体,减少重复实现。 +1. **新增单据**:若未来出现调拨单、退货单或更多资金类单据,优先复用 `OrderDirectionMixin + OrderCounterpartyMixin`(如有明细再叠加 `OrderItemsAggregationMixin`),仅通过 `get_direction()` / `get_counterparty_field_name()` 区别方向与主体,减少重复实现。 2. **审批/状态机**:采购与销售如需共享状态流转,可提炼状态机或 service 层 mixin,而无需在模型层合并。 3. **统计与报表**:财务/库存统计应依赖 `get_signed_total_amount()` / `get_direction()`,确保采购/销售、退货/正向都能通过统一接口处理。 4. **文档同步**:新增单据或服务时必须更新本文件,描述新增模型如何复用 mixin、如何影响下游模块,保持设计透明。 -以上约定的目标是:**保持采购单与未来销售单等业务对象的独立性,同时通过 mixin/服务层抽象复用绝大多数公共逻辑**。如需变更此架构(例如重新合并模型或修改核心 mixin 行为),请在评估后更新本文件,明确变化原因与迁移方案。 +以上约定的目标是:**保持各类业务单据的独立性,同时通过 mixin/服务层抽象复用绝大多数公共逻辑**。如需变更此架构,请在评估后更新本文件,说明原因与迁移方案。 diff --git a/business/migrations/0011_paymentorder_receiptorder.py b/business/migrations/0011_paymentorder_receiptorder.py new file mode 100644 index 0000000..2671812 --- /dev/null +++ b/business/migrations/0011_paymentorder_receiptorder.py @@ -0,0 +1,56 @@ +# Generated by Django 5.2.7 on 2025-11-30 13:20 + +import business.models +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('basic_info', '0016_merchantsetting_type'), + ('business', '0010_salesorder_salesorderitem'), + ] + + operations = [ + migrations.CreateModel( + name='PaymentOrder', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('payment_date', models.DateField(verbose_name='付款日期')), + ('amount', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='金额')), + ('status', models.IntegerField(choices=[(1, '审批中'), (2, '审批通过'), (3, '作废')], default=1, verbose_name='状态')), + ('remarks', models.TextField(blank=True, null=True, verbose_name='备注')), + ('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payment_orders', to='basic_info.merchant', verbose_name='所属商户')), + ('operator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payment_orders', to='basic_info.employee', verbose_name='经办人')), + ('supplier', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payment_orders', to='basic_info.supplier', verbose_name='供应商')), + ], + options={ + 'verbose_name': '付款单', + 'verbose_name_plural': '付款单', + }, + bases=(business.models.OrderDirectionMixin, business.models.OrderCounterpartyMixin, models.Model), + ), + migrations.CreateModel( + name='ReceiptOrder', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('receipt_date', models.DateField(verbose_name='收款日期')), + ('amount', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='金额')), + ('status', models.IntegerField(choices=[(1, '审批中'), (2, '审批通过'), (3, '作废')], default=1, verbose_name='状态')), + ('remarks', models.TextField(blank=True, null=True, verbose_name='备注')), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='receipt_orders', to='basic_info.customer', verbose_name='客户')), + ('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='receipt_orders', to='basic_info.merchant', verbose_name='所属商户')), + ('operator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='receipt_orders', to='basic_info.employee', verbose_name='经办人')), + ], + options={ + 'verbose_name': '收款单', + 'verbose_name_plural': '收款单', + }, + bases=(business.models.OrderDirectionMixin, business.models.OrderCounterpartyMixin, models.Model), + ), + ] diff --git a/business/models.py b/business/models.py index c070334..4edabe9 100644 --- a/business/models.py +++ b/business/models.py @@ -316,3 +316,107 @@ class SalesOrderItem(ModelBase): def total_amount(self): return round(self.price * self.real_quantity(), 2) + + +class PaymentOrderStatusEnum(models.IntegerChoices): + PENDING = 1, '审批中' + APPROVED = 2, '审批通过' + CANCELLED = 3, '作废' + + +class PaymentOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase): + id = models.BigAutoField(primary_key=True) + merchant = models.ForeignKey( + basic_info_models.Merchant, + on_delete=models.PROTECT, + related_name='payment_orders', + verbose_name='所属商户', + ) + supplier = models.ForeignKey( + basic_info_models.Supplier, + on_delete=models.PROTECT, + related_name='payment_orders', + verbose_name='供应商', + ) + payment_date = models.DateField(verbose_name='付款日期') + amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='金额') + operator = models.ForeignKey( + basic_info_models.Employee, + on_delete=models.PROTECT, + related_name='payment_orders', + verbose_name='经办人', + ) + status = models.IntegerField( + choices=PaymentOrderStatusEnum.choices, + default=PaymentOrderStatusEnum.PENDING, + verbose_name='状态', + ) + remarks = models.TextField(blank=True, null=True, verbose_name='备注') + + class Meta: + verbose_name = '付款单' + verbose_name_plural = '付款单' + + def __str__(self): + return f'付款单 {self.id} - {self.supplier.name}' + + def get_total_amount(self) -> Decimal: + return self.amount + + def get_direction(self) -> int: + return -1 + + def get_counterparty_field_name(self) -> str: + return 'supplier' + + +class ReceiptOrderStatusEnum(models.IntegerChoices): + PENDING = 1, '审批中' + APPROVED = 2, '审批通过' + CANCELLED = 3, '作废' + + +class ReceiptOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase): + id = models.BigAutoField(primary_key=True) + merchant = models.ForeignKey( + basic_info_models.Merchant, + on_delete=models.PROTECT, + related_name='receipt_orders', + verbose_name='所属商户', + ) + customer = models.ForeignKey( + basic_info_models.Customer, + on_delete=models.PROTECT, + related_name='receipt_orders', + verbose_name='客户', + ) + receipt_date = models.DateField(verbose_name='收款日期') + amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='金额') + operator = models.ForeignKey( + basic_info_models.Employee, + on_delete=models.PROTECT, + related_name='receipt_orders', + verbose_name='经办人', + ) + status = models.IntegerField( + choices=ReceiptOrderStatusEnum.choices, + default=ReceiptOrderStatusEnum.PENDING, + verbose_name='状态', + ) + remarks = models.TextField(blank=True, null=True, verbose_name='备注') + + class Meta: + verbose_name = '收款单' + verbose_name_plural = '收款单' + + def __str__(self): + return f'收款单 {self.id} - {self.customer.name}' + + def get_total_amount(self) -> Decimal: + return self.amount + + def get_direction(self) -> int: + return -1 + + def get_counterparty_field_name(self) -> str: + return 'customer' diff --git a/business/services.py b/business/services.py index 0ac14d6..b730d28 100644 --- a/business/services.py +++ b/business/services.py @@ -161,6 +161,62 @@ def create_sales_order( return sales_order +def create_payment_order( + *, + merchant: basic_info_models.Merchant, + supplier: basic_info_models.Supplier, + payment_date, + amount, + operator: basic_info_models.Employee, + remarks: str | None = '', +) -> models.PaymentOrder: + """ + 创建付款单(资金流出)。 + """ + normalized_date = _normalize_order_date(payment_date) + normalized_amount = _ensure_positive_amount(amount, 'amount') + + with transaction.atomic(): + payment_order = models.PaymentOrder.objects.create( + merchant=merchant, + supplier=supplier, + payment_date=normalized_date, + amount=normalized_amount, + operator=operator, + remarks=remarks, + ) + payment_order.refresh_from_db() + return payment_order + + +def create_receipt_order( + *, + merchant: basic_info_models.Merchant, + customer: basic_info_models.Customer, + receipt_date, + amount, + operator: basic_info_models.Employee, + remarks: str | None = '', +) -> models.ReceiptOrder: + """ + 创建收款单(资金流入)。 + """ + normalized_date = _normalize_order_date(receipt_date) + normalized_amount = _ensure_positive_amount(amount, 'amount') + + with transaction.atomic(): + receipt_order = models.ReceiptOrder.objects.create( + merchant=merchant, + customer=customer, + receipt_date=normalized_date, + amount=normalized_amount, + operator=operator, + remarks=remarks, + ) + receipt_order.refresh_from_db() + return receipt_order + + def review_purchase_order( *, purchase_order: models.PurchaseOrder | None = None, @@ -222,6 +278,58 @@ def review_sales_order( return _cancel_sales_order(order) +def review_payment_order( + *, + payment_order: models.PaymentOrder | None = None, + payment_order_id: int | None = None, + target_status: models.PaymentOrderStatusEnum, + reviewed_by=None, +) -> models.PaymentOrder: + order = _resolve_payment_order_instance(payment_order, payment_order_id) + + if target_status not in { + models.PaymentOrderStatusEnum.APPROVED, + models.PaymentOrderStatusEnum.CANCELLED, + }: + raise ValueError('target_status 只能是 APPROVED 或 CANCELLED') + + if order.status == target_status: + return order + + with transaction.atomic(): + order.status = target_status + order.save(update_fields=['status', 'updated_at']) + + order.refresh_from_db(fields=['status', 'updated_at']) + return order + + +def review_receipt_order( + *, + receipt_order: models.ReceiptOrder | None = None, + receipt_order_id: int | None = None, + target_status: models.ReceiptOrderStatusEnum, + reviewed_by=None, +) -> models.ReceiptOrder: + order = _resolve_receipt_order_instance(receipt_order, receipt_order_id) + + if target_status not in { + models.ReceiptOrderStatusEnum.APPROVED, + models.ReceiptOrderStatusEnum.CANCELLED, + }: + raise ValueError('target_status 只能是 APPROVED 或 CANCELLED') + + if order.status == target_status: + return order + + with transaction.atomic(): + order.status = target_status + order.save(update_fields=['status', 'updated_at']) + + order.refresh_from_db(fields=['status', 'updated_at']) + return order + + def _normalize_order_items( *, merchant: basic_info_models.Merchant, @@ -596,6 +704,28 @@ def _resolve_sales_order_instance( ) +def _resolve_payment_order_instance( + payment_order: models.PaymentOrder | None, + payment_order_id: int | None, +) -> models.PaymentOrder: + if payment_order is None and payment_order_id is None: + raise ValueError('必须提供 payment_order 或 payment_order_id') + if payment_order is not None: + payment_order_id = payment_order.id + return models.PaymentOrder.objects.select_related('merchant', 'supplier').get(id=payment_order_id) + + +def _resolve_receipt_order_instance( + receipt_order: models.ReceiptOrder | None, + receipt_order_id: int | None, +) -> models.ReceiptOrder: + if receipt_order is None and receipt_order_id is None: + raise ValueError('必须提供 receipt_order 或 receipt_order_id') + if receipt_order is not None: + receipt_order_id = receipt_order.id + return models.ReceiptOrder.objects.select_related('merchant', 'customer').get(id=receipt_order_id) + + def _to_decimal(value, field_name: str) -> Decimal: try: return Decimal(str(value)) @@ -603,6 +733,13 @@ def _to_decimal(value, field_name: str) -> Decimal: raise ValueError(f'{field_name} 必须是合法数值') from exc +def _ensure_positive_amount(value, field_name: str) -> Decimal: + amount = _to_decimal(value, field_name) + if amount <= 0: + raise ValueError(f'{field_name} 必须大于 0') + return amount + + def _to_positive_int(value, field_name: str) -> int: if value is None: raise ValueError(f'{field_name} 不能为空') diff --git a/business/tests.py b/business/tests.py index 680de31..1503bf2 100644 --- a/business/tests.py +++ b/business/tests.py @@ -374,6 +374,105 @@ class SalesOrderServiceTestCase(TestCase): item = sales_order.items.first() self.assertEqual(item.consume_detail_ids, '1,2') + def test_sales_order_outgoing_missing_consume_ids_raises(self): + payload = [{ + 'product_id': self.product.id, + 'quantity': 30, + 'price': '25', + 'unit': '米', + }] + with self.assertRaises(ValueError): + services.create_sales_order( + merchant=self.merchant, + customer=self.customer, + order_date=timezone.now().date(), + warehouse=self.warehouse_strict_out, + operator=self.operator, + items=payload, + created_by=self.user, + ) + + +class PaymentReceiptServiceTestCase(TestCase): + def setUp(self): + ( + self.merchant, + self.supplier, + self.warehouse_strict, + self.warehouse_relaxed, + self.product, + self.operator, + ) = create_basic_fixtures() + self.customer = basic_models.Customer.objects.create( + merchant=self.merchant, + name='客户B', + mobile='13888888888', + created_by=None, + ) + + def test_create_payment_order_and_approve(self): + order = services.create_payment_order( + merchant=self.merchant, + supplier=self.supplier, + payment_date=timezone.now().date(), + amount='120.50', + operator=self.operator, + remarks='付款测试', + ) + self.assertEqual(order.status, business_models.PaymentOrderStatusEnum.PENDING) + reviewed = services.review_payment_order( + payment_order=order, + target_status=business_models.PaymentOrderStatusEnum.APPROVED, + reviewed_by=self.operator, + ) + self.assertEqual(reviewed.status, business_models.PaymentOrderStatusEnum.APPROVED) + + def test_create_receipt_order_and_cancel(self): + order = services.create_receipt_order( + merchant=self.merchant, + customer=self.customer, + receipt_date=timezone.now().date(), + amount='80.00', + operator=self.operator, + remarks='收款测试', + ) + cancelled = services.review_receipt_order( + receipt_order=order, + target_status=business_models.ReceiptOrderStatusEnum.CANCELLED, + reviewed_by=self.operator, + ) + self.assertEqual(cancelled.status, business_models.ReceiptOrderStatusEnum.CANCELLED) + + def test_payment_amount_must_be_positive(self): + with self.assertRaises(ValueError): + services.create_payment_order( + merchant=self.merchant, + supplier=self.supplier, + payment_date=timezone.now().date(), + amount='-1', + operator=self.operator, + ) + + def test_receipt_approval_is_idempotent(self): + order = services.create_receipt_order( + merchant=self.merchant, + customer=self.customer, + receipt_date=timezone.now().date(), + amount='10', + operator=self.operator, + ) + services.review_receipt_order( + receipt_order=order, + target_status=business_models.ReceiptOrderStatusEnum.APPROVED, + reviewed_by=self.operator, + ) + reviewed_again = services.review_receipt_order( + receipt_order=order, + target_status=business_models.ReceiptOrderStatusEnum.APPROVED, + reviewed_by=self.operator, + ) + self.assertEqual(reviewed_again.status, business_models.ReceiptOrderStatusEnum.APPROVED) + class PurchaseOrderStockServiceTestCase(TestCase): def setUp(self): @@ -463,6 +562,36 @@ class SalesOrderStockServiceTestCase(TestCase): self.assertEqual(payload['sales_order_id'], self.sales_order.id) self.assertEqual(payload['stock_change_record_id'], 654) + def test_service_calls_stock_out_strict_mode(self): + strict_order = business_models.SalesOrder.objects.create( + merchant=self.merchant, + customer=self.customer, + sales_date=timezone.now().date(), + operator=self.operator, + warehouse=self.warehouse_strict_out, + ) + consume_items = [{'product_id': self.product.id, 'consume_detail_ids': [11, 12], 'quantity': 50}] + with patch('business.services.StockFlowService') as mock_flow_cls: + mock_instance = mock_flow_cls.return_value + mock_instance.stock_out.return_value = (MagicMock(id=987), [], 2) + + payload = services.create_sales_order_stock_entries_sync( + sales_order_id=strict_order.id, + warehouse_id=self.warehouse_strict_out.id, + items=consume_items, + created_by_id=self.user.id, + ) + + mock_flow_cls.assert_called_once_with(merchant=self.merchant, created_by=self.user) + mock_instance.stock_out.assert_called_once_with( + warehouse_id=self.warehouse_strict_out.id, + source_type=stock_models.StockChangeSourceEnum.SALES, + source_id=strict_order.id, + items=[{'product_id': self.product.id, 'consume_detail_ids': [11, 12], 'quantity': 50}], + ) + self.assertEqual(payload['sales_order_id'], strict_order.id) + self.assertEqual(payload['stock_change_record_id'], 987) + @override_settings( CELERY_TASK_ALWAYS_EAGER=True, diff --git a/docs/payment_receipt_workflow.md b/docs/payment_receipt_workflow.md new file mode 100644 index 0000000..fb1f2b0 --- /dev/null +++ b/docs/payment_receipt_workflow.md @@ -0,0 +1,36 @@ +# 付款单 / 收款单流程说明 + +## 1. 概述 +- `PaymentOrder`:针对供应商的资金支出单据,审批通过后代表“确认付款”。 +- `ReceiptOrder`:针对客户的资金收入单据,审批通过后代表“确认收款”。 +- 两者均位于 `business` 模块,API 路径分别为 `/api/v1/payment-orders/`、`/api/v1/receipt-orders/`。 +- 与采购/销售相比,不涉及产品与库存,仅维护资金方向、对方主体、金额与状态。 + +## 2. 创建流程 +| 字段 | 付款单 | 收款单 | +|------|--------|--------| +| 对方主体 | `supplier` (供应商 ID) | `customer` (客户 ID) | +| 日期 | `payment_date` | `receipt_date` | +| 金额 | `amount`,必须 > 0 | `amount`,必须 > 0 | +| 其他 | `remarks`(可选) | `remarks`(可选) | + +创建成功返回 `201`,包含新建单据 ID、状态(默认 `PENDING`)与提示信息。 + +## 3. 审批 / 作废 +- 接口:`POST /api/v1/-orders//review/` +- 请求体:`{"action": "approve"}` 或 `{"action": "cancel"}` +- 审批通过:状态变为 `APPROVED`,后续可用于应付/应收对账。 +- 作废:状态变为 `CANCELLED`;若已是目标状态则返回原状态(幂等)。 + +## 4. 常见异常 +| 场景 | 响应 | +|------|------| +| 缺少主体/日期/金额 | `400 + {"error": "缺少 ... "}` | +| 金额 ≤ 0 | `400 + {"error": "amount 必须大于 0"}` | +| 目标单据不存在或非当前商户 | `404` | +| 无权限(用户非员工) | `403` | + +## 5. 与采购/销售单的关系 +- 继承相同的方向抽象(`get_direction()`),可与采购/销售的金额统计组合成统一的应收/应付报表。 +- 不触发库存任务,也没有 `items`、仓库等字段;扩展字段(如付款方式、附件)可在此基础上继续添加。 +