forked from erp-dev/erp
feat: red flush
This commit is contained in:
326
api_v1/tests.py
326
api_v1/tests.py
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
106
business/migrations/0031_red_flush_fields.py
Normal file
106
business/migrations/0031_red_flush_fields.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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 = '余额变动记录'
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
438
business/tests/test_red_flush_services.py
Normal file
438
business/tests/test_red_flush_services.py
Normal 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,
|
||||||
|
)
|
||||||
343
business/tests/test_service_edge_cases.py
Normal file
343
business/tests/test_service_edge_cases.py
Normal 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,
|
||||||
|
)
|
||||||
165
business/tests/test_statement_services.py
Normal file
165
business/tests/test_statement_services.py
Normal 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'))
|
||||||
75
docs/2026-06-12_business_red_flush_api.md
Normal file
75
docs/2026-06-12_business_red_flush_api.md
Normal 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.
|
||||||
159
docs/2026-06-12_business_red_flush_design.md
Normal file
159
docs/2026-06-12_business_red_flush_design.md
Normal 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
|
||||||
|
```
|
||||||
158
docs/2026-06-12_business_reverse_audit.md
Normal file
158
docs/2026-06-12_business_reverse_audit.md
Normal 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`。
|
||||||
|
- 按产品、颜色、规格、批次等维度限制退货数量。
|
||||||
|
- 校验同一原单累计已审核退货数量。
|
||||||
|
- 在退货单或余额变动记录中明确记录冲销关系。
|
||||||
BIN
docs/plant/business-function-architecture-simple.png
Normal file
BIN
docs/plant/business-function-architecture-simple.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
55
docs/plant/business-function-architecture-simple.puml
Normal file
55
docs/plant/business-function-architecture-simple.puml
Normal 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
|
||||||
1
docs/plant/business-function-architecture-simple.svg
Normal file
1
docs/plant/business-function-architecture-simple.svg
Normal file
File diff suppressed because one or more lines are too long
BIN
docs/plant/business-function-architecture.png
Normal file
BIN
docs/plant/business-function-architecture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 460 KiB |
137
docs/plant/business-function-architecture.puml
Normal file
137
docs/plant/business-function-architecture.puml
Normal 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
|
||||||
1
docs/plant/business-function-architecture.svg
Normal file
1
docs/plant/business-function-architecture.svg
Normal file
File diff suppressed because one or more lines are too long
BIN
docs/plant/business-module-object-catalog.png
Normal file
BIN
docs/plant/business-module-object-catalog.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
146
docs/plant/business-module-object-catalog.puml
Normal file
146
docs/plant/business-module-object-catalog.puml
Normal 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
|
||||||
1
docs/plant/business-module-object-catalog.svg
Normal file
1
docs/plant/business-module-object-catalog.svg
Normal file
File diff suppressed because one or more lines are too long
BIN
docs/plant/business-module-object-map.png
Normal file
BIN
docs/plant/business-module-object-map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 288 KiB |
227
docs/plant/business-module-object-map.puml
Normal file
227
docs/plant/business-module-object-map.puml
Normal 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
|
||||||
1
docs/plant/business-module-object-map.svg
Normal file
1
docs/plant/business-module-object-map.svg
Normal file
File diff suppressed because one or more lines are too long
16
stock/migrations/0010_stockchangerecord_red_flush_id.py
Normal file
16
stock/migrations/0010_stockchangerecord_red_flush_id.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user