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

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