1
0
forked from erp-dev/erp

feat: red flush

This commit is contained in:
2026-06-12 13:57:21 +08:00
parent 80db7867f4
commit 0bcaa2db44
33 changed files with 2997 additions and 17 deletions

BIN
.coverage

Binary file not shown.

View File

@@ -1231,6 +1231,332 @@ class ReceiptOrderAPITestCase(TestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('银行账户不存在', response.data['error']) 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): class CustomerBalanceAPITestCase(TestCase):
def setUp(self): def setUp(self):
self.merchant = Merchant.objects.create(name='余额商户', type=MerchantTypeEnum.FACTORY) self.merchant = Merchant.objects.create(name='余额商户', type=MerchantTypeEnum.FACTORY)

View File

@@ -155,6 +155,11 @@ urlpatterns = [
purchase_views.PurchaseOrderReviewView.as_view(), purchase_views.PurchaseOrderReviewView.as_view(),
name="purchase_order_review", name="purchase_order_review",
), ),
path(
"purchase-orders/<int:pk>/red-flush/",
purchase_views.PurchaseOrderRedFlushView.as_view(),
name="purchase_order_red_flush",
),
path( path(
"purchase-orders/<int:pk>/bind-stock-change/", "purchase-orders/<int:pk>/bind-stock-change/",
purchase_views.PurchaseOrderBindStockChangeView.as_view(), purchase_views.PurchaseOrderBindStockChangeView.as_view(),
@@ -175,6 +180,11 @@ urlpatterns = [
purchase_return_views.PurchaseReturnOrderReviewView.as_view(), purchase_return_views.PurchaseReturnOrderReviewView.as_view(),
name="purchase_return_order_review", name="purchase_return_order_review",
), ),
path(
"purchase-return-orders/<int:pk>/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/", sales_views.SalesOrderView.as_view(), name="sales_orders"),
path( path(
"sales-orders/<int:pk>/", "sales-orders/<int:pk>/",
@@ -186,6 +196,11 @@ urlpatterns = [
sales_views.SalesOrderReviewView.as_view(), sales_views.SalesOrderReviewView.as_view(),
name="sales_order_review", name="sales_order_review",
), ),
path(
"sales-orders/<int:pk>/red-flush/",
sales_views.SalesOrderRedFlushView.as_view(),
name="sales_order_red_flush",
),
path( path(
"sales-return-orders/", "sales-return-orders/",
sales_return_views.SalesReturnOrderView.as_view(), sales_return_views.SalesReturnOrderView.as_view(),
@@ -201,6 +216,11 @@ urlpatterns = [
sales_return_views.SalesReturnOrderReviewView.as_view(), sales_return_views.SalesReturnOrderReviewView.as_view(),
name="sales_return_order_review", name="sales_return_order_review",
), ),
path(
"sales-return-orders/<int:pk>/red-flush/",
sales_return_views.SalesReturnOrderRedFlushView.as_view(),
name="sales_return_order_red_flush",
),
path( path(
"pre-sales-orders/", "pre-sales-orders/",
pre_sales_views.PreSalesOrderView.as_view(), pre_sales_views.PreSalesOrderView.as_view(),
@@ -246,6 +266,11 @@ urlpatterns = [
payment_views.PaymentOrderReviewView.as_view(), payment_views.PaymentOrderReviewView.as_view(),
name="payment_order_review", name="payment_order_review",
), ),
path(
"payment-orders/<int:pk>/red-flush/",
payment_views.PaymentOrderRedFlushView.as_view(),
name="payment_order_red_flush",
),
path( path(
"receipt-orders/", "receipt-orders/",
receipt_views.ReceiptOrderView.as_view(), receipt_views.ReceiptOrderView.as_view(),
@@ -256,6 +281,11 @@ urlpatterns = [
receipt_views.ReceiptOrderReviewView.as_view(), receipt_views.ReceiptOrderReviewView.as_view(),
name="receipt_order_review", name="receipt_order_review",
), ),
path(
"receipt-orders/<int:pk>/red-flush/",
receipt_views.ReceiptOrderRedFlushView.as_view(),
name="receipt_order_red_flush",
),
path( path(
"customers/<int:customer_id>/balance/", "customers/<int:customer_id>/balance/",
balance_views.CustomerBalanceView.as_view(), balance_views.CustomerBalanceView.as_view(),

View File

@@ -20,6 +20,7 @@ class PaymentOrderSerializer(serializers.ModelSerializer):
'id', 'supplier', 'supplier_name', 'bank_account', 'bank_account_name', 'id', 'supplier', 'supplier_name', 'bank_account', 'bank_account_name',
'payment_date', 'amount', 'discount_amount', 'settlement_amount', 'payment_date', 'amount', 'discount_amount', 'settlement_amount',
'operator', 'operator_name', 'status', 'is_external_source', 'external_source_id', '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', 'remarks', 'markup', 'created_at', 'updated_at',
] ]
read_only_fields = [ read_only_fields = [
@@ -31,6 +32,9 @@ class PaymentOrderSerializer(serializers.ModelSerializer):
'status', 'status',
'is_external_source', 'is_external_source',
'external_source_id', 'external_source_id',
'is_red_flushed',
'red_flush_id',
'red_flushed_at',
'created_at', 'created_at',
'updated_at', 'updated_at',
] ]
@@ -164,3 +168,42 @@ class PaymentOrderReviewView(StockChangeViewMixin, views.APIView):
) )
return Response(PaymentOrderSerializer(refreshed).data, status=status.HTTP_200_OK) 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)

View File

@@ -53,10 +53,14 @@ class PurchaseOrderSerializer(serializers.ModelSerializer):
'id', 'human_id', 'supplier', 'supplier_name', 'purchase_date', 'kind', 'id', 'human_id', 'supplier', 'supplier_name', 'purchase_date', 'kind',
'total_amount', 'diff_quantity', 'total_quantity', 'total_amount', 'diff_quantity', 'total_quantity',
'operator', 'operator_name', 'warehouse', 'warehouse_name', 'operator', 'operator_name', 'warehouse', 'warehouse_name',
'status', 'remarks', 'created_at', 'updated_at', 'items', 'status', 'is_red_flushed', 'red_flush_id', 'red_flushed_at',
'quantity_of_rolls', '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): class PurchaseOrderPagination(pagination.LimitOffsetPagination):
@@ -258,6 +262,46 @@ class PurchaseOrderReviewView(StockChangeViewMixin, views.APIView):
return Response(PurchaseOrderSerializer(refreshed_order).data, status=status.HTTP_200_OK) 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): class PurchaseOrderBindStockChangeSerializer(serializers.Serializer):
stock_change_record_id = serializers.IntegerField(min_value=1) stock_change_record_id = serializers.IntegerField(min_value=1)
@@ -300,4 +344,3 @@ class PurchaseOrderBindStockChangeView(StockChangeViewMixin, views.APIView):
}, },
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )

