1
0
forked from erp-dev/erp

feat: red flush and cost

This commit is contained in:
2026-06-22 22:27:28 +08:00
parent 9a1c92febf
commit 70b3a0d246
25 changed files with 1375 additions and 20 deletions

2
.env
View File

@@ -1,6 +1,6 @@
SECRET_KEY=testing_$weak_secret_is_allowd
DEBUG=True
ALLOWED_HOSTS=yuwenerp.yuwen.cloud,localhost,
ALLOWED_HOSTS=yuwenerp.yuwen.cloud,localhost,testbackend.yuwen.cloud
DB_HOST=pgm-7xvr252q2037b3s5.pg.rds.aliyuncs.com
DB_PORT=6432
DB_NAME=flower

View File

@@ -224,8 +224,10 @@ class PurchaseOrderAPITestCase(TestCase):
self.assertIn('id', response.data)
self.assertIn('human_id', response.data)
self.assertEqual(response.data['status'], 1)
self.assertEqual(response.data['balance_before_snapshot'], Decimal('0'))
self.assertIn('等待审批', response.data['message'])
order = business_models.PurchaseOrder.objects.get(id=response.data['id'])
self.assertEqual(order.balance_before_snapshot, Decimal('0'))
expected_human_id = f"CG{order.created_at.strftime('%Y%m%d')}{order.id:06d}"
self.assertEqual(response.data['human_id'], expected_human_id)
mock_delay.assert_not_called()
@@ -499,6 +501,8 @@ class SalesOrderAPITestCase(TestCase):
expected_human_id = f"XS{order.created_at.strftime('%Y%m%d')}{order.id:06d}"
self.assertEqual(response.data['human_id'], expected_human_id)
self.assertEqual(response.data['status'], business_models.SalesOrderStatusEnum.PENDING)
self.assertEqual(response.data['balance_before_snapshot'], Decimal('0'))
self.assertEqual(order.balance_before_snapshot, Decimal('0'))
mock_delay.assert_not_called()
def test_create_sales_order_invalid_customer(self):
@@ -726,7 +730,9 @@ class PurchaseReturnOrderAPITestCase(TestCase):
self.assertIn('id', response.data)
self.assertIn('human_id', response.data)
self.assertEqual(response.data['status'], business_models.PurchaseReturnStatusEnum.PENDING)
self.assertEqual(response.data['balance_before_snapshot'], Decimal('0'))
order = business_models.PurchaseReturnOrder.objects.get(id=response.data['id'])
self.assertEqual(order.balance_before_snapshot, Decimal('0'))
expected_human_id = f"CT{order.created_at.strftime('%Y%m%d')}{order.id:06d}"
self.assertEqual(response.data['human_id'], expected_human_id)
@@ -880,7 +886,9 @@ class SalesReturnOrderAPITestCase(TestCase):
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertIn('human_id', response.data)
self.assertEqual(response.data['status'], business_models.SalesReturnStatusEnum.PENDING)
self.assertEqual(response.data['balance_before_snapshot'], Decimal('0'))
order = business_models.SalesReturnOrder.objects.get(id=response.data['id'])
self.assertEqual(order.balance_before_snapshot, Decimal('0'))
expected_human_id = f"XT{order.created_at.strftime('%Y%m%d')}{order.id:06d}"
self.assertEqual(response.data['human_id'], expected_human_id)
@@ -1462,6 +1470,43 @@ class BusinessRedFlushAPITestCase(TestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data['is_red_flushed'])
def test_sales_order_red_flush_without_stock_success(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,
)
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'])
order.refresh_from_db()
self.assertTrue(order.is_red_flushed)
self.assertEqual(
business_models.BalanceChangeRecord.objects.filter(
source_type=business_models.BalanceChangeSourceEnum.SALES_ORDER,
source_id=order.id,
red_flush_id=order.red_flush_id,
).count(),
2,
)
self.assertFalse(
stock_models.StockChangeRecord.objects.filter(red_flush_id=order.red_flush_id).exists()
)
def test_purchase_return_order_red_flush_success(self):
order = self._create_approved_purchase_return_order()
response = self.client.post(

View File

@@ -54,12 +54,14 @@ class PurchaseOrderSerializer(serializers.ModelSerializer):
'total_amount', 'diff_quantity', 'total_quantity',
'operator', 'operator_name', 'warehouse', 'warehouse_name',
'status', 'is_red_flushed', 'red_flush_id', 'red_flushed_at',
'balance_before_snapshot',
'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',
'balance_before_snapshot',
]
@@ -142,6 +144,7 @@ class PurchaseOrderView(StockChangeViewMixin, views.APIView):
'id': purchase_order.id,
'human_id': purchase_order.human_id,
'status': purchase_order.status,
'balance_before_snapshot': purchase_order.balance_before_snapshot,
'message': '采购单创建成功,等待审批',
},
status=status.HTTP_201_CREATED,

View File

@@ -58,12 +58,14 @@ class PurchaseReturnOrderSerializer(serializers.ModelSerializer):
'total_amount', 'diff_quantity', 'total_quantity',
'operator', 'operator_name', 'warehouse', 'warehouse_name',
'status', 'is_red_flushed', 'red_flush_id', 'red_flushed_at',
'balance_before_snapshot',
'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',
'balance_before_snapshot',
]
@@ -144,6 +146,7 @@ class PurchaseReturnOrderView(StockChangeViewMixin, views.APIView):
'id': purchase_return.id,
'human_id': purchase_return.human_id,
'status': purchase_return.status,
'balance_before_snapshot': purchase_return.balance_before_snapshot,
'message': '采购退货单创建成功,等待审批',
},
status=status.HTTP_201_CREATED,

View File

@@ -55,12 +55,14 @@ class SalesOrderSerializer(serializers.ModelSerializer):
'total_amount', 'diff_quantity', 'total_quantity',
'operator', 'operator_name', 'warehouse', 'warehouse_name',
'status', 'is_red_flushed', 'red_flush_id', 'red_flushed_at',
'balance_before_snapshot',
'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',
'balance_before_snapshot',
]
@@ -83,6 +85,29 @@ class SalesOrderView(StockChangeViewMixin, views.APIView):
queryset = business_models.SalesOrder.objects.filter(merchant=merchant).prefetch_related(
'items', 'customer', 'operator', 'warehouse'
)
# Query 参数过滤
qp = request.query_params
customer_id = qp.get('customer')
warehouse_id = qp.get('warehouse')
status_value = qp.get('status')
kind = qp.get('kind')
if customer_id:
try:
queryset = queryset.filter(customer_id=int(customer_id))
except (TypeError, ValueError):
pass
if warehouse_id:
try:
queryset = queryset.filter(warehouse_id=int(warehouse_id))
except (TypeError, ValueError):
pass
if status_value:
queryset = queryset.filter(status=status_value)
if kind:
queryset = queryset.filter(kind=kind)
paginator = self.pagination_class()
page = paginator.paginate_queryset(queryset.order_by('-created_at'), request, view=self)
serializer = SalesOrderSerializer(page, many=True)
@@ -145,6 +170,7 @@ class SalesOrderView(StockChangeViewMixin, views.APIView):
'id': sales_order.id,
'human_id': sales_order.human_id,
'status': sales_order.status,
'balance_before_snapshot': sales_order.balance_before_snapshot,
'message': '销售单创建成功,等待审批',
},
status=status.HTTP_201_CREATED,

View File

@@ -57,12 +57,14 @@ class SalesReturnOrderSerializer(serializers.ModelSerializer):
'total_amount', 'diff_quantity', 'total_quantity', 'quantity_of_rolls',
'operator', 'operator_name', 'warehouse', 'warehouse_name',
'status', 'is_red_flushed', 'red_flush_id', 'red_flushed_at',
'balance_before_snapshot',
'remarks', 'created_at', 'updated_at', 'items',
]
read_only_fields = [
'id', 'created_at', 'updated_at', 'items',
'customer_name', 'operator_name', 'warehouse_name',
'is_red_flushed', 'red_flush_id', 'red_flushed_at',
'balance_before_snapshot',
]
@@ -143,6 +145,7 @@ class SalesReturnOrderView(StockChangeViewMixin, views.APIView):
'id': sales_return.id,
'human_id': sales_return.human_id,
'status': sales_return.status,
'balance_before_snapshot': sales_return.balance_before_snapshot,
'message': '销售退货单创建成功,等待审批',
},
status=status.HTTP_201_CREATED,

View File

