1
0
forked from erp-dev/erp

fix: added merchant_id to printing_order and plate_order

This commit is contained in:
2026-01-14 14:26:03 +08:00
parent 3e75328156
commit fae667c965
20 changed files with 1049 additions and 17 deletions

View File

@@ -25,6 +25,7 @@ from .views.products import ProductQuickViewSet
from .views.parameters import StateParameterViewSet from .views.parameters import StateParameterViewSet
from .views.users import CreateUserWithProfileView from .views.users import CreateUserWithProfileView
from .views.mingdaoyun import MDYPlateOrderStagingViewSet from .views.mingdaoyun import MDYPlateOrderStagingViewSet
from .views.shipment import SalesItemByPrintingOrderView
# 创建 DRF Router for Stateflow # 创建 DRF Router for Stateflow
stateflow_router = DefaultRouter() stateflow_router = DefaultRouter()
@@ -98,6 +99,13 @@ urlpatterns = [
# Stateflow API (使用 Router) # Stateflow API (使用 Router)
path('stateflow/', include(stateflow_router.urls)), path('stateflow/', include(stateflow_router.urls)),
# Shipment API
path(
'shipment/sales-items/by-printing-order/<int:printing_order_id>/',
SalesItemByPrintingOrderView.as_view(),
name='sales_items_by_printing_order'
),
# 主 Router (printing-orders 等) # 主 Router (printing-orders 等)
path('', include(main_router.urls)), path('', include(main_router.urls)),
] ]

View File