View File

@@ -57,12 +57,13 @@ class PurchaseReturnOrderSerializer(serializers.ModelSerializer):
'id', 'human_id', 'supplier', 'supplier_name', 'purchase_order', 'return_date', 'id', 'human_id', 'supplier', 'supplier_name', 'purchase_order', 'return_date',
'total_amount', 'diff_quantity', 'total_quantity', 'total_amount', 'diff_quantity', 'total_quantity',
'operator', 'operator_name', 'warehouse', 'warehouse_name', 'operator', 'operator_name', 'warehouse', 'warehouse_name',
'status', 'remarks', 'created_at', 'updated_at', 'items', 'status', 'is_red_flushed', 'red_flush_id', 'red_flushed_at',
'quantity_of_rolls', 'remarks', 'created_at', 'updated_at', 'items', 'quantity_of_rolls',
] ]
read_only_fields = [ read_only_fields = [
'id', 'created_at', 'updated_at', 'items', 'id', 'created_at', 'updated_at', 'items',
'supplier_name', 'operator_name', 'warehouse_name', '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) ).prefetch_related('items').get(id=purchase_return_order.id)
return Response(PurchaseReturnOrderSerializer(refreshed_order).data, status=status.HTTP_200_OK) 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)

View File

@@ -20,6 +20,7 @@ class ReceiptOrderSerializer(serializers.ModelSerializer):
'id', 'customer', 'customer_name', 'bank_account', 'bank_account_name', 'id', 'customer', 'customer_name', 'bank_account', 'bank_account_name',
'receipt_date', 'amount', 'discount_amount', 'settlement_amount', 'receipt_date', 'amount', 'discount_amount', 'settlement_amount',
'operator', 'operator_name', 'status', 'is_external_source', 'external_source_id', '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', 'remarks', 'markup', 'created_at', 'updated_at',
] ]
read_only_fields = [ read_only_fields = [
@@ -31,6 +32,9 @@ class ReceiptOrderSerializer(serializers.ModelSerializer):
'status', 'status',
'is_external_source', 'is_external_source',
'external_source_id', 'external_source_id',
'is_red_flushed',
'red_flush_id',
'red_flushed_at',
'created_at', 'created_at',
'updated_at', 'updated_at',
] ]
@@ -164,3 +168,42 @@ class ReceiptOrderReviewView(StockChangeViewMixin, views.APIView):
) )
return Response(ReceiptOrderSerializer(refreshed).data, status=status.HTTP_200_OK) 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)