@@ -303,7 +303,7 @@ class PrintingOrderCreateUpdateSerializer(serializers.ModelSerializer):
'fabric_source', 'is_fabric_received', 'craft', 'description',
'outgoing_date', 'curve', 'new_curve', 'position',
'printing_warn', 'rolling_warn', 'production_warn', 'is_invalid',
'process'
'process', 'external_order_id',
]
read_only_fields = ['id']
@@ -651,6 +651,58 @@ class PlateOrderDesignCodeMixin:
return data
class PlateOrderKanbanListSerializer(serializers.ModelSerializer):
"""开版订单看板列表序列化器(精简版,仅包含看板实际使用的字段,消除 N+1 查询)"""
customer_name = serializers.CharField(source="customer.name", read_only=True)
salesperson_name = serializers.CharField(source="salesperson.name", read_only=True)
merchandiser_name = serializers.CharField(source="merchandiser.name", read_only=True)
created_by_name = serializers.CharField(source="created_by.employee.name", read_only=True, default=None)
process_name = serializers.CharField(source="annotated_process_name", read_only=True, default=None)
plate_image = serializers.SerializerMethodField()
plate_image_url = serializers.SerializerMethodField()
last_completed_state = serializers.CharField(read_only=True)
class Meta:
model = models.PlateOrder
fields = [
'id', 'design_code', 'customer_name', 'area',
'salesperson_name', 'merchandiser_name', 'fabric_source', 'fabric',
'width', 'plate_type', 'plate_date', 'plate_method',
'plate_image', 'plate_image_url', 'plate_notes', 'reprint_reason',
'urgency_level', 'production_method', 'is_mark_frame',
'drawing_rating', 'color_matching_rating', 'sample_rating',
'difficulty_rating', 'style_name', 'required_sample_meters',
'required_completion_date', 'completion_date', 'approval_result',
'customer_feedback', 'last_completed_state', 'business_object_id',
'original_id', 'process_name', 'print_count', 'created_at',
'created_by_name',
]
def _get_cached_plate_images(self, obj):
cache_attr = '_cached_plate_images'
images = getattr(obj, cache_attr, None)
if images is None:
images = _serialize_plate_images(
getattr(obj, 'plate_image', None),
self.context.get('request'),
)
setattr(obj, cache_attr, images)
return images
def get_plate_image(self, obj):
return self._get_cached_plate_images(obj)
def get_plate_image_url(self, obj):
images = self._get_cached_plate_images(obj)
return [entry.get('url') for entry in images if entry.get('url')]
def to_representation(self, instance):
data = super().to_representation(instance)
if not data.get('design_code') and instance.id:
data['design_code'] = str(instance.id)
return data
class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSerializer):
"""开版订单列表序列化器"""
customer_name = serializers.CharField(source="customer.name", read_only=True)

View File

@@ -10,7 +10,7 @@ from rest_framework.permissions import BasePermission
from rest_framework.permissions import DjangoModelPermissions
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from django.core.exceptions import ValidationError
from django.db.models import CharField, Exists, OuterRef, Prefetch
from django.db.models import CharField, Exists, OuterRef, Prefetch, Subquery
from django.db.models.functions import Cast, Coalesce
from django.views.decorators.cache import cache_page
from django.views.decorators.http import condition
@@ -1065,13 +1065,14 @@ class PlateOrderViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
def get_serializer_class(self):
"""根据动作选择序列化器"""
from .serializers import (
PlateOrderKanbanListSerializer,
PlateOrderListSerializer,
PlateOrderDetailSerializer,
PlateOrderCreateUpdateSerializer,
)
if self.action == "list":
return PlateOrderListSerializer
return PlateOrderKanbanListSerializer
elif self.action in ["create", "update", "partial_update"]:
return PlateOrderCreateUpdateSerializer
else: # retrieve
@@ -1092,13 +1093,24 @@ class PlateOrderViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
if self.action in ["list", "retrieve"]:
queryset = queryset.select_related(
"customer", "salesperson", "merchandiser", "business_object"
"customer",
"salesperson",
"merchandiser",
"business_object",
"created_by__employee",
)
# 为搜索提供 design_code 的兜底(为空时使用主键字符串)
# 预取流程名称process 是 IntegerField 非外键,用 Subquery 避免 N+1
from stateflow.models import Process
queryset = queryset.annotate(
design_code_normalized=Coalesce(
"design_code", Cast("id", output_field=CharField())
)
),
annotated_process_name=Subquery(
Process.objects.filter(id=OuterRef("process")).values("name")[:1],
output_field=CharField(),
),
)
return queryset

View File

@@ -0,0 +1,31 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('business', '0031_red_flush_fields'),
]
operations = [
migrations.AddField(
model_name='purchaseorder',
name='balance_before_snapshot',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True, verbose_name='创建前供应商余额快照'),
),
migrations.AddField(
model_name='salesorder',
name='balance_before_snapshot',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True, verbose_name='创建前客户余额快照'),
),
migrations.AddField(
model_name='purchasereturnorder',
name='balance_before_snapshot',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True, verbose_name='创建前供应商余额快照'),
),
migrations.AddField(
model_name='salesreturnorder',
name='balance_before_snapshot',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True, verbose_name='创建前客户余额快照'),
),
]

View File

@@ -133,6 +133,10 @@ class PurchaseOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounte
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='红冲时间')
balance_before_snapshot = models.DecimalField(
max_digits=15, decimal_places=2, null=True, blank=True,
verbose_name='创建前供应商余额快照',
)
from_pre_purchase_order_id = models.BigIntegerField(
null=True,
blank=True,
@@ -283,6 +287,10 @@ class SalesOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounterpa
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='红冲时间')
balance_before_snapshot = models.DecimalField(
max_digits=15, decimal_places=2, null=True, blank=True,
verbose_name='创建前客户余额快照',
)
remarks = models.TextField(blank=True, null=True, verbose_name='备注')
from_pre_sales_order_id = models.BigIntegerField(
blank=True,
@@ -795,6 +803,10 @@ class PurchaseReturnOrder(OrderItemsAggregationMixin, OrderDirectionMixin, Order
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='红冲时间')
balance_before_snapshot = models.DecimalField(
max_digits=15, decimal_places=2, null=True, blank=True,
verbose_name='创建前供应商余额快照',
)
remarks = models.TextField(blank=True, null=True, verbose_name='备注')
class Meta:
@@ -923,6 +935,10 @@ class SalesReturnOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCou
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='红冲时间')
balance_before_snapshot = models.DecimalField(
max_digits=15, decimal_places=2, null=True, blank=True,
verbose_name='创建前客户余额快照',
)
remarks = models.TextField(blank=True, null=True, verbose_name='备注')
class Meta:

View File

