forked from erp-dev/erp
feat: red flush and cost
This commit is contained in:
2
.env
2
.env
@@ -1,6 +1,6 @@
|
|||||||
SECRET_KEY=testing_$weak_secret_is_allowd
|
SECRET_KEY=testing_$weak_secret_is_allowd
|
||||||
DEBUG=True
|
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_HOST=pgm-7xvr252q2037b3s5.pg.rds.aliyuncs.com
|
||||||
DB_PORT=6432
|
DB_PORT=6432
|
||||||
DB_NAME=flower
|
DB_NAME=flower
|
||||||
|
|||||||
@@ -224,8 +224,10 @@ class PurchaseOrderAPITestCase(TestCase):
|
|||||||
self.assertIn('id', response.data)
|
self.assertIn('id', response.data)
|
||||||
self.assertIn('human_id', response.data)
|
self.assertIn('human_id', response.data)
|
||||||
self.assertEqual(response.data['status'], 1)
|
self.assertEqual(response.data['status'], 1)
|
||||||
|
self.assertEqual(response.data['balance_before_snapshot'], Decimal('0'))
|
||||||
self.assertIn('等待审批', response.data['message'])
|
self.assertIn('等待审批', response.data['message'])
|
||||||
order = business_models.PurchaseOrder.objects.get(id=response.data['id'])
|
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}"
|
expected_human_id = f"CG{order.created_at.strftime('%Y%m%d')}{order.id:06d}"
|
||||||
self.assertEqual(response.data['human_id'], expected_human_id)
|
self.assertEqual(response.data['human_id'], expected_human_id)
|
||||||
mock_delay.assert_not_called()
|
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}"
|
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['human_id'], expected_human_id)
|
||||||
self.assertEqual(response.data['status'], business_models.SalesOrderStatusEnum.PENDING)
|
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()
|
mock_delay.assert_not_called()
|
||||||
|
|
||||||
def test_create_sales_order_invalid_customer(self):
|
def test_create_sales_order_invalid_customer(self):
|
||||||
@@ -726,7 +730,9 @@ class PurchaseReturnOrderAPITestCase(TestCase):
|
|||||||
self.assertIn('id', response.data)
|
self.assertIn('id', response.data)
|
||||||
self.assertIn('human_id', response.data)
|
self.assertIn('human_id', response.data)
|
||||||
self.assertEqual(response.data['status'], business_models.PurchaseReturnStatusEnum.PENDING)
|
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'])
|
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}"
|
expected_human_id = f"CT{order.created_at.strftime('%Y%m%d')}{order.id:06d}"
|
||||||
self.assertEqual(response.data['human_id'], expected_human_id)
|
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.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
self.assertIn('human_id', response.data)
|
self.assertIn('human_id', response.data)
|
||||||
self.assertEqual(response.data['status'], business_models.SalesReturnStatusEnum.PENDING)
|
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'])
|
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}"
|
expected_human_id = f"XT{order.created_at.strftime('%Y%m%d')}{order.id:06d}"
|
||||||
self.assertEqual(response.data['human_id'], expected_human_id)
|
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.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertTrue(response.data['is_red_flushed'])
|
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):
|
def test_purchase_return_order_red_flush_success(self):
|
||||||
order = self._create_approved_purchase_return_order()
|
order = self._create_approved_purchase_return_order()
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
|
|||||||
@@ -54,12 +54,14 @@ class PurchaseOrderSerializer(serializers.ModelSerializer):
|
|||||||
'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', 'is_red_flushed', 'red_flush_id', 'red_flushed_at',
|
'status', 'is_red_flushed', 'red_flush_id', 'red_flushed_at',
|
||||||
|
'balance_before_snapshot',
|
||||||
'remarks', 'created_at', 'updated_at', 'items', 'quantity_of_rolls',
|
'remarks', 'created_at', 'updated_at', 'items', 'quantity_of_rolls',
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
'id', 'created_at', 'updated_at', 'items', 'supplier_name',
|
'id', 'created_at', 'updated_at', 'items', 'supplier_name',
|
||||||
'operator_name', 'warehouse_name', 'is_red_flushed',
|
'operator_name', 'warehouse_name', 'is_red_flushed',
|
||||||
'red_flush_id', 'red_flushed_at',
|
'red_flush_id', 'red_flushed_at',
|
||||||
|
'balance_before_snapshot',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -142,6 +144,7 @@ class PurchaseOrderView(StockChangeViewMixin, views.APIView):
|
|||||||
'id': purchase_order.id,
|
'id': purchase_order.id,
|
||||||
'human_id': purchase_order.human_id,
|
'human_id': purchase_order.human_id,
|
||||||
'status': purchase_order.status,
|
'status': purchase_order.status,
|
||||||
|
'balance_before_snapshot': purchase_order.balance_before_snapshot,
|
||||||
'message': '采购单创建成功,等待审批',
|
'message': '采购单创建成功,等待审批',
|
||||||
},
|
},
|
||||||
status=status.HTTP_201_CREATED,
|
status=status.HTTP_201_CREATED,
|
||||||
|
|||||||
@@ -58,12 +58,14 @@ class PurchaseReturnOrderSerializer(serializers.ModelSerializer):
|
|||||||
'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', 'is_red_flushed', 'red_flush_id', 'red_flushed_at',
|
'status', 'is_red_flushed', 'red_flush_id', 'red_flushed_at',
|
||||||
|
'balance_before_snapshot',
|
||||||
'remarks', 'created_at', 'updated_at', 'items', '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',
|
'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,
|
'id': purchase_return.id,
|
||||||
'human_id': purchase_return.human_id,
|
'human_id': purchase_return.human_id,
|
||||||
'status': purchase_return.status,
|
'status': purchase_return.status,
|
||||||
|
'balance_before_snapshot': purchase_return.balance_before_snapshot,
|
||||||
'message': '采购退货单创建成功,等待审批',
|
'message': '采购退货单创建成功,等待审批',
|
||||||
},
|
},
|
||||||
status=status.HTTP_201_CREATED,
|
status=status.HTTP_201_CREATED,
|
||||||
|
|||||||
@@ -55,12 +55,14 @@ class SalesOrderSerializer(serializers.ModelSerializer):
|
|||||||
'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', 'is_red_flushed', 'red_flush_id', 'red_flushed_at',
|
'status', 'is_red_flushed', 'red_flush_id', 'red_flushed_at',
|
||||||
|
'balance_before_snapshot',
|
||||||
'remarks', 'created_at', 'updated_at', 'items', 'quantity_of_rolls',
|
'remarks', 'created_at', 'updated_at', 'items', 'quantity_of_rolls',
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
'id', 'created_at', 'updated_at', 'items', 'customer_name',
|
'id', 'created_at', 'updated_at', 'items', 'customer_name',
|
||||||
'operator_name', 'warehouse_name', 'is_red_flushed',
|
'operator_name', 'warehouse_name', 'is_red_flushed',
|
||||||
'red_flush_id', 'red_flushed_at',
|
'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(
|
queryset = business_models.SalesOrder.objects.filter(merchant=merchant).prefetch_related(
|
||||||
'items', 'customer', 'operator', 'warehouse'
|
'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()
|
paginator = self.pagination_class()
|
||||||
page = paginator.paginate_queryset(queryset.order_by('-created_at'), request, view=self)
|
page = paginator.paginate_queryset(queryset.order_by('-created_at'), request, view=self)
|
||||||
serializer = SalesOrderSerializer(page, many=True)
|
serializer = SalesOrderSerializer(page, many=True)
|
||||||
@@ -145,6 +170,7 @@ class SalesOrderView(StockChangeViewMixin, views.APIView):
|
|||||||
'id': sales_order.id,
|
'id': sales_order.id,
|
||||||
'human_id': sales_order.human_id,
|
'human_id': sales_order.human_id,
|
||||||
'status': sales_order.status,
|
'status': sales_order.status,
|
||||||
|
'balance_before_snapshot': sales_order.balance_before_snapshot,
|
||||||
'message': '销售单创建成功,等待审批',
|
'message': '销售单创建成功,等待审批',
|
||||||
},
|
},
|
||||||
status=status.HTTP_201_CREATED,
|
status=status.HTTP_201_CREATED,
|
||||||
|
|||||||
@@ -57,12 +57,14 @@ class SalesReturnOrderSerializer(serializers.ModelSerializer):
|
|||||||
'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', 'is_red_flushed', 'red_flush_id', 'red_flushed_at',
|
'status', 'is_red_flushed', 'red_flush_id', 'red_flushed_at',
|
||||||
|
'balance_before_snapshot',
|
||||||
'remarks', 'created_at', 'updated_at', 'items',
|
'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',
|
'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,
|
'id': sales_return.id,
|
||||||
'human_id': sales_return.human_id,
|
'human_id': sales_return.human_id,
|
||||||
'status': sales_return.status,
|
'status': sales_return.status,
|
||||||
|
'balance_before_snapshot': sales_return.balance_before_snapshot,
|
||||||
'message': '销售退货单创建成功,等待审批',
|
'message': '销售退货单创建成功,等待审批',
|
||||||
},
|
},
|
||||||
status=status.HTTP_201_CREATED,
|
status=status.HTTP_201_CREATED,
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ class PrintingOrderCreateUpdateSerializer(serializers.ModelSerializer):
|
|||||||
'fabric_source', 'is_fabric_received', 'craft', 'description',
|
'fabric_source', 'is_fabric_received', 'craft', 'description',
|
||||||
'outgoing_date', 'curve', 'new_curve', 'position',
|
'outgoing_date', 'curve', 'new_curve', 'position',
|
||||||
'printing_warn', 'rolling_warn', 'production_warn', 'is_invalid',
|
'printing_warn', 'rolling_warn', 'production_warn', 'is_invalid',
|
||||||
'process'
|
'process', 'external_order_id',
|
||||||
]
|
]
|
||||||
read_only_fields = ['id']
|
read_only_fields = ['id']
|
||||||
|
|
||||||
@@ -651,6 +651,58 @@ class PlateOrderDesignCodeMixin:
|
|||||||
return data
|
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):
|
class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSerializer):
|
||||||
"""开版订单列表序列化器"""
|
"""开版订单列表序列化器"""
|
||||||
customer_name = serializers.CharField(source="customer.name", read_only=True)
|
customer_name = serializers.CharField(source="customer.name", read_only=True)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from rest_framework.permissions import BasePermission
|
|||||||
from rest_framework.permissions import DjangoModelPermissions
|
from rest_framework.permissions import DjangoModelPermissions
|
||||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||||
from django.core.exceptions import ValidationError
|
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.db.models.functions import Cast, Coalesce
|
||||||
from django.views.decorators.cache import cache_page
|
from django.views.decorators.cache import cache_page
|
||||||
from django.views.decorators.http import condition
|
from django.views.decorators.http import condition
|
||||||
@@ -1065,13 +1065,14 @@ class PlateOrderViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
|
|||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
"""根据动作选择序列化器"""
|
"""根据动作选择序列化器"""
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
|
PlateOrderKanbanListSerializer,
|
||||||
PlateOrderListSerializer,
|
PlateOrderListSerializer,
|
||||||
PlateOrderDetailSerializer,
|
PlateOrderDetailSerializer,
|
||||||
PlateOrderCreateUpdateSerializer,
|
PlateOrderCreateUpdateSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.action == "list":
|
if self.action == "list":
|
||||||
return PlateOrderListSerializer
|
return PlateOrderKanbanListSerializer
|
||||||
elif self.action in ["create", "update", "partial_update"]:
|
elif self.action in ["create", "update", "partial_update"]:
|
||||||
return PlateOrderCreateUpdateSerializer
|
return PlateOrderCreateUpdateSerializer
|
||||||
else: # retrieve
|
else: # retrieve
|
||||||
@@ -1092,13 +1093,24 @@ class PlateOrderViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
|
|||||||
|
|
||||||
if self.action in ["list", "retrieve"]:
|
if self.action in ["list", "retrieve"]:
|
||||||
queryset = queryset.select_related(
|
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(
|
queryset = queryset.annotate(
|
||||||
design_code_normalized=Coalesce(
|
design_code_normalized=Coalesce(
|
||||||
"design_code", Cast("id", output_field=CharField())
|
"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
|
return queryset
|
||||||
|
|
||||||
|
|||||||
31
business/migrations/0032_order_balance_before_snapshot.py
Normal file
31
business/migrations/0032_order_balance_before_snapshot.py
Normal 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='创建前客户余额快照'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -133,6 +133,10 @@ class PurchaseOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounte
|
|||||||
is_red_flushed = models.BooleanField(default=False, db_index=True, 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_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='红冲时间')
|
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(
|
from_pre_purchase_order_id = models.BigIntegerField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
@@ -283,6 +287,10 @@ class SalesOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounterpa
|
|||||||
is_red_flushed = models.BooleanField(default=False, db_index=True, 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_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='红冲时间')
|
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='备注')
|
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,
|
||||||
@@ -795,6 +803,10 @@ class PurchaseReturnOrder(OrderItemsAggregationMixin, OrderDirectionMixin, Order
|
|||||||
is_red_flushed = models.BooleanField(default=False, db_index=True, 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_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='红冲时间')
|
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='备注')
|
remarks = models.TextField(blank=True, null=True, verbose_name='备注')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -923,6 +935,10 @@ class SalesReturnOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCou
|
|||||||
is_red_flushed = models.BooleanField(default=False, db_index=True, 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_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='红冲时间')
|
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='备注')
|
remarks = models.TextField(blank=True, null=True, verbose_name='备注')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -288,6 +288,9 @@ def create_purchase_order(
|
|||||||
purchase_order = models.PurchaseOrder.objects.create(
|
purchase_order = models.PurchaseOrder.objects.create(
|
||||||
merchant=merchant,
|
merchant=merchant,
|
||||||
supplier=supplier,
|
supplier=supplier,
|
||||||
|
balance_before_snapshot=BalanceService.get_supplier_balance(
|
||||||
|
merchant=merchant, supplier=supplier,
|
||||||
|
),
|
||||||
purchase_date=normalized_date,
|
purchase_date=normalized_date,
|
||||||
operator=operator,
|
operator=operator,
|
||||||
warehouse=warehouse,
|
warehouse=warehouse,
|
||||||
@@ -425,6 +428,9 @@ def create_sales_order(
|
|||||||
sales_order = models.SalesOrder.objects.create(
|
sales_order = models.SalesOrder.objects.create(
|
||||||
merchant=merchant,
|
merchant=merchant,
|
||||||
customer=customer,
|
customer=customer,
|
||||||
|
balance_before_snapshot=BalanceService.get_customer_balance(
|
||||||
|
merchant=merchant, customer=customer,
|
||||||
|
),
|
||||||
sales_date=normalized_date,
|
sales_date=normalized_date,
|
||||||
operator=operator,
|
operator=operator,
|
||||||
warehouse=warehouse,
|
warehouse=warehouse,
|
||||||
@@ -577,6 +583,9 @@ def create_purchase_return_order(
|
|||||||
return_order = models.PurchaseReturnOrder.objects.create(
|
return_order = models.PurchaseReturnOrder.objects.create(
|
||||||
merchant=merchant,
|
merchant=merchant,
|
||||||
supplier=supplier,
|
supplier=supplier,
|
||||||
|
balance_before_snapshot=BalanceService.get_supplier_balance(
|
||||||
|
merchant=merchant, supplier=supplier,
|
||||||
|
),
|
||||||
purchase_order=resolved_purchase_order,
|
purchase_order=resolved_purchase_order,
|
||||||
return_date=normalized_date,
|
return_date=normalized_date,
|
||||||
operator=operator,
|
operator=operator,
|
||||||
@@ -727,6 +736,9 @@ def create_sales_return_order(
|
|||||||
return_order = models.SalesReturnOrder.objects.create(
|
return_order = models.SalesReturnOrder.objects.create(
|
||||||
merchant=merchant,
|
merchant=merchant,
|
||||||
customer=customer,
|
customer=customer,
|
||||||
|
balance_before_snapshot=BalanceService.get_customer_balance(
|
||||||
|
merchant=merchant, customer=customer,
|
||||||
|
),
|
||||||
sales_order=resolved_sales_order,
|
sales_order=resolved_sales_order,
|
||||||
return_date=normalized_date,
|
return_date=normalized_date,
|
||||||
operator=operator,
|
operator=operator,
|
||||||
@@ -1164,6 +1176,7 @@ def red_flush_sales_order(
|
|||||||
approved_status=models.SalesOrderStatusEnum.APPROVED,
|
approved_status=models.SalesOrderStatusEnum.APPROVED,
|
||||||
balance_source_type=models.BalanceChangeSourceEnum.SALES_ORDER,
|
balance_source_type=models.BalanceChangeSourceEnum.SALES_ORDER,
|
||||||
stock_source_type=stock_models.StockChangeSourceEnum.SALES,
|
stock_source_type=stock_models.StockChangeSourceEnum.SALES,
|
||||||
|
require_stock_records=False,
|
||||||
counterparty_field='customer',
|
counterparty_field='customer',
|
||||||
error_label='销售单',
|
error_label='销售单',
|
||||||
red_flushed_by=red_flushed_by,
|
red_flushed_by=red_flushed_by,
|
||||||
@@ -1279,6 +1292,7 @@ def _red_flush_order_impl(
|
|||||||
stock_source_type: stock_models.StockChangeSourceEnum | None,
|
stock_source_type: stock_models.StockChangeSourceEnum | None,
|
||||||
counterparty_field: str,
|
counterparty_field: str,
|
||||||
error_label: str,
|
error_label: str,
|
||||||
|
require_stock_records: bool = True,
|
||||||
red_flushed_by=None,
|
red_flushed_by=None,
|
||||||
reason: str | None = '',
|
reason: str | None = '',
|
||||||
):
|
):
|
||||||
@@ -1328,6 +1342,7 @@ def _red_flush_order_impl(
|
|||||||
red_flushed_by=red_flushed_by,
|
red_flushed_by=red_flushed_by,
|
||||||
reason=reason_text,
|
reason=reason_text,
|
||||||
error_label=error_label,
|
error_label=error_label,
|
||||||
|
require_stock_records=require_stock_records,
|
||||||
)
|
)
|
||||||
|
|
||||||
locked_order.is_red_flushed = True
|
locked_order.is_red_flushed = True
|
||||||
@@ -1412,6 +1427,7 @@ def _red_flush_stock_records_for_order(
|
|||||||
red_flushed_by,
|
red_flushed_by,
|
||||||
reason: str,
|
reason: str,
|
||||||
error_label: str,
|
error_label: str,
|
||||||
|
require_stock_records: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
stock_records = list(
|
stock_records = list(
|
||||||
stock_models.StockChangeRecord.objects.select_for_update().filter(
|
stock_models.StockChangeRecord.objects.select_for_update().filter(
|
||||||
@@ -1421,7 +1437,9 @@ def _red_flush_stock_records_for_order(
|
|||||||
).order_by('id')
|
).order_by('id')
|
||||||
)
|
)
|
||||||
if not stock_records:
|
if not stock_records:
|
||||||
|
if require_stock_records:
|
||||||
raise ValueError(f'{error_label}缺少可红冲的库存记录')
|
raise ValueError(f'{error_label}缺少可红冲的库存记录')
|
||||||
|
return
|
||||||
|
|
||||||
stock_service = StockFlowService(merchant=order.merchant, created_by=red_flushed_by)
|
stock_service = StockFlowService(merchant=order.merchant, created_by=red_flushed_by)
|
||||||
for stock_record in stock_records:
|
for stock_record in stock_records:
|
||||||
@@ -2532,6 +2550,7 @@ class _CustomerStatementBuilder(_StatementBuilder):
|
|||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
customer=customer,
|
customer=customer,
|
||||||
status=models.SalesOrderStatusEnum.APPROVED,
|
status=models.SalesOrderStatusEnum.APPROVED,
|
||||||
|
is_red_flushed=False,
|
||||||
)
|
)
|
||||||
.select_related('customer', 'warehouse')
|
.select_related('customer', 'warehouse')
|
||||||
.prefetch_related('items__product')
|
.prefetch_related('items__product')
|
||||||
@@ -2565,6 +2584,7 @@ class _CustomerStatementBuilder(_StatementBuilder):
|
|||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
customer=customer,
|
customer=customer,
|
||||||
status=models.SalesReturnStatusEnum.APPROVED,
|
status=models.SalesReturnStatusEnum.APPROVED,
|
||||||
|
is_red_flushed=False,
|
||||||
)
|
)
|
||||||
.select_related('customer', 'warehouse')
|
.select_related('customer', 'warehouse')
|
||||||
.prefetch_related('items__product')
|
.prefetch_related('items__product')
|
||||||
@@ -2598,6 +2618,7 @@ class _CustomerStatementBuilder(_StatementBuilder):
|
|||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
customer=customer,
|
customer=customer,
|
||||||
status=models.ReceiptOrderStatusEnum.APPROVED,
|
status=models.ReceiptOrderStatusEnum.APPROVED,
|
||||||
|
is_red_flushed=False,
|
||||||
)
|
)
|
||||||
.select_related('customer')
|
.select_related('customer')
|
||||||
)
|
)
|
||||||
@@ -2685,6 +2706,7 @@ class _SupplierStatementBuilder(_StatementBuilder):
|
|||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
supplier=supplier,
|
supplier=supplier,
|
||||||
status=models.PurchaseOrderStatusEnum.APPROVED,
|
status=models.PurchaseOrderStatusEnum.APPROVED,
|
||||||
|
is_red_flushed=False,
|
||||||
)
|
)
|
||||||
.select_related('supplier', 'warehouse')
|
.select_related('supplier', 'warehouse')
|
||||||
.prefetch_related('items__product')
|
.prefetch_related('items__product')
|
||||||
@@ -2718,6 +2740,7 @@ class _SupplierStatementBuilder(_StatementBuilder):
|
|||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
supplier=supplier,
|
supplier=supplier,
|
||||||
status=models.PurchaseReturnStatusEnum.APPROVED,
|
status=models.PurchaseReturnStatusEnum.APPROVED,
|
||||||
|
is_red_flushed=False,
|
||||||
)
|
)
|
||||||
.select_related('supplier', 'warehouse')
|
.select_related('supplier', 'warehouse')
|
||||||
.prefetch_related('items__product')
|
.prefetch_related('items__product')
|
||||||
@@ -2751,6 +2774,7 @@ class _SupplierStatementBuilder(_StatementBuilder):
|
|||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
supplier=supplier,
|
supplier=supplier,
|
||||||
status=models.PaymentOrderStatusEnum.APPROVED,
|
status=models.PaymentOrderStatusEnum.APPROVED,
|
||||||
|
is_red_flushed=False,
|
||||||
)
|
)
|
||||||
.select_related('supplier')
|
.select_related('supplier')
|
||||||
)
|
)
|
||||||
|
|||||||
225
business/tests/test_balance_before_snapshot.py
Normal file
225
business/tests/test_balance_before_snapshot.py
Normal 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)
|
||||||
@@ -146,6 +146,37 @@ class BusinessRedFlushServiceTestCase(TestCase):
|
|||||||
red_flush_id=flushed.red_flush_id,
|
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):
|
def test_red_flush_purchase_return_order_reverses_balance_and_stock(self):
|
||||||
merchant, supplier, _, warehouse, product, operator = create_basic_fixtures()
|
merchant, supplier, _, warehouse, product, operator = create_basic_fixtures()
|
||||||
self._disable_auto_stock_tasks(merchant)
|
self._disable_auto_stock_tasks(merchant)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from basic_info import models as basic_models
|
from basic_info import models as basic_models
|
||||||
from business import models as business_models, services
|
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
|
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)
|
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')
|
payment_record = next(record for record in payload['records'] if record['source_type'] == 'payment_order')
|
||||||
self.assertEqual(payment_record['negative_amount'], Decimal('15.00'))
|
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.
@@ -85,7 +85,7 @@ services:
|
|||||||
DJANGO_SETTINGS_MODULE: flower.settings
|
DJANGO_SETTINGS_MODULE: flower.settings
|
||||||
PYTHONPATH: /app
|
PYTHONPATH: /app
|
||||||
DEBUG: "1"
|
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_HOST: pgbouncer
|
||||||
DB_PORT: "6432"
|
DB_PORT: "6432"
|
||||||
DB_NAME: flower
|
DB_NAME: flower
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ The API first scopes order lookup by `request.user.employee.merchant`. The servi
|
|||||||
|
|
||||||
## Audit Notes
|
## 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
|
## Test Coverage
|
||||||
|
|
||||||
@@ -85,4 +85,4 @@ python manage.py test business.tests.test_red_flush_services api_v1.tests.Busine
|
|||||||
Coverage expectations:
|
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.
|
- 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.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
- 红冲后原单据 `status` 保持 `APPROVED`,不回退、不作废。
|
- 红冲后原单据 `status` 保持 `APPROVED`,不回退、不作废。
|
||||||
- 原单据增加“已红冲”标记,用于列表查询提速。
|
- 原单据增加“已红冲”标记,用于列表查询提速。
|
||||||
|
- 因为 `status` 保持 `APPROVED`,**对账单 builder 必须额外按 `is_red_flushed=False` 过滤**,否则红冲单会被原值计入 `positive_amount`/`negative_amount`,summary 多算一倍。
|
||||||
- 每次红冲由 service 自动生成一个 UUID 类型的 `red_flush_id`。
|
- 每次红冲由 service 自动生成一个 UUID 类型的 `red_flush_id`。
|
||||||
- `red_flush_id` 用于跨表、跨记录追踪同一次红冲涉及的所有数据,是审计关联批次 ID。
|
- `red_flush_id` 用于跨表、跨记录追踪同一次红冲涉及的所有数据,是审计关联批次 ID。
|
||||||
- 不新增 `red_flush_no`。该编号只适合人工展示,目前无需求。
|
- 不新增 `red_flush_no`。该编号只适合人工展示,目前无需求。
|
||||||
@@ -103,7 +104,7 @@
|
|||||||
- 只允许 `APPROVED` 单据红冲。
|
- 只允许 `APPROVED` 单据红冲。
|
||||||
- `is_red_flushed=True` 的单据不能重复红冲。
|
- `is_red_flushed=True` 的单据不能重复红冲。
|
||||||
- 原始余额变动记录必须存在且未被冲抵。
|
- 原始余额变动记录必须存在且未被冲抵。
|
||||||
- 有库存影响的单据必须找到对应库存记录,库存记录必须已完成且未被红冲。
|
- 有库存影响且要求库存闭环的单据必须找到对应库存记录,库存记录必须已完成且未被红冲;销售单允许不存在库存记录,此时只红冲余额。
|
||||||
- 资金和库存任一红冲失败,整体事务回滚。
|
- 资金和库存任一红冲失败,整体事务回滚。
|
||||||
|
|
||||||
资金侧实现:
|
资金侧实现:
|
||||||
@@ -125,7 +126,7 @@
|
|||||||
新增 service 测试覆盖:
|
新增 service 测试覆盖:
|
||||||
|
|
||||||
- 采购单整单红冲:余额反向、库存反向、单据标记、批次 ID 写入。
|
- 采购单整单红冲:余额反向、库存反向、单据标记、批次 ID 写入。
|
||||||
- 销售单整单红冲:余额反向、库存反向、单据标记、批次 ID 写入。
|
- 销售单整单红冲:余额反向、单据标记、批次 ID 写入;若存在销售出库记录则同步库存反向,若无库存记录则跳过库存红冲。
|
||||||
- 采购退货单整单红冲。
|
- 采购退货单整单红冲。
|
||||||
- 销售退货单整单红冲。
|
- 销售退货单整单红冲。
|
||||||
- 付款单整单红冲。
|
- 付款单整单红冲。
|
||||||
@@ -143,11 +144,25 @@ API 测试覆盖:
|
|||||||
- 无员工身份权限拒绝。
|
- 无员工身份权限拒绝。
|
||||||
- 跨商户查询隔离。
|
- 跨商户查询隔离。
|
||||||
- 外部付款/收款单拒绝。
|
- 外部付款/收款单拒绝。
|
||||||
- 未审核、重复红冲、缺库存记录等 service 校验错误映射为 400。
|
- 未审核、重复红冲、采购等要求库存闭环的单据缺库存记录等 service 校验错误映射为 400。
|
||||||
- 成功响应包含 `is_red_flushed`、`red_flush_id`、`red_flushed_at`,并抽样断言余额/库存副作用。
|
- 成功响应包含 `is_red_flushed`、`red_flush_id`、`red_flushed_at`,并抽样断言余额/库存副作用。
|
||||||
|
|
||||||
目标:red flush 关键 service/API 路径需要有定向测试覆盖。
|
目标: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:
|
开发环境在容器内运行测试,并绕过 PgBouncer:
|
||||||
|
|||||||
423
docs/2026-06-22_balance_before_snapshot_handoff.md
Normal file
423
docs/2026-06-22_balance_before_snapshot_handoff.md
Normal 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 能返回快照,且客户端不能写入或篡改。
|
||||||
|
- 模型与迁移一致。
|
||||||
|
- 定向测试通过,既有四类业务单据测试无本任务引入的回归。
|
||||||
@@ -293,7 +293,7 @@
|
|||||||
| API | 方法 | 描述 |
|
| API | 方法 | 描述 |
|
||||||
|-----|------|------|
|
|-----|------|------|
|
||||||
| `/purchase-orders/<id>/red-flush/` | POST | 红冲已审批采购单,反向余额和库存影响 |
|
| `/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 | 红冲已审批采购退货单,反向余额和库存影响 |
|
| `/purchase-return-orders/<id>/red-flush/` | POST | 红冲已审批采购退货单,反向余额和库存影响 |
|
||||||
| `/sales-return-orders/<id>/red-flush/` | POST | 红冲已审批销售退货单,反向余额和库存影响 |
|
| `/sales-return-orders/<id>/red-flush/` | POST | 红冲已审批销售退货单,反向余额和库存影响 |
|
||||||
| `/payment-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)
|
## 7. 对账单(Statements)
|
||||||
|
|
||||||
@@ -337,7 +337,7 @@
|
|||||||
| 非本商户数据 | 403 | `{"error": "forbidden", "message": "无权限访问"}` |
|
| 非本商户数据 | 403 | `{"error": "forbidden", "message": "无权限访问"}` |
|
||||||
| 单据不存在 | 404 | `{"error": "purchase_order_not_found"}` 等 |
|
| 单据不存在 | 404 | `{"error": "purchase_order_not_found"}` 等 |
|
||||||
| 审批非法状态 | 400 | 例如 `{"error": "purchase_order_has_stock_records"}`、`{"error": "receipt_order_already_approved"}` |
|
| 审批非法状态 | 400 | 例如 `{"error": "purchase_order_has_stock_records"}`、`{"error": "receipt_order_already_approved"}` |
|
||||||
| 红冲非法状态 | 400 | 例如未审批、重复红冲、缺少可红冲库存/余额记录、外部来源单据不允许红冲 |
|
| 红冲非法状态 | 400 | 例如未审批、重复红冲、要求库存闭环的单据缺少可红冲库存记录、缺少可红冲余额记录、外部来源单据不允许红冲 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -70,11 +70,11 @@
|
|||||||
|
|
||||||
## 8. 红冲(对冲)落地状态
|
## 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`。
|
红冲能力已补充独立 API 文档与定向测试,确保审批、出库、红冲形成闭环。详细 API 见 `docs/2026-06-12_business_red_flush_api.md`。
|
||||||
|
|
||||||
|
|||||||
102
docs/开版看板-plate-orders接口字段使用清单.md
Normal file
102
docs/开版看板-plate-orders接口字段使用清单.md
Normal 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 响应的实际展示或看板流程消费字段,未计入本清单。
|
||||||
@@ -52,9 +52,11 @@ DEBUG = env('DEBUG', default=False)
|
|||||||
|
|
||||||
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[
|
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[
|
||||||
'yuwenerp.yuwen.cloud',
|
'yuwenerp.yuwen.cloud',
|
||||||
|
'testbackend.yuwen.cloud',
|
||||||
])
|
])
|
||||||
CSRF_TRUSTED_ORIGINS = env.list('CSRF_TRUSTED_ORIGINS', default=[
|
CSRF_TRUSTED_ORIGINS = env.list('CSRF_TRUSTED_ORIGINS', default=[
|
||||||
'https://yuwenerp.yuwen.cloud',
|
'https://yuwenerp.yuwen.cloud',
|
||||||
|
'https://testbackend.yuwen.cloud',
|
||||||
])
|
])
|
||||||
|
|
||||||
# 明道云同步配置
|
# 明道云同步配置
|
||||||
|
|||||||
Reference in New Issue
Block a user