View File

@@ -54,10 +54,14 @@ class SalesOrderSerializer(serializers.ModelSerializer):
'id', 'human_id', 'customer', 'customer_name', 'sales_date', 'kind', 'id', 'human_id', 'customer', 'customer_name', 'sales_date', 'kind',
'total_amount', 'diff_quantity', 'total_quantity', 'total_amount', 'diff_quantity', 'total_quantity',
'operator', 'operator_name', 'warehouse', 'warehouse_name', 'operator', 'operator_name', 'warehouse', 'warehouse_name',
'status', 'remarks', 'created_at', 'updated_at', 'items', 'status', 'is_red_flushed', 'red_flush_id', 'red_flushed_at',
'quantity_of_rolls', '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): class SalesOrderPagination(pagination.LimitOffsetPagination):
@@ -267,3 +271,42 @@ class SalesOrderReviewView(StockChangeViewMixin, views.APIView):
).prefetch_related('items').get(id=sales_order.id) ).prefetch_related('items').get(id=sales_order.id)
return Response(SalesOrderSerializer(refreshed_order).data, status=status.HTTP_200_OK) 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)

View File

@@ -56,11 +56,13 @@ class SalesReturnOrderSerializer(serializers.ModelSerializer):
'id', 'human_id', 'customer', 'customer_name', 'sales_order', 'return_date', 'id', 'human_id', 'customer', 'customer_name', 'sales_order', 'return_date',
'total_amount', 'diff_quantity', 'total_quantity', 'quantity_of_rolls', 'total_amount', 'diff_quantity', 'total_quantity', 'quantity_of_rolls',
'operator', 'operator_name', 'warehouse', 'warehouse_name', '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 = [ read_only_fields = [
'id', 'created_at', 'updated_at', 'items', 'id', 'created_at', 'updated_at', 'items',
'customer_name', 'operator_name', 'warehouse_name', '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) ).prefetch_related('items').get(id=sales_return_order.id)
return Response(SalesReturnOrderSerializer(refreshed_order).data, status=status.HTTP_200_OK) 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)

View File

@@ -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'),
),
]

View File