@@ -288,6 +288,9 @@ def create_purchase_order(
purchase_order = models.PurchaseOrder.objects.create(
merchant=merchant,
supplier=supplier,
balance_before_snapshot=BalanceService.get_supplier_balance(
merchant=merchant, supplier=supplier,
),
purchase_date=normalized_date,
operator=operator,
warehouse=warehouse,
@@ -425,6 +428,9 @@ def create_sales_order(
sales_order = models.SalesOrder.objects.create(
merchant=merchant,
customer=customer,
balance_before_snapshot=BalanceService.get_customer_balance(
merchant=merchant, customer=customer,
),
sales_date=normalized_date,
operator=operator,
warehouse=warehouse,
@@ -577,6 +583,9 @@ def create_purchase_return_order(
return_order = models.PurchaseReturnOrder.objects.create(
merchant=merchant,
supplier=supplier,
balance_before_snapshot=BalanceService.get_supplier_balance(
merchant=merchant, supplier=supplier,
),
purchase_order=resolved_purchase_order,
return_date=normalized_date,
operator=operator,
@@ -727,6 +736,9 @@ def create_sales_return_order(
return_order = models.SalesReturnOrder.objects.create(
merchant=merchant,
customer=customer,
balance_before_snapshot=BalanceService.get_customer_balance(
merchant=merchant, customer=customer,
),
sales_order=resolved_sales_order,
return_date=normalized_date,
operator=operator,
@@ -1164,6 +1176,7 @@ def red_flush_sales_order(
approved_status=models.SalesOrderStatusEnum.APPROVED,
balance_source_type=models.BalanceChangeSourceEnum.SALES_ORDER,
stock_source_type=stock_models.StockChangeSourceEnum.SALES,
require_stock_records=False,
counterparty_field='customer',
error_label='销售单',
red_flushed_by=red_flushed_by,
@@ -1279,6 +1292,7 @@ def _red_flush_order_impl(
stock_source_type: stock_models.StockChangeSourceEnum | None,
counterparty_field: str,
error_label: str,
require_stock_records: bool = True,
red_flushed_by=None,
reason: str | None = '',
):
@@ -1328,6 +1342,7 @@ def _red_flush_order_impl(
red_flushed_by=red_flushed_by,
reason=reason_text,
error_label=error_label,
require_stock_records=require_stock_records,
)
locked_order.is_red_flushed = True
@@ -1412,6 +1427,7 @@ def _red_flush_stock_records_for_order(
red_flushed_by,
reason: str,
error_label: str,
require_stock_records: bool = True,
) -> None:
stock_records = list(
stock_models.StockChangeRecord.objects.select_for_update().filter(
@@ -1421,7 +1437,9 @@ def _red_flush_stock_records_for_order(
).order_by('id')
)
if not stock_records:
raise ValueError(f'{error_label}缺少可红冲的库存记录')
if require_stock_records:
raise ValueError(f'{error_label}缺少可红冲的库存记录')
return
stock_service = StockFlowService(merchant=order.merchant, created_by=red_flushed_by)
for stock_record in stock_records:
@@ -2532,6 +2550,7 @@ class _CustomerStatementBuilder(_StatementBuilder):
merchant=self.merchant,
customer=customer,
status=models.SalesOrderStatusEnum.APPROVED,
is_red_flushed=False,
)
.select_related('customer', 'warehouse')
.prefetch_related('items__product')
@@ -2565,6 +2584,7 @@ class _CustomerStatementBuilder(_StatementBuilder):
merchant=self.merchant,
customer=customer,
status=models.SalesReturnStatusEnum.APPROVED,
is_red_flushed=False,
)
.select_related('customer', 'warehouse')
.prefetch_related('items__product')
@@ -2598,6 +2618,7 @@ class _CustomerStatementBuilder(_StatementBuilder):
merchant=self.merchant,
customer=customer,
status=models.ReceiptOrderStatusEnum.APPROVED,
is_red_flushed=False,
)
.select_related('customer')
)
@@ -2685,6 +2706,7 @@ class _SupplierStatementBuilder(_StatementBuilder):
merchant=self.merchant,
supplier=supplier,
status=models.PurchaseOrderStatusEnum.APPROVED,
is_red_flushed=False,
)
.select_related('supplier', 'warehouse')
.prefetch_related('items__product')
@@ -2718,6 +2740,7 @@ class _SupplierStatementBuilder(_StatementBuilder):
merchant=self.merchant,
supplier=supplier,
status=models.PurchaseReturnStatusEnum.APPROVED,
is_red_flushed=False,
)
.select_related('supplier', 'warehouse')
.prefetch_related('items__product')
@@ -2751,6 +2774,7 @@ class _SupplierStatementBuilder(_StatementBuilder):
merchant=self.merchant,
supplier=supplier,
status=models.PaymentOrderStatusEnum.APPROVED,
is_red_flushed=False,
)
.select_related('supplier')
)

View File

@@ -0,0 +1,225 @@
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 OrderBalanceBeforeSnapshotTestCase(TestCase):
def setUp(self):
(
self.supplier_merchant,
self.supplier,
_supplier_strict_warehouse,
self.supplier_warehouse,
self.supplier_product,
self.supplier_operator,
) = create_basic_fixtures()
(
self.customer_merchant,
self.customer,
_customer_strict_warehouse,
self.customer_warehouse,
_customer_strict_out_warehouse,
self.customer_product,
self.customer_operator,
) = create_sales_fixtures()
def _supplier_items(self):
return [
{
'product_id': self.supplier_product.id,
'quantity': '3',
'num_of_rolls': 1,
'price': '12.50',
}
]
def _customer_items(self):
return [
{
'product_id': self.customer_product.id,
'quantity': '4',
'num_of_rolls': 1,
'price': '8.25',
}
]
def _create_supplier_orders(self):
purchase_order = services.create_purchase_order(
merchant=self.supplier_merchant,
supplier=self.supplier,
order_date=timezone.now().date(),
warehouse=self.supplier_warehouse,
operator=self.supplier_operator,
items=self._supplier_items(),
)
purchase_return_order = services.create_purchase_return_order(
merchant=self.supplier_merchant,
supplier=self.supplier,
return_date=timezone.now().date(),
warehouse=self.supplier_warehouse,
operator=self.supplier_operator,
items=self._supplier_items(),
purchase_order=purchase_order,
)
return purchase_order, purchase_return_order
def _create_customer_orders(self):
sales_order = services.create_sales_order(
merchant=self.customer_merchant,
customer=self.customer,
order_date=timezone.now().date(),
warehouse=self.customer_warehouse,
operator=self.customer_operator,
items=self._customer_items(),
)
sales_return_order = services.create_sales_return_order(
merchant=self.customer_merchant,
customer=self.customer,
return_date=timezone.now().date(),
warehouse=self.customer_warehouse,
operator=self.customer_operator,
items=self._customer_items(),
sales_order=sales_order,
)
return sales_order, sales_return_order
def test_all_four_creation_services_capture_current_balance(self):
business_models.SupplierBalance.objects.create(
merchant=self.supplier_merchant,
supplier=self.supplier,
balance=Decimal('123.45'),
)
business_models.CustomerBalance.objects.create(
merchant=self.customer_merchant,
customer=self.customer,
balance=Decimal('-67.89'),
)
purchase_order, purchase_return_order = self._create_supplier_orders()
sales_order, sales_return_order = self._create_customer_orders()
self.assertEqual(purchase_order.balance_before_snapshot, Decimal('123.45'))
self.assertEqual(purchase_return_order.balance_before_snapshot, Decimal('123.45'))
self.assertEqual(sales_order.balance_before_snapshot, Decimal('-67.89'))
self.assertEqual(sales_return_order.balance_before_snapshot, Decimal('-67.89'))
def test_missing_balance_rows_snapshot_zero_instead_of_null(self):
purchase_order, purchase_return_order = self._create_supplier_orders()
sales_order, sales_return_order = self._create_customer_orders()
for order in (
purchase_order,
purchase_return_order,
sales_order,
sales_return_order,
):
self.assertEqual(order.balance_before_snapshot, Decimal('0'))
self.assertIsNotNone(order.balance_before_snapshot)
def test_updates_and_later_balance_changes_do_not_recalculate_snapshot(self):
supplier_balance = business_models.SupplierBalance.objects.create(
merchant=self.supplier_merchant,
supplier=self.supplier,
balance=Decimal('100.00'),
)
customer_balance = business_models.CustomerBalance.objects.create(
merchant=self.customer_merchant,
customer=self.customer,
balance=Decimal('200.00'),
)
purchase_order, purchase_return_order = self._create_supplier_orders()
sales_order, sales_return_order = self._create_customer_orders()
other_supplier = basic_models.Supplier.objects.create(
merchant=self.supplier_merchant,
name='快照测试供应商B',
)
other_customer = basic_models.Customer.objects.create(
merchant=self.customer_merchant,
name='快照测试客户B',
)
business_models.SupplierBalance.objects.create(
merchant=self.supplier_merchant,
supplier=other_supplier,
balance=Decimal('999.00'),
)
business_models.CustomerBalance.objects.create(
merchant=self.customer_merchant,
customer=other_customer,
balance=Decimal('888.00'),
)
supplier_balance.balance = Decimal('300.00')
supplier_balance.save(update_fields=['balance', 'updated_at'])
customer_balance.balance = Decimal('400.00')
customer_balance.save(update_fields=['balance', 'updated_at'])
services.update_purchase_order(
purchase_order=purchase_order,
supplier=other_supplier,
items=self._supplier_items(),
)
services.update_purchase_return_order(
purchase_return_order=purchase_return_order,
supplier=other_supplier,
items=self._supplier_items(),
)
services.update_sales_order(
sales_order=sales_order,
customer=other_customer,
items=self._customer_items(),
)
services.update_sales_return_order(
sales_return_order=sales_return_order,
customer=other_customer,
items=self._customer_items(),
)
for order in (purchase_order, purchase_return_order):
order.refresh_from_db()
self.assertEqual(order.balance_before_snapshot, Decimal('100.00'))
for order in (sales_order, sales_return_order):
order.refresh_from_db()
self.assertEqual(order.balance_before_snapshot, Decimal('200.00'))
def test_api_serializers_expose_snapshot_as_read_only(self):
business_models.SupplierBalance.objects.create(
merchant=self.supplier_merchant,
supplier=self.supplier,
balance=Decimal('123.45'),
)
business_models.CustomerBalance.objects.create(
merchant=self.customer_merchant,
customer=self.customer,
balance=Decimal('67.89'),
)
purchase_order, purchase_return_order = self._create_supplier_orders()
sales_order, sales_return_order = self._create_customer_orders()
from api_v1.views.business.purchase.views import PurchaseOrderSerializer
from api_v1.views.business.purchase_return.views import PurchaseReturnOrderSerializer
from api_v1.views.business.sales.views import SalesOrderSerializer
from api_v1.views.business.sales_return.views import SalesReturnOrderSerializer
cases = (
(PurchaseOrderSerializer, purchase_order, '123.45'),
(PurchaseReturnOrderSerializer, purchase_return_order, '123.45'),
(SalesOrderSerializer, sales_order, '67.89'),
(SalesReturnOrderSerializer, sales_return_order, '67.89'),
)
for serializer_class, order, expected in cases:
with self.subTest(serializer=serializer_class.__name__):
self.assertEqual(serializer_class(order).data['balance_before_snapshot'], expected)
serializer = serializer_class(
order,
data={'balance_before_snapshot': '9999.99'},
partial=True,
)
self.assertTrue(serializer.is_valid(), serializer.errors)
self.assertNotIn('balance_before_snapshot', serializer.validated_data)

View File

@@ -146,6 +146,37 @@ class BusinessRedFlushServiceTestCase(TestCase):
red_flush_id=flushed.red_flush_id,
)
def test_red_flush_sales_order_without_stock_reverses_balance_only(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,
)
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.assertFalse(
stock_models.StockChangeRecord.objects.filter(red_flush_id=flushed.red_flush_id).exists()
)
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)