@@ -141,11 +141,12 @@ class PrintingOrderListSerializer(serializers.ModelSerializer):
jobs_last_status_summary = serializers.SerializerMethodField() jobs_last_status_summary = serializers.SerializerMethodField()
created_by = serializers.IntegerField(source='created_by_id', read_only=True) created_by = serializers.IntegerField(source='created_by_id', read_only=True)
created_by_name = serializers.SerializerMethodField() created_by_name = serializers.SerializerMethodField()
merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True)
class Meta: class Meta:
model = models.PrintingOrder model = models.PrintingOrder
fields = [ fields = [
'id', 'human_id', 'customer', 'customer_name', 'customer_phone', 'id', 'human_id', 'merchant_id', 'customer', 'customer_name', 'customer_phone',
'fabric', 'width', 'is_urgent', 'area', 'address', 'curve', 'fabric', 'width', 'is_urgent', 'area', 'address', 'curve',
'is_fabric_received', 'outgoing_date', 'is_invalid', 'new_curve', 'is_fabric_received', 'outgoing_date', 'is_invalid', 'new_curve',
'process', 'process_name', 'progress', 'position', 'print_count', 'process', 'process_name', 'progress', 'position', 'print_count',
@@ -153,7 +154,7 @@ class PrintingOrderListSerializer(serializers.ModelSerializer):
'created_by', 'created_by_name', 'created_by', 'created_by_name',
'created_at', 'updated_at', 'created_at', 'updated_at',
] ]
read_only_fields = ['id', 'human_id', 'created_at', 'updated_at', 'progress', 'print_count'] read_only_fields = ['id', 'human_id', 'created_at', 'updated_at', 'progress', 'print_count', 'merchant_id']
def get_jobs_status_summary(self, obj): def get_jobs_status_summary(self, obj):
""" """
@@ -228,11 +229,12 @@ class PrintingOrderDetailSerializer(serializers.ModelSerializer):
process_name = serializers.CharField(source='process.name', read_only=True) process_name = serializers.CharField(source='process.name', read_only=True)
created_by_name = serializers.SerializerMethodField() created_by_name = serializers.SerializerMethodField()
progress = serializers.IntegerField(read_only=True) progress = serializers.IntegerField(read_only=True)
merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True)
class Meta: class Meta:
model = models.PrintingOrder model = models.PrintingOrder
fields = [ fields = [
'id', 'human_id', 'customer', 'customer_name', 'customer_phone', 'customer_area', 'id', 'human_id', 'merchant_id', 'customer', 'customer_name', 'customer_phone', 'customer_area',
'fabric', 'width', 'is_urgent', 'area', 'address', 'fabric_source', 'fabric', 'width', 'is_urgent', 'area', 'address', 'fabric_source',
'is_fabric_received', 'craft', 'description', 'outgoing_date', 'is_fabric_received', 'craft', 'description', 'outgoing_date',
'curve', 'new_curve', 'position', 'created_by_name', 'curve', 'new_curve', 'position', 'created_by_name',
@@ -240,7 +242,7 @@ class PrintingOrderDetailSerializer(serializers.ModelSerializer):
'is_invalid', 'process', 'process_name', 'progress', 'print_count', 'is_invalid', 'process', 'process_name', 'progress', 'print_count',
'created_at', 'updated_at' 'created_at', 'updated_at'
] ]
read_only_fields = ['id', 'human_id', 'created_at', 'updated_at', 'progress', 'print_count'] read_only_fields = ['id', 'human_id', 'created_at', 'updated_at', 'progress', 'print_count', 'merchant_id']
def get_created_by_name(self, obj): def get_created_by_name(self, obj):
"""获取创建人名称(员工姓名)""" """获取创建人名称(员工姓名)"""
@@ -316,11 +318,12 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
last_completed_state = serializers.CharField(read_only=True) last_completed_state = serializers.CharField(read_only=True)
business_object_id = serializers.SerializerMethodField() business_object_id = serializers.SerializerMethodField()
batch_advance_records = serializers.SerializerMethodField() batch_advance_records = serializers.SerializerMethodField()
merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True)
class Meta: class Meta:
model = models.PrintingJob model = models.PrintingJob
fields = [ fields = [
'id', 'original_id', 'printing_order', 'printing_order_id', 'product', 'product_name', 'id', 'original_id', 'merchant_id', 'printing_order', 'printing_order_id', 'product', 'product_name',
'product_image_url', 'has_started', 'product_image_url', 'has_started',
'quantity', 'unit', 'size', 'pieces', 'description', 'quantity', 'unit', 'size', 'pieces', 'description',
'work_state', 'work_state_display', 'work_state', 'work_state_display',
@@ -332,7 +335,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
read_only_fields = [ read_only_fields = [
'id', 'created_at', 'updated_at', 'id', 'created_at', 'updated_at',
'status', 'is_completed', 'progress_percentage', 'last_completed_state', 'status', 'is_completed', 'progress_percentage', 'last_completed_state',
'business_object_id' 'business_object_id', 'merchant_id'
] ]
def get_business_object_id(self, obj): def get_business_object_id(self, obj):
@@ -394,11 +397,12 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
last_completed_state = serializers.CharField(read_only=True) last_completed_state = serializers.CharField(read_only=True)
business_object_id = serializers.SerializerMethodField() business_object_id = serializers.SerializerMethodField()
batch_advance_records = serializers.SerializerMethodField() batch_advance_records = serializers.SerializerMethodField()
merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True)
class Meta: class Meta:
model = models.PrintingJob model = models.PrintingJob
fields = [ fields = [
'id', 'original_id', 'printing_order', 'printing_order_id', 'product', 'product_name', 'product_code', 'id', 'original_id', 'merchant_id', 'printing_order', 'printing_order_id', 'product', 'product_name', 'product_code',
'quantity', 'unit', 'size', 'pieces', 'description', 'quantity', 'unit', 'size', 'pieces', 'description',
'work_state', 'work_state_display', 'work_state', 'work_state_display',
'status', 'status_id', 'is_completed', 'has_started', 'status', 'status_id', 'is_completed', 'has_started',
@@ -411,7 +415,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
'id', 'created_at', 'updated_at', 'id', 'created_at', 'updated_at',
'status', 'status_id', 'is_completed', 'has_started', 'status', 'status_id', 'is_completed', 'has_started',
'progress_percentage', 'last_completed_state', 'progress_percentage', 'last_completed_state',
'business_object_id' 'business_object_id', 'merchant_id'
] ]
def get_business_object_id(self, obj): def get_business_object_id(self, obj):
@@ -550,12 +554,13 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria
last_completed_state = serializers.CharField(read_only=True) last_completed_state = serializers.CharField(read_only=True)
content_type_id = serializers.SerializerMethodField() content_type_id = serializers.SerializerMethodField()
created_by_name = serializers.SerializerMethodField() created_by_name = serializers.SerializerMethodField()
merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True)
class Meta: class Meta:
model = models.PlateOrder model = models.PlateOrder
fields = [ fields = [
'id', 'original_id', 'design_code', 'plate_type', 'plate_date', 'plate_method', 'id', 'original_id', 'merchant_id', 'design_code', 'plate_type', 'plate_date', 'plate_method',
'plate_image', 'plate_image_url', 'image_name', 'plate_notes', 'reprint_reason', 'plate_image', 'plate_image_url', 'image_name', 'plate_notes', 'reprint_reason',
'urgency_level', 'is_invalid', 'urgency_level', 'is_invalid',
'customer', 'customer_name', 'area', 'default_address', 'customer', 'customer_name', 'area', 'default_address',
@@ -576,7 +581,7 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria
] ]
read_only_fields = [ read_only_fields = [
'id', 'status', 'progress_percentage', 'last_completed_state', 'id', 'status', 'progress_percentage', 'last_completed_state',
'created_at', 'updated_at', 'print_count' 'created_at', 'updated_at', 'print_count', 'merchant_id'
] ]
def get_plate_image_url(self, obj): def get_plate_image_url(self, obj):
@@ -630,11 +635,12 @@ class PlateOrderDetailSerializer(PlateOrderDesignCodeMixin, serializers.ModelSer
plate_image_url = serializers.SerializerMethodField() plate_image_url = serializers.SerializerMethodField()
process_name = serializers.SerializerMethodField() process_name = serializers.SerializerMethodField()
last_completed_state = serializers.CharField(read_only=True) last_completed_state = serializers.CharField(read_only=True)
merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True)
class Meta: class Meta:
model = models.PlateOrder model = models.PlateOrder
fields = [ fields = [
'id', 'original_id', 'design_code', 'plate_type', 'plate_date', 'plate_method', 'id', 'original_id', 'merchant_id', 'design_code', 'plate_type', 'plate_date', 'plate_method',
'plate_image', 'plate_image_url', 'image_name', 'plate_notes', 'reprint_reason', 'plate_image', 'plate_image_url', 'image_name', 'plate_notes', 'reprint_reason',
'urgency_level', 'is_invalid', 'urgency_level', 'is_invalid',
'customer', 'customer_name', 'customer_phone', 'area', 'default_address', 'customer', 'customer_name', 'customer_phone', 'area', 'default_address',
@@ -656,7 +662,7 @@ class PlateOrderDetailSerializer(PlateOrderDesignCodeMixin, serializers.ModelSer
read_only_fields = [ read_only_fields = [
'id', 'status', 'status_id', 'is_completed', 'has_started', 'id', 'status', 'status_id', 'is_completed', 'has_started',
'progress_percentage', 'business_object_id', 'last_completed_state', 'progress_percentage', 'business_object_id', 'last_completed_state',
'created_at', 'updated_at', 'print_count' 'created_at', 'updated_at', 'print_count', 'merchant_id'
] ]
def get_business_object_id(self, obj): def get_business_object_id(self, obj):

View File

@@ -46,6 +46,10 @@ class PrintingOrderService:
# 绑定创建人 # 绑定创建人
data['created_by'] = user data['created_by'] = user
# 绑定商户(从当前用户的 employee 获取)
if hasattr(user, 'employee') and user.employee and user.employee.merchant:
data['merchant'] = user.employee.merchant
order = printing_models.PrintingOrder.objects.create(**data) order = printing_models.PrintingOrder.objects.create(**data)
return order return order
@@ -128,6 +132,10 @@ class PrintingJobService:
# 绑定创建人 # 绑定创建人
data['created_by'] = user data['created_by'] = user
# 绑定商户(从当前用户的 employee 获取)
if hasattr(user, 'employee') and user.employee and user.employee.merchant:
data['merchant'] = user.employee.merchant
# 创建 PrintingJob # 创建 PrintingJob
job = printing_models.PrintingJob.objects.create(**data) job = printing_models.PrintingJob.objects.create(**data)

View File

@@ -106,6 +106,8 @@ class PrintingOrderAPITestCase(TestCase):
self.assertIsNotNone(order) self.assertIsNotNone(order)
self.assertEqual(order.customer.id, self.customer.id) self.assertEqual(order.customer.id, self.customer.id)
self.assertEqual(order.fabric, '纯棉布料') self.assertEqual(order.fabric, '纯棉布料')
# 验证 merchant 自动绑定
self.assertEqual(order.merchant.id, self.merchant.id)
def test_list_printing_orders(self): def test_list_printing_orders(self):
"""测试获取订单列表""" """测试获取订单列表"""

View File

@@ -740,9 +740,13 @@ class PlateOrderViewSet(LimitedModelViewSet):
def perform_create(self, serializer): def perform_create(self, serializer):
""" """
创建时自动绑定创建人created_by不允许前端传参控制。 创建时自动绑定创建人created_by和商户merchant,不允许前端传参控制。
""" """
serializer.save(created_by=self.request.user) user = self.request.user
merchant = None
if hasattr(user, 'employee') and user.employee and user.employee.merchant:
merchant = user.employee.merchant
serializer.save(created_by=user, merchant=merchant)
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def invalidate(self, request, pk=None): def invalidate(self, request, pk=None):

View File

@@ -0,0 +1,8 @@
"""
Shipment API 模块
提供出货单和销售品相关的 API 接口
"""
from .views import SalesItemByPrintingOrderView
__all__ = ['SalesItemByPrintingOrderView']

View File

@@ -0,0 +1,37 @@
"""
Shipment API 序列化器
"""
from rest_framework import serializers
class SalesItemSerializer(serializers.Serializer):
"""
销售品序列化器(只读)
用于返回销售品数据
"""
id = serializers.IntegerField(read_only=True)
name = serializers.CharField(read_only=True)
quantity = serializers.DecimalField(max_digits=12, decimal_places=2, read_only=True)
unit = serializers.IntegerField(read_only=True)
unit_display = serializers.SerializerMethodField()
position = serializers.CharField(read_only=True)
remark = serializers.CharField(read_only=True)
printing_job_id = serializers.IntegerField(read_only=True)
customer_id = serializers.IntegerField(read_only=True)
shipment_id = serializers.IntegerField(source='shipment.id', read_only=True, allow_null=True)
shipment_date = serializers.DateField(source='shipment.shipment_date', read_only=True, allow_null=True)
created_at = serializers.DateTimeField(read_only=True)
created_by_id = serializers.IntegerField(source='created_by.id', read_only=True, allow_null=True)
created_by_name = serializers.SerializerMethodField()
def get_unit_display(self, obj):
return obj.get_unit_display()
def get_created_by_name(self, obj):
if obj.created_by:
employee = getattr(obj.created_by, 'employee', None)
if employee:
return employee.name
return obj.created_by.username
return None

View File

@@ -0,0 +1,266 @@
"""
Shipment API 测试
"""
from decimal import Decimal
from django.test import TestCase
from django.conf import settings
from rest_framework.test import APIClient
from rest_framework import status
from django.contrib.auth import get_user_model
from basic_info import models as basic_models
from printing import models as printing_models
from shipment import models as shipment_models
from stateflow import models as stateflow_models
User = get_user_model()
class SalesItemByPrintingOrderAPITestCase(TestCase):
"""测试通过生产订单查询销售品 API"""
def setUp(self):
self.client = APIClient()
# 创建商户
self.merchant = basic_models.Merchant.objects.create(
name='测试印花厂',
type=basic_models.MerchantTypeEnum.FACTORY
)
# 创建用户
self.user = User.objects.create_user(
username='testuser',
password='testpass123',
email='test@example.com'
)
# 创建员工并关联商户
self.employee = basic_models.Employee.objects.create(
sys_user=self.user,
merchant=self.merchant,
name='测试员工',
mobile='13800138000',
status=basic_models.EmployeeStatusEnum.ACTIVE
)
# 创建客户
self.customer = basic_models.Customer.objects.create(
merchant=self.merchant,
name='测试客户',
mobile='13900139000',
area='测试地区'
)
# 创建流程
self.state1 = stateflow_models.State.objects.create(name='待印染')
self.state2 = stateflow_models.State.objects.create(name='印染中')
self.state3 = stateflow_models.State.objects.create(name='已完成')
self.process = stateflow_models.Process.objects.create(name='印染流程')
self.process.replace_nodes([self.state1, self.state2, self.state3])
# 创建产品分类
self.category = basic_models.ProductCategory.objects.create(
merchant=self.merchant,
name='测试分类',
)
# 创建产品
self.product = basic_models.Product.objects.create(
merchant=self.merchant,
category=self.category,
name='测试产品',
human_id='TEST001',
)
# 创建印染订单
self.printing_order = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer,
fabric='测试面料',
width='150cm',
process=self.process,
created_by=self.user,
)
# 创建印染任务
self.printing_job1 = printing_models.PrintingJob.objects.create(
merchant=self.merchant,
printing_order=self.printing_order,
product=self.product,
quantity=100,
unit='',
created_by=self.user,
)
self.printing_job2 = printing_models.PrintingJob.objects.create(
merchant=self.merchant,
printing_order=self.printing_order,
product=self.product,
quantity=200,
unit='',
created_by=self.user,
)
# 创建出货单
self.shipment = shipment_models.Shipment.objects.create(
customer=self.customer,
shipment_date='2026-01-14',
created_by=self.user,
)
# 创建销售品 - 未关联出货单
self.sales_item1 = shipment_models.SalesItem.objects.create(
name='销售品1',
quantity=Decimal('50.00'),
unit=shipment_models.UnitChoices.METER,
printing_job_id=self.printing_job1.id,
created_by=self.user,
)
self.sales_item2 = shipment_models.SalesItem.objects.create(
name='销售品2',
quantity=Decimal('30.00'),
unit=shipment_models.UnitChoices.METER,
printing_job_id=self.printing_job1.id,
position='A1-01',
remark='备注信息',
created_by=self.user,
)
# 创建销售品 - 已关联出货单
self.sales_item3 = shipment_models.SalesItem.objects.create(
name='销售品3已出货',
quantity=Decimal('100.00'),
unit=shipment_models.UnitChoices.METER,
printing_job_id=self.printing_job2.id,
shipment=self.shipment,
created_by=self.user,
)
# 创建与该订单无关的销售品
self.sales_item_other = shipment_models.SalesItem.objects.create(
name='其它销售品',
quantity=Decimal('999.00'),
unit=shipment_models.UnitChoices.PIECE,
printing_job_id=99999, # 不存在的 job
created_by=self.user,
)
# 认证用户
self.client.force_authenticate(user=self.user)
def test_get_sales_items_by_printing_order_exclude_shipped(self):
"""测试查询销售品 - 默认不包含已出货的"""
url = f'/api/v1/shipment/sales-items/by-printing-order/{self.printing_order.id}/'
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
# 应该只返回2个未出货的销售品
self.assertEqual(data['count'], 2)
# 检查返回的销售品
item_ids = [item['id'] for item in data['results']]
self.assertIn(self.sales_item1.id, item_ids)
self.assertIn(self.sales_item2.id, item_ids)
self.assertNotIn(self.sales_item3.id, item_ids) # 已出货的不应该在列表中
self.assertNotIn(self.sales_item_other.id, item_ids) # 其它订单的也不在
def test_get_sales_items_by_printing_order_include_shipped(self):
"""测试查询销售品 - 包含已出货的"""
url = f'/api/v1/shipment/sales-items/by-printing-order/{self.printing_order.id}/?include_already_has_shipment=true'
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
# 应该返回3个销售品包含已出货的
self.assertEqual(data['count'], 3)
# 检查返回的销售品
item_ids = [item['id'] for item in data['results']]
self.assertIn(self.sales_item1.id, item_ids)
self.assertIn(self.sales_item2.id, item_ids)
self.assertIn(self.sales_item3.id, item_ids) # 已出货的也应该在列表中
self.assertNotIn(self.sales_item_other.id, item_ids) # 其它订单的依然不在
def test_get_sales_items_response_format(self):
"""测试返回数据格式"""
url = f'/api/v1/shipment/sales-items/by-printing-order/{self.printing_order.id}/'
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
# 找到 sales_item2包含 position 和 remark
item = next(item for item in data['results'] if item['id'] == self.sales_item2.id)
# 检查所有字段
self.assertEqual(item['name'], '销售品2')
self.assertEqual(Decimal(item['quantity']), Decimal('30.00'))
self.assertEqual(item['unit'], shipment_models.UnitChoices.METER)
self.assertEqual(item['unit_display'], '')
self.assertEqual(item['position'], 'A1-01')
self.assertEqual(item['remark'], '备注信息')
self.assertEqual(item['printing_job_id'], self.printing_job1.id)
self.assertIsNone(item['shipment_id'])
self.assertIsNone(item['shipment_date'])
self.assertIsNotNone(item['created_at'])
self.assertEqual(item['created_by_id'], self.user.id)
def test_get_sales_items_shipped_item_format(self):
"""测试已出货的销售品返回格式"""
url = f'/api/v1/shipment/sales-items/by-printing-order/{self.printing_order.id}/?include_already_has_shipment=true'
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
# 找到已出货的销售品
item = next(item for item in data['results'] if item['id'] == self.sales_item3.id)
# 检查出货单信息
self.assertEqual(item['shipment_id'], self.shipment.id)
self.assertEqual(item['shipment_date'], '2026-01-14')
def test_get_sales_items_printing_order_not_found(self):
"""测试生产订单不存在"""
url = '/api/v1/shipment/sales-items/by-printing-order/99999/'
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertIn('不存在', response.json()['detail'])
def test_get_sales_items_empty_result(self):
"""测试生产订单没有关联销售品"""
# 创建一个没有销售品的订单
empty_order = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer,
fabric='测试面料2',
width='150cm',
process=self.process,
created_by=self.user,
)
url = f'/api/v1/shipment/sales-items/by-printing-order/{empty_order.id}/'
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertEqual(data['count'], 0)
self.assertEqual(data['results'], [])
def test_get_sales_items_unauthenticated(self):
"""测试未认证用户"""
self.client.logout()
url = f'/api/v1/shipment/sales-items/by-printing-order/{self.printing_order.id}/'
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

View File

@@ -0,0 +1,78 @@
"""
Shipment API ViewSet
"""
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from .serializers import SalesItemSerializer
class SalesItemByPrintingOrderView(APIView):
"""
通过生产订单查询销售品
GET /api/v1/shipment/sales-items/by-printing-order/<printing_order_id>/
返回与该 PrintingOrder 下所有 PrintingJob 关联的 SalesItem 列表。
查询参数:
- include_already_has_shipment: 是否包含已关联出货单的销售品true/false默认 false
返回:
{
"count": 5,
"results": [
{
"id": 1,
"name": "产品A",
"quantity": "100.00",
"unit": 1,
"unit_display": "",
"position": "A1-01",
"remark": "",
"printing_job_id": 123,
"customer_id": null,
"shipment_id": null,
"shipment_date": null,
"created_at": "2026-01-14T10:00:00Z",
"created_by_id": 1,
"created_by_name": "张三"
},
...
]
}
"""
permission_classes = [IsAuthenticated]
def get(self, request, printing_order_id):
# 验证生产订单是否存在
from printing.models import PrintingOrder
try:
printing_order = PrintingOrder.objects.get(id=printing_order_id)
except PrintingOrder.DoesNotExist:
return Response(
{'detail': f'生产订单 {printing_order_id} 不存在'},
status=status.HTTP_404_NOT_FOUND
)
# 获取查询参数
include_already_has_shipment = request.query_params.get(
'include_already_has_shipment', 'false'
).lower() == 'true'
# 调用 shipment 业务逻辑
from shipment.services import get_sales_items_by_printing_order
sales_items = get_sales_items_by_printing_order(
printing_order_id=printing_order.id,
include_already_has_shipment=include_already_has_shipment,
)
# 序列化返回
serializer = SalesItemSerializer(sales_items, many=True)
return Response({
'count': len(serializer.data),
'results': serializer.data
})

191
docs/2026-01-13_summary.md Normal file
View File

@@ -0,0 +1,191 @@
# 2026-01-13 工作日志
## 今日目标
1. SSE 模块重构 → Notifications 通知系统(独立分支)
2. Stateflow 信号机制 + Shipment 模块开发(主分支)
---
## 已完成工作SSE 重构分支)
### A1. SSE 问题修复
- **根因**SSE 长连接占用数据库连接不释放,导致连接池耗尽
- **修复**:在 `sse/views.py` 中认证后立即 `connection.close()`
- **新增**连接超时30分钟、心跳超时2分钟、连接数上限每商户30个
### A2. Notifications 通知系统(新模块)
- **目的**:将事件发布与 SSE 渠道解耦,支持未来扩展(企业微信等)
- **文件结构**
```
notifications/
├── __init__.py
├── apps.py # 渠道注册
├── base.py # NotificationPayload + NotificationChannel 抽象基类
├── dispatcher.py # NotificationDispatcher 分发器
├── events.py # resource_changed(), object_event()
└── channels/
└── sse.py # SSE 渠道实现
```
- **核心设计**
- 业务代码调用 `notifications.events`,不关心具体渠道
- 分发器同步调用各渠道的 `send()`
- 各渠道自行决定同步/异步SSE 同步放入队列,企业微信应内部 Celery
- 新增渠道只需实现 `NotificationChannel` 并注册
- **配置**
- `SSE_ENABLED` 同时控制 SSE 端点和通知渠道
### A3. 文档整理
- **创建**
- `docs/notifications.md` — 后端通知接口文档
- `docs/notifications_frontend.md` — 前端 SSE 接入文档
- `docs/sse_refactor_execute_django_ver.md` — 技术方案文档
- **删除**(减少心智成本):
- `docs/sse.md`
- `docs/sse_event_interface.md`
- `docs/sse_refactor.md`
### A4. 兼容处理
- `sse/events.py` 改为兼容层,调用会触发 `DeprecationWarning`
- 现有业务代码无需立即修改
---
## 已完成工作(主分支)
### 1. Stateflow 信号机制
- **文件**`stateflow/signals.py`(新增)
- **功能**
- 定义 `process_completed` 信号 — 流程全部完成时触发
- 定义 `state_advanced` 信号 — 每次状态推进时触发
- `sender` 使用 `content_object.__class__`,支持按类型过滤
- 参数包含:`process_id`、`business_object`、`content_object`、`last_completed_state_id`、`last_completed_by` 等
- **文件**`stateflow/services.py`(修改)
- 在 `advance_to_next_state` 中发送信号
- 添加日志记录信号发送过程
---
### 2. Shipment 模块(新建)
- **文件结构**
```
shipment/
├── __init__.py
├── apps.py
├── models.py
├── admin.py
└── migrations/
├── 0001_initial.py
├── 0002_salesitem_shipment_nullable.py
└── 0003_salesitem_position_remark.py
```
- **模型**
- `Shipment`(出货单)— 关联客户,包含多个销售品
- `SalesItem`(销售品)— 名称、数量、单位(米/件/码/个、货位、备注、关联生产任务ID
- **设计特点**
- `SalesItem.shipment` 可空,支持"待分配"状态
- `printing_job_id` 使用整数而非外键,避免模块间强依赖
- 提供 `get_printing_job()` 方法获取关联对象,带完整 type hint
---
### 3. PrintingJob 流程完成自动创建销售品
- **文件**`printing/handlers.py`(新增)
- 监听 `process_completed` 信号sender=PrintingJob
- 从指定流程节点获取"米数"参数
- 自动创建 `SalesItem`,关联 `printing_job_id`
- **文件**`printing/apps.py`(修改)
- 在 `ready()` 中注册信号处理器
- 添加启动日志确认注册成功
- **配置项**settings.py + .env
```python
PRINTING_SALES_ITEM_SOURCE_STATE_ID = env.int('PRINTING_SALES_ITEM_SOURCE_STATE_ID') # 必须配置
PRINTING_SALES_ITEM_QUANTITY_KEY = '米数'
```
---
### 4. 其他修改
- **stateflow/services.py**
- 添加 `logging` 模块导入
- `clone_business_object` 函数暂停使用2026-01-13抛出 `NotImplementedError`
- **flower/settings.py**
- 添加 `'shipment'` 到 `INSTALLED_APPS`
- 添加销售品自动创建配置项
---
## 架构说明
### 信号流程
```
PrintingJob 流程推进完成
stateflow.services.advance_to_next_state()
发送 process_completed 信号 (sender=PrintingJob)
printing.handlers.on_printing_job_process_completed() 接收
从指定节点获取"米数"参数
创建 SalesItem待分配出货单
```
### 解耦设计
- **stateflow** 不依赖任何业务模块,只发送信号
- **printing** 监听信号并处理自己的业务逻辑
- **shipment** 被 printing 调用,但不知道调用者是谁
---
## 待办事项
### SSE 重构分支
- [ ] 合并到主分支后,在测试环境启用 SSE`SSE_ENABLED=True`
- [ ] 前端实现重连逻辑
- [ ] 监控数据库连接数,确认不再泄漏
- [ ] 逐步迁移业务代码到 `notifications.events`
### 主分支
- [ ] 在 `.env` 中配置 `PRINTING_SALES_ITEM_SOURCE_STATE_ID`
- [ ] 应用数据库迁移:`uv run python manage.py migrate shipment`
- [ ] 测试完整流程:推进 PrintingJob 直到完成,验证 SalesItem 创建
- [ ] 移除调试日志(生产环境前)
---
## 备注
### SSE 重构分支
- SSE 模块此前因数据库连接泄漏导致线上灾难,已被禁用
- Notifications 模块设计考虑了未来 Golang 迁移和企业微信扩展
- 企业微信渠道仅有示例代码,尚未实现
### 主分支
- Shipment 模块是全新创建的,需要执行迁移
- 信号处理器只在流程**全部完成**时触发,不是每次推进
- 如果指定节点的"米数"参数不存在,会跳过创建并记录警告日志

View File

@@ -0,0 +1,78 @@
# 2026-01-14 工作日志
## 已完成
### 1. 通过生产订单查询销售品 API
创建了独立的 Shipment API 模块,提供出货管理相关接口。
- **接口路径**: `GET /api/v1/shipment/sales-items/by-printing-order/<printing_order_id>/`
- **查询参数**: `include_already_has_shipment`(默认 false不包含已出货的销售品
#### 新建文件
- `api_v1/views/shipment/__init__.py`: 模块入口
- `api_v1/views/shipment/views.py`: API 视图 `SalesItemByPrintingOrderView`
- `api_v1/views/shipment/serializers.py`: 序列化器 `SalesItemSerializer`
- `api_v1/views/shipment/test_api.py`: API 测试用例8 个测试场景)
- `shipment/services.py`: 业务逻辑层 `get_sales_items_by_printing_order()`
- `docs/shipment_api.md`: API 文档
#### 修改文件
- `api_v1/urls.py`: 注册新路由
#### 业务逻辑
1. 根据 PrintingOrder ID 获取所有 PrintingJob 的 ID
2. 查询 SalesItem过滤 printing_job_id 在这些 job_ids 中
3. 根据参数决定是否过滤已关联出货单的销售品
---
### 2. Printing 模块添加 merchant 字段
为多租户支持,为 printing 模块的核心模型添加 `merchant` 外键字段:
- `PlateOrder.merchant` - 开版订单所属商户
- `PrintingOrder.merchant` - 印染订单所属商户
- `PrintingJob.merchant` - 印染任务所属商户
所有字段设置为可空(`null=True, blank=True`),以兼容现有数据。
**迁移文件**: `printing/migrations/0029_add_merchant_to_models.py`
---
### 3. 确保 API 创建时自动绑定 merchant
修复了创建 API确保新建记录时自动从当前用户获取 merchant
- `PrintingOrderService.create_printing_order()` - 自动绑定 merchant
- `PrintingJobService.create_printing_job()` - 自动绑定 merchant
- `PlateOrderViewSet.perform_create()` - 自动绑定 merchant
---
### 4. 数据补录命令
创建了 management command 用于补录历史数据的 merchant_id
```bash
# 预览(不执行)
python manage.py backfill_merchant <merchant_id> --dry-run
# 执行补录
python manage.py backfill_merchant <merchant_id>
```
**文件**: `printing/management/commands/backfill_merchant.py`
---
## 待办
---
## 备注
- API 独立于 printing 模块,避免影响现有功能
- 完整的测试覆盖正常查询、包含已出货、数据格式、404 错误、空结果、未认证
- printing 和 shipment 模块全部测试通过31个测试用例

157
docs/shipment_api.md Normal file
View File

@@ -0,0 +1,157 @@
# Shipment API 文档
出货管理模块 API 文档,包含出货单和销售品相关接口。
## 目录
- [通过生产订单查询销售品](#通过生产订单查询销售品)
---
## 通过生产订单查询销售品
查询与指定生产订单PrintingOrder关联的所有销售品SalesItem
### 接口信息
- **URL**: `/api/v1/shipment/sales-items/by-printing-order/<printing_order_id>/`
- **Method**: `GET`
- **认证**: 需要登录JWT Token
### 路径参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| printing_order_id | int | 是 | 生产订单ID |
### 查询参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| include_already_has_shipment | bool | 否 | false | 是否包含已关联出货单的销售品 |
### 业务逻辑
1. 根据 `printing_order_id` 获取该生产订单下所有 `PrintingJob` 的 ID
2. 查询 `SalesItem`,过滤 `printing_job_id` 在这些 job ID 中的记录
3. 根据 `include_already_has_shipment` 参数决定是否过滤已关联出货单的销售品:
- `false`(默认):只返回 `shipment` 为空的销售品(待出货)
- `true`:返回所有销售品(包含已出货的)
### 响应格式
```json
{
"count": 2,
"results": [
{
"id": 1,
"name": "产品A - 红色",
"quantity": "100.00",
"unit": 1,
"unit_display": "米",
"position": "A1-01",
"remark": "加急处理",
"printing_job_id": 123,
"customer_id": null,
"shipment_id": null,
"shipment_date": null,
"created_at": "2026-01-14T10:00:00Z",
"created_by_id": 1,
"created_by_name": "张三"
},
{
"id": 2,
"name": "产品B - 蓝色",
"quantity": "50.50",
"unit": 1,
"unit_display": "米",
"position": "",
"remark": "",
"printing_job_id": 124,
"customer_id": 10,
"shipment_id": 5,
"shipment_date": "2026-01-13",
"created_at": "2026-01-13T15:30:00Z",
"created_by_id": 2,
"created_by_name": "李四"
}
]
}
```
### 响应字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| count | int | 结果总数 |
| results | array | 销售品列表 |
| results[].id | int | 销售品ID |
| results[].name | string | 销售品名称 |
| results[].quantity | string | 数量Decimal保留2位小数 |
| results[].unit | int | 单位编码1=米, 2=件, 3=码, 4=个) |
| results[].unit_display | string | 单位显示名称 |
| results[].position | string | 货位(可能为空) |
| results[].remark | string | 备注(可能为空) |
| results[].printing_job_id | int/null | 关联的生产任务ID |
| results[].customer_id | int/null | 销售品级别的客户ID |
| results[].shipment_id | int/null | 关联的出货单IDnull 表示未出货 |
| results[].shipment_date | string/null | 出货日期YYYY-MM-DDnull 表示未出货 |
| results[].created_at | string | 创建时间ISO 8601 |
| results[].created_by_id | int/null | 创建人ID |
| results[].created_by_name | string/null | 创建人名称 |
### 错误响应
#### 404 Not Found - 生产订单不存在
```json
{
"detail": "生产订单 999 不存在"
}
```
#### 401 Unauthorized - 未登录
```json
{
"detail": "Authentication credentials were not provided."
}
```
### 使用示例
#### 查询待出货的销售品(默认)
```bash
curl -X GET \
'https://api.example.com/api/v1/shipment/sales-items/by-printing-order/123/' \
-H 'Authorization: Bearer <token>'
```
#### 查询所有销售品(包含已出货)
```bash
curl -X GET \
'https://api.example.com/api/v1/shipment/sales-items/by-printing-order/123/?include_already_has_shipment=true' \
-H 'Authorization: Bearer <token>'
```
---
## 单位编码对照表
| 编码 | 名称 |
|------|------|
| 1 | 米 |
| 2 | 件 |
| 3 | 码 |
| 4 | 个 |
---
## 相关模块
- `shipment/services.py`: 业务逻辑层
- `api_v1/views/shipment/`: API 视图层
- `shipment/models.py`: 数据模型SalesItem, Shipment

View File

View File

View File

@@ -0,0 +1,79 @@
"""
补录 merchant_id 字段的 management command
用于为历史数据补充 merchant_id 字段值。
"""
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from basic_info.models import Merchant
from printing.models import PlateOrder, PrintingOrder, PrintingJob
class Command(BaseCommand):
help = '为 PlateOrder、PrintingOrder、PrintingJob 补录 merchant_id 字段'
def add_arguments(self, parser):
parser.add_argument(
'merchant_id',
type=int,
help='要设置的商户ID'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='仅显示将要更新的记录数,不实际执行'
)
def handle(self, *args, **options):
merchant_id = options['merchant_id']
dry_run = options['dry_run']
# 验证商户是否存在
try:
merchant = Merchant.objects.get(id=merchant_id)
except Merchant.DoesNotExist:
raise CommandError(f'商户 ID {merchant_id} 不存在')
self.stdout.write(f'目标商户: {merchant.name} (ID: {merchant.id})')
self.stdout.write('')
# 统计需要更新的记录
plate_orders_count = PlateOrder.objects.filter(merchant__isnull=True).count()
printing_orders_count = PrintingOrder.objects.filter(merchant__isnull=True).count()
printing_jobs_count = PrintingJob.objects.filter(merchant__isnull=True).count()
self.stdout.write(f'待更新记录:')
self.stdout.write(f' - PlateOrder: {plate_orders_count}')
self.stdout.write(f' - PrintingOrder: {printing_orders_count}')
self.stdout.write(f' - PrintingJob: {printing_jobs_count}')
self.stdout.write('')
total = plate_orders_count + printing_orders_count + printing_jobs_count
if total == 0:
self.stdout.write(self.style.SUCCESS('没有需要更新的记录'))
return
if dry_run:
self.stdout.write(self.style.WARNING('--dry-run 模式,未执行实际更新'))
return
# 执行更新
with transaction.atomic():
updated_plate_orders = PlateOrder.objects.filter(
merchant__isnull=True
).update(merchant=merchant)
updated_printing_orders = PrintingOrder.objects.filter(
merchant__isnull=True
).update(merchant=merchant)
updated_printing_jobs = PrintingJob.objects.filter(
merchant__isnull=True
).update(merchant=merchant)
self.stdout.write('')
self.stdout.write(self.style.SUCCESS(f'更新完成:'))
self.stdout.write(self.style.SUCCESS(f' - PlateOrder: {updated_plate_orders}'))
self.stdout.write(self.style.SUCCESS(f' - PrintingOrder: {updated_printing_orders}'))
self.stdout.write(self.style.SUCCESS(f' - PrintingJob: {updated_printing_jobs}'))

View File

@@ -0,0 +1,30 @@
# Generated by Django 5.2.8 on 2026-01-14 05:41
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('basic_info', '0024_frontend_page_and_visible_pages'),
('printing', '0028_batch_advance_record_only_parameters'),
]
operations = [
migrations.AddField(
model_name='plateorder',
name='merchant',
field=models.ForeignKey(blank=True, help_text='所属商户,可空以兼容历史数据', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='plate_orders', to='basic_info.merchant', verbose_name='商户'),
),
migrations.AddField(
model_name='printingjob',
name='merchant',
field=models.ForeignKey(blank=True, help_text='所属商户,可空以兼容历史数据', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='printing_jobs', to='basic_info.merchant', verbose_name='商户'),
),
migrations.AddField(
model_name='printingorder',
name='merchant',
field=models.ForeignKey(blank=True, help_text='所属商户,可空以兼容历史数据', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='printing_orders', to='basic_info.merchant', verbose_name='商户'),
),
]

View File

@@ -11,6 +11,17 @@ from stateflow import models as stateflow_models
class PlateOrder(ModelBase): class PlateOrder(ModelBase):
"""开版管理订单""" """开版管理订单"""
# 商户关联(多租户)
merchant = models.ForeignKey(
basic_models.Merchant,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='plate_orders',
verbose_name='商户',
help_text='所属商户,可空以兼容历史数据'
)
# 自动编号相关 # 自动编号相关
design_code = models.CharField(max_length=50, blank=True, null=True, verbose_name='设计编号') design_code = models.CharField(max_length=50, blank=True, null=True, verbose_name='设计编号')
original_id = models.PositiveBigIntegerField( original_id = models.PositiveBigIntegerField(
@@ -245,6 +256,18 @@ class PlateOrder(ModelBase):
class PrintingOrder(ModelBase): class PrintingOrder(ModelBase):
"""PrintingOrder model representing a printing order.""" """PrintingOrder model representing a printing order."""
# 商户关联(多租户)
merchant = models.ForeignKey(
basic_models.Merchant,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='printing_orders',
verbose_name='商户',
help_text='所属商户,可空以兼容历史数据'
)
customer = models.ForeignKey( customer = models.ForeignKey(
basic_models.Customer, basic_models.Customer,
on_delete=models.PROTECT, on_delete=models.PROTECT,
@@ -327,6 +350,18 @@ class PrintingJobWorkStateEnum(models.IntegerChoices):
class PrintingJob(ModelBase): class PrintingJob(ModelBase):
"""PrintingJob model representing a printing job associated with an order.""" """PrintingJob model representing a printing job associated with an order."""
# 商户关联(多租户)
merchant = models.ForeignKey(
basic_models.Merchant,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='printing_jobs',
verbose_name='商户',
help_text='所属商户,可空以兼容历史数据'
)
printing_order = models.ForeignKey( printing_order = models.ForeignKey(
PrintingOrder, PrintingOrder,
on_delete=models.CASCADE, on_delete=models.CASCADE,

View File

@@ -1,8 +1,14 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from django.db import models from django.db import models
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from flower.common import ModelBase from flower.common import ModelBase
from basic_info import models as basic_models from basic_info import models as basic_models
if TYPE_CHECKING:
from printing.models import PrintingJob
User = get_user_model() User = get_user_model()
@@ -138,7 +144,7 @@ class SalesItem(ModelBase):
def __str__(self): def __str__(self):
return f'{self.name} x {self.quantity} {self.get_unit_display()}' return f'{self.name} x {self.quantity} {self.get_unit_display()}'
def get_printing_job(self): def get_printing_job(self) -> PrintingJob | None:
""" """
获取关联的 PrintingJob 实例 获取关联的 PrintingJob 实例
@@ -150,10 +156,10 @@ class SalesItem(ModelBase):
try: try:
from printing.models import PrintingJob from printing.models import PrintingJob
return PrintingJob.objects.get(id=self.printing_job_id) return PrintingJob.objects.get(id=self.printing_job_id)
except Exception: except PrintingJob.DoesNotExist:
return None return None
def get_customer(self): def get_customer(self) -> basic_models.Customer | None:
""" """
获取关联的 Customer 实例 获取关联的 Customer 实例

39
shipment/services.py Normal file
View File

@@ -0,0 +1,39 @@
"""
Shipment 模块业务逻辑层
"""
from __future__ import annotations
from django.db.models import QuerySet
from shipment.models import SalesItem
def get_sales_items_by_printing_order(
printing_order_id: int,
include_already_has_shipment: bool = False,
) -> QuerySet[SalesItem]:
"""
通过生产订单ID查询对应的销售品
Args:
printing_order_id: 生产订单ID
include_already_has_shipment: 是否包含已关联出货单的销售品,默认为 False
Returns:
SalesItem 查询集
"""
from printing.models import PrintingJob
# 1. 获取该生产订单下所有 PrintingJob 的 ID
job_ids = PrintingJob.objects.filter(
printing_order_id=printing_order_id
).values_list('id', flat=True)
# 2. 查询 SalesItem过滤 printing_job_id 在这些 job_ids 中
queryset = SalesItem.objects.filter(printing_job_id__in=list(job_ids))
# 3. 根据参数决定是否过滤已出货的销售品
if not include_already_has_shipment:
queryset = queryset.filter(shipment__isnull=True)
return queryset.select_related('shipment').order_by('id')