diff --git a/.coverage b/.coverage index fa0c08e..d6707e9 100644 Binary files a/.coverage and b/.coverage differ diff --git a/api_v1/tests.py b/api_v1/tests.py index 063a35b..4c0fca6 100644 --- a/api_v1/tests.py +++ b/api_v1/tests.py @@ -1231,6 +1231,332 @@ class ReceiptOrderAPITestCase(TestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn('银行账户不存在', response.data['error']) + +class BusinessRedFlushAPITestCase(TestCase): + def setUp(self): + self.merchant = Merchant.objects.create(name='红冲商户', type=MerchantTypeEnum.FACTORY) + self.other_merchant = Merchant.objects.create(name='其他红冲商户', type=MerchantTypeEnum.FACTORY) + self.supplier = Supplier.objects.create(merchant=self.merchant, name='红冲供应商') + self.other_supplier = Supplier.objects.create(merchant=self.other_merchant, name='其他供应商') + self.customer = Customer.objects.create( + merchant=self.merchant, + name='红冲客户', + mobile='13800001111', + created_by=None, + ) + self.warehouse = WareHouse.objects.create( + merchant=self.merchant, + name='红冲仓', + mode=WareHouseModeEnum.UNRESTRICTED, + ) + category = ProductCategory.objects.create( + merchant=self.merchant, + name='红冲品类', + product_prefix='RF', + ) + self.product = Product.objects.create( + merchant=self.merchant, + category=category, + name='红冲产品', + human_id='RF-001', + unit=ProductUnitEnum.METER, + ) + self.user = User.objects.create_user(username='red_flush_api_user', password='pass123') + self.employee = Employee.objects.create( + merchant=self.merchant, + sys_user=self.user, + name='红冲操作员', + status=EmployeeStatusEnum.ACTIVE, + ) + self.other_user = User.objects.create_user(username='other_red_flush_api_user', password='pass123') + self.other_employee = Employee.objects.create( + merchant=self.other_merchant, + sys_user=self.other_user, + name='其他红冲操作员', + status=EmployeeStatusEnum.ACTIVE, + ) + self.client = APIClient() + self.client.force_authenticate(user=self.user) + MerchantSetting.objects.filter( + merchant=self.merchant, + key=MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS, + ).update(val_bool=False) + + def _items(self, quantity='10', price='5'): + return [ + { + 'product_id': self.product.id, + 'quantity': quantity, + 'num_of_rolls': 1, + 'price': price, + } + ] + + def _complete_stock_record(self, record_id): + record = stock_models.StockChangeRecord.objects.get(id=record_id) + stock_services.make_stock_change_completed(record) + + def _create_approved_purchase_order(self): + order = services.create_purchase_order( + merchant=self.merchant, + supplier=self.supplier, + order_date=datetime.date(2025, 11, 26), + warehouse=self.warehouse, + operator=self.employee, + items=self._items(), + ) + services.review_purchase_order( + purchase_order=order, + target_status=business_models.PurchaseOrderStatusEnum.APPROVED, + reviewed_by=self.user, + ) + payload = services.create_purchase_order_stock_entries_sync( + purchase_order_id=order.id, + warehouse_id=self.warehouse.id, + items=services._build_stock_flow_items_from_order(order), + created_by_id=self.user.id, + ) + self._complete_stock_record(payload['stock_change_record_id']) + return order + + def _create_approved_sales_order(self): + order = services.create_sales_order( + merchant=self.merchant, + customer=self.customer, + order_date=datetime.date(2025, 11, 26), + warehouse=self.warehouse, + operator=self.employee, + items=self._items(quantity='8', price='7'), + ) + services.review_sales_order( + sales_order=order, + target_status=business_models.SalesOrderStatusEnum.APPROVED, + reviewed_by=self.user, + ) + payload = services.create_sales_order_stock_entries_sync( + sales_order_id=order.id, + warehouse_id=self.warehouse.id, + items=services._build_stock_flow_items_from_order(order), + created_by_id=self.user.id, + ) + self._complete_stock_record(payload['stock_change_record_id']) + return order + + def _create_approved_purchase_return_order(self): + order = services.create_purchase_return_order( + merchant=self.merchant, + supplier=self.supplier, + return_date=datetime.date(2025, 11, 26), + warehouse=self.warehouse, + operator=self.employee, + items=self._items(quantity='6', price='4'), + ) + services.review_purchase_return_order( + purchase_return_order=order, + target_status=business_models.PurchaseReturnStatusEnum.APPROVED, + reviewed_by=self.user, + ) + payload = services.create_purchase_return_order_stock_entries_sync( + purchase_return_order_id=order.id, + warehouse_id=self.warehouse.id, + items=services._build_stock_flow_items_from_order(order), + created_by_id=self.user.id, + ) + self._complete_stock_record(payload['stock_change_record_id']) + return order + + def _create_approved_sales_return_order(self): + order = services.create_sales_return_order( + merchant=self.merchant, + customer=self.customer, + return_date=datetime.date(2025, 11, 26), + warehouse=self.warehouse, + operator=self.employee, + items=self._items(quantity='6', price='4'), + ) + services.review_sales_return_order( + sales_return_order=order, + target_status=business_models.SalesReturnStatusEnum.APPROVED, + reviewed_by=self.user, + ) + payload = services.create_sales_return_order_stock_entries_sync( + sales_return_order_id=order.id, + warehouse_id=self.warehouse.id, + items=services._build_stock_flow_items_from_order(order), + created_by_id=self.user.id, + ) + self._complete_stock_record(payload['stock_change_record_id']) + return order + + def _create_approved_payment_order(self): + order = services.create_payment_order( + merchant=self.merchant, + supplier=self.supplier, + payment_date=datetime.date(2025, 11, 26), + amount='30', + operator=self.employee, + ) + services.review_payment_order( + payment_order=order, + target_status=business_models.PaymentOrderStatusEnum.APPROVED, + reviewed_by=self.user, + ) + return order + + def _create_approved_receipt_order(self): + order = services.create_receipt_order( + merchant=self.merchant, + customer=self.customer, + receipt_date=datetime.date(2025, 11, 26), + amount='30', + operator=self.employee, + ) + services.review_receipt_order( + receipt_order=order, + target_status=business_models.ReceiptOrderStatusEnum.APPROVED, + reviewed_by=self.user, + ) + return order + + def test_purchase_order_red_flush_requires_reason(self): + order = self._create_approved_purchase_order() + response = self.client.post(f'/api/v1/purchase-orders/{order.id}/red-flush/', {}, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('reason', response.data) + + def test_purchase_order_red_flush_success(self): + order = self._create_approved_purchase_order() + response = self.client.post( + f'/api/v1/purchase-orders/{order.id}/red-flush/', + {'reason': '采购接口红冲'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data['is_red_flushed']) + self.assertIsNotNone(response.data['red_flush_id']) + order.refresh_from_db() + self.assertEqual(order.status, business_models.PurchaseOrderStatusEnum.APPROVED) + self.assertTrue(order.is_red_flushed) + + def test_sales_order_red_flush_success(self): + order = self._create_approved_sales_order() + response = self.client.post( + f'/api/v1/sales-orders/{order.id}/red-flush/', + {'reason': '销售接口红冲'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data['is_red_flushed']) + + def test_purchase_return_order_red_flush_success(self): + order = self._create_approved_purchase_return_order() + response = self.client.post( + f'/api/v1/purchase-return-orders/{order.id}/red-flush/', + {'reason': '采购退货接口红冲'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data['is_red_flushed']) + + def test_sales_return_order_red_flush_success(self): + order = self._create_approved_sales_return_order() + response = self.client.post( + f'/api/v1/sales-return-orders/{order.id}/red-flush/', + {'reason': '销售退货接口红冲'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data['is_red_flushed']) + + def test_payment_order_red_flush_success_and_duplicate_blocked(self): + order = self._create_approved_payment_order() + response = self.client.post( + f'/api/v1/payment-orders/{order.id}/red-flush/', + {'reason': '付款接口红冲'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data['is_red_flushed']) + + duplicate = self.client.post( + f'/api/v1/payment-orders/{order.id}/red-flush/', + {'reason': '重复红冲'}, + format='json', + ) + self.assertEqual(duplicate.status_code, status.HTTP_400_BAD_REQUEST) + + def test_receipt_order_red_flush_success(self): + order = self._create_approved_receipt_order() + response = self.client.post( + f'/api/v1/receipt-orders/{order.id}/red-flush/', + {'reason': '收款接口红冲'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data['is_red_flushed']) + + def test_red_flush_rejects_external_payment_order(self): + order = self._create_approved_payment_order() + order.is_external_source = True + order.external_source_id = 'XT-API-EXTERNAL-001' + order.save(update_fields=['is_external_source', 'external_source_id', 'updated_at']) + + response = self.client.post( + f'/api/v1/payment-orders/{order.id}/red-flush/', + {'reason': '外部付款红冲'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('外部来源付款单不允许红冲', response.data['error']) + order.refresh_from_db() + self.assertFalse(order.is_red_flushed) + + def test_red_flush_rejects_external_receipt_order(self): + order = self._create_approved_receipt_order() + order.external_source_id = 'SK-API-EXTERNAL-001' + order.save(update_fields=['external_source_id', 'updated_at']) + + response = self.client.post( + f'/api/v1/receipt-orders/{order.id}/red-flush/', + {'reason': '外部收款红冲'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('外部来源收款单不允许红冲', response.data['error']) + order.refresh_from_db() + self.assertFalse(order.is_red_flushed) + + def test_red_flush_rejects_non_current_merchant_order(self): + other_order = services.create_payment_order( + merchant=self.other_merchant, + supplier=self.other_supplier, + payment_date=datetime.date(2025, 11, 26), + amount='20', + operator=self.other_employee, + ) + response = self.client.post( + f'/api/v1/payment-orders/{other_order.id}/red-flush/', + {'reason': '跨商户红冲'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_red_flush_rejects_non_approved_order(self): + order = services.create_payment_order( + merchant=self.merchant, + supplier=self.supplier, + payment_date=datetime.date(2025, 11, 26), + amount='20', + operator=self.employee, + ) + response = self.client.post( + f'/api/v1/payment-orders/{order.id}/red-flush/', + {'reason': '未审核红冲'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + class CustomerBalanceAPITestCase(TestCase): def setUp(self): self.merchant = Merchant.objects.create(name='余额商户', type=MerchantTypeEnum.FACTORY) diff --git a/api_v1/urls.py b/api_v1/urls.py index 54b20e4..5201213 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -155,6 +155,11 @@ urlpatterns = [ purchase_views.PurchaseOrderReviewView.as_view(), name="purchase_order_review", ), + path( + "purchase-orders//red-flush/", + purchase_views.PurchaseOrderRedFlushView.as_view(), + name="purchase_order_red_flush", + ), path( "purchase-orders//bind-stock-change/", purchase_views.PurchaseOrderBindStockChangeView.as_view(), @@ -175,6 +180,11 @@ urlpatterns = [ purchase_return_views.PurchaseReturnOrderReviewView.as_view(), name="purchase_return_order_review", ), + path( + "purchase-return-orders//red-flush/", + purchase_return_views.PurchaseReturnOrderRedFlushView.as_view(), + name="purchase_return_order_red_flush", + ), path("sales-orders/", sales_views.SalesOrderView.as_view(), name="sales_orders"), path( "sales-orders//", @@ -186,6 +196,11 @@ urlpatterns = [ sales_views.SalesOrderReviewView.as_view(), name="sales_order_review", ), + path( + "sales-orders//red-flush/", + sales_views.SalesOrderRedFlushView.as_view(), + name="sales_order_red_flush", + ), path( "sales-return-orders/", sales_return_views.SalesReturnOrderView.as_view(), @@ -201,6 +216,11 @@ urlpatterns = [ sales_return_views.SalesReturnOrderReviewView.as_view(), name="sales_return_order_review", ), + path( + "sales-return-orders//red-flush/", + sales_return_views.SalesReturnOrderRedFlushView.as_view(), + name="sales_return_order_red_flush", + ), path( "pre-sales-orders/", pre_sales_views.PreSalesOrderView.as_view(), @@ -246,6 +266,11 @@ urlpatterns = [ payment_views.PaymentOrderReviewView.as_view(), name="payment_order_review", ), + path( + "payment-orders//red-flush/", + payment_views.PaymentOrderRedFlushView.as_view(), + name="payment_order_red_flush", + ), path( "receipt-orders/", receipt_views.ReceiptOrderView.as_view(), @@ -256,6 +281,11 @@ urlpatterns = [ receipt_views.ReceiptOrderReviewView.as_view(), name="receipt_order_review", ), + path( + "receipt-orders//red-flush/", + receipt_views.ReceiptOrderRedFlushView.as_view(), + name="receipt_order_red_flush", + ), path( "customers//balance/", balance_views.CustomerBalanceView.as_view(), diff --git a/api_v1/views/business/payment/views.py b/api_v1/views/business/payment/views.py index 00a70ba..f5c922c 100644 --- a/api_v1/views/business/payment/views.py +++ b/api_v1/views/business/payment/views.py @@ -20,6 +20,7 @@ class PaymentOrderSerializer(serializers.ModelSerializer): 'id', 'supplier', 'supplier_name', 'bank_account', 'bank_account_name', 'payment_date', 'amount', 'discount_amount', 'settlement_amount', 'operator', 'operator_name', 'status', 'is_external_source', 'external_source_id', + 'is_red_flushed', 'red_flush_id', 'red_flushed_at', 'remarks', 'markup', 'created_at', 'updated_at', ] read_only_fields = [ @@ -31,6 +32,9 @@ class PaymentOrderSerializer(serializers.ModelSerializer): 'status', 'is_external_source', 'external_source_id', + 'is_red_flushed', + 'red_flush_id', + 'red_flushed_at', 'created_at', 'updated_at', ] @@ -164,3 +168,42 @@ class PaymentOrderReviewView(StockChangeViewMixin, views.APIView): ) return Response(PaymentOrderSerializer(refreshed).data, status=status.HTTP_200_OK) + +class PaymentOrderRedFlushSerializer(serializers.Serializer): + reason = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True) + + +class PaymentOrderRedFlushView(StockChangeViewMixin, views.APIView): + permission_classes = [IsAuthenticated] + + 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', 'bank_account') + .get(id=pk, merchant=merchant) + ) + except business_models.PaymentOrder.DoesNotExist: + return self.not_found_response('付款单不存在') + + serializer = PaymentOrderRedFlushSerializer(data=request.data or {}) + serializer.is_valid(raise_exception=True) + + try: + red_flushed_order = business_services.red_flush_payment_order( + merchant=merchant, + payment_order=payment_order, + red_flushed_by=request.user, + reason=serializer.validated_data['reason'], + ) + except ValueError as exc: + return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + refreshed = ( + business_models.PaymentOrder.objects.select_related('supplier', 'operator', 'bank_account') + .get(id=red_flushed_order.id) + ) + return Response(PaymentOrderSerializer(refreshed).data, status=status.HTTP_200_OK) diff --git a/api_v1/views/business/purchase/views.py b/api_v1/views/business/purchase/views.py index 44955a2..5161437 100644 --- a/api_v1/views/business/purchase/views.py +++ b/api_v1/views/business/purchase/views.py @@ -53,10 +53,14 @@ class PurchaseOrderSerializer(serializers.ModelSerializer): 'id', 'human_id', 'supplier', 'supplier_name', 'purchase_date', 'kind', 'total_amount', 'diff_quantity', 'total_quantity', 'operator', 'operator_name', 'warehouse', 'warehouse_name', - 'status', 'remarks', 'created_at', 'updated_at', 'items', - 'quantity_of_rolls', + 'status', 'is_red_flushed', 'red_flush_id', 'red_flushed_at', + 'remarks', 'created_at', 'updated_at', 'items', 'quantity_of_rolls', + ] + read_only_fields = [ + 'id', 'created_at', 'updated_at', 'items', 'supplier_name', + 'operator_name', 'warehouse_name', 'is_red_flushed', + 'red_flush_id', 'red_flushed_at', ] - read_only_fields = ['id', 'created_at', 'updated_at', 'items', 'supplier_name', 'operator_name', 'warehouse_name'] class PurchaseOrderPagination(pagination.LimitOffsetPagination): @@ -258,6 +262,46 @@ class PurchaseOrderReviewView(StockChangeViewMixin, views.APIView): return Response(PurchaseOrderSerializer(refreshed_order).data, status=status.HTTP_200_OK) +class PurchaseOrderRedFlushSerializer(serializers.Serializer): + reason = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True) + + +class PurchaseOrderRedFlushView(StockChangeViewMixin, views.APIView): + """采购单红冲""" + + permission_classes = [IsAuthenticated] + + def post(self, request, pk: int): + if not self.check_employee_permission(request): + return self.permission_error_response('无权限访问') + + merchant = request.user.employee.merchant + try: + purchase_order = business_models.PurchaseOrder.objects.select_related( + 'supplier', 'operator', 'warehouse' + ).prefetch_related('items').get(id=pk, merchant=merchant) + except business_models.PurchaseOrder.DoesNotExist: + return self.not_found_response('采购单不存在') + + serializer = PurchaseOrderRedFlushSerializer(data=request.data or {}) + serializer.is_valid(raise_exception=True) + + try: + red_flushed_order = business_services.red_flush_purchase_order( + merchant=merchant, + purchase_order=purchase_order, + red_flushed_by=request.user, + reason=serializer.validated_data['reason'], + ) + except ValueError as exc: + return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + refreshed_order = business_models.PurchaseOrder.objects.select_related( + 'supplier', 'operator', 'warehouse' + ).prefetch_related('items').get(id=red_flushed_order.id) + return Response(PurchaseOrderSerializer(refreshed_order).data, status=status.HTTP_200_OK) + + class PurchaseOrderBindStockChangeSerializer(serializers.Serializer): stock_change_record_id = serializers.IntegerField(min_value=1) @@ -300,4 +344,3 @@ class PurchaseOrderBindStockChangeView(StockChangeViewMixin, views.APIView): }, status=status.HTTP_200_OK, ) - diff --git a/api_v1/views/business/purchase_return/views.py b/api_v1/views/business/purchase_return/views.py index 81b0aed..245b7b0 100644 --- a/api_v1/views/business/purchase_return/views.py +++ b/api_v1/views/business/purchase_return/views.py @@ -57,12 +57,13 @@ class PurchaseReturnOrderSerializer(serializers.ModelSerializer): 'id', 'human_id', 'supplier', 'supplier_name', 'purchase_order', 'return_date', 'total_amount', 'diff_quantity', 'total_quantity', 'operator', 'operator_name', 'warehouse', 'warehouse_name', - 'status', 'remarks', 'created_at', 'updated_at', 'items', - 'quantity_of_rolls', + 'status', 'is_red_flushed', 'red_flush_id', 'red_flushed_at', + 'remarks', 'created_at', 'updated_at', 'items', 'quantity_of_rolls', ] read_only_fields = [ 'id', 'created_at', 'updated_at', 'items', 'supplier_name', 'operator_name', 'warehouse_name', + 'is_red_flushed', 'red_flush_id', 'red_flushed_at', ] @@ -260,3 +261,40 @@ class PurchaseReturnOrderReviewView(StockChangeViewMixin, views.APIView): ).prefetch_related('items').get(id=purchase_return_order.id) return Response(PurchaseReturnOrderSerializer(refreshed_order).data, status=status.HTTP_200_OK) + +class PurchaseReturnOrderRedFlushSerializer(serializers.Serializer): + reason = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True) + + +class PurchaseReturnOrderRedFlushView(StockChangeViewMixin, views.APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, pk: int): + if not self.check_employee_permission(request): + return self.permission_error_response('无权限访问') + + merchant = request.user.employee.merchant + try: + purchase_return_order = business_models.PurchaseReturnOrder.objects.select_related( + 'supplier', 'operator', 'warehouse' + ).prefetch_related('items').get(id=pk, merchant=merchant) + except business_models.PurchaseReturnOrder.DoesNotExist: + return self.not_found_response('采购退货单不存在') + + serializer = PurchaseReturnOrderRedFlushSerializer(data=request.data or {}) + serializer.is_valid(raise_exception=True) + + try: + red_flushed_order = business_services.red_flush_purchase_return_order( + merchant=merchant, + purchase_return_order=purchase_return_order, + red_flushed_by=request.user, + reason=serializer.validated_data['reason'], + ) + except ValueError as exc: + return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + refreshed_order = business_models.PurchaseReturnOrder.objects.select_related( + 'supplier', 'operator', 'warehouse' + ).prefetch_related('items').get(id=red_flushed_order.id) + return Response(PurchaseReturnOrderSerializer(refreshed_order).data, status=status.HTTP_200_OK) diff --git a/api_v1/views/business/receipt/views.py b/api_v1/views/business/receipt/views.py index 73ca4fe..9842752 100644 --- a/api_v1/views/business/receipt/views.py +++ b/api_v1/views/business/receipt/views.py @@ -20,6 +20,7 @@ class ReceiptOrderSerializer(serializers.ModelSerializer): 'id', 'customer', 'customer_name', 'bank_account', 'bank_account_name', 'receipt_date', 'amount', 'discount_amount', 'settlement_amount', 'operator', 'operator_name', 'status', 'is_external_source', 'external_source_id', + 'is_red_flushed', 'red_flush_id', 'red_flushed_at', 'remarks', 'markup', 'created_at', 'updated_at', ] read_only_fields = [ @@ -31,6 +32,9 @@ class ReceiptOrderSerializer(serializers.ModelSerializer): 'status', 'is_external_source', 'external_source_id', + 'is_red_flushed', + 'red_flush_id', + 'red_flushed_at', 'created_at', 'updated_at', ] @@ -164,3 +168,42 @@ class ReceiptOrderReviewView(StockChangeViewMixin, views.APIView): ) return Response(ReceiptOrderSerializer(refreshed).data, status=status.HTTP_200_OK) + +class ReceiptOrderRedFlushSerializer(serializers.Serializer): + reason = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True) + + +class ReceiptOrderRedFlushView(StockChangeViewMixin, views.APIView): + permission_classes = [IsAuthenticated] + + 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', 'bank_account') + .get(id=pk, merchant=merchant) + ) + except business_models.ReceiptOrder.DoesNotExist: + return self.not_found_response('收款单不存在') + + serializer = ReceiptOrderRedFlushSerializer(data=request.data or {}) + serializer.is_valid(raise_exception=True) + + try: + red_flushed_order = business_services.red_flush_receipt_order( + merchant=merchant, + receipt_order=receipt_order, + red_flushed_by=request.user, + reason=serializer.validated_data['reason'], + ) + except ValueError as exc: + return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + refreshed = ( + business_models.ReceiptOrder.objects.select_related('customer', 'operator', 'bank_account') + .get(id=red_flushed_order.id) + ) + return Response(ReceiptOrderSerializer(refreshed).data, status=status.HTTP_200_OK) diff --git a/api_v1/views/business/sales/views.py b/api_v1/views/business/sales/views.py index baa28bd..9f81009 100644 --- a/api_v1/views/business/sales/views.py +++ b/api_v1/views/business/sales/views.py @@ -54,10 +54,14 @@ class SalesOrderSerializer(serializers.ModelSerializer): 'id', 'human_id', 'customer', 'customer_name', 'sales_date', 'kind', 'total_amount', 'diff_quantity', 'total_quantity', 'operator', 'operator_name', 'warehouse', 'warehouse_name', - 'status', 'remarks', 'created_at', 'updated_at', 'items', - 'quantity_of_rolls', + 'status', 'is_red_flushed', 'red_flush_id', 'red_flushed_at', + 'remarks', 'created_at', 'updated_at', 'items', 'quantity_of_rolls', + ] + read_only_fields = [ + 'id', 'created_at', 'updated_at', 'items', 'customer_name', + 'operator_name', 'warehouse_name', 'is_red_flushed', + 'red_flush_id', 'red_flushed_at', ] - read_only_fields = ['id', 'created_at', 'updated_at', 'items', 'customer_name', 'operator_name', 'warehouse_name'] class SalesOrderPagination(pagination.LimitOffsetPagination): @@ -267,3 +271,42 @@ class SalesOrderReviewView(StockChangeViewMixin, views.APIView): ).prefetch_related('items').get(id=sales_order.id) return Response(SalesOrderSerializer(refreshed_order).data, status=status.HTTP_200_OK) + +class SalesOrderRedFlushSerializer(serializers.Serializer): + reason = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True) + + +class SalesOrderRedFlushView(StockChangeViewMixin, views.APIView): + """销售单红冲""" + + permission_classes = [IsAuthenticated] + + def post(self, request, pk: int): + if not self.check_employee_permission(request): + return self.permission_error_response('无权限访问') + + merchant = request.user.employee.merchant + try: + sales_order = business_models.SalesOrder.objects.select_related( + 'customer', 'operator', 'warehouse' + ).prefetch_related('items').get(id=pk, merchant=merchant) + except business_models.SalesOrder.DoesNotExist: + return self.not_found_response('销售单不存在') + + serializer = SalesOrderRedFlushSerializer(data=request.data or {}) + serializer.is_valid(raise_exception=True) + + try: + red_flushed_order = business_services.red_flush_sales_order( + merchant=merchant, + sales_order=sales_order, + red_flushed_by=request.user, + reason=serializer.validated_data['reason'], + ) + except ValueError as exc: + return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + refreshed_order = business_models.SalesOrder.objects.select_related( + 'customer', 'operator', 'warehouse' + ).prefetch_related('items').get(id=red_flushed_order.id) + return Response(SalesOrderSerializer(refreshed_order).data, status=status.HTTP_200_OK) diff --git a/api_v1/views/business/sales_return/views.py b/api_v1/views/business/sales_return/views.py index 8939182..205d833 100644 --- a/api_v1/views/business/sales_return/views.py +++ b/api_v1/views/business/sales_return/views.py @@ -56,11 +56,13 @@ class SalesReturnOrderSerializer(serializers.ModelSerializer): 'id', 'human_id', 'customer', 'customer_name', 'sales_order', 'return_date', 'total_amount', 'diff_quantity', 'total_quantity', 'quantity_of_rolls', 'operator', 'operator_name', 'warehouse', 'warehouse_name', - 'status', 'remarks', 'created_at', 'updated_at', 'items', + 'status', 'is_red_flushed', 'red_flush_id', 'red_flushed_at', + 'remarks', 'created_at', 'updated_at', 'items', ] read_only_fields = [ 'id', 'created_at', 'updated_at', 'items', 'customer_name', 'operator_name', 'warehouse_name', + 'is_red_flushed', 'red_flush_id', 'red_flushed_at', ] @@ -258,3 +260,40 @@ class SalesReturnOrderReviewView(StockChangeViewMixin, views.APIView): ).prefetch_related('items').get(id=sales_return_order.id) return Response(SalesReturnOrderSerializer(refreshed_order).data, status=status.HTTP_200_OK) + +class SalesReturnOrderRedFlushSerializer(serializers.Serializer): + reason = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True) + + +class SalesReturnOrderRedFlushView(StockChangeViewMixin, views.APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, pk: int): + if not self.check_employee_permission(request): + return self.permission_error_response('无权限访问') + + merchant = request.user.employee.merchant + try: + sales_return_order = business_models.SalesReturnOrder.objects.select_related( + 'customer', 'operator', 'warehouse' + ).prefetch_related('items').get(id=pk, merchant=merchant) + except business_models.SalesReturnOrder.DoesNotExist: + return self.not_found_response('销售退货单不存在') + + serializer = SalesReturnOrderRedFlushSerializer(data=request.data or {}) + serializer.is_valid(raise_exception=True) + + try: + red_flushed_order = business_services.red_flush_sales_return_order( + merchant=merchant, + sales_return_order=sales_return_order, + red_flushed_by=request.user, + reason=serializer.validated_data['reason'], + ) + except ValueError as exc: + return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + refreshed_order = business_models.SalesReturnOrder.objects.select_related( + 'customer', 'operator', 'warehouse' + ).prefetch_related('items').get(id=red_flushed_order.id) + return Response(SalesReturnOrderSerializer(refreshed_order).data, status=status.HTTP_200_OK) diff --git a/business/migrations/0031_red_flush_fields.py b/business/migrations/0031_red_flush_fields.py new file mode 100644 index 0000000..10ec525 --- /dev/null +++ b/business/migrations/0031_red_flush_fields.py @@ -0,0 +1,106 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('business', '0030_external_statement_add_sf_zk_amount'), + ] + + operations = [ + migrations.AddField( + model_name='purchaseorder', + name='is_red_flushed', + field=models.BooleanField(db_index=True, default=False, verbose_name='已红冲'), + ), + migrations.AddField( + model_name='purchaseorder', + name='red_flush_id', + field=models.UUIDField(blank=True, db_index=True, null=True, verbose_name='红冲批次ID'), + ), + migrations.AddField( + model_name='purchaseorder', + name='red_flushed_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='红冲时间'), + ), + migrations.AddField( + model_name='salesorder', + name='is_red_flushed', + field=models.BooleanField(db_index=True, default=False, verbose_name='已红冲'), + ), + migrations.AddField( + model_name='salesorder', + name='red_flush_id', + field=models.UUIDField(blank=True, db_index=True, null=True, verbose_name='红冲批次ID'), + ), + migrations.AddField( + model_name='salesorder', + name='red_flushed_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='红冲时间'), + ), + migrations.AddField( + model_name='purchasereturnorder', + name='is_red_flushed', + field=models.BooleanField(db_index=True, default=False, verbose_name='已红冲'), + ), + migrations.AddField( + model_name='purchasereturnorder', + name='red_flush_id', + field=models.UUIDField(blank=True, db_index=True, null=True, verbose_name='红冲批次ID'), + ), + migrations.AddField( + model_name='purchasereturnorder', + name='red_flushed_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='红冲时间'), + ), + migrations.AddField( + model_name='salesreturnorder', + name='is_red_flushed', + field=models.BooleanField(db_index=True, default=False, verbose_name='已红冲'), + ), + migrations.AddField( + model_name='salesreturnorder', + name='red_flush_id', + field=models.UUIDField(blank=True, db_index=True, null=True, verbose_name='红冲批次ID'), + ), + migrations.AddField( + model_name='salesreturnorder', + name='red_flushed_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='红冲时间'), + ), + migrations.AddField( + model_name='paymentorder', + name='is_red_flushed', + field=models.BooleanField(db_index=True, default=False, verbose_name='已红冲'), + ), + migrations.AddField( + model_name='paymentorder', + name='red_flush_id', + field=models.UUIDField(blank=True, db_index=True, null=True, verbose_name='红冲批次ID'), + ), + migrations.AddField( + model_name='paymentorder', + name='red_flushed_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='红冲时间'), + ), + migrations.AddField( + model_name='receiptorder', + name='is_red_flushed', + field=models.BooleanField(db_index=True, default=False, verbose_name='已红冲'), + ), + migrations.AddField( + model_name='receiptorder', + name='red_flush_id', + field=models.UUIDField(blank=True, db_index=True, null=True, verbose_name='红冲批次ID'), + ), + migrations.AddField( + model_name='receiptorder', + name='red_flushed_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='红冲时间'), + ), + migrations.AddField( + model_name='balancechangerecord', + name='red_flush_id', + field=models.UUIDField(blank=True, db_index=True, null=True, verbose_name='红冲批次ID'), + ), + ] diff --git a/business/models.py b/business/models.py index 9cf3589..db392ba 100644 --- a/business/models.py +++ b/business/models.py @@ -130,6 +130,9 @@ class PurchaseOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounte default=PurchaseOrderStatusEnum.PENDING, verbose_name='状态', ) + is_red_flushed = models.BooleanField(default=False, db_index=True, verbose_name='已红冲') + red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID') + red_flushed_at = models.DateTimeField(null=True, blank=True, verbose_name='红冲时间') from_pre_purchase_order_id = models.BigIntegerField( null=True, blank=True, @@ -277,6 +280,9 @@ class SalesOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounterpa default=SalesOrderStatusEnum.PENDING, verbose_name='状态', ) + is_red_flushed = models.BooleanField(default=False, db_index=True, verbose_name='已红冲') + red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID') + red_flushed_at = models.DateTimeField(null=True, blank=True, verbose_name='红冲时间') remarks = models.TextField(blank=True, null=True, verbose_name='备注') from_pre_sales_order_id = models.BigIntegerField( blank=True, @@ -786,6 +792,9 @@ class PurchaseReturnOrder(OrderItemsAggregationMixin, OrderDirectionMixin, Order default=PurchaseReturnStatusEnum.PENDING, verbose_name='状态', ) + is_red_flushed = models.BooleanField(default=False, db_index=True, verbose_name='已红冲') + red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID') + red_flushed_at = models.DateTimeField(null=True, blank=True, verbose_name='红冲时间') remarks = models.TextField(blank=True, null=True, verbose_name='备注') class Meta: @@ -911,6 +920,9 @@ class SalesReturnOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCou default=SalesReturnStatusEnum.PENDING, verbose_name='状态', ) + is_red_flushed = models.BooleanField(default=False, db_index=True, verbose_name='已红冲') + red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID') + red_flushed_at = models.DateTimeField(null=True, blank=True, verbose_name='红冲时间') remarks = models.TextField(blank=True, null=True, verbose_name='备注') class Meta: @@ -1098,6 +1110,9 @@ class PaymentOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase): default=PaymentOrderStatusEnum.PENDING, verbose_name='状态', ) + is_red_flushed = models.BooleanField(default=False, db_index=True, verbose_name='已红冲') + red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID') + red_flushed_at = models.DateTimeField(null=True, blank=True, verbose_name='红冲时间') is_external_source = models.BooleanField( default=False, verbose_name='是否外部来源', @@ -1181,6 +1196,9 @@ class ReceiptOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase): default=ReceiptOrderStatusEnum.PENDING, verbose_name='状态', ) + is_red_flushed = models.BooleanField(default=False, db_index=True, verbose_name='已红冲') + red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID') + red_flushed_at = models.DateTimeField(null=True, blank=True, verbose_name='红冲时间') is_external_source = models.BooleanField( default=False, verbose_name='是否外部来源', @@ -1324,6 +1342,7 @@ class BalanceChangeRecord(ModelBase): offset_id = models.BigIntegerField(null=True, blank=True, verbose_name='冲抵来源ID') cancelled = models.BooleanField(default=False, verbose_name='已被冲抵') cancelled_at = models.DateTimeField(null=True, blank=True, verbose_name='被冲抵时间') + red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID') class Meta: verbose_name = '余额变动记录' diff --git a/business/services.py b/business/services.py index 3b4828a..b246b5c 100644 --- a/business/services.py +++ b/business/services.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import uuid from collections import OrderedDict from datetime import date, datetime from decimal import Decimal, InvalidOperation, ROUND_HALF_UP @@ -34,8 +35,9 @@ class BalanceService: request_id: str | None = None, remarks: str | None = '', extra_meta: Dict[str, Any] | None = None, - ): - BalanceService._adjust_balance( + red_flush_id=None, + ) -> models.BalanceChangeRecord: + return BalanceService._adjust_balance( merchant=merchant, counterparty=supplier, balance_model=models.SupplierBalance, @@ -47,6 +49,7 @@ class BalanceService: request_id=request_id, remarks=remarks, extra_meta=extra_meta, + red_flush_id=red_flush_id, ) @staticmethod @@ -60,8 +63,9 @@ class BalanceService: request_id: str | None = None, remarks: str | None = '', extra_meta: Dict[str, Any] | None = None, - ): - BalanceService._adjust_balance( + red_flush_id=None, + ) -> models.BalanceChangeRecord: + return BalanceService._adjust_balance( merchant=merchant, counterparty=customer, balance_model=models.CustomerBalance, @@ -73,6 +77,7 @@ class BalanceService: request_id=request_id, remarks=remarks, extra_meta=extra_meta, + red_flush_id=red_flush_id, ) @staticmethod @@ -89,7 +94,8 @@ class BalanceService: request_id: str | None, remarks: str | None, extra_meta: Dict[str, Any] | None, - ): + red_flush_id, + ) -> models.BalanceChangeRecord: meta_payload = extra_meta or {} remarks_value = remarks or '' with transaction.atomic(): @@ -118,13 +124,14 @@ class BalanceService: 'request_id': request_id, 'remarks': remarks_value, 'extra_meta': meta_payload, + 'red_flush_id': red_flush_id, } if target_type == models.BalanceChangeTargetEnum.SUPPLIER: record_kwargs['supplier'] = counterparty else: record_kwargs['customer'] = counterparty - models.BalanceChangeRecord.objects.create(**record_kwargs) + return models.BalanceChangeRecord.objects.create(**record_kwargs) @staticmethod def get_customer_balance( @@ -1118,6 +1125,317 @@ def review_receipt_order( ) +def red_flush_purchase_order( + *, + merchant: basic_info_models.Merchant, + purchase_order: models.PurchaseOrder | None = None, + purchase_order_id: int | None = None, + red_flushed_by=None, + reason: str | None = '', +) -> models.PurchaseOrder: + order = _resolve_purchase_order_instance(purchase_order, purchase_order_id) + _ensure_order_merchant(order, merchant, '采购单') + return _red_flush_order_impl( + order=order, + order_model_cls=models.PurchaseOrder, + approved_status=models.PurchaseOrderStatusEnum.APPROVED, + balance_source_type=models.BalanceChangeSourceEnum.PURCHASE_ORDER, + stock_source_type=stock_models.StockChangeSourceEnum.PURCHASE, + counterparty_field='supplier', + error_label='采购单', + red_flushed_by=red_flushed_by, + reason=reason, + ) + + +def red_flush_sales_order( + *, + merchant: basic_info_models.Merchant, + sales_order: models.SalesOrder | None = None, + sales_order_id: int | None = None, + red_flushed_by=None, + reason: str | None = '', +) -> models.SalesOrder: + order = _resolve_sales_order_instance(sales_order, sales_order_id) + _ensure_order_merchant(order, merchant, '销售单') + return _red_flush_order_impl( + order=order, + order_model_cls=models.SalesOrder, + approved_status=models.SalesOrderStatusEnum.APPROVED, + balance_source_type=models.BalanceChangeSourceEnum.SALES_ORDER, + stock_source_type=stock_models.StockChangeSourceEnum.SALES, + counterparty_field='customer', + error_label='销售单', + red_flushed_by=red_flushed_by, + reason=reason, + ) + + +def red_flush_purchase_return_order( + *, + merchant: basic_info_models.Merchant, + purchase_return_order: models.PurchaseReturnOrder | None = None, + purchase_return_order_id: int | None = None, + red_flushed_by=None, + reason: str | None = '', +) -> models.PurchaseReturnOrder: + order = _resolve_purchase_return_order_instance(purchase_return_order, purchase_return_order_id) + _ensure_order_merchant(order, merchant, '采购退货单') + return _red_flush_order_impl( + order=order, + order_model_cls=models.PurchaseReturnOrder, + approved_status=models.PurchaseReturnStatusEnum.APPROVED, + balance_source_type=models.BalanceChangeSourceEnum.PURCHASE_RETURN_ORDER, + stock_source_type=stock_models.StockChangeSourceEnum.PURCHASE_RETURN, + counterparty_field='supplier', + error_label='采购退货单', + red_flushed_by=red_flushed_by, + reason=reason, + ) + + +def red_flush_sales_return_order( + *, + merchant: basic_info_models.Merchant, + sales_return_order: models.SalesReturnOrder | None = None, + sales_return_order_id: int | None = None, + red_flushed_by=None, + reason: str | None = '', +) -> models.SalesReturnOrder: + order = _resolve_sales_return_order_instance(sales_return_order, sales_return_order_id) + _ensure_order_merchant(order, merchant, '销售退货单') + return _red_flush_order_impl( + order=order, + order_model_cls=models.SalesReturnOrder, + approved_status=models.SalesReturnStatusEnum.APPROVED, + balance_source_type=models.BalanceChangeSourceEnum.SALES_RETURN_ORDER, + stock_source_type=stock_models.StockChangeSourceEnum.SALES_RETURN, + counterparty_field='customer', + error_label='销售退货单', + red_flushed_by=red_flushed_by, + reason=reason, + ) + + +def red_flush_payment_order( + *, + merchant: basic_info_models.Merchant, + payment_order: models.PaymentOrder | None = None, + payment_order_id: int | None = None, + red_flushed_by=None, + reason: str | None = '', +) -> models.PaymentOrder: + order = _resolve_payment_order_instance(payment_order, payment_order_id) + _ensure_order_merchant(order, merchant, '付款单') + return _red_flush_order_impl( + order=order, + order_model_cls=models.PaymentOrder, + approved_status=models.PaymentOrderStatusEnum.APPROVED, + balance_source_type=models.BalanceChangeSourceEnum.PAYMENT_ORDER, + stock_source_type=None, + counterparty_field='supplier', + error_label='付款单', + red_flushed_by=red_flushed_by, + reason=reason, + ) + + +def red_flush_receipt_order( + *, + merchant: basic_info_models.Merchant, + receipt_order: models.ReceiptOrder | None = None, + receipt_order_id: int | None = None, + red_flushed_by=None, + reason: str | None = '', +) -> models.ReceiptOrder: + order = _resolve_receipt_order_instance(receipt_order, receipt_order_id) + _ensure_order_merchant(order, merchant, '收款单') + return _red_flush_order_impl( + order=order, + order_model_cls=models.ReceiptOrder, + approved_status=models.ReceiptOrderStatusEnum.APPROVED, + balance_source_type=models.BalanceChangeSourceEnum.RECEIPT_ORDER, + stock_source_type=None, + counterparty_field='customer', + error_label='收款单', + red_flushed_by=red_flushed_by, + reason=reason, + ) + + +def _ensure_order_merchant(order, merchant: basic_info_models.Merchant, error_label: str) -> None: + if merchant is None: + raise ValueError('必须提供 merchant') + if order.merchant_id != merchant.id: + raise ValueError(f'{error_label}不属于当前商户') + + +def _red_flush_order_impl( + *, + order, + order_model_cls, + approved_status, + balance_source_type: models.BalanceChangeSourceEnum, + stock_source_type: stock_models.StockChangeSourceEnum | None, + counterparty_field: str, + error_label: str, + red_flushed_by=None, + reason: str | None = '', +): + reason_text = (reason or '').strip() or f'红冲{error_label}' + current_red_flush_id = uuid.uuid4() + + with transaction.atomic(): + locked_order = order_model_cls.objects.select_for_update().get(id=order.id) + if locked_order.status != approved_status: + raise ValueError(f'仅允许红冲已审批的{error_label}') + if locked_order.is_red_flushed: + raise ValueError(f'{error_label}已红冲,不能重复红冲') + _ensure_order_not_external_source(locked_order, error_label) + + original_balance_record = _get_original_balance_change_record( + merchant_id=locked_order.merchant_id, + source_type=balance_source_type, + source_id=locked_order.id, + error_label=error_label, + ) + + reverse_record = _create_reverse_balance_change_record( + original_record=original_balance_record, + order=locked_order, + counterparty_field=counterparty_field, + red_flush_id=current_red_flush_id, + reason=reason_text, + ) + + now = timezone.now() + original_balance_record.cancelled = True + original_balance_record.cancelled_at = now + original_balance_record.offset_id = reverse_record.id + original_balance_record.red_flush_id = current_red_flush_id + original_balance_record.save(update_fields=['cancelled', 'cancelled_at', 'offset_id', 'red_flush_id', 'updated_at']) + + reverse_record.offset_to = original_balance_record.id + reverse_record.offset_at = now + reverse_record.red_flush_id = current_red_flush_id + reverse_record.save(update_fields=['offset_to', 'offset_at', 'red_flush_id', 'updated_at']) + + if stock_source_type is not None: + _red_flush_stock_records_for_order( + order=locked_order, + stock_source_type=stock_source_type, + red_flush_id=current_red_flush_id, + red_flushed_by=red_flushed_by, + reason=reason_text, + error_label=error_label, + ) + + locked_order.is_red_flushed = True + locked_order.red_flush_id = current_red_flush_id + locked_order.red_flushed_at = now + locked_order.save(update_fields=['is_red_flushed', 'red_flush_id', 'red_flushed_at', 'updated_at']) + + locked_order.refresh_from_db() + return locked_order + + +def _ensure_order_not_external_source(order, error_label: str) -> None: + if getattr(order, 'is_external_source', False) or getattr(order, 'external_source_id', None): + raise ValueError(f'外部来源{error_label}不允许红冲') + + +def _get_original_balance_change_record( + *, + merchant_id: int, + source_type: models.BalanceChangeSourceEnum, + source_id: int, + error_label: str, +) -> models.BalanceChangeRecord: + qs = models.BalanceChangeRecord.objects.select_for_update().filter( + merchant_id=merchant_id, + source_type=source_type, + source_id=source_id, + offset_to__isnull=True, + cancelled=False, + ) + try: + return qs.get() + except models.BalanceChangeRecord.DoesNotExist as exc: + raise ValueError(f'{error_label}缺少可红冲的余额变动记录') from exc + except models.BalanceChangeRecord.MultipleObjectsReturned as exc: + raise ValueError(f'{error_label}存在多条可红冲的余额变动记录') from exc + + +def _create_reverse_balance_change_record( + *, + original_record: models.BalanceChangeRecord, + order, + counterparty_field: str, + red_flush_id, + reason: str, +) -> models.BalanceChangeRecord: + reverse_delta = -original_record.delta + remarks = f'红冲余额记录 {original_record.id}: {reason}' + extra_meta = { + 'red_flush_of_balance_change_record': original_record.id, + } + if counterparty_field == 'supplier': + return BalanceService.adjust_supplier_balance( + merchant=order.merchant, + supplier=order.supplier, + delta=reverse_delta, + source_type=original_record.source_type, + source_id=original_record.source_id, + remarks=remarks, + extra_meta=extra_meta, + red_flush_id=red_flush_id, + ) + if counterparty_field == 'customer': + return BalanceService.adjust_customer_balance( + merchant=order.merchant, + customer=order.customer, + delta=reverse_delta, + source_type=original_record.source_type, + source_id=original_record.source_id, + remarks=remarks, + extra_meta=extra_meta, + red_flush_id=red_flush_id, + ) + raise ValueError('未知的业务主体类型') + + +def _red_flush_stock_records_for_order( + *, + order, + stock_source_type: stock_models.StockChangeSourceEnum, + red_flush_id, + red_flushed_by, + reason: str, + error_label: str, +) -> None: + stock_records = list( + stock_models.StockChangeRecord.objects.select_for_update().filter( + merchant_id=order.merchant_id, + source_type=stock_source_type, + source_id=order.id, + ).order_by('id') + ) + if not stock_records: + raise ValueError(f'{error_label}缺少可红冲的库存记录') + + stock_service = StockFlowService(merchant=order.merchant, created_by=red_flushed_by) + for stock_record in stock_records: + stock_service.offset_stock_change( + source_record_id=stock_record.id, + reason=reason, + red_flush_id=red_flush_id, + extra_meta={ + 'business_order_type': order.__class__.__name__, + 'business_order_id': order.id, + }, + ) + + def _normalize_order_items( *, merchant: basic_info_models.Merchant, @@ -2468,4 +2786,3 @@ def _normalize_statement_amount(value) -> Decimal: def _decimal_to_string(value: Decimal) -> str: normalized = _normalize_statement_amount(value) return format(normalized, 'f') - diff --git a/business/tests/test_red_flush_services.py b/business/tests/test_red_flush_services.py new file mode 100644 index 0000000..27bb854 --- /dev/null +++ b/business/tests/test_red_flush_services.py @@ -0,0 +1,438 @@ +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils import timezone + +from basic_info import models as basic_models +from business import models as business_models, services +from stock import models as stock_models +from stock import services as stock_services + +from .fixtures import create_basic_fixtures, create_sales_fixtures + + +class BusinessRedFlushServiceTestCase(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user(username='red-flush', password='pass123') + + def _disable_auto_stock_tasks(self, merchant): + basic_models.MerchantSetting.objects.filter( + merchant=merchant, + key=basic_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS, + ).update(val_bool=False) + + def _complete_stock_record(self, record_id): + record = stock_models.StockChangeRecord.objects.get(id=record_id) + stock_services.make_stock_change_completed(record) + return record + + def _assert_balance_red_flushed(self, *, source_type, source_id, red_flush_id): + records = business_models.BalanceChangeRecord.objects.filter( + source_type=source_type, + source_id=source_id, + ).order_by('id') + self.assertEqual(records.count(), 2) + original = records[0] + reverse = records[1] + self.assertTrue(original.cancelled) + self.assertEqual(original.offset_id, reverse.id) + self.assertEqual(original.red_flush_id, red_flush_id) + self.assertEqual(reverse.offset_to, original.id) + self.assertEqual(reverse.red_flush_id, red_flush_id) + self.assertEqual(reverse.delta, -original.delta) + return original, reverse + + def _assert_stock_red_flushed(self, *, source_type, source_id, red_flush_id): + original = stock_models.StockChangeRecord.objects.get( + source_type=source_type, + source_id=source_id, + ) + reverse = stock_models.StockChangeRecord.objects.get( + source_type=stock_models.StockChangeSourceEnum.OFFSET, + source_id=original.id, + ) + self.assertEqual(original.red_flush_id, red_flush_id) + self.assertEqual(reverse.red_flush_id, red_flush_id) + self.assertTrue(original.is_reversed) + self.assertTrue(reverse.is_offset) + return original, reverse + + def test_red_flush_purchase_order_reverses_balance_and_stock(self): + merchant, supplier, _, warehouse, product, operator = create_basic_fixtures() + self._disable_auto_stock_tasks(merchant) + order = services.create_purchase_order( + merchant=merchant, + supplier=supplier, + order_date=timezone.now().date(), + warehouse=warehouse, + operator=operator, + items=[{'product_id': product.id, 'quantity': 40, 'num_of_rolls': 2, 'price': '10'}], + ) + services.review_purchase_order( + purchase_order=order, + target_status=business_models.PurchaseOrderStatusEnum.APPROVED, + reviewed_by=self.user, + ) + payload = services.create_purchase_order_stock_entries_sync( + purchase_order_id=order.id, + warehouse_id=warehouse.id, + items=services._build_stock_flow_items_from_order(order), + created_by_id=self.user.id, + ) + self._complete_stock_record(payload['stock_change_record_id']) + + flushed = services.red_flush_purchase_order( + merchant=merchant, + purchase_order=order, + red_flushed_by=self.user, + reason='采购单测试红冲', + ) + + self.assertEqual(flushed.status, business_models.PurchaseOrderStatusEnum.APPROVED) + self.assertTrue(flushed.is_red_flushed) + self.assertIsNotNone(flushed.red_flush_id) + balance = business_models.SupplierBalance.objects.get(merchant=merchant, supplier=supplier) + self.assertEqual(balance.balance, Decimal('0.00')) + self._assert_balance_red_flushed( + source_type=business_models.BalanceChangeSourceEnum.PURCHASE_ORDER, + source_id=order.id, + red_flush_id=flushed.red_flush_id, + ) + self._assert_stock_red_flushed( + source_type=stock_models.StockChangeSourceEnum.PURCHASE, + source_id=order.id, + red_flush_id=flushed.red_flush_id, + ) + + def test_red_flush_sales_order_reverses_balance_and_stock(self): + merchant, customer, _, warehouse, _, product, operator = create_sales_fixtures() + self._disable_auto_stock_tasks(merchant) + order = services.create_sales_order( + merchant=merchant, + customer=customer, + order_date=timezone.now().date(), + warehouse=warehouse, + operator=operator, + items=[{'product_id': product.id, 'quantity': 20, 'num_of_rolls': 1, 'price': '15'}], + ) + services.review_sales_order( + sales_order=order, + target_status=business_models.SalesOrderStatusEnum.APPROVED, + reviewed_by=self.user, + ) + payload = services.create_sales_order_stock_entries_sync( + sales_order_id=order.id, + warehouse_id=warehouse.id, + items=services._build_stock_flow_items_from_order(order), + created_by_id=self.user.id, + ) + self._complete_stock_record(payload['stock_change_record_id']) + + flushed = services.red_flush_sales_order(merchant=merchant, sales_order=order, red_flushed_by=self.user) + + self.assertTrue(flushed.is_red_flushed) + balance = business_models.CustomerBalance.objects.get(merchant=merchant, customer=customer) + self.assertEqual(balance.balance, Decimal('0.00')) + self._assert_balance_red_flushed( + source_type=business_models.BalanceChangeSourceEnum.SALES_ORDER, + source_id=order.id, + red_flush_id=flushed.red_flush_id, + ) + self._assert_stock_red_flushed( + source_type=stock_models.StockChangeSourceEnum.SALES, + source_id=order.id, + red_flush_id=flushed.red_flush_id, + ) + + def test_red_flush_purchase_return_order_reverses_balance_and_stock(self): + merchant, supplier, _, warehouse, product, operator = create_basic_fixtures() + self._disable_auto_stock_tasks(merchant) + order = services.create_purchase_return_order( + merchant=merchant, + supplier=supplier, + return_date=timezone.now().date(), + warehouse=warehouse, + operator=operator, + items=[{'product_id': product.id, 'quantity': 12, 'num_of_rolls': 1, 'price': '8'}], + ) + services.review_purchase_return_order( + purchase_return_order=order, + target_status=business_models.PurchaseReturnStatusEnum.APPROVED, + reviewed_by=self.user, + ) + payload = services.create_purchase_return_order_stock_entries_sync( + purchase_return_order_id=order.id, + warehouse_id=warehouse.id, + items=services._build_stock_flow_items_from_order(order), + created_by_id=self.user.id, + ) + self._complete_stock_record(payload['stock_change_record_id']) + + flushed = services.red_flush_purchase_return_order( + merchant=merchant, + purchase_return_order=order, + red_flushed_by=self.user, + ) + + self.assertTrue(flushed.is_red_flushed) + balance = business_models.SupplierBalance.objects.get(merchant=merchant, supplier=supplier) + self.assertEqual(balance.balance, Decimal('0.00')) + self._assert_balance_red_flushed( + source_type=business_models.BalanceChangeSourceEnum.PURCHASE_RETURN_ORDER, + source_id=order.id, + red_flush_id=flushed.red_flush_id, + ) + self._assert_stock_red_flushed( + source_type=stock_models.StockChangeSourceEnum.PURCHASE_RETURN, + source_id=order.id, + red_flush_id=flushed.red_flush_id, + ) + + def test_red_flush_sales_return_order_reverses_balance_and_stock(self): + merchant, customer, _, warehouse, _, product, operator = create_sales_fixtures() + self._disable_auto_stock_tasks(merchant) + order = services.create_sales_return_order( + merchant=merchant, + customer=customer, + return_date=timezone.now().date(), + warehouse=warehouse, + operator=operator, + items=[{'product_id': product.id, 'quantity': 12, 'num_of_rolls': 1, 'price': '8'}], + ) + services.review_sales_return_order( + sales_return_order=order, + target_status=business_models.SalesReturnStatusEnum.APPROVED, + reviewed_by=self.user, + ) + payload = services.create_sales_return_order_stock_entries_sync( + sales_return_order_id=order.id, + warehouse_id=warehouse.id, + items=services._build_stock_flow_items_from_order(order), + created_by_id=self.user.id, + ) + self._complete_stock_record(payload['stock_change_record_id']) + + flushed = services.red_flush_sales_return_order( + merchant=merchant, + sales_return_order=order, + red_flushed_by=self.user, + ) + + self.assertTrue(flushed.is_red_flushed) + balance = business_models.CustomerBalance.objects.get(merchant=merchant, customer=customer) + self.assertEqual(balance.balance, Decimal('0.00')) + self._assert_balance_red_flushed( + source_type=business_models.BalanceChangeSourceEnum.SALES_RETURN_ORDER, + source_id=order.id, + red_flush_id=flushed.red_flush_id, + ) + self._assert_stock_red_flushed( + source_type=stock_models.StockChangeSourceEnum.SALES_RETURN, + source_id=order.id, + red_flush_id=flushed.red_flush_id, + ) + + def test_red_flush_payment_order_reverses_balance_only(self): + merchant, supplier, _, _, _, operator = create_basic_fixtures() + order = services.create_payment_order( + merchant=merchant, + supplier=supplier, + payment_date=timezone.now().date(), + amount='30', + operator=operator, + ) + services.review_payment_order( + payment_order=order, + target_status=business_models.PaymentOrderStatusEnum.APPROVED, + reviewed_by=self.user, + ) + + flushed = services.red_flush_payment_order(merchant=merchant, payment_order=order, red_flushed_by=self.user) + + self.assertTrue(flushed.is_red_flushed) + balance = business_models.SupplierBalance.objects.get(merchant=merchant, supplier=supplier) + self.assertEqual(balance.balance, Decimal('0.00')) + self._assert_balance_red_flushed( + source_type=business_models.BalanceChangeSourceEnum.PAYMENT_ORDER, + source_id=order.id, + red_flush_id=flushed.red_flush_id, + ) + self.assertFalse(stock_models.StockChangeRecord.objects.filter(red_flush_id=flushed.red_flush_id).exists()) + + def test_red_flush_receipt_order_reverses_balance_only(self): + merchant, _, _, _, _, operator = create_basic_fixtures() + customer = basic_models.Customer.objects.create(merchant=merchant, name='红冲客户') + order = services.create_receipt_order( + merchant=merchant, + customer=customer, + receipt_date=timezone.now().date(), + amount='45', + operator=operator, + ) + services.review_receipt_order( + receipt_order=order, + target_status=business_models.ReceiptOrderStatusEnum.APPROVED, + reviewed_by=self.user, + ) + + flushed = services.red_flush_receipt_order(merchant=merchant, receipt_order=order, red_flushed_by=self.user) + + self.assertTrue(flushed.is_red_flushed) + balance = business_models.CustomerBalance.objects.get(merchant=merchant, customer=customer) + self.assertEqual(balance.balance, Decimal('0.00')) + self._assert_balance_red_flushed( + source_type=business_models.BalanceChangeSourceEnum.RECEIPT_ORDER, + source_id=order.id, + red_flush_id=flushed.red_flush_id, + ) + self.assertFalse(stock_models.StockChangeRecord.objects.filter(red_flush_id=flushed.red_flush_id).exists()) + + def test_red_flush_rejects_external_payment_order(self): + merchant, supplier, _, _, _, operator = create_basic_fixtures() + order = services.create_payment_order( + merchant=merchant, + supplier=supplier, + payment_date=timezone.now().date(), + amount='30', + operator=operator, + ) + order.is_external_source = True + order.external_source_id = 'XT-EXTERNAL-001' + order.save(update_fields=['is_external_source', 'external_source_id', 'updated_at']) + services.review_payment_order( + payment_order=order, + target_status=business_models.PaymentOrderStatusEnum.APPROVED, + reviewed_by=self.user, + ) + + with self.assertRaisesMessage(ValueError, '外部来源付款单不允许红冲'): + services.red_flush_payment_order(merchant=merchant, payment_order=order, red_flushed_by=self.user) + + order.refresh_from_db() + self.assertFalse(order.is_red_flushed) + + def test_red_flush_rejects_external_receipt_order(self): + merchant, _, _, _, _, operator = create_basic_fixtures() + customer = basic_models.Customer.objects.create(merchant=merchant, name='外部红冲客户') + order = services.create_receipt_order( + merchant=merchant, + customer=customer, + receipt_date=timezone.now().date(), + amount='45', + operator=operator, + ) + order.external_source_id = 'SK-EXTERNAL-001' + order.save(update_fields=['external_source_id', 'updated_at']) + services.review_receipt_order( + receipt_order=order, + target_status=business_models.ReceiptOrderStatusEnum.APPROVED, + reviewed_by=self.user, + ) + + with self.assertRaisesMessage(ValueError, '外部来源收款单不允许红冲'): + services.red_flush_receipt_order(merchant=merchant, receipt_order=order, red_flushed_by=self.user) + + order.refresh_from_db() + self.assertFalse(order.is_red_flushed) + + def test_red_flush_rejects_non_approved_order(self): + merchant, supplier, _, warehouse, product, operator = create_basic_fixtures() + order = services.create_purchase_order( + merchant=merchant, + supplier=supplier, + order_date=timezone.now().date(), + warehouse=warehouse, + operator=operator, + items=[{'product_id': product.id, 'quantity': 10, 'num_of_rolls': 1, 'price': '5'}], + ) + + with self.assertRaises(ValueError): + services.red_flush_purchase_order(merchant=merchant, purchase_order=order, red_flushed_by=self.user) + + def test_red_flush_rejects_repeated_order(self): + merchant, supplier, _, _, _, operator = create_basic_fixtures() + order = services.create_payment_order( + merchant=merchant, + supplier=supplier, + payment_date=timezone.now().date(), + amount='10', + operator=operator, + ) + services.review_payment_order( + payment_order=order, + target_status=business_models.PaymentOrderStatusEnum.APPROVED, + ) + services.red_flush_payment_order(merchant=merchant, payment_order=order, red_flushed_by=self.user) + + with self.assertRaises(ValueError): + services.red_flush_payment_order(merchant=merchant, payment_order=order, red_flushed_by=self.user) + + def test_red_flush_rejects_missing_balance_record(self): + merchant, supplier, _, _, _, operator = create_basic_fixtures() + order = services.create_payment_order( + merchant=merchant, + supplier=supplier, + payment_date=timezone.now().date(), + amount='10', + operator=operator, + ) + order.status = business_models.PaymentOrderStatusEnum.APPROVED + order.save(update_fields=['status']) + + with self.assertRaises(ValueError): + services.red_flush_payment_order(merchant=merchant, payment_order=order, red_flushed_by=self.user) + + def test_red_flush_rolls_back_balance_when_stock_missing(self): + merchant, supplier, _, warehouse, product, operator = create_basic_fixtures() + self._disable_auto_stock_tasks(merchant) + order = services.create_purchase_order( + merchant=merchant, + supplier=supplier, + order_date=timezone.now().date(), + warehouse=warehouse, + operator=operator, + items=[{'product_id': product.id, 'quantity': 10, 'num_of_rolls': 1, 'price': '5'}], + ) + services.review_purchase_order( + purchase_order=order, + target_status=business_models.PurchaseOrderStatusEnum.APPROVED, + ) + + with self.assertRaises(ValueError): + services.red_flush_purchase_order(merchant=merchant, purchase_order=order, red_flushed_by=self.user) + + order.refresh_from_db() + self.assertFalse(order.is_red_flushed) + balance_records = business_models.BalanceChangeRecord.objects.filter( + source_type=business_models.BalanceChangeSourceEnum.PURCHASE_ORDER, + source_id=order.id, + ) + self.assertEqual(balance_records.count(), 1) + self.assertFalse(balance_records.first().cancelled) + + def test_red_flush_rejects_cross_merchant_order(self): + merchant, supplier, _, _, _, operator = create_basic_fixtures() + other_merchant = basic_models.Merchant.objects.create( + name='其他商户', + type=basic_models.MerchantTypeEnum.FACTORY, + ) + order = services.create_payment_order( + merchant=merchant, + supplier=supplier, + payment_date=timezone.now().date(), + amount='10', + operator=operator, + ) + services.review_payment_order( + payment_order=order, + target_status=business_models.PaymentOrderStatusEnum.APPROVED, + ) + + with self.assertRaises(ValueError): + services.red_flush_payment_order( + merchant=other_merchant, + payment_order_id=order.id, + red_flushed_by=self.user, + ) diff --git a/business/tests/test_service_edge_cases.py b/business/tests/test_service_edge_cases.py new file mode 100644 index 0000000..d47970e --- /dev/null +++ b/business/tests/test_service_edge_cases.py @@ -0,0 +1,343 @@ +from django.test import TestCase +from django.utils import timezone + +from basic_info import models as basic_models +from business import models as business_models, services +from stock import models as stock_models + +from .fixtures import create_basic_fixtures, create_sales_fixtures + + +class BusinessServiceEdgeCaseTestCase(TestCase): + def _disable_auto_stock_tasks(self, merchant): + basic_models.MerchantSetting.objects.filter( + merchant=merchant, + key=basic_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS, + ).update(val_bool=False) + + def test_sales_order_kind_and_date_validation_errors(self): + merchant, customer, _, warehouse, _, product, operator = create_sales_fixtures() + with self.assertRaises(ValueError): + services.create_sales_order( + merchant=merchant, + customer=customer, + order_date=timezone.now().date(), + warehouse=warehouse, + operator=operator, + items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}], + kind='invalid', + ) + with self.assertRaises(ValueError): + services.create_sales_order( + merchant=merchant, + customer=customer, + order_date=timezone.now().date(), + warehouse=warehouse, + operator=operator, + items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}], + kind=999, + ) + with self.assertRaises(ValueError): + services.create_sales_order( + merchant=merchant, + customer=customer, + order_date='not-a-date', + warehouse=warehouse, + operator=operator, + items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}], + ) + with self.assertRaises(ValueError): + services.create_sales_order( + merchant=merchant, + customer=customer, + order_date=object(), + warehouse=warehouse, + operator=operator, + items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}], + ) + + def test_return_order_source_validation_errors(self): + merchant, supplier, _, warehouse, product, operator = create_basic_fixtures() + other_supplier = basic_models.Supplier.objects.create(merchant=merchant, name='其他供应商') + purchase_order = services.create_purchase_order( + merchant=merchant, + supplier=supplier, + order_date=timezone.now().date(), + warehouse=warehouse, + operator=operator, + items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}], + ) + with self.assertRaises(ValueError): + services.create_purchase_return_order( + merchant=merchant, + supplier=supplier, + return_date=timezone.now().date(), + warehouse=warehouse, + operator=operator, + purchase_order_id=999999, + items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}], + ) + with self.assertRaises(ValueError): + services.create_purchase_return_order( + merchant=merchant, + supplier=other_supplier, + return_date=timezone.now().date(), + warehouse=warehouse, + operator=operator, + purchase_order=purchase_order, + items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}], + ) + + sales_merchant, customer, _, sales_warehouse, _, sales_product, sales_operator = create_sales_fixtures() + other_customer = basic_models.Customer.objects.create(merchant=sales_merchant, name='其他客户') + sales_order = services.create_sales_order( + merchant=sales_merchant, + customer=customer, + order_date=timezone.now().date(), + warehouse=sales_warehouse, + operator=sales_operator, + items=[{'product_id': sales_product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}], + ) + with self.assertRaises(ValueError): + services.create_sales_return_order( + merchant=sales_merchant, + customer=customer, + return_date=timezone.now().date(), + warehouse=sales_warehouse, + operator=sales_operator, + sales_order_id=999999, + items=[{'product_id': sales_product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}], + ) + with self.assertRaises(ValueError): + services.create_sales_return_order( + merchant=sales_merchant, + customer=other_customer, + return_date=timezone.now().date(), + warehouse=sales_warehouse, + operator=sales_operator, + sales_order=sales_order, + items=[{'product_id': sales_product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}], + ) + + def test_empty_items_and_update_source_validation_errors(self): + merchant, supplier, _, warehouse, product, operator = create_basic_fixtures() + purchase_order = services.create_purchase_order( + merchant=merchant, + supplier=supplier, + order_date=timezone.now().date(), + warehouse=warehouse, + operator=operator, + items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}], + ) + with self.assertRaises(ValueError): + services.update_purchase_order(purchase_order=purchase_order, items=[]) + updated = services.update_purchase_order( + purchase_order=purchase_order, + items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}], + from_pre_purchase_order_id=123, + ) + self.assertEqual(updated.from_pre_purchase_order_id, 123) + sales_merchant, customer, _, sales_warehouse, _, sales_product, sales_operator = create_sales_fixtures() + with self.assertRaises(ValueError): + services.create_sales_order( + merchant=sales_merchant, + customer=customer, + order_date=timezone.now().date(), + warehouse=sales_warehouse, + operator=sales_operator, + items=[], + ) + with self.assertRaises(ValueError): + services.create_purchase_return_order( + merchant=merchant, + supplier=supplier, + return_date=timezone.now().date(), + warehouse=warehouse, + operator=operator, + items=[], + ) + + return_order = services.create_purchase_return_order( + merchant=merchant, + supplier=supplier, + return_date=timezone.now().date(), + warehouse=warehouse, + operator=operator, + items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}], + ) + return_order.purchase_order = None + return_order.save(update_fields=['purchase_order']) + with self.assertRaises(ValueError): + services.update_purchase_return_order( + purchase_return_order=return_order, + purchase_order_id=999999, + items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}], + ) + + with self.assertRaises(ValueError): + services.create_sales_return_order( + merchant=sales_merchant, + customer=customer, + return_date=timezone.now().date(), + warehouse=sales_warehouse, + operator=sales_operator, + items=[], + ) + + def test_review_invalid_targets_and_cancelled_reapprove_errors(self): + merchant, supplier, _, warehouse, product, operator = create_basic_fixtures() + purchase_order = services.create_purchase_order( + merchant=merchant, + supplier=supplier, + order_date=timezone.now().date(), + warehouse=warehouse, + operator=operator, + items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}], + ) + with self.assertRaises(ValueError): + services.review_purchase_order(purchase_order=purchase_order, target_status=999) + services.review_purchase_order( + purchase_order=purchase_order, + target_status=business_models.PurchaseOrderStatusEnum.CANCELLED, + ) + with self.assertRaises(ValueError): + services.review_purchase_order( + purchase_order=purchase_order, + target_status=business_models.PurchaseOrderStatusEnum.APPROVED, + ) + + payment = services.create_payment_order( + merchant=merchant, + supplier=supplier, + payment_date=timezone.now().date(), + amount='1', + operator=operator, + ) + services.review_payment_order(payment_order=payment, target_status=business_models.PaymentOrderStatusEnum.CANCELLED) + with self.assertRaises(ValueError): + services.review_payment_order(payment_order=payment, target_status=business_models.PaymentOrderStatusEnum.APPROVED) + + def test_resolve_helpers_require_instance_or_id(self): + with self.assertRaises(ValueError): + services._resolve_purchase_order_instance(None, None) + with self.assertRaises(ValueError): + services._resolve_sales_order_instance(None, None) + with self.assertRaises(ValueError): + services._resolve_purchase_return_order_instance(None, None) + with self.assertRaises(ValueError): + services._resolve_sales_return_order_instance(None, None) + with self.assertRaises(ValueError): + services._resolve_payment_order_instance(None, None) + with self.assertRaises(ValueError): + services._resolve_receipt_order_instance(None, None) + + def test_stock_entry_sync_not_found_payloads(self): + self.assertEqual( + services.create_purchase_order_stock_entries_sync( + purchase_order_id=999999, + warehouse_id=1, + items=[], + )['error'], + 'purchase_order_not_found', + ) + self.assertEqual( + services.create_sales_order_stock_entries_sync( + sales_order_id=999999, + warehouse_id=1, + items=[], + )['error'], + 'sales_order_not_found', + ) + self.assertEqual( + services.create_purchase_return_order_stock_entries_sync( + purchase_return_order_id=999999, + warehouse_id=1, + items=[], + )['error'], + 'purchase_return_order_not_found', + ) + self.assertEqual( + services.create_sales_return_order_stock_entries_sync( + sales_return_order_id=999999, + warehouse_id=1, + items=[], + )['error'], + 'sales_return_order_not_found', + ) + + def test_build_stock_flow_items_error_branches(self): + merchant, supplier, warehouse_strict, _, product, operator = create_basic_fixtures() + order = services.create_purchase_order( + merchant=merchant, + supplier=supplier, + order_date=timezone.now().date(), + warehouse=warehouse_strict, + operator=operator, + items=[{'product_id': product.id, 'numbers': [1], 'price': '1'}], + ) + order.items.all().delete() + with self.assertRaises(ValueError): + services._build_stock_flow_items_from_order(order) + + order = services.create_purchase_order( + merchant=merchant, + supplier=supplier, + order_date=timezone.now().date(), + warehouse=warehouse_strict, + operator=operator, + items=[{'product_id': product.id, 'numbers': [1], 'price': '1'}], + ) + item = order.items.first() + item.quantity_of_rolls = '' + item.save(update_fields=['quantity_of_rolls']) + with self.assertRaises(ValueError): + services._build_stock_flow_items_from_order(order) + + def test_bind_purchase_order_stock_change_record_error_branches(self): + merchant, supplier, warehouse_strict, warehouse_relaxed, product, operator = create_basic_fixtures() + self._disable_auto_stock_tasks(merchant) + order = services.create_purchase_order( + merchant=merchant, + supplier=supplier, + order_date=timezone.now().date(), + warehouse=warehouse_strict, + operator=operator, + items=[{'product_id': product.id, 'numbers': [1], 'price': '1'}], + ) + + bound_record = stock_models.StockChangeRecord.objects.create( + merchant=merchant, + type=stock_models.StockChangeTypeEnum.ADD, + warehouse=warehouse_strict, + source_type=stock_models.StockChangeSourceEnum.PURCHASE, + source_id=123, + ) + with self.assertRaises(ValueError): + services.bind_purchase_order_stock_change_record( + purchase_order_id=order.id, + stock_change_record_id=bound_record.id, + ) + + wrong_warehouse_record = stock_models.StockChangeRecord.objects.create( + merchant=merchant, + type=stock_models.StockChangeTypeEnum.ADD, + warehouse=warehouse_relaxed, + source_type=stock_models.StockChangeSourceEnum.PURCHASE, + ) + with self.assertRaises(ValueError): + services.bind_purchase_order_stock_change_record( + purchase_order_id=order.id, + stock_change_record_id=wrong_warehouse_record.id, + ) + + sales_source_record = stock_models.StockChangeRecord.objects.create( + merchant=merchant, + type=stock_models.StockChangeTypeEnum.ADD, + warehouse=warehouse_strict, + source_type=stock_models.StockChangeSourceEnum.SALES_RETURN, + ) + with self.assertRaises(ValueError): + services.bind_purchase_order_stock_change_record( + purchase_order_id=order.id, + stock_change_record_id=sales_source_record.id, + ) diff --git a/business/tests/test_statement_services.py b/business/tests/test_statement_services.py new file mode 100644 index 0000000..3e0c753 --- /dev/null +++ b/business/tests/test_statement_services.py @@ -0,0 +1,165 @@ +from decimal import Decimal + +from django.test import TestCase +from django.utils import timezone + +from basic_info import models as basic_models +from business import models as business_models, services + +from .fixtures import create_basic_fixtures, create_sales_fixtures + + +class BusinessStatementServiceTestCase(TestCase): + def _disable_auto_stock_tasks(self, merchant): + basic_models.MerchantSetting.objects.filter( + merchant=merchant, + key=basic_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS, + ).update(val_bool=False) + + def test_build_customer_statement_collects_all_customer_records(self): + merchant, customer, warehouse_strict, warehouse_relaxed, _, product, operator = create_sales_fixtures() + self._disable_auto_stock_tasks(merchant) + today = timezone.now().date() + + sales_order = services.create_sales_order( + merchant=merchant, + customer=customer, + order_date=today, + warehouse=warehouse_strict, + operator=operator, + items=[{'product_id': product.id, 'numbers': [6, 4], 'price': '10', 'unit': '米'}], + remarks='销售对账', + ) + services.review_sales_order( + sales_order=sales_order, + target_status=business_models.SalesOrderStatusEnum.APPROVED, + ) + + return_order = services.create_sales_return_order( + merchant=merchant, + customer=customer, + return_date=today, + warehouse=warehouse_relaxed, + operator=operator, + items=[{'product_id': product.id, 'quantity': 3, 'num_of_rolls': 1, 'price': '10'}], + remarks='销退对账', + ) + services.review_sales_return_order( + sales_return_order=return_order, + target_status=business_models.SalesReturnStatusEnum.APPROVED, + ) + + receipt_order = services.create_receipt_order( + merchant=merchant, + customer=customer, + receipt_date=today, + amount='20', + discount_amount='2', + operator=operator, + remarks='收款对账', + ) + services.review_receipt_order( + receipt_order=receipt_order, + target_status=business_models.ReceiptOrderStatusEnum.APPROVED, + ) + + business_models.ExternalCustomerStatementOrder.objects.create( + merchant=merchant, + customer=customer, + category=business_models.ExternalCustomerStatementCategoryEnum.SALE, + external_source_id='EXT-SALE-1', + occurred_at=today, + total_amount=Decimal('50'), + sf_amount=Decimal('5'), + zk_amount=Decimal('3'), + items_payload=[{'product_name': '外部销售'}], + ) + business_models.ExternalCustomerStatementOrder.objects.create( + merchant=merchant, + customer=customer, + category=business_models.ExternalCustomerStatementCategoryEnum.SALE_RETURN, + external_source_id='EXT-RETURN-1', + occurred_at=today, + total_amount=Decimal('7'), + zk_amount=Decimal('1'), + items_payload=[{'product_name': '外部退货'}], + ) + + payload = services.build_customer_statement(merchant=merchant, customer=customer) + + source_types = {record['source_type'] for record in payload['records']} + self.assertEqual( + source_types, + { + 'sales_order', + 'sales_return_order', + 'receipt_order', + 'external_sales_order', + 'external_sales_return_order', + }, + ) + sales_record = next(record for record in payload['records'] if record['source_type'] == 'sales_order') + self.assertEqual(sales_record['items'][0]['quantity'], Decimal('10')) + self.assertEqual(sales_record['items'][0]['quantity_of_rolls'], [6, 4]) + receipt_record = next(record for record in payload['records'] if record['source_type'] == 'receipt_order') + self.assertEqual(receipt_record['positive_amount'], Decimal('-2.00')) + self.assertEqual(receipt_record['negative_amount'], Decimal('20.00')) + summary = services.build_statement_summary(payload) + self.assertIn('positive_total', summary) + self.assertIn('negative_total', summary) + + def test_build_supplier_statement_collects_all_supplier_records(self): + merchant, supplier, warehouse_strict, warehouse_relaxed, product, operator = create_basic_fixtures() + self._disable_auto_stock_tasks(merchant) + today = timezone.now().date() + + purchase_order = services.create_purchase_order( + merchant=merchant, + supplier=supplier, + order_date=today, + warehouse=warehouse_strict, + operator=operator, + items=[{'product_id': product.id, 'numbers': [8, 2], 'price': '9', 'unit': '米'}], + remarks='采购对账', + ) + services.review_purchase_order( + purchase_order=purchase_order, + target_status=business_models.PurchaseOrderStatusEnum.APPROVED, + ) + + return_order = services.create_purchase_return_order( + merchant=merchant, + supplier=supplier, + return_date=today, + warehouse=warehouse_relaxed, + operator=operator, + items=[{'product_id': product.id, 'quantity': 4, 'num_of_rolls': 1, 'price': '9'}], + remarks='采退对账', + ) + services.review_purchase_return_order( + purchase_return_order=return_order, + target_status=business_models.PurchaseReturnStatusEnum.APPROVED, + ) + + payment_order = services.create_payment_order( + merchant=merchant, + supplier=supplier, + payment_date=today, + amount='15', + operator=operator, + remarks='付款对账', + ) + services.review_payment_order( + payment_order=payment_order, + target_status=business_models.PaymentOrderStatusEnum.APPROVED, + ) + + payload = services.build_supplier_statement(merchant=merchant, supplier=supplier) + + source_types = {record['source_type'] for record in payload['records']} + self.assertEqual(source_types, {'purchase_order', 'purchase_return_order', 'payment_order'}) + purchase_record = next(record for record in payload['records'] if record['source_type'] == 'purchase_order') + self.assertEqual(purchase_record['items'][0]['quantity'], Decimal('10')) + self.assertEqual(purchase_record['items'][0]['num_of_rolls'], 2) + payment_record = next(record for record in payload['records'] if record['source_type'] == 'payment_order') + self.assertEqual(payment_record['negative_amount'], Decimal('15.00')) diff --git a/docs/2026-06-12_business_red_flush_api.md b/docs/2026-06-12_business_red_flush_api.md new file mode 100644 index 0000000..2d00c72 --- /dev/null +++ b/docs/2026-06-12_business_red_flush_api.md @@ -0,0 +1,75 @@ +# Business Red-Flush API + +## Scope + +Approved internal business orders can be red-flushed through dedicated endpoints. The first version supports whole-order red flush only. + +External-source data must not participate in red flush. If an order has `is_external_source=true` or a non-empty `external_source_id`, the service rejects the red-flush operation even when the API request is otherwise valid. + +## Endpoints + +All endpoints require authentication and reuse the existing business order review/cancel employee permission checks. + +| Order type | Method | Path | +| --- | --- | --- | +| Purchase order | POST | `/api/v1/purchase-orders/{id}/red-flush/` | +| Sales order | POST | `/api/v1/sales-orders/{id}/red-flush/` | +| Purchase return order | POST | `/api/v1/purchase-return-orders/{id}/red-flush/` | +| Sales return order | POST | `/api/v1/sales-return-orders/{id}/red-flush/` | +| Payment order | POST | `/api/v1/payment-orders/{id}/red-flush/` | +| Receipt order | POST | `/api/v1/receipt-orders/{id}/red-flush/` | + +## Request Body + +```json +{ + "reason": "录入错误,需要红冲" +} +``` + +`reason` is required and cannot be blank at the API layer. + +## Success Response + +Returns the refreshed order serializer with HTTP 200. + +Important red-flush fields included in responses: + +| Field | Description | +| --- | --- | +| `is_red_flushed` | Whether the source order has been red-flushed | +| `red_flush_id` | UUID audit batch ID for this red flush | +| `red_flushed_at` | Timestamp when the source order was marked red-flushed | + +Example: + +```json +{ + "id": 123, + "status": 2, + "is_red_flushed": true, + "red_flush_id": "1c8e4c7a-6f2a-4d6b-8f8e-674a2d6b67f4", + "red_flushed_at": "2026-06-12T10:00:00Z" +} +``` + +The source order `status` remains approved after red flush. + +## Error Responses + +| Condition | Status | Response shape | +| --- | --- | --- | +| Missing or blank `reason` | 400 | DRF serializer error, keyed by `reason` | +| Order not found in current merchant | 404 | Existing API not-found response | +| Order is not approved | 400 | `{ "error": "..." }` | +| Order already red-flushed | 400 | `{ "error": "..." }` | +| Order is external-source data | 400 | `{ "error": "外部来源...不允许红冲" }` | +| Service validation failure | 400 | `{ "error": "..." }` | + +## Merchant Safety + +The API first scopes order lookup by `request.user.employee.merchant`. The service layer also requires `merchant` and verifies that the order belongs to that merchant. + +## Audit Notes + +`red_flush_id` is written to the source order, related `BalanceChangeRecord` rows, and related `StockChangeRecord` rows when inventory is involved. Existing `offset_to`/`offset_id` relationships remain the precise reverse-link mechanism for balance and stock records. diff --git a/docs/2026-06-12_business_red_flush_design.md b/docs/2026-06-12_business_red_flush_design.md new file mode 100644 index 0000000..0d187f0 --- /dev/null +++ b/docs/2026-06-12_business_red_flush_design.md @@ -0,0 +1,159 @@ +# Business 单据红冲服务设计备查 + +日期:2026-06-12 + +## 目标 + +在 `business` 模块为已审核正式单据增加整单红冲能力。第一阶段只实现 service 和测试,不新增 API。 + +## 已确认口径 + +- 红冲后原单据 `status` 保持 `APPROVED`,不回退、不作废。 +- 原单据增加“已红冲”标记,用于列表查询提速。 +- 每次红冲由 service 自动生成一个 UUID 类型的 `red_flush_id`。 +- `red_flush_id` 用于跨表、跨记录追踪同一次红冲涉及的所有数据,是审计关联批次 ID。 +- 不新增 `red_flush_no`。该编号只适合人工展示,目前无需求。 +- 第一版只支持整单红冲,不支持部分红冲。 +- API 在 service 与测试完成、覆盖率达标后再增加;API 层红冲原因必填。 +- API 权限复用现有审批/作废权限,不新增独立红冲权限。 +- 多商户隔离必须下沉到 service。红冲 service 调用方必须传入当前 `merchant`,service 在解析单据后校验单据所属商户。 + +## 字段方案 + +6 类正式业务单据增加: + +- `is_red_flushed`: 是否已红冲,布尔值,建索引。 +- `red_flush_id`: 红冲批次 UUID,可空,建索引。 +- `red_flushed_at`: 红冲时间,可空。 + +覆盖单据: + +- `PurchaseOrder` +- `SalesOrder` +- `PurchaseReturnOrder` +- `SalesReturnOrder` +- `PaymentOrder` +- `ReceiptOrder` + +`BalanceChangeRecord` 增加: + +- `red_flush_id`: 红冲批次 UUID,可空,建索引。 + +`StockChangeRecord` 增加: + +- `red_flush_id`: 红冲批次 UUID,可空,建索引。 + +暂不在 `StockSnapshot` 增加 `red_flush_id`,因为快照已有 `offset_to/offset_id/cancelled` 精确匹配关系,且可以通过 `StockChangeRecord.red_flush_id` 查到相关快照。 + +## 关系标记规则 + +一次红冲生成一个 `red_flush_id`,并在同一个事务中写入相关数据。 + +原业务单据: + +- `is_red_flushed=True` +- `red_flush_id=<本次 UUID>` +- `red_flushed_at=<当前时间>` + +原余额变动记录: + +- `cancelled=True` +- `cancelled_at=<当前时间>` +- `offset_id=<反向余额记录 ID>` +- `red_flush_id=<本次 UUID>` + +反向余额变动记录: + +- `offset_to=<原余额记录 ID>` +- `offset_at=<当前时间>` +- `red_flush_id=<本次 UUID>` + +原库存记录: + +- `red_flush_id=<本次 UUID>` + +反向库存记录: + +- `source_type=OFFSET` +- `source_id=<原库存记录 ID>` +- `red_flush_id=<本次 UUID>` + +库存快照继续复用现有关系: + +- 原快照:`cancelled=True`、`cancelled_at`、`offset_id=<反向快照 ID>` +- 反向快照:`offset_to=<原快照 ID>`、`offset_at` + +## Service 方案 + +新增业务红冲入口: + +- `red_flush_purchase_order(...)` +- `red_flush_sales_order(...)` +- `red_flush_purchase_return_order(...)` +- `red_flush_sales_return_order(...)` +- `red_flush_payment_order(...)` +- `red_flush_receipt_order(...)` + +内部复用统一实现,避免 6 类单据逻辑分叉。 + +校验规则: + +- 必须提供当前 `merchant`。 +- 单据必须属于当前 `merchant`。 +- 只允许 `APPROVED` 单据红冲。 +- `is_red_flushed=True` 的单据不能重复红冲。 +- 原始余额变动记录必须存在且未被冲抵。 +- 有库存影响的单据必须找到对应库存记录,库存记录必须已完成且未被红冲。 +- 资金和库存任一红冲失败,整体事务回滚。 + +资金侧实现: + +- 基于原 `BalanceChangeRecord.delta` 创建反向余额变动,`delta=-original.delta`。 +- 使用现有 `BalanceService.adjust_supplier_balance()` / `adjust_customer_balance()` 写余额与记录。 +- 调整 `BalanceService` 让它返回新创建的 `BalanceChangeRecord`,方便写 `offset_to/offset_id/red_flush_id`。 + +库存侧实现: + +- 复用 `StockFlowService.offset_stock_change()`。 +- 扩展其参数 `red_flush_id=None`。 +- 该方法负责把 `red_flush_id` 写入原库存记录和反向库存记录。 + +## 测试要求 + +先跑现有 `business.tests` baseline。 + +新增 service 测试覆盖: + +- 采购单整单红冲:余额反向、库存反向、单据标记、批次 ID 写入。 +- 销售单整单红冲:余额反向、库存反向、单据标记、批次 ID 写入。 +- 采购退货单整单红冲。 +- 销售退货单整单红冲。 +- 付款单整单红冲。 +- 收款单整单红冲。 +- 非已审核单据不能红冲。 +- 已红冲单据不能重复红冲。 +- 缺少原始余额记录时报错。 +- 库存记录未完成或已红冲时报错。 +- 事务回滚场景。 + +目标:`business` 模块覆盖率 90%+。 + +## 测试命令 + +开发环境在容器内运行测试,并绕过 PgBouncer: + +```bash +docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web \ + uv run python manage.py test business.tests --keepdb --noinput +``` + +覆盖率: + +```bash +docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web \ + uv run coverage run --source=business manage.py test business.tests --keepdb --noinput +``` + +```bash +docker compose exec -T web uv run coverage report -m +``` diff --git a/docs/2026-06-12_business_reverse_audit.md b/docs/2026-06-12_business_reverse_audit.md new file mode 100644 index 0000000..c397fd8 --- /dev/null +++ b/docs/2026-06-12_business_reverse_audit.md @@ -0,0 +1,158 @@ +# Business 模块已审核单据反向能力审计 + +审计日期:2026-06-12 + +## 结论 + +`business` 模块的正式单据在 `status = APPROVED` 后,没有发现可将原单据改回审批中、撤销审核、反审或直接红冲原单据的 service/API。 + +已审核单据不能作废这一点由统一作废逻辑实现:`_cancel_order_impl()` 在目标单据已审批时直接抛错。 + +但存在“通过新单据抵消业务影响”的能力: + +- 采购单、销售单:可通过新建并审核采购退货单/销售退货单形成库存和应收/应付反向影响。 +- 付款单、收款单:创建时允许负金额,用于退款场景,审核后形成与正向付款/收款相反的余额影响。 +- 余额变动记录模型有冲抵字段,但当前 service 未使用,未形成可调用的冲抵能力。 + +## 审计范围 + +核心文件: + +- `business/services.py` +- `business/models.py` +- `api_v1/views/business/*/views.py` + +正式单据类型: + +- 采购单 `PurchaseOrder` +- 销售单 `SalesOrder` +- 采购退货单 `PurchaseReturnOrder` +- 销售退货单 `SalesReturnOrder` +- 付款单 `PaymentOrder` +- 收款单 `ReceiptOrder` + +## 已审核后禁止作废 + +所有正式单据的作废最终进入 `business/services.py::_cancel_order_impl()`: + +```python +if locked.status == approved_status: + raise ValueError(f'已审批的{error_label}无法作废') +``` + +覆盖的入口: + +- `review_purchase_order(..., target_status=CANCELLED)` +- `review_sales_order(..., target_status=CANCELLED)` +- `review_purchase_return_order(..., target_status=CANCELLED)` +- `review_sales_return_order(..., target_status=CANCELLED)` +- `review_payment_order(..., target_status=CANCELLED)` +- `review_receipt_order(..., target_status=CANCELLED)` + +API 层只暴露 `approve` 和 `cancel` 两类 action,没有发现 `unapprove`、`reverse`、`red_flush` 等动作。 + +## 已审核后的反向能力 + +| 原单据 | 是否可直接撤销/反审 | 是否有业务反向单据 | 说明 | +| --- | --- | --- | --- | +| 采购单 | 否 | 有,采购退货单 | 采购退货单审核后出库,并减少供应商应付。 | +| 销售单 | 否 | 有,销售退货单 | 销售退货单审核后入库,并减少客户应收。 | +| 采购退货单 | 否 | 未发现专用反向单据 | 只能再做新的采购单产生正向影响。 | +| 销售退货单 | 否 | 未发现专用反向单据 | 只能再做新的销售单产生正向影响。 | +| 付款单 | 否 | 无专用反向单据,但允许负金额 | `create_payment_order()` 允许 `amount < 0`,注释说明为供应商退款。 | +| 收款单 | 否 | 无专用反向单据,但允许负金额 | `create_receipt_order()` 允许 `amount < 0`,注释说明为退款给客户。 | + +## 采购/销售退货单细节 + +`create_purchase_return_order()` 可传 `purchase_order` 或 `purchase_order_id` 关联原采购单。 + +当前校验: + +- 原采购单存在且属于当前商户。 +- 原采购单供应商必须与退货单供应商一致。 + +当前未校验: + +- 原采购单必须是 `APPROVED`。 +- 退货数量不能超过原采购单数量。 +- 同一原采购单累计退货数量不能超过原单数量。 + +`create_sales_return_order()` 可传 `sales_order` 或 `sales_order_id` 关联原销售单。 + +当前校验: + +- 原销售单存在且属于当前商户。 +- 原销售单客户必须与退货单客户一致。 + +当前未校验: + +- 原销售单必须是 `APPROVED`。 +- 退货数量不能超过原销售单数量。 +- 同一原销售单累计退货数量不能超过原单数量。 + +因此,退货单属于“新单据反向业务影响”,不是“撤销原单据”;它不会改变原采购单/销售单的 `status`,也不会标记原单已被红冲。 + +## 付款/收款负金额细节 + +`_ensure_non_zero_amount()` 只禁止金额为 0,不禁止负数,并有明确注释: + +```python +# 金额类字段要求非零(允许负数用于处理退款场景) +# 付款单负金额 = 供应商退款,收款单负金额 = 退款给客户 +``` + +影响: + +- 负数付款单审核后,`review_payment_order()` 使用 `delta=-locked.settlement_amount`,当 `settlement_amount < 0` 时会增加供应商应付余额。 +- 负数收款单审核后,`review_receipt_order()` 使用 `delta=-locked.settlement_amount`,当 `settlement_amount < 0` 时会增加客户应收余额。 + +这是一种资金侧反向能力,但没有和原付款/收款单建立关联,也没有把原单据标记为红冲或冲销。 + +## 余额冲抵字段 + +`BalanceChangeRecord` 存在以下字段: + +- `offset_to` +- `offset_at` +- `offset_id` +- `cancelled` +- `cancelled_at` + +但代码搜索只发现模型定义和创建余额变动记录,未发现 service/API 更新这些字段。因此当前没有可用的余额变动冲抵流程。 + +## API 暴露情况 + +`api_v1/views/business/*/views.py` 的审核接口统一只接受: + +```python +action = approve | cancel +``` + +对应模块: + +- `purchase/views.py` +- `sales/views.py` +- `purchase_return/views.py` +- `sales_return/views.py` +- `payment/views.py` +- `receipt/views.py` + +未发现 API 层绕过 service 暴露已审核撤销、反审或红冲原单的能力。 + +## 风险点 + +1. 退货单可作为采购单/销售单的反向能力,但当前不要求原单已审核。 +2. 退货单未限制数量,可能产生超原单退货或重复累计退货。 +3. 负金额付款/收款具备资金反向效果,但没有原单关联,后续对账只能从业务含义判断,不能从数据结构追溯到被冲销单据。 +4. `BalanceChangeRecord` 的冲抵字段未被 service 使用,容易让维护者误以为已有冲抵流程。 + +## 建议 + +如果业务需求只是确认“已审核后是否有撤销/红冲能力”,当前答案是:没有直接撤销/反审/红冲原单的能力;有通过新单据产生反向影响的能力。 + +如果要把退货单作为正式红冲/撤销路径,建议补充: + +- 原单必须 `APPROVED`。 +- 按产品、颜色、规格、批次等维度限制退货数量。 +- 校验同一原单累计已审核退货数量。 +- 在退货单或余额变动记录中明确记录冲销关系。 diff --git a/docs/plant/business-function-architecture-simple.png b/docs/plant/business-function-architecture-simple.png new file mode 100644 index 0000000..2000574 Binary files /dev/null and b/docs/plant/business-function-architecture-simple.png differ diff --git a/docs/plant/business-function-architecture-simple.puml b/docs/plant/business-function-architecture-simple.puml new file mode 100644 index 0000000..fae6d2d --- /dev/null +++ b/docs/plant/business-function-architecture-simple.puml @@ -0,0 +1,55 @@ +@startuml +title Flower ERP 业务功能架构简图 + +skinparam shadowing false +skinparam defaultFontName Noto Sans CJK SC +skinparam backgroundColor #FFFFFF +skinparam ArrowColor #5B6472 +skinparam ArrowThickness 1.3 +skinparam componentStyle rectangle +skinparam rectangle { + FontName Noto Sans CJK SC + BorderColor #516174 + RoundCorner 8 +} +skinparam package { + FontName Noto Sans CJK SC + BorderColor #516174 + BackgroundColor #F8FAFC +} + +top to bottom direction + +rectangle "外部系统与前端入口\n明道云 / 好布业财务 / 企业微信 / 管理后台 / 移动端" as Entry #F5F7FA + +rectangle "Flower ERP 业务平台" as Platform #EAF3FF { + rectangle "基础资料中心\n商户、客户、供应商、产品、仓库、员工、车辆" as MasterData #FFFFFF + + rectangle "业务经营中心\n采购、销售、退货、预订单、收付款、往来余额" as Business #F2F8FF + + rectangle "生产履约中心\n开版、印花订单、工序推进、MES 设备指派" as Production #FFF8EF + + rectangle "库存物流中心\n出入库、库存台账、销售品、出货单、送货单" as StockLogistics #F7FBF4 + + rectangle "协同运营中心\n流程引擎、任务协同、通知中心、费用、日结" as Collaboration #F8F5FF +} + +rectangle "运行与数据支撑\n异步任务、文件图片、审计日志、外部同步" as Support #F5F7FA + +Entry --> Platform : 业务录入 / 外部同步 / 消息触达 +MasterData --> Business : 基础资料支撑经营单据 +Business --> Production : 销售需求驱动生产履约 +Production --> StockLogistics : 生产成果进入发货履约 +Business --> StockLogistics : 采购销售触发库存变化 +Business --> Collaboration : 审批、对账、统计与提醒 +Production --> Collaboration : 流程推进与任务协同 +StockLogistics --> Collaboration : 发货通知与物流跟进 +Platform --> Support : 定时、异步、图片、审计支撑 + +note right of Platform + 汇报口径: + 展示业务能力版图与主链路, + 不展开具体技术实现。 +end note + +@enduml diff --git a/docs/plant/business-function-architecture-simple.svg b/docs/plant/business-function-architecture-simple.svg new file mode 100644 index 0000000..64e2863 --- /dev/null +++ b/docs/plant/business-function-architecture-simple.svg @@ -0,0 +1 @@ +Flower ERP 业务功能架构简图Flower ERP 业务功能架构简图Flower ERP 业务平台基础资料中心商户、客户、供应商、产品、仓库、员工、车辆业务经营中心采购、销售、退货、预订单、收付款、往来余额生产履约中心开版、印花订单、工序推进、MES 设备指派库存物流中心出入库、库存台账、销售品、出货单、送货单协同运营中心流程引擎、任务协同、通知中心、费用、日结外部系统与前端入口明道云 / 好布业财务 / 企业微信 / 管理后台 / 移动端运行与数据支撑异步任务、文件图片、审计日志、外部同步汇报口径:展示业务能力版图与主链路,不展开具体技术实现。业务录入 / 外部同步 / 消息触达基础资料支撑经营单据销售需求驱动生产履约生产成果进入发货履约采购销售触发库存变化审批、对账、统计与提醒流程推进与任务协同发货通知与物流跟进定时、异步、图片、审计支撑 \ No newline at end of file diff --git a/docs/plant/business-function-architecture.png b/docs/plant/business-function-architecture.png new file mode 100644 index 0000000..3480eee Binary files /dev/null and b/docs/plant/business-function-architecture.png differ diff --git a/docs/plant/business-function-architecture.puml b/docs/plant/business-function-architecture.puml new file mode 100644 index 0000000..6e532e1 --- /dev/null +++ b/docs/plant/business-function-architecture.puml @@ -0,0 +1,137 @@ +@startuml +title Flower ERP 业务功能架构图 + +skinparam shadowing false +skinparam defaultFontName Noto Sans CJK SC +skinparam backgroundColor #FFFFFF +skinparam ArrowColor #5B6472 +skinparam ArrowThickness 1.2 +skinparam componentStyle rectangle +skinparam rectangle { + FontName Noto Sans CJK SC + BorderColor #516174 + RoundCorner 8 +} +skinparam package { + FontName Noto Sans CJK SC + BorderColor #516174 + BackgroundColor #F8FAFC +} +skinparam note { + FontName Noto Sans CJK SC + BackgroundColor #FFF7E6 + BorderColor #B7791F +} + +left to right direction + +rectangle "Flower ERP 业务平台" as Platform #EAF3FF { + + package "基础资料中心" as MasterData #F6F8FB { + rectangle "商户 / 客户 / 供应商" as MDParty #FFFFFF + rectangle "产品 / 品类 / 仓库" as MDProduct #FFFFFF + rectangle "员工 / 角色 / 可见页面" as MDStaff #FFFFFF + rectangle "车辆 / 银行账户 / 快捷输入" as MDOther #FFFFFF + } + + package "业务经营中心" as Business #F2F8FF { + rectangle "采购管理\n采购单 / 采购退货" as Purchase #FFFFFF + rectangle "销售管理\n预销售 / 销售单 / 销售退货" as Sales #FFFFFF + rectangle "资金往来\n付款单 / 收款单" as Cash #FFFFFF + rectangle "往来余额\n客户应收 / 供应商应付 / 余额流水" as Balance #FFFFFF + rectangle "外部对账\n好布业财务同步" as ExternalFinance #FFFFFF + } + + package "库存物流中心" as StockLogistics #F7FBF4 { + rectangle "库存管理\n现存量 / 库存快照" as Inventory #FFFFFF + rectangle "出入库执行\n入库 / 出库 / 调拨 / 红冲" as StockFlow #FFFFFF + rectangle "销售品管理\n待分配销售品 / 合卷 / 重建" as SalesItem #FFFFFF + rectangle "发货管理\n出货单 / 送货单 / 送达照片" as Shipment #FFFFFF + } + + package "生产履约中心" as Production #FFF8EF { + rectangle "开版管理\n开版订单 / 样品 / 客户反馈" as PlateOrder #FFFFFF + rectangle "印花订单\n生产订单 / 款式明细" as PrintingOrder #FFFFFF + rectangle "工序推进\n批量推进 / 工艺参数 / 进度" as ProcessAdvance #FFFFFF + rectangle "MES 生产指派\n设备 / 产能 / 指派状态" as MES #FFFFFF + } + + package "协同运营中心" as Collaboration #F8F5FF { + rectangle "流程引擎\n流程 / 节点 / 状态记录" as Stateflow #FFFFFF + rectangle "任务协同\n任务 / 参与者 / 回复 / 完成" as Mission #FFFFFF + rectangle "通知中心\n事件路由 / 模板 / 企业微信" as Notifier #FFFFFF + rectangle "费用与日结\n支出记账 / 成本汇总 / 日结配置" as CostSettlement #FFFFFF + } + + package "对外能力与运行支撑" as Integration #F5F7FA { + rectangle "API 入口\n移动端 / 前端 / 管理后台" as API #FFFFFF + rectangle "文件与图片\n七牛云 / 上传 / 图片检索" as FileImage #FFFFFF + rectangle "外部系统同步\n明道云 / 外部生产订单 / TIIA" as ExternalSync #FFFFFF + rectangle "异步任务\n同步 / 通知 / 审计 / 定时任务" as AsyncJobs #FFFFFF + } +} + +API --> MasterData : 基础资料维护 +API --> Business : 单据录入与审批 +API --> Production : 生产过程操作 +API --> StockLogistics : 库存与发货操作 +API --> Collaboration : 协同与统计查询 + +MasterData --> Business : 客户、供应商、产品、仓库、员工 +MasterData --> Production : 客户、产品、员工、设备基础 +MasterData --> StockLogistics : 产品、仓库、客户、车辆 +MasterData --> Collaboration : 商户、员工、权限范围 + +Purchase --> StockFlow : 审批后触发入库/退货出库 +Sales --> StockFlow : 销售出库/销退入库 +Purchase --> Balance : 形成供应商应付 +Sales --> Balance : 形成客户应收 +Cash --> Balance : 收付款冲减余额 +ExternalFinance --> Cash : 外部收付款同步 +ExternalFinance --> Balance : 对账依据补充 + +PlateOrder --> Stateflow : 开版流程跟踪 +PrintingOrder --> Stateflow : 印花任务工序流转 +PrintingOrder --> ProcessAdvance : 批量推进生产节点 +PrintingOrder --> SalesItem : 生产明细生成可发货销售品 +MES --> PrintingOrder : 生产对象指派到设备 + +Inventory <-- StockFlow : 更新库存台账 +StockFlow --> Inventory : 形成库存快照 +SalesItem --> Shipment : 组建出货单 +Shipment --> Notifier : 出货事件通知 + +Mission --> Notifier : 任务事件通知 +Mission --> Production : 关联开版/印花对象 +Mission --> Shipment : 关联发货对象 +CostSettlement --> Production : 采集生产相关成本 +CostSettlement --> StockLogistics : 采集库存/物流相关成本 +CostSettlement --> Notifier : 日结结果通知 + +ExternalSync --> MasterData : 同步产品、客户 +ExternalSync --> PlateOrder : 明道云开版暂存/导入 +ExternalSync --> PrintingOrder : 外部生产订单快照 +ExternalSync --> FileImage : 图片上传与 TIIA 检索 +FileImage --> PlateOrder : 开版图与图库 +FileImage --> PrintingOrder : 产品图片 +AsyncJobs --> ExternalSync : 定时同步 +AsyncJobs --> Notifier : 异步投递 +AsyncJobs --> StockFlow : 异步生成库存记录 +AsyncJobs --> CostSettlement : 定时日结 + +note top of Platform + 本图用于向上汇报,表达业务能力版图与模块协作关系; + 不展示数据库、容器、框架等技术实现细节。 +end note + +note right of Business + 经营闭环: + 单据审批 -> 库存变化 -> 往来余额 -> 收付款/对账。 +end note + +note bottom of Production + 履约闭环: + 开版 -> 印花生产 -> 工序推进 -> 销售品 -> 发货。 +end note + +@enduml diff --git a/docs/plant/business-function-architecture.svg b/docs/plant/business-function-architecture.svg new file mode 100644 index 0000000..32e3f0a --- /dev/null +++ b/docs/plant/business-function-architecture.svg @@ -0,0 +1 @@ +Flower ERP 业务功能架构图Flower ERP 业务功能架构图Flower ERP 业务平台基础资料中心业务经营中心库存物流中心生产履约中心协同运营中心对外能力与运行支撑商户 / 客户 / 供应商产品 / 品类 / 仓库员工 / 角色 / 可见页面车辆 / 银行账户 / 快捷输入采购管理采购单 / 采购退货销售管理预销售 / 销售单 / 销售退货资金往来付款单 / 收款单往来余额客户应收 / 供应商应付 / 余额流水外部对账好布业财务同步库存管理现存量 / 库存快照出入库执行入库 / 出库 / 调拨 / 红冲销售品管理待分配销售品 / 合卷 / 重建发货管理出货单 / 送货单 / 送达照片开版管理开版订单 / 样品 / 客户反馈印花订单生产订单 / 款式明细工序推进批量推进 / 工艺参数 / 进度MES 生产指派设备 / 产能 / 指派状态流程引擎流程 / 节点 / 状态记录任务协同任务 / 参与者 / 回复 / 完成通知中心事件路由 / 模板 / 企业微信费用与日结支出记账 / 成本汇总 / 日结配置API 入口移动端 / 前端 / 管理后台文件与图片七牛云 / 上传 / 图片检索外部系统同步明道云 / 外部生产订单 / TIIA异步任务同步 / 通知 / 审计 / 定时任务本图用于向上汇报,表达业务能力版图与模块协作关系;不展示数据库、容器、框架等技术实现细节。经营闭环:单据审批 -> 库存变化 -> 往来余额 -> 收付款/对账。履约闭环:开版 -> 印花生产 -> 工序推进 -> 销售品 -> 发货。基础资料维护单据录入与审批生产过程操作库存与发货操作协同与统计查询客户、供应商、产品、仓库、员工客户、产品、员工、设备基础产品、仓库、客户、车辆商户、员工、权限范围审批后触发入库/退货出库销售出库/销退入库形成供应商应付形成客户应收收付款冲减余额外部收付款同步对账依据补充开版流程跟踪印花任务工序流转批量推进生产节点生产明细生成可发货销售品生产对象指派到设备更新库存台账形成库存快照组建出货单出货事件通知任务事件通知关联开版/印花对象关联发货对象采集生产相关成本采集库存/物流相关成本日结结果通知同步产品、客户明道云开版暂存/导入外部生产订单快照图片上传与 TIIA 检索开版图与图库产品图片定时同步异步投递异步生成库存记录定时日结 \ No newline at end of file diff --git a/docs/plant/business-module-object-catalog.png b/docs/plant/business-module-object-catalog.png new file mode 100644 index 0000000..bff9c96 Binary files /dev/null and b/docs/plant/business-module-object-catalog.png differ diff --git a/docs/plant/business-module-object-catalog.puml b/docs/plant/business-module-object-catalog.puml new file mode 100644 index 0000000..e1462b9 --- /dev/null +++ b/docs/plant/business-module-object-catalog.puml @@ -0,0 +1,146 @@ +@startuml +title Flower ERP 模块功能对象图(目录版) + +skinparam shadowing false +skinparam defaultFontName Noto Sans CJK SC +skinparam backgroundColor #FFFFFF +skinparam object { + FontName Noto Sans CJK SC + BackgroundColor #FFFFFF + BorderColor #4B5563 +} +skinparam package { + FontName Noto Sans CJK SC + BorderColor #64748B + BackgroundColor #F8FAFC +} + +left to right direction + +package "入口与主数据" as P1 #F6F8FB { + object "API / Admin" as ApiEntry { + api/backend 基础资料后台 + api/v1 核心业务接口 + api/v2 新版业务接口 + admin 管理后台 + auth 登录与员工身份 + SSE 实时推送 + } + + object "basic_info" as BasicInfo { + 商户 / 多租户 + 客户 / 供应商 + 产品 / 品类 / 仓库 + 员工 / 角色 / 页面权限 + 车辆 / 银行账户 / 快捷输入 + } +} + +package "经营与库存" as P2 #F2F8FF { + object "business" as Business { + 采购 / 采购退货 + 销售 / 销售退货 + 预销售 / 预采购 + 收款 / 付款 + 客户应收 / 供应商应付 + 余额流水 / 对账 / 红冲 + } + + object "stock" as Stock { + 出入库记录 + 出入库明细 + 库存现存量 + 库存快照 + 库存冻结 + 调拨 / 红冲 / 冲抵 + } + + object "cost" as Cost { + 支出类目 + 支出明细 + 凭证图片 + 成本汇总 + 成本采集端口 + } +} + +package "生产与交付" as P3 #FFF8EF { + object "printing" as Printing { + 开版订单 + 开版图 / 样品 / 客户反馈 + 印花订单 + 印花款式明细 + 工序批量推进 + 外部订单快照 + 生产完成通知 + } + + object "stateflow" as Stateflow { + 状态参数 + 流程模板 + 流程节点 + 业务对象 + 流转记录 + 节点参数记录 + } + + object "mes" as MES { + 设备分类 + 设备管理 + 产能信息 + 生产指派 + 指派状态流转 + } + + object "shipment" as Shipment { + 销售品 + 出货单 + 送货单 + 送达照片 + 销售品变更 / 重建 + 外部成品表 + } +} + +package "协同与集成" as P4 #F8F5FF { + object "mission" as Mission { + 任务分类 + 任务 + 参与者 + 回复 + 完成 / 重开 / 取消 + 关联任意业务对象 + } + + object "notifier" as Notifier { + 通知器 + 通知路由 + 事件匹配 + 模板渲染 + 企业微信 webhook + message_api + 未回复提醒 + } + + object "settlement" as Settlement { + 日结配置 + 统计模块选择 + 通知渠道配置 + 商户日结任务 + } + + object "外部与异步支撑" as Support { + 明道云同步 + 好布业财务同步 + 腾讯云 TIIA + 七牛云文件图片 + Celery 定时与异步任务 + API 审计日志 + } +} + +P1 -[hidden]right- P2 +P2 -[hidden]right- P3 +P3 -[hidden]right- P4 + +@enduml diff --git a/docs/plant/business-module-object-catalog.svg b/docs/plant/business-module-object-catalog.svg new file mode 100644 index 0000000..284c873 --- /dev/null +++ b/docs/plant/business-module-object-catalog.svg @@ -0,0 +1 @@ +Flower ERP 模块功能对象图(目录版)Flower ERP 模块功能对象图(目录版)入口与主数据经营与库存生产与交付协同与集成API / Adminapi/backend 基础资料后台api/v1 核心业务接口api/v2 新版业务接口admin 管理后台auth 登录与员工身份SSE 实时推送basic_info商户 / 多租户客户 / 供应商产品 / 品类 / 仓库员工 / 角色 / 页面权限车辆 / 银行账户 / 快捷输入business采购 / 采购退货销售 / 销售退货预销售 / 预采购收款 / 付款客户应收 / 供应商应付余额流水 / 对账 / 红冲stock出入库记录出入库明细库存现存量库存快照库存冻结调拨 / 红冲 / 冲抵cost支出类目支出明细凭证图片成本汇总成本采集端口printing开版订单开版图 / 样品 / 客户反馈印花订单印花款式明细工序批量推进外部订单快照生产完成通知stateflow状态参数流程模板流程节点业务对象流转记录节点参数记录mes设备分类设备管理产能信息生产指派指派状态流转shipment销售品出货单送货单送达照片销售品变更 / 重建外部成品表mission任务分类任务参与者回复完成 / 重开 / 取消关联任意业务对象notifier通知器通知路由事件匹配模板渲染企业微信 webhookmessage_api未回复提醒settlement日结配置统计模块选择通知渠道配置商户日结任务外部与异步支撑明道云同步好布业财务同步腾讯云 TIIA七牛云文件图片Celery 定时与异步任务API 审计日志 \ No newline at end of file diff --git a/docs/plant/business-module-object-map.png b/docs/plant/business-module-object-map.png new file mode 100644 index 0000000..e6979ac Binary files /dev/null and b/docs/plant/business-module-object-map.png differ diff --git a/docs/plant/business-module-object-map.puml b/docs/plant/business-module-object-map.puml new file mode 100644 index 0000000..2e0a9c7 --- /dev/null +++ b/docs/plant/business-module-object-map.puml @@ -0,0 +1,227 @@ +@startuml +title Flower ERP 模块功能对象图 + +skinparam shadowing false +skinparam defaultFontName Noto Sans CJK SC +skinparam backgroundColor #FFFFFF +skinparam ArrowColor #6B7280 +skinparam ArrowThickness 1 +skinparam object { + FontName Noto Sans CJK SC + BackgroundColor #FFFFFF + BorderColor #4B5563 +} +skinparam package { + FontName Noto Sans CJK SC + BorderColor #64748B + BackgroundColor #F8FAFC +} +skinparam note { + FontName Noto Sans CJK SC + BackgroundColor #FFF7E6 + BorderColor #B7791F +} + +left to right direction + +package "访问入口层" as EntryLayer #F5F7FA { + object "API / Admin\n访问入口" as ApiEntry { + api/backend: 基础资料后台接口 + api/v1: 核心业务接口 + api/v2: 新版业务接口 + admin: 管理后台 + auth: 登录与员工身份校验 + } + + object "SSE\n实时通道" as SSE { + 事件推送 + 连接状态 + 测试推送 + } +} + +package "基础资料层" as MasterLayer #F6F8FB { + object "basic_info\n基础资料" as BasicInfo { + 商户 / 多租户 + 客户 / 供应商 + 产品 / 品类 + 仓库 / 仓库模式 + 员工 / 员工类型 / 用户档案 + 前端页面权限 + 车辆 / 车次 / 银行账户 + 快捷输入 / 商户设置 + } + + object "api_man\n后台资料接口" as ApiMan { + 产品维护 + 仓库维护 + 客户维护 + 供应商维护 + 员工维护 + 车辆与账户维护 + } +} + +package "经营单据层" as BusinessLayer #F2F8FF { + object "business\n业务单据" as Business { + 采购单 / 采购退货单 + 销售单 / 销售退货单 + 预销售单 / 预采购单 + 收款单 / 付款单 + 客户应收 / 供应商应付 + 余额变动流水 + 客户对账单 + 红冲 / 作废 / 审批 + } + + object "stock\n库存管理" as Stock { + 出入库记录 + 出入库明细 + 库存现存量 + 库存快照 + 库存冻结 + 调拨单 + 红冲 / 冲抵 + 严谨 / 宽进宽出 / 严进严出 + } + + object "cost\n成本费用" as Cost { + 支出类目 + 支出明细 + 凭证图片 + 按类目汇总 + 成本采集端口 + } +} + +package "生产履约层" as ProductionLayer #FFF8EF { + object "printing\n开版与印花" as Printing { + 开版订单 + 开版图 / 样品 / 客户反馈 + 印花订单 + 印花款式明细 + 工序批量推进 + 工艺参数补充 + 外部订单快照同步 + 生产完成通知 + } + + object "stateflow\n流程引擎" as Stateflow { + 状态参数 + 状态节点 + 流程模板 + 流程节点 + 业务对象 + 流转记录 + 节点参数记录 + } + + object "mes\nMES 生产指派" as MES { + 设备分类 + 设备管理 + 峰值产能 + 生产指派 + 指派状态流转 + 指派对象关联 + } +} + +package "物流交付层" as DeliveryLayer #F7FBF4 { + object "shipment\n发货交付" as Shipment { + 销售品 + 销售品变更记录 + 销售品重建记录 + 出货单 + 送货单 + 送达照片 + 外部成品表 + 出货状态流转 + } +} + +package "协同运营层" as OperationLayer #F8F5FF { + object "mission\n任务协同" as Mission { + 任务分类 + 任务 + 任务参与者 + 任务回复 + 任务完成 / 重开 / 取消 + 回复驳回 + 关联任意业务对象 + } + + object "notifier\n通知中心" as Notifier { + 通知器 + 通知路由 + 事件匹配 + 模板渲染 + 企业微信 webhook + message_api 渠道 + 未回复任务提醒 + } + + object "settlement\n日结统计" as Settlement { + 日结配置 + 统计模块选择 + 通知渠道配置 + 商户日结任务 + 开版 / 印花日结口径 + } +} + +package "集成与支撑层" as SupportLayer #F5F7FA { + object "api_v1.tasks\n异步与同步任务" as Tasks { + 数据库备份 + 明道云产品同步 + 明道云客户同步 + 明道云开版暂存同步 + 外部印花订单同步 + 外部图片回填 + 腾讯云 TIIA 上传 + API 审计日志写入 + } + + object "business.tasks\n经营异步任务" as BusinessTasks { + 采购审批后入库 + 销售审批后出库 + 退货审批后出入库 + 好布业财务定时同步 + } + + object "外部服务" as ExternalSystems { + 明道云 + 好布业财务系统 + 腾讯云 TIIA + 七牛云存储 + 企业微信 / message_api + } +} + +ApiEntry ..> BasicInfo +ApiEntry ..> Business +ApiEntry ..> Printing +ApiEntry ..> Shipment +ApiEntry ..> Mission +ApiEntry ..> MES +ApiEntry ..> Cost + +ApiMan ..> BasicInfo +Business ..> Stock +Business ..> BusinessTasks +Printing ..> Stateflow +Printing ..> Shipment +MES ..> Printing +Shipment ..> Notifier +Mission ..> Notifier +Settlement ..> Notifier +Tasks ..> ExternalSystems +BusinessTasks ..> ExternalSystems +Tasks ..> BasicInfo +Tasks ..> Printing + +note bottom + 读图方式:每个对象代表一个业务模块,内部列出主要功能。 + 虚线只保留关键依赖,避免把图变成技术调用链。 +end note + +@enduml diff --git a/docs/plant/business-module-object-map.svg b/docs/plant/business-module-object-map.svg new file mode 100644 index 0000000..52edd9e --- /dev/null +++ b/docs/plant/business-module-object-map.svg @@ -0,0 +1 @@ +Flower ERP 模块功能对象图Flower ERP 模块功能对象图访问入口层基础资料层经营单据层生产履约层物流交付层协同运营层集成与支撑层API / Admin访问入口api/backend: 基础资料后台接口api/v1: 核心业务接口api/v2: 新版业务接口admin: 管理后台auth: 登录与员工身份校验SSE实时通道事件推送连接状态测试推送basic_info基础资料商户 / 多租户客户 / 供应商产品 / 品类仓库 / 仓库模式员工 / 员工类型 / 用户档案前端页面权限车辆 / 车次 / 银行账户快捷输入 / 商户设置api_man后台资料接口产品维护仓库维护客户维护供应商维护员工维护车辆与账户维护business业务单据采购单 / 采购退货单销售单 / 销售退货单预销售单 / 预采购单收款单 / 付款单客户应收 / 供应商应付余额变动流水客户对账单红冲 / 作废 / 审批stock库存管理出入库记录出入库明细库存现存量库存快照库存冻结调拨单红冲 / 冲抵严谨 / 宽进宽出 / 严进严出cost成本费用支出类目支出明细凭证图片按类目汇总成本采集端口printing开版与印花开版订单开版图 / 样品 / 客户反馈印花订单印花款式明细工序批量推进工艺参数补充外部订单快照同步生产完成通知stateflow流程引擎状态参数状态节点流程模板流程节点业务对象流转记录节点参数记录mesMES 生产指派设备分类设备管理峰值产能生产指派指派状态流转指派对象关联shipment发货交付销售品销售品变更记录销售品重建记录出货单送货单送达照片外部成品表出货状态流转mission任务协同任务分类任务任务参与者任务回复任务完成 / 重开 / 取消回复驳回关联任意业务对象notifier通知中心通知器通知路由事件匹配模板渲染企业微信 webhookmessage_api 渠道未回复任务提醒settlement日结统计日结配置统计模块选择通知渠道配置商户日结任务开版 / 印花日结口径api_v1.tasks异步与同步任务数据库备份明道云产品同步明道云客户同步明道云开版暂存同步外部印花订单同步外部图片回填腾讯云 TIIA 上传API 审计日志写入business.tasks经营异步任务采购审批后入库销售审批后出库退货审批后出入库好布业财务定时同步外部服务明道云好布业财务系统腾讯云 TIIA七牛云存储企业微信 / message_api读图方式:每个对象代表一个业务模块,内部列出主要功能。虚线只保留关键依赖,避免把图变成技术调用链。 \ No newline at end of file diff --git a/stock/migrations/0010_stockchangerecord_red_flush_id.py b/stock/migrations/0010_stockchangerecord_red_flush_id.py new file mode 100644 index 0000000..b7520cf --- /dev/null +++ b/stock/migrations/0010_stockchangerecord_red_flush_id.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0009_alter_stockchangedetail_unit_alter_stockfreeze_unit_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='stockchangerecord', + name='red_flush_id', + field=models.UUIDField(blank=True, db_index=True, null=True, verbose_name='红冲批次ID'), + ), + ] diff --git a/stock/models.py b/stock/models.py index dd73fab..2b2d917 100644 --- a/stock/models.py +++ b/stock/models.py @@ -96,6 +96,7 @@ class StockChangeRecord(ModelBase): source_id = models.BigIntegerField(null=True, blank=True, verbose_name='来源单据ID') is_finished = models.BooleanField(default=False, verbose_name='完成出入库') finished_at = models.DateTimeField(null=True, blank=True, verbose_name='完成时间') + red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID') remarks = models.TextField(blank=True, null=True, verbose_name='备注') def __str__(self): diff --git a/stock/services.py b/stock/services.py index f949532..cc9913b 100644 --- a/stock/services.py +++ b/stock/services.py @@ -428,6 +428,7 @@ class StockFlowService: items: List[Dict[str, Any]] | None = None, request_id: str | None = None, extra_meta: Dict[str, Any] | None = None, + red_flush_id=None, ) -> Tuple[models.StockChangeRecord, List[models.StockChangeDetail], int]: """ 创建针对既有库存变动记录的红冲(对冲)占位接口。 @@ -438,6 +439,7 @@ class StockFlowService: items: 可选的部分红冲明细(支持宽进/严进等结构);为空时表示全量对冲。 request_id: 幂等键;同一 request_id 的请求应视为一次操作。 extra_meta: 额外的上下文信息(触发来源、操作者备注等)。 + red_flush_id: 业务红冲批次 ID,用于跨模块审计追踪。 Returns: 新生成的反向 `StockChangeRecord`、其明细列表以及明细数量。 @@ -452,6 +454,7 @@ class StockFlowService: reason=reason.strip(), request_id=request_id, extra_meta=extra_meta or {}, + red_flush_id=red_flush_id, ) return offset_record, created_details, len(created_details) @@ -462,6 +465,7 @@ class StockFlowService: reason: str, request_id: str | None, extra_meta: Dict[str, Any], + red_flush_id, ) -> Tuple[models.StockChangeRecord, List[models.StockChangeDetail]]: with transaction.atomic(): source_record = ( @@ -503,9 +507,14 @@ class StockFlowService: source_type=models.StockChangeSourceEnum.OFFSET, source_id=source_record.id, created_by=self.created_by, + red_flush_id=red_flush_id, remarks=remarks[:500], ) + if red_flush_id and source_record.red_flush_id is None: + source_record.red_flush_id = red_flush_id + source_record.save(update_fields=['red_flush_id', 'updated_at']) + created_details: List[models.StockChangeDetail] = [] for detail in source_details: new_detail = models.StockChangeDetail.objects.create(