View File

@@ -1,10 +1,13 @@
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
@@ -163,3 +166,342 @@ class BusinessStatementServiceTestCase(TestCase):
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'))
class BusinessStatementRedFlushExclusionTestCase(TestCase):
"""对账单 builders 必须排除已红冲的正式单据。
Bug 复核:红冲服务在保留单据 status=APPROVED 的同时把 is_red_flushed 置为 True。
若 _build_*_records 仅过滤 status已红冲单据会以原金额继续出现在对账单 records 中,
导致 summary 的 positive_total/negative_total 把红冲金额多算一次。
本测试类对客户侧 3 类与供应商侧 3 类单据各红冲一次后,断言:
- 对应 source_type 不在 records 列表中
- summary 的金额合计中没有该单金额
"""
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(username='statement-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 _create_and_complete_purchase_order(self, *, merchant, supplier, warehouse, operator, product, quantity, price):
order = services.create_purchase_order(
merchant=merchant,
supplier=supplier,
order_date=timezone.now().date(),
warehouse=warehouse,
operator=operator,
items=[{'product_id': product.id, 'quantity': quantity, 'num_of_rolls': 1, 'price': str(price)}],
remarks='采购对账',
)
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'])
return order
# ============ 客户侧 ============
def test_red_flushed_sales_order_excluded_from_customer_statement(self):
merchant, customer, warehouse_strict, warehouse_relaxed, _, product, operator = create_sales_fixtures()
self._disable_auto_stock_tasks(merchant)
today = timezone.now().date()
# 一个保留的销售单 + 一个会被红冲的销售单
keep = 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=keep,
target_status=business_models.SalesOrderStatusEnum.APPROVED,
reviewed_by=self.user,
)
to_flush = services.create_sales_order(
merchant=merchant,
customer=customer,
order_date=today,
warehouse=warehouse_strict,
operator=operator,
items=[{'product_id': product.id, 'numbers': [5, 5], 'price': '20', 'unit': ''}],
remarks='红冲销售单',
)
services.review_sales_order(
sales_order=to_flush,
target_status=business_models.SalesOrderStatusEnum.APPROVED,
reviewed_by=self.user,
)
services.red_flush_sales_order(
merchant=merchant,
sales_order=to_flush,
red_flushed_by=self.user,
reason='测试红冲销售单',
)
payload = services.build_customer_statement(merchant=merchant, customer=customer)
sales_records = [r for r in payload['records'] if r['source_type'] == 'sales_order']
self.assertEqual(len(sales_records), 1)
self.assertEqual(sales_records[0]['source_id'], keep.id)
summary = services.build_statement_summary(payload)
# 保留单金额 = 10 * 10 = 100若红冲单未排除会再多 200 = 300
self.assertEqual(summary['positive_total'], '100.00')
def test_red_flushed_sales_return_order_excluded_from_customer_statement(self):
merchant, customer, warehouse_strict, warehouse_relaxed, _, product, operator = create_sales_fixtures()
self._disable_auto_stock_tasks(merchant)
today = timezone.now().date()
def _make_sales_return(quantity, price, remark):
ret = services.create_sales_return_order(
merchant=merchant,
customer=customer,
return_date=today,
warehouse=warehouse_relaxed,
operator=operator,
items=[{'product_id': product.id, 'quantity': quantity, 'num_of_rolls': 1, 'price': str(price)}],
remarks=remark,
)
services.review_sales_return_order(
sales_return_order=ret,
target_status=business_models.SalesReturnStatusEnum.APPROVED,
reviewed_by=self.user,
)
sync_payload = services.create_sales_return_order_stock_entries_sync(
sales_return_order_id=ret.id,
warehouse_id=warehouse_relaxed.id,
items=services._build_stock_flow_items_from_order(ret),
created_by_id=self.user.id,
)
self._complete_stock_record(sync_payload['stock_change_record_id'])
return ret
keep = _make_sales_return(3, Decimal('10'), '保留销退')
to_flush = _make_sales_return(2, Decimal('20'), '红冲销退')
services.red_flush_sales_return_order(
merchant=merchant,
sales_return_order=to_flush,
red_flushed_by=self.user,
reason='测试红冲销退单',
)
payload = services.build_customer_statement(merchant=merchant, customer=customer)
sr_records = [r for r in payload['records'] if r['source_type'] == 'sales_return_order']
self.assertEqual(len(sr_records), 1)
self.assertEqual(sr_records[0]['source_id'], keep.id)
summary = services.build_statement_summary(payload)
# 保留销退 negative=3*10=30红冲若未排除会再多 2*20=40合计 70
self.assertEqual(summary['negative_total'], '30.00')
def test_red_flushed_receipt_order_excluded_from_customer_statement(self):
merchant, customer, _wh1, _wh2, _, _, operator = create_sales_fixtures()
self._disable_auto_stock_tasks(merchant)
today = timezone.now().date()
keep = services.create_receipt_order(
merchant=merchant,
customer=customer,
receipt_date=today,
amount='40',
operator=operator,
remarks='保留收款',
)
services.review_receipt_order(
receipt_order=keep,
target_status=business_models.ReceiptOrderStatusEnum.APPROVED,
reviewed_by=self.user,
)
to_flush = services.create_receipt_order(
merchant=merchant,
customer=customer,
receipt_date=today,
amount='25',
operator=operator,
remarks='红冲收款',
)
services.review_receipt_order(
receipt_order=to_flush,
target_status=business_models.ReceiptOrderStatusEnum.APPROVED,
reviewed_by=self.user,
)
services.red_flush_receipt_order(
merchant=merchant,
receipt_order=to_flush,
red_flushed_by=self.user,
reason='测试红冲收款单',
)
payload = services.build_customer_statement(merchant=merchant, customer=customer)
receipt_records = [r for r in payload['records'] if r['source_type'] == 'receipt_order']
self.assertEqual(len(receipt_records), 1)
self.assertEqual(receipt_records[0]['source_id'], keep.id)
summary = services.build_statement_summary(payload)
# 保留收款 negative=40红冲未排除会再 +25 = 65
self.assertEqual(summary['negative_total'], '40.00')
# ============ 供应商侧 ============
def test_red_flushed_purchase_order_excluded_from_supplier_statement(self):
merchant, supplier, warehouse_strict, warehouse_relaxed, product, operator = create_basic_fixtures()
self._disable_auto_stock_tasks(merchant)
keep = self._create_and_complete_purchase_order(
merchant=merchant, supplier=supplier, warehouse=warehouse_relaxed,
operator=operator, product=product, quantity=10, price=Decimal('5'),
)
to_flush = self._create_and_complete_purchase_order(
merchant=merchant, supplier=supplier, warehouse=warehouse_relaxed,
operator=operator, product=product, quantity=8, price=Decimal('7'),
)
services.red_flush_purchase_order(
merchant=merchant,
purchase_order=to_flush,
red_flushed_by=self.user,
reason='测试红冲采购单',
)
payload = services.build_supplier_statement(merchant=merchant, supplier=supplier)
purchase_records = [r for r in payload['records'] if r['source_type'] == 'purchase_order']
self.assertEqual(len(purchase_records), 1)
self.assertEqual(purchase_records[0]['source_id'], keep.id)
summary = services.build_statement_summary(payload)
# 保留单 10*5=50若红冲单未排除会再多 8*7=56合计 106
self.assertEqual(summary['positive_total'], '50.00')
def test_red_flushed_purchase_return_order_excluded_from_supplier_statement(self):
merchant, supplier, warehouse_strict, warehouse_relaxed, product, operator = create_basic_fixtures()
self._disable_auto_stock_tasks(merchant)
today = timezone.now().date()
# 先建一个采购单提供库存底盘,然后做两个退货单(一个保留 + 一个红冲)
purchase = self._create_and_complete_purchase_order(
merchant=merchant, supplier=supplier, warehouse=warehouse_relaxed,
operator=operator, product=product, quantity=50, price=Decimal('5'),
)
def _make_return(quantity, price, remark):
ret = services.create_purchase_return_order(
merchant=merchant,
supplier=supplier,
return_date=today,
warehouse=warehouse_relaxed,
operator=operator,
items=[{'product_id': product.id, 'quantity': quantity, 'num_of_rolls': 1, 'price': str(price)}],
remarks=remark,
purchase_order=purchase,
)
services.review_purchase_return_order(
purchase_return_order=ret,
target_status=business_models.PurchaseReturnStatusEnum.APPROVED,
reviewed_by=self.user,
)
sync_payload = services.create_purchase_return_order_stock_entries_sync(
purchase_return_order_id=ret.id,
warehouse_id=warehouse_relaxed.id,
items=services._build_stock_flow_items_from_order(ret),
created_by_id=self.user.id,
)
self._complete_stock_record(sync_payload['stock_change_record_id'])
return ret
keep = _make_return(3, Decimal('5'), '保留采退')
to_flush = _make_return(2, Decimal('7'), '红冲采退')
services.red_flush_purchase_return_order(
merchant=merchant,
purchase_return_order=to_flush,
red_flushed_by=self.user,
reason='测试红冲采退单',
)
payload = services.build_supplier_statement(merchant=merchant, supplier=supplier)
pr_records = [r for r in payload['records'] if r['source_type'] == 'purchase_return_order']
self.assertEqual(len(pr_records), 1)
self.assertEqual(pr_records[0]['source_id'], keep.id)
summary = services.build_statement_summary(payload)
# 保留采退 negative=3*5=15若红冲未排除会再多 2*7=14合计 29
self.assertEqual(summary['negative_total'], '15.00')
def test_red_flushed_payment_order_excluded_from_supplier_statement(self):
merchant, supplier, _wh1, _wh2, _product, operator = create_basic_fixtures()
self._disable_auto_stock_tasks(merchant)
today = timezone.now().date()
keep = services.create_payment_order(
merchant=merchant,
supplier=supplier,
payment_date=today,
amount='30',
operator=operator,
remarks='保留付款',
)
services.review_payment_order(
payment_order=keep,
target_status=business_models.PaymentOrderStatusEnum.APPROVED,
reviewed_by=self.user,
)
to_flush = services.create_payment_order(
merchant=merchant,
supplier=supplier,
payment_date=today,
amount='18',
operator=operator,
remarks='红冲付款',
)
services.review_payment_order(
payment_order=to_flush,
target_status=business_models.PaymentOrderStatusEnum.APPROVED,
reviewed_by=self.user,
)
services.red_flush_payment_order(
merchant=merchant,
payment_order=to_flush,
red_flushed_by=self.user,
reason='测试红冲付款单',
)
payload = services.build_supplier_statement(merchant=merchant, supplier=supplier)
payment_records = [r for r in payload['records'] if r['source_type'] == 'payment_order']
self.assertEqual(len(payment_records), 1)
self.assertEqual(payment_records[0]['source_id'], keep.id)
summary = services.build_statement_summary(payload)
# 保留付款 negative=30若红冲未排除会再多 18 合计 48
self.assertEqual(summary['negative_total'], '30.00')

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -85,7 +85,7 @@ services:
DJANGO_SETTINGS_MODULE: flower.settings
PYTHONPATH: /app
DEBUG: "1"
ALLOWED_HOSTS: "localhost,127.0.0.1,0.0.0.0,8.148.215.233,yuwenerp.yuwen.cloud"
ALLOWED_HOSTS: "localhost,127.0.0.1,0.0.0.0,8.148.215.233,yuwenerp.yuwen.cloud,testbackend.yuwen.cloud"
DB_HOST: pgbouncer
DB_PORT: "6432"
DB_NAME: flower