@@ -130,6 +130,9 @@ class PurchaseOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounte
default=PurchaseOrderStatusEnum.PENDING, default=PurchaseOrderStatusEnum.PENDING,
verbose_name='状态', 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( from_pre_purchase_order_id = models.BigIntegerField(
null=True, null=True,
blank=True, blank=True,
@@ -277,6 +280,9 @@ class SalesOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounterpa
default=SalesOrderStatusEnum.PENDING, default=SalesOrderStatusEnum.PENDING,
verbose_name='状态', 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='备注') remarks = models.TextField(blank=True, null=True, verbose_name='备注')
from_pre_sales_order_id = models.BigIntegerField( from_pre_sales_order_id = models.BigIntegerField(
blank=True, blank=True,
@@ -786,6 +792,9 @@ class PurchaseReturnOrder(OrderItemsAggregationMixin, OrderDirectionMixin, Order
default=PurchaseReturnStatusEnum.PENDING, default=PurchaseReturnStatusEnum.PENDING,
verbose_name='状态', 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='备注') remarks = models.TextField(blank=True, null=True, verbose_name='备注')
class Meta: class Meta:
@@ -911,6 +920,9 @@ class SalesReturnOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCou
default=SalesReturnStatusEnum.PENDING, default=SalesReturnStatusEnum.PENDING,
verbose_name='状态', 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='备注') remarks = models.TextField(blank=True, null=True, verbose_name='备注')
class Meta: class Meta:
@@ -1098,6 +1110,9 @@ class PaymentOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
default=PaymentOrderStatusEnum.PENDING, default=PaymentOrderStatusEnum.PENDING,
verbose_name='状态', 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( is_external_source = models.BooleanField(
default=False, default=False,
verbose_name='是否外部来源', verbose_name='是否外部来源',
@@ -1181,6 +1196,9 @@ class ReceiptOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
default=ReceiptOrderStatusEnum.PENDING, default=ReceiptOrderStatusEnum.PENDING,
verbose_name='状态', 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( is_external_source = models.BooleanField(
default=False, default=False,
verbose_name='是否外部来源', verbose_name='是否外部来源',
@@ -1324,6 +1342,7 @@ class BalanceChangeRecord(ModelBase):
offset_id = models.BigIntegerField(null=True, blank=True, verbose_name='冲抵来源ID') offset_id = models.BigIntegerField(null=True, blank=True, verbose_name='冲抵来源ID')
cancelled = models.BooleanField(default=False, verbose_name='已被冲抵') cancelled = models.BooleanField(default=False, verbose_name='已被冲抵')
cancelled_at = models.DateTimeField(null=True, blank=True, 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: class Meta:
verbose_name = '余额变动记录' verbose_name = '余额变动记录'

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import uuid
from collections import OrderedDict from collections import OrderedDict
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
@@ -34,8 +35,9 @@ class BalanceService:
request_id: str | None = None, request_id: str | None = None,
remarks: str | None = '', remarks: str | None = '',
extra_meta: Dict[str, Any] | None = None, extra_meta: Dict[str, Any] | None = None,
): red_flush_id=None,
BalanceService._adjust_balance( ) -> models.BalanceChangeRecord:
return BalanceService._adjust_balance(
merchant=merchant, merchant=merchant,
counterparty=supplier, counterparty=supplier,
balance_model=models.SupplierBalance, balance_model=models.SupplierBalance,
@@ -47,6 +49,7 @@ class BalanceService:
request_id=request_id, request_id=request_id,
remarks=remarks, remarks=remarks,
extra_meta=extra_meta, extra_meta=extra_meta,
red_flush_id=red_flush_id,
) )
@staticmethod @staticmethod
@@ -60,8 +63,9 @@ class BalanceService:
request_id: str | None = None, request_id: str | None = None,
remarks: str | None = '', remarks: str | None = '',
extra_meta: Dict[str, Any] | None = None, extra_meta: Dict[str, Any] | None = None,
): red_flush_id=None,
BalanceService._adjust_balance( ) -> models.BalanceChangeRecord:
return BalanceService._adjust_balance(
merchant=merchant, merchant=merchant,
counterparty=customer, counterparty=customer,
balance_model=models.CustomerBalance, balance_model=models.CustomerBalance,
@@ -73,6 +77,7 @@ class BalanceService:
request_id=request_id, request_id=request_id,
remarks=remarks, remarks=remarks,
extra_meta=extra_meta, extra_meta=extra_meta,
red_flush_id=red_flush_id,
) )
@staticmethod @staticmethod
@@ -89,7 +94,8 @@ class BalanceService:
request_id: str | None, request_id: str | None,
remarks: str | None, remarks: str | None,
extra_meta: Dict[str, Any] | None, extra_meta: Dict[str, Any] | None,
): red_flush_id,
) -> models.BalanceChangeRecord:
meta_payload = extra_meta or {} meta_payload = extra_meta or {}
remarks_value = remarks or '' remarks_value = remarks or ''
with transaction.atomic(): with transaction.atomic():
@@ -118,13 +124,14 @@ class BalanceService:
'request_id': request_id, 'request_id': request_id,
'remarks': remarks_value, 'remarks': remarks_value,
'extra_meta': meta_payload, 'extra_meta': meta_payload,
'red_flush_id': red_flush_id,
} }
if target_type == models.BalanceChangeTargetEnum.SUPPLIER: if target_type == models.BalanceChangeTargetEnum.SUPPLIER:
record_kwargs['supplier'] = counterparty record_kwargs['supplier'] = counterparty
else: else:
record_kwargs['customer'] = counterparty record_kwargs['customer'] = counterparty
models.BalanceChangeRecord.objects.create(**record_kwargs) return models.BalanceChangeRecord.objects.create(**record_kwargs)
@staticmethod @staticmethod
def get_customer_balance( 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( def _normalize_order_items(
*, *,
merchant: basic_info_models.Merchant, merchant: basic_info_models.Merchant,
@@ -2468,4 +2786,3 @@ def _normalize_statement_amount(value) -> Decimal:
def _decimal_to_string(value: Decimal) -> str: def _decimal_to_string(value: Decimal) -> str:
normalized = _normalize_statement_amount(value) normalized = _normalize_statement_amount(value)
return format(normalized, 'f') return format(normalized, 'f')

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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'))

View File

@@ -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.

View File

@@ -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
```

View File

@@ -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`
- 按产品、颜色、规格、批次等维度限制退货数量。
- 校验同一原单累计已审核退货数量。
- 在退货单或余额变动记录中明确记录冲销关系。

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -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

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

View File

@@ -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

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -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

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -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'),
),
]

View File

@@ -96,6 +96,7 @@ class StockChangeRecord(ModelBase):
source_id = models.BigIntegerField(null=True, blank=True, verbose_name='来源单据ID') source_id = models.BigIntegerField(null=True, blank=True, verbose_name='来源单据ID')
is_finished = models.BooleanField(default=False, verbose_name='完成出入库') is_finished = models.BooleanField(default=False, verbose_name='完成出入库')
finished_at = models.DateTimeField(null=True, blank=True, 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='备注') remarks = models.TextField(blank=True, null=True, verbose_name='备注')
def __str__(self): def __str__(self):

View File

@@ -428,6 +428,7 @@ class StockFlowService:
items: List[Dict[str, Any]] | None = None, items: List[Dict[str, Any]] | None = None,
request_id: str | None = None, request_id: str | None = None,
extra_meta: Dict[str, Any] | None = None, extra_meta: Dict[str, Any] | None = None,
red_flush_id=None,
) -> Tuple[models.StockChangeRecord, List[models.StockChangeDetail], int]: ) -> Tuple[models.StockChangeRecord, List[models.StockChangeDetail], int]:
""" """
创建针对既有库存变动记录的红冲(对冲)占位接口。 创建针对既有库存变动记录的红冲(对冲)占位接口。
@@ -438,6 +439,7 @@ class StockFlowService:
items: 可选的部分红冲明细(支持宽进/严进等结构);为空时表示全量对冲。 items: 可选的部分红冲明细(支持宽进/严进等结构);为空时表示全量对冲。
request_id: 幂等键;同一 request_id 的请求应视为一次操作。 request_id: 幂等键;同一 request_id 的请求应视为一次操作。
extra_meta: 额外的上下文信息(触发来源、操作者备注等)。 extra_meta: 额外的上下文信息(触发来源、操作者备注等)。
red_flush_id: 业务红冲批次 ID用于跨模块审计追踪。
Returns: Returns:
新生成的反向 `StockChangeRecord`、其明细列表以及明细数量。 新生成的反向 `StockChangeRecord`、其明细列表以及明细数量。
@@ -452,6 +454,7 @@ class StockFlowService:
reason=reason.strip(), reason=reason.strip(),
request_id=request_id, request_id=request_id,
extra_meta=extra_meta or {}, extra_meta=extra_meta or {},
red_flush_id=red_flush_id,
) )
return offset_record, created_details, len(created_details) return offset_record, created_details, len(created_details)
@@ -462,6 +465,7 @@ class StockFlowService:
reason: str, reason: str,
request_id: str | None, request_id: str | None,
extra_meta: Dict[str, Any], extra_meta: Dict[str, Any],
red_flush_id,
) -> Tuple[models.StockChangeRecord, List[models.StockChangeDetail]]: ) -> Tuple[models.StockChangeRecord, List[models.StockChangeDetail]]:
with transaction.atomic(): with transaction.atomic():
source_record = ( source_record = (
@@ -503,9 +507,14 @@ class StockFlowService:
source_type=models.StockChangeSourceEnum.OFFSET, source_type=models.StockChangeSourceEnum.OFFSET,
source_id=source_record.id, source_id=source_record.id,
created_by=self.created_by, created_by=self.created_by,
red_flush_id=red_flush_id,
remarks=remarks[:500], 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] = [] created_details: List[models.StockChangeDetail] = []
for detail in source_details: for detail in source_details:
new_detail = models.StockChangeDetail.objects.create( new_detail = models.StockChangeDetail.objects.create(