From fae667c9657290ea2ee5cc82b7010c4cff329398 Mon Sep 17 00:00:00 2001 From: colaftc Date: Wed, 14 Jan 2026 14:26:03 +0800 Subject: [PATCH] fix: added merchant_id to printing_order and plate_order --- api_v1/urls.py | 8 + api_v1/views/printing/serializers.py | 30 +- api_v1/views/printing/services.py | 8 + api_v1/views/printing/test_api.py | 2 + api_v1/views/printing/views.py | 8 +- api_v1/views/shipment/__init__.py | 8 + api_v1/views/shipment/serializers.py | 37 +++ api_v1/views/shipment/test_api.py | 266 ++++++++++++++++++ api_v1/views/shipment/views.py | 78 +++++ ...01-11_summary.md => 2026-01-12_summary.md} | 0 docs/2026-01-13_summary.md | 191 +++++++++++++ docs/2026-01-14_summary.md | 78 +++++ docs/shipment_api.md | 157 +++++++++++ printing/management/__init__.py | 0 printing/management/commands/__init__.py | 0 .../management/commands/backfill_merchant.py | 79 ++++++ .../migrations/0029_add_merchant_to_models.py | 30 ++ printing/models.py | 35 +++ shipment/models.py | 12 +- shipment/services.py | 39 +++ 20 files changed, 1049 insertions(+), 17 deletions(-) create mode 100644 api_v1/views/shipment/__init__.py create mode 100644 api_v1/views/shipment/serializers.py create mode 100644 api_v1/views/shipment/test_api.py create mode 100644 api_v1/views/shipment/views.py rename docs/{2026-01-11_summary.md => 2026-01-12_summary.md} (100%) create mode 100644 docs/2026-01-13_summary.md create mode 100644 docs/2026-01-14_summary.md create mode 100644 docs/shipment_api.md create mode 100644 printing/management/__init__.py create mode 100644 printing/management/commands/__init__.py create mode 100644 printing/management/commands/backfill_merchant.py create mode 100644 printing/migrations/0029_add_merchant_to_models.py create mode 100644 shipment/services.py diff --git a/api_v1/urls.py b/api_v1/urls.py index 70521ce..4200fed 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -25,6 +25,7 @@ from .views.products import ProductQuickViewSet from .views.parameters import StateParameterViewSet from .views.users import CreateUserWithProfileView from .views.mingdaoyun import MDYPlateOrderStagingViewSet +from .views.shipment import SalesItemByPrintingOrderView # 创建 DRF Router for Stateflow stateflow_router = DefaultRouter() @@ -98,6 +99,13 @@ urlpatterns = [ # Stateflow API (使用 Router) path('stateflow/', include(stateflow_router.urls)), + # Shipment API + path( + 'shipment/sales-items/by-printing-order//', + SalesItemByPrintingOrderView.as_view(), + name='sales_items_by_printing_order' + ), + # 主 Router (printing-orders 等) path('', include(main_router.urls)), ] diff --git a/api_v1/views/printing/serializers.py b/api_v1/views/printing/serializers.py index 84fa519..e890715 100644 --- a/api_v1/views/printing/serializers.py +++ b/api_v1/views/printing/serializers.py @@ -141,11 +141,12 @@ class PrintingOrderListSerializer(serializers.ModelSerializer): jobs_last_status_summary = serializers.SerializerMethodField() created_by = serializers.IntegerField(source='created_by_id', read_only=True) created_by_name = serializers.SerializerMethodField() + merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True) class Meta: model = models.PrintingOrder 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', 'is_fabric_received', 'outgoing_date', 'is_invalid', 'new_curve', 'process', 'process_name', 'progress', 'position', 'print_count', @@ -153,7 +154,7 @@ class PrintingOrderListSerializer(serializers.ModelSerializer): 'created_by', 'created_by_name', '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): """ @@ -228,11 +229,12 @@ class PrintingOrderDetailSerializer(serializers.ModelSerializer): process_name = serializers.CharField(source='process.name', read_only=True) created_by_name = serializers.SerializerMethodField() progress = serializers.IntegerField(read_only=True) + merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True) class Meta: model = models.PrintingOrder 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', 'is_fabric_received', 'craft', 'description', 'outgoing_date', 'curve', 'new_curve', 'position', 'created_by_name', @@ -240,7 +242,7 @@ class PrintingOrderDetailSerializer(serializers.ModelSerializer): 'is_invalid', 'process', 'process_name', 'progress', 'print_count', '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): """获取创建人名称(员工姓名)""" @@ -316,11 +318,12 @@ class PrintingJobListSerializer(serializers.ModelSerializer): last_completed_state = serializers.CharField(read_only=True) business_object_id = serializers.SerializerMethodField() batch_advance_records = serializers.SerializerMethodField() + merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True) class Meta: model = models.PrintingJob 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', 'quantity', 'unit', 'size', 'pieces', 'description', 'work_state', 'work_state_display', @@ -332,7 +335,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer): read_only_fields = [ 'id', 'created_at', 'updated_at', 'status', 'is_completed', 'progress_percentage', 'last_completed_state', - 'business_object_id' + 'business_object_id', 'merchant_id' ] def get_business_object_id(self, obj): @@ -394,11 +397,12 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer): last_completed_state = serializers.CharField(read_only=True) business_object_id = serializers.SerializerMethodField() batch_advance_records = serializers.SerializerMethodField() + merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True) class Meta: model = models.PrintingJob 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', 'work_state', 'work_state_display', 'status', 'status_id', 'is_completed', 'has_started', @@ -411,7 +415,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer): 'id', 'created_at', 'updated_at', 'status', 'status_id', 'is_completed', 'has_started', 'progress_percentage', 'last_completed_state', - 'business_object_id' + 'business_object_id', 'merchant_id' ] def get_business_object_id(self, obj): @@ -550,12 +554,13 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria last_completed_state = serializers.CharField(read_only=True) content_type_id = serializers.SerializerMethodField() created_by_name = serializers.SerializerMethodField() + merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True) class Meta: model = models.PlateOrder 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', 'urgency_level', 'is_invalid', 'customer', 'customer_name', 'area', 'default_address', @@ -576,7 +581,7 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria ] read_only_fields = [ '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): @@ -630,11 +635,12 @@ class PlateOrderDetailSerializer(PlateOrderDesignCodeMixin, serializers.ModelSer plate_image_url = serializers.SerializerMethodField() process_name = serializers.SerializerMethodField() last_completed_state = serializers.CharField(read_only=True) + merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True) class Meta: model = models.PlateOrder 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', 'urgency_level', 'is_invalid', 'customer', 'customer_name', 'customer_phone', 'area', 'default_address', @@ -656,7 +662,7 @@ class PlateOrderDetailSerializer(PlateOrderDesignCodeMixin, serializers.ModelSer read_only_fields = [ 'id', 'status', 'status_id', 'is_completed', 'has_started', '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): diff --git a/api_v1/views/printing/services.py b/api_v1/views/printing/services.py index b88cf62..82a50ce 100644 --- a/api_v1/views/printing/services.py +++ b/api_v1/views/printing/services.py @@ -46,6 +46,10 @@ class PrintingOrderService: # 绑定创建人 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) return order @@ -128,6 +132,10 @@ class PrintingJobService: # 绑定创建人 data['created_by'] = user + # 绑定商户(从当前用户的 employee 获取) + if hasattr(user, 'employee') and user.employee and user.employee.merchant: + data['merchant'] = user.employee.merchant + # 创建 PrintingJob job = printing_models.PrintingJob.objects.create(**data) diff --git a/api_v1/views/printing/test_api.py b/api_v1/views/printing/test_api.py index 8b943e2..f6ad1f3 100644 --- a/api_v1/views/printing/test_api.py +++ b/api_v1/views/printing/test_api.py @@ -106,6 +106,8 @@ class PrintingOrderAPITestCase(TestCase): self.assertIsNotNone(order) self.assertEqual(order.customer.id, self.customer.id) self.assertEqual(order.fabric, '纯棉布料') + # 验证 merchant 自动绑定 + self.assertEqual(order.merchant.id, self.merchant.id) def test_list_printing_orders(self): """测试获取订单列表""" diff --git a/api_v1/views/printing/views.py b/api_v1/views/printing/views.py index 9a32a30..fe58d8d 100644 --- a/api_v1/views/printing/views.py +++ b/api_v1/views/printing/views.py @@ -740,9 +740,13 @@ class PlateOrderViewSet(LimitedModelViewSet): 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']) def invalidate(self, request, pk=None): diff --git a/api_v1/views/shipment/__init__.py b/api_v1/views/shipment/__init__.py new file mode 100644 index 0000000..25cff4c --- /dev/null +++ b/api_v1/views/shipment/__init__.py @@ -0,0 +1,8 @@ +""" +Shipment API 模块 + +提供出货单和销售品相关的 API 接口 +""" +from .views import SalesItemByPrintingOrderView + +__all__ = ['SalesItemByPrintingOrderView'] diff --git a/api_v1/views/shipment/serializers.py b/api_v1/views/shipment/serializers.py new file mode 100644 index 0000000..2f90534 --- /dev/null +++ b/api_v1/views/shipment/serializers.py @@ -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 diff --git a/api_v1/views/shipment/test_api.py b/api_v1/views/shipment/test_api.py new file mode 100644 index 0000000..468e406 --- /dev/null +++ b/api_v1/views/shipment/test_api.py @@ -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) diff --git a/api_v1/views/shipment/views.py b/api_v1/views/shipment/views.py new file mode 100644 index 0000000..1e0d95c --- /dev/null +++ b/api_v1/views/shipment/views.py @@ -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// + + 返回与该 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 + }) diff --git a/docs/2026-01-11_summary.md b/docs/2026-01-12_summary.md similarity index 100% rename from docs/2026-01-11_summary.md rename to docs/2026-01-12_summary.md diff --git a/docs/2026-01-13_summary.md b/docs/2026-01-13_summary.md new file mode 100644 index 0000000..6e3383b --- /dev/null +++ b/docs/2026-01-13_summary.md @@ -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 模块是全新创建的,需要执行迁移 +- 信号处理器只在流程**全部完成**时触发,不是每次推进 +- 如果指定节点的"米数"参数不存在,会跳过创建并记录警告日志 diff --git a/docs/2026-01-14_summary.md b/docs/2026-01-14_summary.md new file mode 100644 index 0000000..c38adcf --- /dev/null +++ b/docs/2026-01-14_summary.md @@ -0,0 +1,78 @@ +# 2026-01-14 工作日志 + +## 已完成 + +### 1. 通过生产订单查询销售品 API + +创建了独立的 Shipment API 模块,提供出货管理相关接口。 + +- **接口路径**: `GET /api/v1/shipment/sales-items/by-printing-order//` +- **查询参数**: `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 --dry-run + +# 执行补录 +python manage.py backfill_merchant +``` + +**文件**: `printing/management/commands/backfill_merchant.py` + +--- + +## 待办 + +--- + +## 备注 + +- API 独立于 printing 模块,避免影响现有功能 +- 完整的测试覆盖:正常查询、包含已出货、数据格式、404 错误、空结果、未认证 +- printing 和 shipment 模块全部测试通过(31个测试用例) \ No newline at end of file diff --git a/docs/shipment_api.md b/docs/shipment_api.md new file mode 100644 index 0000000..f1be1cf --- /dev/null +++ b/docs/shipment_api.md @@ -0,0 +1,157 @@ +# Shipment API 文档 + +出货管理模块 API 文档,包含出货单和销售品相关接口。 + +## 目录 + +- [通过生产订单查询销售品](#通过生产订单查询销售品) + +--- + +## 通过生产订单查询销售品 + +查询与指定生产订单(PrintingOrder)关联的所有销售品(SalesItem)。 + +### 接口信息 + +- **URL**: `/api/v1/shipment/sales-items/by-printing-order//` +- **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 | 关联的出货单ID,null 表示未出货 | +| results[].shipment_date | string/null | 出货日期(YYYY-MM-DD),null 表示未出货 | +| 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 ' +``` + +#### 查询所有销售品(包含已出货) + +```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 ' +``` + +--- + +## 单位编码对照表 + +| 编码 | 名称 | +|------|------| +| 1 | 米 | +| 2 | 件 | +| 3 | 码 | +| 4 | 个 | + +--- + +## 相关模块 + +- `shipment/services.py`: 业务逻辑层 +- `api_v1/views/shipment/`: API 视图层 +- `shipment/models.py`: 数据模型(SalesItem, Shipment) diff --git a/printing/management/__init__.py b/printing/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/printing/management/commands/__init__.py b/printing/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/printing/management/commands/backfill_merchant.py b/printing/management/commands/backfill_merchant.py new file mode 100644 index 0000000..9eb7a87 --- /dev/null +++ b/printing/management/commands/backfill_merchant.py @@ -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} 条')) diff --git a/printing/migrations/0029_add_merchant_to_models.py b/printing/migrations/0029_add_merchant_to_models.py new file mode 100644 index 0000000..26fd4ae --- /dev/null +++ b/printing/migrations/0029_add_merchant_to_models.py @@ -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='商户'), + ), + ] diff --git a/printing/models.py b/printing/models.py index e02bac8..0e3210e 100644 --- a/printing/models.py +++ b/printing/models.py @@ -11,6 +11,17 @@ from stateflow import models as stateflow_models 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='设计编号') original_id = models.PositiveBigIntegerField( @@ -245,6 +256,18 @@ class PlateOrder(ModelBase): class PrintingOrder(ModelBase): """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( basic_models.Customer, on_delete=models.PROTECT, @@ -327,6 +350,18 @@ class PrintingJobWorkStateEnum(models.IntegerChoices): class PrintingJob(ModelBase): """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( PrintingOrder, on_delete=models.CASCADE, diff --git a/shipment/models.py b/shipment/models.py index 0ee33ea..18a75d4 100644 --- a/shipment/models.py +++ b/shipment/models.py @@ -1,8 +1,14 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + from django.db import models from django.contrib.auth import get_user_model from flower.common import ModelBase from basic_info import models as basic_models +if TYPE_CHECKING: + from printing.models import PrintingJob + User = get_user_model() @@ -138,7 +144,7 @@ class SalesItem(ModelBase): def __str__(self): return f'{self.name} x {self.quantity} {self.get_unit_display()}' - def get_printing_job(self): + def get_printing_job(self) -> PrintingJob | None: """ 获取关联的 PrintingJob 实例 @@ -150,10 +156,10 @@ class SalesItem(ModelBase): try: from printing.models import PrintingJob return PrintingJob.objects.get(id=self.printing_job_id) - except Exception: + except PrintingJob.DoesNotExist: return None - def get_customer(self): + def get_customer(self) -> basic_models.Customer | None: """ 获取关联的 Customer 实例 diff --git a/shipment/services.py b/shipment/services.py new file mode 100644 index 0000000..4578b69 --- /dev/null +++ b/shipment/services.py @@ -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')