View File

@@ -72,7 +72,7 @@ The API first scopes order lookup by `request.user.employee.merchant`. The servi
## 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.
`red_flush_id` is written to the source order and related `BalanceChangeRecord` rows. It is also written to related `StockChangeRecord` rows when the source order actually has inventory records. Sales orders may be red-flushed without inventory records; in that case only the balance side is reversed. Existing `offset_to`/`offset_id` relationships remain the precise reverse-link mechanism for balance and stock records.
## Test Coverage
@@ -85,4 +85,4 @@ python manage.py test business.tests.test_red_flush_services api_v1.tests.Busine
Coverage expectations:
- Service tests cover all six order types, balance reversal, inventory reversal, duplicate blocking, merchant isolation, missing balance/stock records, transaction rollback, and external payment/receipt rejection.
- API tests cover all six endpoints, required `reason`, employee permission rejection, merchant-scoped 404, non-approved orders, duplicate red flush, external payment/receipt rejection, stock-validation error mapping, and sampled balance/stock side effects.
- API tests cover all six endpoints, required `reason`, employee permission rejection, merchant-scoped 404, non-approved orders, duplicate red flush, external payment/receipt rejection, stock-validation error mapping, sales red flush without stock records, and sampled balance/stock side effects.

View File

@@ -10,6 +10,7 @@
- 红冲后原单据 `status` 保持 `APPROVED`,不回退、不作废。
- 原单据增加“已红冲”标记,用于列表查询提速。
- 因为 `status` 保持 `APPROVED`**对账单 builder 必须额外按 `is_red_flushed=False` 过滤**,否则红冲单会被原值计入 `positive_amount`/`negative_amount`summary 多算一倍。
- 每次红冲由 service 自动生成一个 UUID 类型的 `red_flush_id`
- `red_flush_id` 用于跨表、跨记录追踪同一次红冲涉及的所有数据,是审计关联批次 ID。
- 不新增 `red_flush_no`。该编号只适合人工展示,目前无需求。
@@ -103,7 +104,7 @@
- 只允许 `APPROVED` 单据红冲。
- `is_red_flushed=True` 的单据不能重复红冲。
- 原始余额变动记录必须存在且未被冲抵。
- 有库存影响的单据必须找到对应库存记录,库存记录必须已完成且未被红冲。
- 有库存影响且要求库存闭环的单据必须找到对应库存记录,库存记录必须已完成且未被红冲;销售单允许不存在库存记录,此时只红冲余额
- 资金和库存任一红冲失败,整体事务回滚。
资金侧实现:
@@ -125,7 +126,7 @@
新增 service 测试覆盖:
- 采购单整单红冲:余额反向、库存反向、单据标记、批次 ID 写入。
- 销售单整单红冲:余额反向、库存反向、单据标记、批次 ID 写入。
- 销售单整单红冲:余额反向、单据标记、批次 ID 写入;若存在销售出库记录则同步库存反向,若无库存记录则跳过库存红冲
- 采购退货单整单红冲。
- 销售退货单整单红冲。
- 付款单整单红冲。
@@ -143,11 +144,25 @@ API 测试覆盖:
- 无员工身份权限拒绝。
- 跨商户查询隔离。
- 外部付款/收款单拒绝。
- 未审核、重复红冲、缺库存记录等 service 校验错误映射为 400。
- 未审核、重复红冲、采购等要求库存闭环的单据缺库存记录等 service 校验错误映射为 400。
- 成功响应包含 `is_red_flushed``red_flush_id``red_flushed_at`,并抽样断言余额/库存副作用。
目标red flush 关键 service/API 路径需要有定向测试覆盖。
## 对账单 builder 联动修复2026-06-22
`business.services._CustomerStatementBuilder._build_sales_records` / `_build_sales_return_records` / `_build_receipt_records` 以及 `_SupplierStatementBuilder._build_purchase_records` / `_build_purchase_return_records` / `_build_payment_records` 之前只过滤 `status=APPROVED`,未过滤 `is_red_flushed`
红冲后单据 `status` 仍为 `APPROVED`,所以会继续以原金额出现在 `build_customer_statement` / `build_supplier_statement``records` 列表中,被 `build_statement_summary` 双倍计入 `positive_total`/`negative_total`
修复办法6 个 builder queryset 全部追加 `is_red_flushed=False`
注意:
- `BalanceService.get_customer_balance` / `get_supplier_balance` 读取 `CustomerBalance/SupplierBalance` 表,红冲服务通过反向 `BalanceChangeRecord` 已经把余额抵消,因此 `current_balance` 字段一直是正确的;只有 `records``summary` 受影响。
- `ExternalCustomerStatementOrder` 目前不在 6 类红冲入口里,相关 builder/adjustment 暂不需要过滤,将来若开放外部单红冲必须同步更新。
定向测试:见 `business.tests.test_statement_services.BusinessStatementRedFlushExclusionTestCase`,覆盖 6 类正式单据红冲后 statement 中 `records` 不再包含、`summary` 不再多算。
## 测试命令
开发环境在容器内运行测试,并绕过 PgBouncer

View File

@@ -0,0 +1,423 @@
# 四类主要业务单据 `balance_before_snapshot` 实现交接
日期2026-06-22
状态已完成。核心实现、入口扫描、定向测试、API 测试、迁移检查及相关回归测试均已通过。
## 0. 最终完成结果
本任务已于 2026-06-22 完成,当前没有余额快照相关的待实现项。
最终落地内容:
- 四类主单模型均已新增可空 `balance_before_snapshot`
- 新增 `0032_order_balance_before_snapshot` 迁移,不回填历史数据。
- 四个正式创建 service 均必写当前本地往来余额;不存在余额行时写 `0`
- 更新、往来单位变更、余额后续变化、审批、作废及红冲均不重算快照。
- 四类 API serializer 输出该字段并设为只读。
- 四个 POST 创建响应均显式返回该字段。
- 生产代码扫描确认没有绕过正式 service 直接创建四类主单的入口。
- 预销售转正式销售调用 `business_services.create_sales_order()`,已自然覆盖快照逻辑。
新增测试文件:`business/tests/test_balance_before_snapshot.py`,共 4 个测试,覆盖:
- 四类创建读取已有正数/负数余额。
- 无余额行时四类创建均写 `0` 而非 `NULL`
- 四类更新、更换往来单位以及余额后续变化均不重算快照。
- 四类 API serializer 输出且拒绝客户端写入快照。
另在 `api_v1/tests.py` 四个既有成功创建测试中加入 POST 响应和数据库落值断言。
已在 `web` 容器内使用 `DB_HOST=postgres DB_PORT=5432` 直连 PostgreSQL、绕过 PgBouncer 完成验证:
- `makemigrations --check --dry-run`:通过,`No changes detected`
- `manage.py check`通过0 issues。
- 新增余额快照定向测试4/4 通过。
- 四类单据 service/API 回归74/74 通过。
- 红冲、对账单及红冲 API 共享服务回归36/36 通过。
- 本任务相关已跟踪文件 `git diff --check`:通过。
- 新增迁移与新增测试文件的补丁格式检查:通过。
下文保留了实现细节和原始验收清单,方便以后维护和排查回归;其中“尚未完成”内容已经全部执行完毕。
## 1. 需求目标
为以下四类主要业务单据增加 `balance_before_snapshot` 字段:
1. `PurchaseOrder`(采购单)
2. `SalesOrder`(销售单)
3. `PurchaseReturnOrder`(采购退货单)
4. `SalesReturnOrder`(销售退货单)
字段语义必须保持一致:
- 字段记录单据正常创建时,对应往来单位在本地余额表中的当前余额。
- 采购单、采购退货单记录供应商余额,即 `SupplierBalance.balance`
- 销售单、销售退货单记录客户余额,即 `CustomerBalance.balance`
- 对应余额记录不存在时,快照写入 `Decimal('0')`,不能因为数据库字段可空而让正常创建的新单据写入 `NULL`
- 数据库字段必须允许 `NULL`,用于兼容迁移前的历史单据。
- 不对历史数据执行回填或推算;历史行保持 `NULL`
- 快照只在创建时写一次。之后即使往来余额发生变化,或者单据被修改、审批、作废、红冲,都不能重新计算或覆盖该字段。
- 客户快照采用本地 `CustomerBalance` 口径,不叠加 `ExternalCustomerStatementOrder`。当前实现调用 `BalanceService.get_customer_balance()`,没有调用 `get_customer_statement_balance()`
## 2. 接手前工作区情况
本次任务开始时工作区已有大量未提交修改,主要涉及:
- 业务单据红冲逻辑和测试。
- 对账单排除已红冲单据。
- 销售单列表查询过滤。
- 开版看板序列化与查询优化。
- 环境、域名、文档及 Celery schedule 文件。
这些改动属于上一轮或用户已有工作,不能清理、回退或覆盖。后续只应增量修改余额快照相关文件。特别不要使用 `git reset --hard``git checkout --` 等命令。
`business/services.py``api_v1/views/business/sales/views.py` 中,本次余额快照修改与已有未提交修改位于同一文件。审阅 diff 时应按具体代码块区分,不要把整份文件都视为本任务新增。
## 3. 已写入的实现
### 3.1 模型字段
文件:`business/models.py`
已在四个模型中加入 `balance_before_snapshot`
```python
balance_before_snapshot = models.DecimalField(
max_digits=15,
decimal_places=2,
null=True,
blank=True,
verbose_name='创建前供应商余额快照', # 客户侧为“创建前客户余额快照”
)
```
具体口径:
| 模型 | 快照对象 | verbose_name |
|---|---|---|
| `PurchaseOrder` | 供应商余额 | `创建前供应商余额快照` |
| `SalesOrder` | 客户余额 | `创建前客户余额快照` |
| `PurchaseReturnOrder` | 供应商余额 | `创建前供应商余额快照` |
| `SalesReturnOrder` | 客户余额 | `创建前客户余额快照` |
字段没有默认值,数据库允许为空。这样迁移不会给历史单据制造一个看似真实但无法验证的余额。
### 3.2 数据库迁移
文件:`business/migrations/0032_order_balance_before_snapshot.py`
已新增迁移,依赖:
```python
dependencies = [
('business', '0031_red_flush_fields'),
]
```
迁移包含四个 `AddField`,全部是:
- `DecimalField(max_digits=15, decimal_places=2)`
- `null=True`
- `blank=True`
-`default`
- 无数据迁移和历史回填
需要在后续验证迁移依赖仍然是当前分支最新叶子;当前检查时 `0031_red_flush_fields.py` 是最新已存在迁移,`0032` 是本任务新文件。
### 3.3 正常创建服务写入快照
文件:`business/services.py`
以下四个服务函数的 `objects.create(...)` 已加入快照赋值:
#### `create_purchase_order(...)`
```python
balance_before_snapshot=BalanceService.get_supplier_balance(
merchant=merchant,
supplier=supplier,
),
```
#### `create_sales_order(...)`
```python
balance_before_snapshot=BalanceService.get_customer_balance(
merchant=merchant,
customer=customer,
),
```
#### `create_purchase_return_order(...)`
```python
balance_before_snapshot=BalanceService.get_supplier_balance(
merchant=merchant,
supplier=supplier,
),
```
#### `create_sales_return_order(...)`
```python
balance_before_snapshot=BalanceService.get_customer_balance(
merchant=merchant,
customer=customer,
),
```
这些调用位于各自已有的 `transaction.atomic()` 中,并在创建主单据时直接写字段。
`BalanceService.get_supplier_balance()``get_customer_balance()` 的既有行为是:余额行存在则返回其 `balance`;不存在则返回 `Decimal('0')`。因此正常服务创建入口理论上不会写入 `NULL`
### 3.4 “之后不重算”的当前实现状态
四个更新服务当前均未把 `balance_before_snapshot` 放进赋值或 `update_fields`
- `update_purchase_order(...)`
- `update_sales_order(...)`
- `update_purchase_return_order(...)`
- `update_sales_return_order(...)`
审批、作废、红冲逻辑也没有写该字段。模型没有为该字段增加 `save()` 自动计算或 signal。因此按照当前代码结构创建完成后不会自动重算。
这一点尚缺定向测试,必须补测试防止未来回归。
### 3.5 API 输出
以下四个 ModelSerializer 的 `fields` 已加入 `balance_before_snapshot`,同时加入 `read_only_fields`
- `api_v1/views/business/purchase/views.py`
- `PurchaseOrderSerializer`
- `api_v1/views/business/sales/views.py`
- `SalesOrderSerializer`
- `api_v1/views/business/purchase_return/views.py`
- `PurchaseReturnOrderSerializer`
- `api_v1/views/business/sales_return/views.py`
- `SalesReturnOrderSerializer`
因此列表、详情、更新结果、审批结果和红冲结果只要使用上述 serializer都会输出该字段且客户端不能通过 serializer 修改它。
四个创建 API 当前返回的是手工构造的精简字典,不走 ModelSerializer所以创建成功响应中也已显式加入
```python
'balance_before_snapshot': order.balance_before_snapshot,
```
具体变量名分别为:
- `purchase_order.balance_before_snapshot`
- `sales_order.balance_before_snapshot`
- `purchase_return.balance_before_snapshot`
- `sales_return.balance_before_snapshot`
## 4. 已完成的收尾工作(原验收计划)
### 4.1 扫描所有生产创建入口
已确认四个主要 API 创建入口均调用 `business.services` 中对应的 `create_*` 函数,因此已覆盖:
- `POST` 采购单
- `POST` 销售单
- `POST` 采购退货单
- `POST` 销售退货单
但最后一次完整生产代码扫描尚未完成。接手后应再次执行:
```bash
rg -n "create_(purchase_order|sales_order|purchase_return_order|sales_return_order)\(" \
api_v1 business \
--glob '!business/services.py' \
--glob '!**/tests/**' \
--glob '!**/test*.py'
```
还应扫描是否有生产代码绕过 service 直接创建四类模型:
```bash
rg -n "(PurchaseOrder|SalesOrder|PurchaseReturnOrder|SalesReturnOrder)\.objects\.(create|get_or_create|update_or_create|bulk_create)" \
api_v1 business \
--glob '!**/tests/**' \
--glob '!**/test*.py'
```
如果发现正常业务入口绕过 service应让其改用 service或在同一创建事务中显式写快照。数据导入、测试 fixture、历史修复脚本不一定属于“正常创建入口”需要按用途判断不能盲目强制。
预销售单/预采购单转正式单通常会调用正式单据 service需要通过上述扫描确认避免漏掉转换入口。
### 4.2 补定向服务测试
建议新增:
`business/tests/test_balance_before_snapshot.py`
至少覆盖以下场景。
#### 场景 A四类创建均写当前余额
1. 使用 `create_basic_fixtures()` 创建供应商侧数据。
2. 建立或更新 `SupplierBalance(balance=Decimal('123.45'))`
3. 通过 `create_purchase_order()` 创建采购单,断言快照为 `123.45`
4. 通过 `create_purchase_return_order()` 创建采购退货单,断言快照为 `123.45`
5. 使用 `create_sales_fixtures()` 创建客户侧数据。
6. 建立或更新 `CustomerBalance(balance=Decimal('-67.89'))`
7. 通过 `create_sales_order()` 创建销售单,断言快照为 `-67.89`
8. 通过 `create_sales_return_order()` 创建销售退货单,断言快照为 `-67.89`
测试应调用正式 service而不是直接 `objects.create()`,以验证正常入口。
#### 场景 B余额行不存在时新单快照写零而不是 NULL
对供应商侧和客户侧至少各测一个:
```python
self.assertEqual(order.balance_before_snapshot, Decimal('0'))
self.assertIsNotNone(order.balance_before_snapshot)
```
这是“数据库允许空”和“正常创建必写”之间最容易回归的边界。
#### 场景 C余额变化后不重算
1. 余额为 `100.00` 时创建单据。
2. 创建后把对应 `SupplierBalance`/`CustomerBalance` 改成其他值,或审批另一张会改变余额的单据。
3. `refresh_from_db()` 原单据。
4. 断言快照仍为 `100.00`
#### 场景 D更新单据后不重算
更新服务允许审批中的单据更换供应商/客户。需要明确验证快照仍是最初创建时的值,而不是新往来单位当前余额:
1. 往来单位 A 余额为 `100.00`,创建单据。
2. 往来单位 B 余额为 `999.00`
3. 调用对应 `update_*` 将单据往来单位改为 B并提交合法 items。
4. 断言 `balance_before_snapshot` 仍为 `100.00`
供应商侧与客户侧至少各覆盖一次。若产品语义认为更换往来单位应另有约束,也不能在更新时重算快照;需求已经明确“之后不重算”。
#### 场景 E序列化字段只读
可以在 API 测试中验证:
- 创建响应包含正确快照。
- 列表或详情响应包含正确快照。
- PATCH/PUT 请求即使携带伪造的 `balance_before_snapshot`,数据库值仍不变。
由于当前 API 更新路径手工提取允许字段本身不会读取该请求字段serializer 中也已标记只读。
### 4.3 迁移与系统检查
需要运行:
```bash
python manage.py makemigrations --check --dry-run
python manage.py check
```
预期:
- `makemigrations --check --dry-run` 不再生成额外迁移。
- `manage.py check` 无本任务引入的问题。
若项目约定在 Docker 内运行,应使用现有项目测试容器,并按当前开发环境配置绕过 PgBouncer。不要擅自修改 `.env``docker-compose.yml` 或数据库配置来迁就测试;这些文件已有其他未提交改动。
### 4.4 定向测试命令
新增测试文件后建议先运行:
```bash
python manage.py test business.tests.test_balance_before_snapshot
```
然后运行四类既有 service/API 测试,具体模块可根据项目现有测试命名选择:
```bash
python manage.py test \
business.tests.test_purchase_order \
business.tests.test_sales_order \
business.tests.test_purchase_return \
business.tests.test_sales_return
```
API 测试集中在 `api_v1/tests.py` 时,可至少运行相关 TestCase
```bash
python manage.py test \
api_v1.tests.PurchaseOrderAPITestCase \
api_v1.tests.SalesOrderAPITestCase \
api_v1.tests.PurchaseReturnOrderAPITestCase \
api_v1.tests.SalesReturnOrderAPITestCase
```
最后根据时间运行更宽范围回归。由于工作区已有红冲与 statement 修改,如果宽范围测试失败,需先判断失败属于余额快照还是已有未提交工作。
## 5. 需要重点复核的设计点
### 5.1 快照时点
当前实现是在四个创建 service 的 `transaction.atomic()` 内、执行主单 `objects.create()` 参数求值时读取余额。这符合“创建时快照”。
当前读取函数没有使用 `select_for_update()`。在绝大多数正常流程中会读取当时已提交的余额;但如果产品要求与并发余额调整建立严格串行顺序,需要额外评估是否应锁定余额行。
不要未经评估直接把读取改成 `get_or_create(...).select_for_update()`:这会使每次创建单据都创建一行零余额记录,改变现有数据行为。若要增强并发语义,应先查看项目现有并发策略和数据库隔离级别,并补并发测试。
本需求当前未明确要求强锁,建议先保持现有简单读取,完成基础测试后再决定是否扩展。
### 5.2 更新时更换往来单位
当前更新服务允许把审批中的采购类单据改为另一供应商,或把销售类单据改为另一客户。快照仍保留创建时旧往来单位余额。
这看起来可能与更新后的往来单位不一致,但符合“创建时快照,之后不重算”的明确要求。不要在更新 service 中加入重算,除非用户重新定义业务语义。
### 5.3 历史数据
迁移不能回填 `0`,也不能依据当前余额倒推历史余额。历史单据的正确创建前余额通常无法可靠恢复,因此 `NULL` 本身就是“未知”的有效表达。
### 5.4 API 可写性
字段必须是输出字段而不是客户端输入字段。当前 serializer 已标记只读,创建 API 也没有读取客户端传入的同名字段。后续若引入新的 create serializer要继续把字段设为 read-only并由 service 计算。
## 6. 建议的完成顺序
1.`git diff``git status --short` 确认上述八个代码/迁移文件的实际状态。
2. 扫描所有生产创建入口和直接 ORM 创建点。
3.`business/tests/test_balance_before_snapshot.py`
4. 补必要的 API 字段测试。
5. 运行 `makemigrations --check --dry-run``manage.py check`
6. 运行定向测试及四类既有回归测试。
7. 执行 `git diff --check`;注意工作区已有文件可能存在与本任务无关的尾部空行问题,应区分来源。
8. 最终审阅只聚焦本任务的文件/代码块,保留所有既有未提交修改。
## 7. 本任务相关文件清单
已修改或新增:
- `business/models.py`
- `business/services.py`
- `business/migrations/0032_order_balance_before_snapshot.py`(新增、未提交)
- `api_v1/views/business/purchase/views.py`
- `api_v1/views/business/sales/views.py`
- `api_v1/views/business/purchase_return/views.py`
- `api_v1/views/business/sales_return/views.py`
计划新增:
- `business/tests/test_balance_before_snapshot.py`
可能按测试结果增量修改:
- `api_v1/tests.py`,或拆分后的对应 API 测试文件
- 业务 API 文档(只有在项目要求公开列出响应字段时再补,不是核心实现的阻塞项)
## 8. 完成判定
只有同时满足以下条件,才能认为本任务完成:
- 四个数据库字段存在且允许 `NULL`
- 迁移不回填历史数据。
- 四个正式创建 service 对余额存在和不存在两种情况均写入非空快照。
- 所有正常生产创建入口都经过上述逻辑。
- 更新、审批、作废、红冲以及余额后续变化不会改写快照。
- 四类 API 能返回快照,且客户端不能写入或篡改。
- 模型与迁移一致。
- 定向测试通过,既有四类业务单据测试无本任务引入的回归。

View File

@@ -293,7 +293,7 @@
| API | 方法 | 描述 |
|-----|------|------|
| `/purchase-orders/<id>/red-flush/` | POST | 红冲已审批采购单,反向余额和库存影响 |
| `/sales-orders/<id>/red-flush/` | POST | 红冲已审批销售单反向余额和库存影响 |
| `/sales-orders/<id>/red-flush/` | POST | 红冲已审批销售单反向余额,若存在销售出库记录则同步反向库存 |
| `/purchase-return-orders/<id>/red-flush/` | POST | 红冲已审批采购退货单,反向余额和库存影响 |
| `/sales-return-orders/<id>/red-flush/` | POST | 红冲已审批销售退货单,反向余额和库存影响 |
| `/payment-orders/<id>/red-flush/` | POST | 红冲已审批付款单,反向供应商余额 |
@@ -307,7 +307,7 @@
}
```
`reason` 必填且不能为空。红冲成功后原单据状态保持已审批,并返回 `is_red_flushed=true``red_flush_id``red_flushed_at`。外部来源付款/收款单不允许红冲。
`reason` 必填且不能为空。红冲成功后原单据状态保持已审批,并返回 `is_red_flushed=true``red_flush_id``red_flushed_at`销售单没有对应库存记录时只反向余额,不执行库存红冲。外部来源付款/收款单不允许红冲。
## 7. 对账单Statements
@@ -337,7 +337,7 @@
| 非本商户数据 | 403 | `{"error": "forbidden", "message": "无权限访问"}` |
| 单据不存在 | 404 | `{"error": "purchase_order_not_found"}` 等 |
| 审批非法状态 | 400 | 例如 `{"error": "purchase_order_has_stock_records"}``{"error": "receipt_order_already_approved"}` |
| 红冲非法状态 | 400 | 例如未审批、重复红冲、缺少可红冲库存/余额记录、外部来源单据不允许红冲 |
| 红冲非法状态 | 400 | 例如未审批、重复红冲、要求库存闭环的单据缺少可红冲库存记录、缺少可红冲余额记录、外部来源单据不允许红冲 |
---

View File

@@ -70,11 +70,11 @@
## 8. 红冲(对冲)落地状态
与采购单一致,销售单已支持整单红冲。已审批销售单可通过 `POST /api/v1/sales-orders/<id>/red-flush/` 触发,生成反向余额记录反向库存记录。关键原则:
销售单已支持整单红冲。已审批销售单可通过 `POST /api/v1/sales-orders/<id>/red-flush/` 触发,生成反向余额记录;若原销售单存在 `source_type=SALES` 的出库记录,则同步生成反向库存记录。关键原则:
- 采用新增反向记录的方式,保留所有历史变动。
- 红冲记录需要指向原 `StockChangeRecord` / `StockSnapshot`,保证审计可追溯。
- 红冲需与业务对象绑定,避免孤立库存操作
- 存在库存记录时,红冲记录需要指向原 `StockChangeRecord` / `StockSnapshot`,保证审计可追溯。
- 红冲需与业务对象绑定;没有库存记录的销售单只处理余额侧,不执行库存红冲
红冲能力已补充独立 API 文档与定向测试,确保审批、出库、红冲形成闭环。详细 API 见 `docs/2026-06-12_business_red_flush_api.md`

View File

@@ -0,0 +1,102 @@
# 开版看板 `/api/v1/plate-orders/` GET 接口字段使用清单
整理范围:只统计开版管理看板页面对 `GET /api/v1/plate-orders/` 列表接口返回数据的字段使用情况。
主要代码位置:
- `src/pages/Production/PlateOrderKanban.tsx`
- `src/pages/Production/PlateOrderDetailModal.tsx`
- `src/pages/Production/PlatePrintTemplate.tsx`
## 响应结构字段
| 字段 | 用途 |
|---|---|
| `results` | 订单列表数据源 |
| `count` | 判断后端总数是否大于本次加载数量,超出时提示用户缩小“下单时间”范围 |
## 订单对象字段
| 字段 | 用途 |
|---|---|
| `id` | 卡片 ID、选择、拖拽、详情、编辑、打印、二维码内容、打印次数对象 ID |
| `auto_code` | 卡片副标题;打印顶部订单编号,缺失时用 `design_code` 兜底 |
| `design_code` | 卡片主标题;详情设计编号;打印设计编号;流程弹窗业务对象名称 |
| `customer_name` | 卡片客户;详情客户名称;打印客户 |
| `area` | 详情客户区域;打印默认地址/区域 |
| `salesperson_name` | 详情业务人员;打印业务员 |
| `merchandiser_name` | 详情跟单人员;打印跟单员 |
| `fabric_source` | 详情面料来源;打印布料 |
| `fabric` | 详情面料名称;打印打版面料 |
| `width` | 详情面料幅宽;打印幅宽 |
| `plate_type` | 卡片起版情况标签;详情起版情况;打印起版情况 |
| `plate_date` | 卡片下版时间;详情订单时间;打印订单时间 |
| `plate_method` | 详情开版方式;打印开版方式 |
| `plate_image_url` | 卡片图片优先来源;详情图片预览;打印开版图 |
| `plate_image` | 卡片图片兜底来源 |
| `plate_notes` | 详情打版备注;打印打版注意事项 |
| `reprint_reason` | 打印复版原因 |
| `urgency_level` | 卡片紧急程度;详情紧急程度;打印紧急程度 |
| `production_method` | 详情做货方式;打印做货方式 |
| `is_mark_frame` | 打印是否套唛架 |
| `drawing_rating` | 详情画图评级;打印画图难度 |
| `color_matching_rating` | 详情调色评级;打印调色难度 |
| `sample_rating` | 详情套样评级;打印套样难度 |
| `difficulty_rating` | 详情难度评级;打印难度评级 |
| `style_name` | 详情款号名称;打印款号名称 |
| `required_sample_meters` | 打印样品米数 |
| `required_completion_date` | 详情开版时间;打印开版时间 |
| `completion_date` | 详情要求时间;打印要求时间 |
| `approval_result` | 打印审批结果 |
| `customer_feedback` | 打印客户反馈 |
| `last_completed_state` | 看板分列依据;卡片状态;动态补充流程列;快速跳转当前状态 |
| `business_object_id` | 推进、回退、快速跳转;详情/打印加载当前订单工序日志 |
| `original_id` | 详情原订单入口;打印历史工序入口;批量打印历史工序入口 |
| `process_name` | 卡片流程标签;详情关联流程;打印开发进程 |
| `print_count` | 卡片打印次数;判断进入“未打印”或“客户开版”列 |
| `created_at` | 组装进打印数据,但当前打印模板未实际展示 |
| `created_by_name` | 打印拥有者/制单人 |
## 去重字段列表
```text
id
auto_code
design_code
customer_name
area
salesperson_name
merchandiser_name
fabric_source
fabric
width
plate_type
plate_date
plate_method
plate_image_url
plate_image
plate_notes
reprint_reason
urgency_level
production_method
is_mark_frame
drawing_rating
color_matching_rating
sample_rating
difficulty_rating
style_name
required_sample_meters
required_completion_date
completion_date
approval_result
customer_feedback
last_completed_state
business_object_id
original_id
process_name
print_count
created_at
created_by_name
```
说明:`next``previous` 当前看板未使用;`status``is_completed``is_ordered``is_invalid``progress_percentage``updated_at``created_by` 只在复制订单时重置/透传逻辑里出现,不属于当前列表 GET 响应的实际展示或看板流程消费字段,未计入本清单。

View File

@@ -52,9 +52,11 @@ DEBUG = env('DEBUG', default=False)
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[
'yuwenerp.yuwen.cloud',
'testbackend.yuwen.cloud',
])
CSRF_TRUSTED_ORIGINS = env.list('CSRF_TRUSTED_ORIGINS', default=[
'https://yuwenerp.yuwen.cloud',
'https://testbackend.yuwen.cloud',
])
# 明道云同步配置