From cae0f8434e37a141de7bc88180c5a9bba1feca23 Mon Sep 17 00:00:00 2001 From: colaftc Date: Wed, 14 Jan 2026 18:14:22 +0800 Subject: [PATCH] fix: added permission for plate_order and printing_order api --- api_v1/tasks.py | 2 +- api_v1/urls.py | 7 +- api_v1/views/printing/mixins.py | 106 +++++++++ api_v1/views/printing/test_api.py | 209 +++++++++++++++++- api_v1/views/printing/views.py | 25 ++- api_v1/views/shipment/__init__.py | 4 +- api_v1/views/shipment/serializers.py | 51 +++++ api_v1/views/shipment/test_api.py | 162 ++++++++++++++ api_v1/views/shipment/views.py | 58 ++++- docs/2026-01-14_summary.md | 67 +++++- docs/shipment_api.md | 91 ++++++++ flower/settings.py | 1 + printing/apps.py | 2 +- printing/handlers.py | 6 + .../0030_add_view_all_permissions.py | 21 ++ printing/models.py | 2 + shipment/services.py | 68 +++++- 17 files changed, 867 insertions(+), 15 deletions(-) create mode 100644 api_v1/views/printing/mixins.py create mode 100644 printing/migrations/0030_add_view_all_permissions.py diff --git a/api_v1/tasks.py b/api_v1/tasks.py index 0c5d2be..68b4c28 100644 --- a/api_v1/tasks.py +++ b/api_v1/tasks.py @@ -209,7 +209,7 @@ def _upsert_product(product_data, merchant, category): merchant=merchant, human_id=product_data.uid, **defaults, - ) + ) return True if not product_obj.from_mdy: diff --git a/api_v1/urls.py b/api_v1/urls.py index 4200fed..dbc178e 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -25,7 +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 +from .views.shipment import SalesItemByPrintingOrderView, ShipmentCreateView # 创建 DRF Router for Stateflow stateflow_router = DefaultRouter() @@ -100,6 +100,11 @@ urlpatterns = [ path('stateflow/', include(stateflow_router.urls)), # Shipment API + path( + 'shipment/shipments/', + ShipmentCreateView.as_view(), + name='shipment_create' + ), path( 'shipment/sales-items/by-printing-order//', SalesItemByPrintingOrderView.as_view(), diff --git a/api_v1/views/printing/mixins.py b/api_v1/views/printing/mixins.py new file mode 100644 index 0000000..f3c9ba7 --- /dev/null +++ b/api_v1/views/printing/mixins.py @@ -0,0 +1,106 @@ +""" +Printing API Mixins + +提供客户可见性过滤等通用功能 +""" +from django.db.models import Q + + +class CustomerVisibilityFilterMixin: + """ + 基于客户可见性的查询过滤 Mixin + + 适用于关联了 Customer 的模型(PlateOrder, PrintingOrder, PrintingJob) + + 使用方式: + 1. 在 ViewSet 中继承此 Mixin(放在 LimitedModelViewSet 之前) + 2. 设置 customer_field 指定关联 customer 的字段路径 + 3. 设置 view_all_permission 指定突破限制的权限名 + + 可见性规则: + - superuser: 无限制 + - 有 view_all_permission 权限: 无限制 + - 订单.customer 为空: 全员可见(同 merchant) + - 订单.customer 不为空: 按客户绑定关系判断 + - customer.created_by == 当前员工 → 可见 + - 当前员工 in customer.visible_employees → 可见 + """ + # 子类需要指定:通过哪个字段关联到 customer + customer_field = 'customer' # PlateOrder/PrintingOrder 直接关联 + # customer_field = 'printing_order__customer' # PrintingJob 间接关联 + + # 子类需要指定:突破限制的权限名(完整格式如 'printing.view_all_plateorders') + view_all_permission = None + + def can_bypass_customer_visibility(self) -> bool: + """ + 是否可以突破客户可见性限制 + + 注意:view_all_customers 权限不影响此判断 + """ + user = self.request.user + if user.is_superuser: + return True + # 仅检查订单级别的专属权限 + if self.view_all_permission and user.has_perm(self.view_all_permission): + return True + return False + + def filter_by_customer_visibility(self, queryset): + """ + 应用客户可见性过滤 + + Returns: + 过滤后的 queryset + """ + if self.can_bypass_customer_visibility(): + return queryset + + user = self.request.user + emp = getattr(user, 'employee', None) + if emp is None: + return queryset.none() + + # 可见的客户条件: + # 1. customer.created_by == 当前员工 + # 2. 当前员工 in customer.visible_employees + visible_customer_filter = ( + Q(**{f'{self.customer_field}__created_by': emp}) | + Q(**{f'{self.customer_field}__visible_employees': emp}) + ) + + # customer 为空时全员可见(同 merchant 下) + no_customer_filter = Q(**{f'{self.customer_field}__isnull': True}) + + return queryset.filter(visible_customer_filter | no_customer_filter).distinct() + + def filter_by_merchant(self, queryset): + """ + 应用 merchant 隔离过滤 + + Returns: + 过滤后的 queryset,如果用户没有关联 merchant 则返回空 + """ + user = self.request.user + if user.is_superuser: + return queryset + + emp = getattr(user, 'employee', None) + if emp is None or emp.merchant is None: + return queryset.none() + + return queryset.filter(merchant=emp.merchant) + + def get_queryset(self): + """ + 重写 get_queryset,应用 merchant 隔离和客户可见性过滤 + """ + queryset = super().get_queryset() + + # 1. merchant 隔离(必须) + queryset = self.filter_by_merchant(queryset) + + # 2. 客户可见性过滤 + queryset = self.filter_by_customer_visibility(queryset) + + return queryset diff --git a/api_v1/views/printing/test_api.py b/api_v1/views/printing/test_api.py index f6ad1f3..8029fb1 100644 --- a/api_v1/views/printing/test_api.py +++ b/api_v1/views/printing/test_api.py @@ -46,12 +46,13 @@ class PrintingOrderAPITestCase(TestCase): status=basic_models.EmployeeStatusEnum.ACTIVE ) - # 创建客户 + # 创建客户(设置 created_by 以便客户可见性过滤) self.customer = basic_models.Customer.objects.create( merchant=self.merchant, name='测试客户', mobile='13900139000', - area='测试地区' + area='测试地区', + created_by=self.employee ) # 创建流程 @@ -72,7 +73,9 @@ class PrintingOrderAPITestCase(TestCase): view_perm = Permission.objects.get(codename='view_printingorder') add_perm = Permission.objects.get(codename='add_printingorder') change_perm = Permission.objects.get(codename='change_printingorder') - self.user.user_permissions.add(view_perm, add_perm, change_perm) + # 添加 view_all 权限以便测试 API 功能(权限过滤逻辑在专门的测试类中验证) + view_all_perm = Permission.objects.get(codename='view_all_printingorders') + self.user.user_permissions.add(view_perm, add_perm, change_perm, view_all_perm) def test_create_printing_order(self): """测试创建印染订单""" @@ -113,11 +116,13 @@ class PrintingOrderAPITestCase(TestCase): """测试获取订单列表""" # 创建测试订单 printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='布料1', width='150cm' ) printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='布料2', width='160cm' @@ -131,6 +136,7 @@ class PrintingOrderAPITestCase(TestCase): def test_retrieve_printing_order(self): """测试获取订单详情""" order = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='测试布料', width='150cm', @@ -148,6 +154,7 @@ class PrintingOrderAPITestCase(TestCase): def test_update_printing_order(self): """测试更新订单""" order = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='旧布料', width='150cm' @@ -176,6 +183,7 @@ class PrintingOrderAPITestCase(TestCase): def test_partial_update_printing_order(self): """测试部分更新订单""" order = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='原布料', width='150cm', @@ -206,6 +214,7 @@ class PrintingOrderAPITestCase(TestCase): self.user.user_permissions.add(delete_perm) order = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='测试布料', width='150cm' @@ -227,6 +236,7 @@ class PrintingOrderAPITestCase(TestCase): self.user.user_permissions.add(invalidate_perm) order = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='测试布料', width='150cm', @@ -243,6 +253,7 @@ class PrintingOrderAPITestCase(TestCase): def test_invalidate_without_permission(self): """测试没有权限时作废订单失败""" order = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='测试布料', width='150cm' @@ -259,6 +270,7 @@ class PrintingOrderAPITestCase(TestCase): self.user.user_permissions.add(activate_perm) order = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='测试布料', width='150cm', @@ -275,6 +287,7 @@ class PrintingOrderAPITestCase(TestCase): def test_activate_without_permission(self): """测试没有权限时恢复订单失败""" order = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='测试布料', width='150cm', @@ -288,6 +301,7 @@ class PrintingOrderAPITestCase(TestCase): def test_mark_fabric_received(self): """测试标记布料已收""" order = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='测试布料', width='150cm', @@ -304,6 +318,7 @@ class PrintingOrderAPITestCase(TestCase): def test_mark_fabric_received_already_received(self): """测试重复标记布料已收""" order = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='测试布料', width='150cm', @@ -319,15 +334,18 @@ class PrintingOrderAPITestCase(TestCase): customer2 = basic_models.Customer.objects.create( merchant=self.merchant, name='客户2', - mobile='13900139001' + mobile='13900139001', + created_by=self.employee ) printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='布料1', width='150cm' ) printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=customer2, fabric='布料2', width='160cm' @@ -340,12 +358,14 @@ class PrintingOrderAPITestCase(TestCase): def test_filter_by_urgent(self): """测试按紧急状态过滤""" printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='布料1', width='150cm', is_urgent=True ) printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='布料2', width='160cm', @@ -360,11 +380,13 @@ class PrintingOrderAPITestCase(TestCase): def test_search_by_fabric(self): """测试按布料搜索""" printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='纯棉布料', width='150cm' ) printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='涤纶布料', width='160cm' @@ -378,11 +400,13 @@ class PrintingOrderAPITestCase(TestCase): def test_ordering(self): """测试排序""" order1 = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='布料1', width='150cm' ) order2 = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='布料2', width='160cm' @@ -406,6 +430,7 @@ class PrintingOrderAPITestCase(TestCase): from datetime import date order = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='测试布料', width='150cm' @@ -452,6 +477,7 @@ class PrintingOrderAPITestCase(TestCase): def test_order_progress_in_list(self): """测试订单列表包含进度字段""" order = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='测试布料', width='150cm', @@ -466,6 +492,7 @@ class PrintingOrderAPITestCase(TestCase): def test_update_process_when_no_jobs(self): """测试没有任务时可以修改流程""" order = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='测试布料', width='150cm', @@ -491,6 +518,7 @@ class PrintingOrderAPITestCase(TestCase): def test_cannot_update_process_when_job_started(self): """测试有已开始的任务时不能修改流程""" order = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='测试布料', width='150cm', @@ -549,6 +577,7 @@ class PrintingOrderAPITestCase(TestCase): """测试订单列表包含 jobs_status_summary 字段(PrintingJob 状态汇总)""" # 创建订单 order = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='测试布料', width='150cm', @@ -622,6 +651,7 @@ class PrintingOrderAPITestCase(TestCase): def test_jobs_status_summary_empty_when_no_jobs(self): """测试没有任务时 jobs_status_summary 为空列表""" order = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, customer=self.customer, fabric='无任务订单', width='150cm', @@ -640,3 +670,174 @@ class PrintingOrderAPITestCase(TestCase): summary = response.data['results'][0]['jobs_status_summary'] self.assertEqual(summary, []) + + +class CustomerVisibilityFilterTestCase(TestCase): + """测试客户可见性过滤""" + + def setUp(self): + from django.core.cache import cache + cache.clear() + + self.client = APIClient() + + # 创建商户 + self.merchant = basic_models.Merchant.objects.create( + name='测试印花厂', + type=basic_models.MerchantTypeEnum.FACTORY + ) + + # 创建第二个商户(用于测试 merchant 隔离) + self.merchant2 = 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.user2 = User.objects.create_user( + username='testuser2', + password='testpass123', + email='test2@example.com' + ) + self.employee2 = basic_models.Employee.objects.create( + sys_user=self.user2, + merchant=self.merchant, + name='测试员工2', + mobile='13800138001', + status=basic_models.EmployeeStatusEnum.ACTIVE + ) + + # 创建客户(employee 创建的,employee 可见) + self.customer_by_emp1 = basic_models.Customer.objects.create( + merchant=self.merchant, + name='员工1的客户', + mobile='13900139000', + created_by=self.employee + ) + + # 创建客户(employee2 创建的,默认 employee 不可见) + self.customer_by_emp2 = basic_models.Customer.objects.create( + merchant=self.merchant, + name='员工2的客户', + mobile='13900139001', + created_by=self.employee2 + ) + + # 创建 employee 可见客户的订单 + self.order_visible = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, + customer=self.customer_by_emp1, + fabric='可见订单', + width='150cm' + ) + + # 创建 employee 不可见客户的订单 + self.order_invisible = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, + customer=self.customer_by_emp2, + fabric='不可见订单', + width='150cm' + ) + + # 创建其他商户的订单(merchant 隔离测试) + self.customer_other_merchant = basic_models.Customer.objects.create( + merchant=self.merchant2, + name='其他商户客户', + mobile='13900139002' + ) + self.order_other_merchant = printing_models.PrintingOrder.objects.create( + merchant=self.merchant2, + customer=self.customer_other_merchant, + fabric='其他商户订单', + width='150cm' + ) + + # 给用户添加基础权限(不含 view_all) + view_perm = Permission.objects.get(codename='view_printingorder') + self.user.user_permissions.add(view_perm) + self.user2.user_permissions.add(view_perm) + + self.client.force_authenticate(user=self.user) + + def test_list_only_visible_orders(self): + """测试普通员工只能看到可见客户的订单""" + response = self.client.get('/api/v1/printing-orders/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # 应该只能看到自己创建的客户的订单 = 1 + self.assertEqual(response.data['count'], 1) + + fabrics = [r['fabric'] for r in response.data['results']] + self.assertIn('可见订单', fabrics) + self.assertNotIn('不可见订单', fabrics) + self.assertNotIn('其他商户订单', fabrics) + + def test_retrieve_visible_order(self): + """测试可以获取可见订单详情""" + response = self.client.get(f'/api/v1/printing-orders/{self.order_visible.id}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_retrieve_invisible_order_404(self): + """测试无法获取不可见订单详情""" + response = self.client.get(f'/api/v1/printing-orders/{self.order_invisible.id}/') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_retrieve_other_merchant_order_404(self): + """测试无法获取其他商户的订单""" + response = self.client.get(f'/api/v1/printing-orders/{self.order_other_merchant.id}/') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_customer_in_visible_employees_can_see(self): + """测试被加入 visible_employees 后可以看到订单""" + # 将 employee 加入 customer_by_emp2 的可见员工列表 + self.customer_by_emp2.visible_employees.add(self.employee) + + response = self.client.get('/api/v1/printing-orders/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # 现在应该能看到 2 个订单(自己创建的 + 被加入可见列表的) + self.assertEqual(response.data['count'], 2) + + fabrics = [r['fabric'] for r in response.data['results']] + self.assertIn('不可见订单', fabrics) + + def test_view_all_permission_bypasses_filter(self): + """测试 view_all 权限可以突破客户可见性限制""" + # 添加 view_all 权限 + view_all_perm = Permission.objects.get(codename='view_all_printingorders') + self.user.user_permissions.add(view_all_perm) + + response = self.client.get('/api/v1/printing-orders/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # 应该能看到本商户的所有订单(2个),但不能看到其他商户的 + self.assertEqual(response.data['count'], 2) + + fabrics = [r['fabric'] for r in response.data['results']] + self.assertIn('不可见订单', fabrics) + self.assertNotIn('其他商户订单', fabrics) + + def test_superuser_sees_all(self): + """测试超级用户可以看到所有订单""" + self.user.is_superuser = True + self.user.save() + + response = self.client.get('/api/v1/printing-orders/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # 超级用户可以看到所有订单(包括其他商户) + self.assertEqual(response.data['count'], 3) diff --git a/api_v1/views/printing/views.py b/api_v1/views/printing/views.py index fe58d8d..e495123 100644 --- a/api_v1/views/printing/views.py +++ b/api_v1/views/printing/views.py @@ -28,6 +28,7 @@ from .serializers import ( PrintingJobDetailSerializer, PrintingJobCreateUpdateSerializer, ) +from .mixins import CustomerVisibilityFilterMixin class IsPrintingFactory(BasePermission): @@ -96,7 +97,7 @@ class PrintingOrderFilterSet(django_filters.FilterSet): @method_decorator(cache_page(20), name='list') -class PrintingOrderViewSet(LimitedModelViewSet): +class PrintingOrderViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet): """ 印染订单 ViewSet @@ -129,8 +130,14 @@ class PrintingOrderViewSet(LimitedModelViewSet): 格式: [{"state_name": "进度一", "state_id": 1, "count": 3}, ...] - jobs_last_status_summary: 订单下所有印染任务按最后完成状态分组的数量汇总 格式: [{"state_name": "进度一", "count": 3}, ...] + + 权限控制: + - 默认按客户可见性过滤(员工只能看到自己负责的客户的订单) + - 拥有 printing.view_all_printingorders 权限可突破此限制 """ queryset = models.PrintingOrder.objects.all() + customer_field = 'customer' + view_all_permission = 'printing.view_all_printingorders' permission_classes = [DjangoModelPermissions] filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filterset_class = PrintingOrderFilterSet @@ -286,7 +293,7 @@ class PrintingJobFilterSet(django_filters.FilterSet): fields = ['printing_order', 'product'] -class PrintingJobViewSet(LimitedModelViewSet): +class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet): """ 印染款式明细 ViewSet @@ -318,8 +325,14 @@ class PrintingJobViewSet(LimitedModelViewSet): - pieces_max: 最大件数 - search: 全文搜索(产品名称、单位、尺寸、备注) - ordering: 排序字段 + + 权限控制: + - 默认按客户可见性过滤(通过 printing_order.customer) + - 拥有 printing.view_all_printingorders 权限可突破此限制 """ queryset = models.PrintingJob.objects.all() + customer_field = 'printing_order__customer' + view_all_permission = 'printing.view_all_printingorders' permission_classes = [DjangoModelPermissions, IsPrintingFactory] filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filterset_class = PrintingJobFilterSet @@ -630,7 +643,7 @@ def _plate_order_last_modified(request, *args, **kwargs): # @method_decorator(condition(last_modified_func=_plate_order_last_modified), name='list') -class PlateOrderViewSet(LimitedModelViewSet): +class PlateOrderViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet): """ 开版订单 ViewSet @@ -677,8 +690,14 @@ class PlateOrderViewSet(LimitedModelViewSet): - created_date_to: 创建日期结束 - search: 全文搜索(设计编号、款式名称、客户名称、面料) - ordering: 排序字段 + + 权限控制: + - 默认按客户可见性过滤(员工只能看到自己负责的客户的订单) + - 拥有 printing.view_all_plateorders 权限可突破此限制 """ queryset = models.PlateOrder.objects.all() + customer_field = 'customer' + view_all_permission = 'printing.view_all_plateorders' permission_classes = [DjangoModelPermissions] parser_classes = [MultiPartParser, FormParser, JSONParser] # 支持文件上传 filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] diff --git a/api_v1/views/shipment/__init__.py b/api_v1/views/shipment/__init__.py index 25cff4c..41f5ae1 100644 --- a/api_v1/views/shipment/__init__.py +++ b/api_v1/views/shipment/__init__.py @@ -3,6 +3,6 @@ Shipment API 模块 提供出货单和销售品相关的 API 接口 """ -from .views import SalesItemByPrintingOrderView +from .views import SalesItemByPrintingOrderView, ShipmentCreateView -__all__ = ['SalesItemByPrintingOrderView'] +__all__ = ['SalesItemByPrintingOrderView', 'ShipmentCreateView'] diff --git a/api_v1/views/shipment/serializers.py b/api_v1/views/shipment/serializers.py index 2f90534..703a782 100644 --- a/api_v1/views/shipment/serializers.py +++ b/api_v1/views/shipment/serializers.py @@ -3,6 +3,57 @@ Shipment API 序列化器 """ from rest_framework import serializers +from shipment.models import Shipment + + +class ShipmentSerializer(serializers.ModelSerializer): + """ + 出货单序列化器(只读,用于返回数据) + """ + customer_name = serializers.CharField(source='customer.name', read_only=True) + created_by_id = serializers.IntegerField(source='created_by.id', read_only=True, allow_null=True) + created_by_name = serializers.SerializerMethodField() + items_count = serializers.SerializerMethodField() + + class Meta: + model = Shipment + fields = [ + 'id', 'customer', 'customer_name', 'shipment_date', 'remark', + 'items_count', 'created_by_id', 'created_by_name', + 'created_at', 'updated_at' + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + 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 + + def get_items_count(self, obj): + return obj.items.count() + + +class ShipmentCreateSerializer(serializers.Serializer): + """ + 出货单创建序列化器 + """ + customer = serializers.IntegerField(help_text='客户ID') + shipment_date = serializers.DateField(help_text='出货日期') + remark = serializers.CharField(required=False, default='', allow_blank=True, help_text='备注') + sales_items = serializers.ListField( + child=serializers.IntegerField(), + required=False, + default=list, + help_text='要关联的销售品ID列表' + ) + + def validate_sales_items(self, value): + # 去重 + return list(set(value)) if value else [] + class SalesItemSerializer(serializers.Serializer): """ diff --git a/api_v1/views/shipment/test_api.py b/api_v1/views/shipment/test_api.py index 468e406..d2972a7 100644 --- a/api_v1/views/shipment/test_api.py +++ b/api_v1/views/shipment/test_api.py @@ -264,3 +264,165 @@ class SalesItemByPrintingOrderAPITestCase(TestCase): response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class ShipmentCreateAPITestCase(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.sales_item1 = shipment_models.SalesItem.objects.create( + name='销售品1', + quantity=Decimal('50.00'), + unit=shipment_models.UnitChoices.METER, + created_by=self.user, + ) + + self.sales_item2 = shipment_models.SalesItem.objects.create( + name='销售品2', + quantity=Decimal('30.00'), + unit=shipment_models.UnitChoices.METER, + created_by=self.user, + ) + + # 创建已关联出货单的销售品 + self.existing_shipment = shipment_models.Shipment.objects.create( + customer=self.customer, + shipment_date='2026-01-13', + created_by=self.user, + ) + self.sales_item_shipped = shipment_models.SalesItem.objects.create( + name='销售品3(已出货)', + quantity=Decimal('100.00'), + unit=shipment_models.UnitChoices.METER, + shipment=self.existing_shipment, + created_by=self.user, + ) + + # 认证用户 + self.client.force_authenticate(user=self.user) + + def test_create_shipment_success(self): + """测试成功创建出货单""" + data = { + 'customer': self.customer.id, + 'shipment_date': '2026-01-14', + 'remark': '测试备注', + 'sales_items': [self.sales_item1.id, self.sales_item2.id] + } + + response = self.client.post('/api/v1/shipment/shipments/', data, format='json') + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + result = response.json() + + # 验证返回数据 + self.assertIn('id', result) + self.assertEqual(result['customer'], self.customer.id) + self.assertEqual(result['customer_name'], self.customer.name) + self.assertEqual(result['shipment_date'], '2026-01-14') + self.assertEqual(result['remark'], '测试备注') + self.assertEqual(result['items_count'], 2) + self.assertEqual(result['created_by_id'], self.user.id) + + # 验证销售品已关联到出货单 + self.sales_item1.refresh_from_db() + self.sales_item2.refresh_from_db() + self.assertEqual(self.sales_item1.shipment_id, result['id']) + self.assertEqual(self.sales_item2.shipment_id, result['id']) + + def test_create_shipment_without_sales_items(self): + """测试创建出货单但不关联销售品""" + data = { + 'customer': self.customer.id, + 'shipment_date': '2026-01-14', + } + + response = self.client.post('/api/v1/shipment/shipments/', data, format='json') + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + result = response.json() + self.assertEqual(result['items_count'], 0) + + def test_create_shipment_customer_not_found(self): + """测试客户不存在""" + data = { + 'customer': 99999, + 'shipment_date': '2026-01-14', + } + + response = self.client.post('/api/v1/shipment/shipments/', data, format='json') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('不存在', response.json()['detail']) + + def test_create_shipment_sales_item_not_found(self): + """测试销售品不存在""" + data = { + 'customer': self.customer.id, + 'shipment_date': '2026-01-14', + 'sales_items': [99999] + } + + response = self.client.post('/api/v1/shipment/shipments/', data, format='json') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('不存在', response.json()['detail']) + + def test_create_shipment_sales_item_already_shipped(self): + """测试销售品已关联到其他出货单""" + data = { + 'customer': self.customer.id, + 'shipment_date': '2026-01-14', + 'sales_items': [self.sales_item_shipped.id] + } + + response = self.client.post('/api/v1/shipment/shipments/', data, format='json') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('已关联', response.json()['detail']) + + def test_create_shipment_unauthenticated(self): + """测试未认证用户""" + self.client.logout() + + data = { + 'customer': self.customer.id, + 'shipment_date': '2026-01-14', + } + + response = self.client.post('/api/v1/shipment/shipments/', data, format='json') + + 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 index 1e0d95c..a118634 100644 --- a/api_v1/views/shipment/views.py +++ b/api_v1/views/shipment/views.py @@ -6,7 +6,63 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated -from .serializers import SalesItemSerializer +from .serializers import SalesItemSerializer, ShipmentSerializer, ShipmentCreateSerializer + + +class ShipmentCreateView(APIView): + """ + 创建出货单 + + POST /api/v1/shipment/shipments/ + + 请求体: + { + "customer": 1, + "shipment_date": "2026-01-14", + "remark": "备注信息(可选)", + "sales_items": [1, 2, 3] + } + + 返回: + { + "id": 1, + "customer": 1, + "customer_name": "客户A", + "shipment_date": "2026-01-14", + "remark": "备注信息", + "items_count": 3, + "created_by_id": 1, + "created_by_name": "张三", + "created_at": "2026-01-14T10:00:00Z", + "updated_at": "2026-01-14T10:00:00Z" + } + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + # 验证请求数据 + serializer = ShipmentCreateSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + data = serializer.validated_data + + # 调用业务逻辑 + from shipment.services import create_shipment + try: + shipment = create_shipment( + customer_id=data['customer'], + shipment_date=data['shipment_date'], + sales_item_ids=data['sales_items'], + created_by=request.user, + remark=data.get('remark', ''), + ) + except ValueError as e: + return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + # 返回创建的出货单 + response_serializer = ShipmentSerializer(shipment) + return Response(response_serializer.data, status=status.HTTP_201_CREATED) class SalesItemByPrintingOrderView(APIView): diff --git a/docs/2026-01-14_summary.md b/docs/2026-01-14_summary.md index c38adcf..b40b6d9 100644 --- a/docs/2026-01-14_summary.md +++ b/docs/2026-01-14_summary.md @@ -67,6 +67,71 @@ python manage.py backfill_merchant --- +### 5. 创建出货单 API + +实现了创建出货单的 API,支持同时关联多个销售品。 + +- **接口路径**: `POST /api/v1/shipment/shipments/` +- **请求参数**: `customer`(客户ID)、`shipment_date`(出货日期)、`remark`(备注)、`sales_items`(销售品ID列表) + +#### 业务逻辑 +1. 验证客户存在 +2. 验证销售品存在且未被关联到其他出货单 +3. 创建出货单并将销售品关联到该出货单(事务保护) + +#### 修改文件 +- `shipment/services.py`: 添加 `create_shipment()` 函数 +- `api_v1/views/shipment/views.py`: 添加 `ShipmentCreateView` +- `api_v1/views/shipment/serializers.py`: 添加 `ShipmentSerializer`、`ShipmentCreateSerializer` +- `api_v1/views/shipment/test_api.py`: 添加 6 个测试用例 +- `api_v1/urls.py`: 注册新路由 +- `docs/shipment_api.md`: 更新 API 文档 + +--- + +### 6. 添加销售品自动创建开关 + +在 `settings.py` 中添加 `AUTO_CREATE_SALESITEM_FROM_PRINT_ORDER` 配置项: + +- **默认值**: `True`(保持现有行为) +- **环境变量**: `AUTO_CREATE_SALESITEM_FROM_PRINT_ORDER` +- **作用**: 当设为 `False` 时,`printing/handlers.py` 中的流程完成信号处理器将不再自动创建销售品 + +#### 修改文件 +- `flower/settings.py`: 添加配置项 +- `printing/handlers.py`: 检查开关状态,为 False 时提前返回 + +--- + +### 7. Printing 模块客户可见性过滤 + +实现了基于客户可见性的订单查询过滤功能,确保员工只能看到自己负责的客户的订单。 + +#### 新增权限 +- `printing.view_all_plateorders`: 查看所有开版订单(突破客户可见性限制) +- `printing.view_all_printingorders`: 查看所有印染订单(突破客户可见性限制) + +#### 可见性规则 +- 超级用户:无限制 +- 有 `view_all_xxx` 权限:可见本商户所有订单 +- 普通员工: + - 可见自己创建的客户的订单 + - 可见被加入 `visible_employees` 的客户的订单 +- 所有用户:受 merchant 隔离限制 + +#### 新建文件 +- `api_v1/views/printing/mixins.py`: `CustomerVisibilityFilterMixin` + +#### 修改文件 +- `printing/models.py`: 添加新权限定义 +- `api_v1/views/printing/views.py`: 三个 ViewSet 继承 Mixin +- `api_v1/views/printing/test_api.py`: 修复测试数据 + 新增 7 个权限过滤测试 + +#### Migration +- `printing/migrations/0030_add_view_all_permissions.py` + +--- + ## 待办 --- @@ -75,4 +140,4 @@ python manage.py backfill_merchant - API 独立于 printing 模块,避免影响现有功能 - 完整的测试覆盖:正常查询、包含已出货、数据格式、404 错误、空结果、未认证 -- printing 和 shipment 模块全部测试通过(31个测试用例) \ No newline at end of file +- printing 和 shipment 模块全部测试通过(共 13 个测试用例) \ No newline at end of file diff --git a/docs/shipment_api.md b/docs/shipment_api.md index f1be1cf..fe692dd 100644 --- a/docs/shipment_api.md +++ b/docs/shipment_api.md @@ -4,10 +4,101 @@ ## 目录 +- [创建出货单](#创建出货单) - [通过生产订单查询销售品](#通过生产订单查询销售品) --- +## 创建出货单 + +创建出货单并关联销售品。 + +### 接口信息 + +- **URL**: `/api/v1/shipment/shipments/` +- **Method**: `POST` +- **认证**: 需要登录(JWT Token) + +### 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| customer | int | 是 | 客户ID | +| shipment_date | string | 是 | 出货日期(YYYY-MM-DD) | +| remark | string | 否 | 备注 | +| sales_items | array[int] | 否 | 要关联的销售品ID列表 | + +### 请求示例 + +```json +{ + "customer": 1, + "shipment_date": "2026-01-14", + "remark": "备注信息", + "sales_items": [1, 2, 3] +} +``` + +### 响应格式 + +```json +{ + "id": 1, + "customer": 1, + "customer_name": "客户A", + "shipment_date": "2026-01-14", + "remark": "备注信息", + "items_count": 3, + "created_by_id": 1, + "created_by_name": "张三", + "created_at": "2026-01-14T10:00:00Z", + "updated_at": "2026-01-14T10:00:00Z" +} +``` + +### 响应字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int | 出货单ID | +| customer | int | 客户ID | +| customer_name | string | 客户名称 | +| shipment_date | string | 出货日期 | +| remark | string | 备注 | +| items_count | int | 关联的销售品数量 | +| created_by_id | int | 创建人ID | +| created_by_name | string | 创建人名称 | +| created_at | string | 创建时间 | +| updated_at | string | 更新时间 | + +### 错误响应 + +#### 400 Bad Request - 客户不存在 + +```json +{ + "detail": "客户 999 不存在" +} +``` + +#### 400 Bad Request - 销售品不存在 + +```json +{ + "detail": "以下销售品不存在: [999]" +} +``` + +#### 400 Bad Request - 销售品已关联其他出货单 + +```json +{ + "detail": "以下销售品已关联到其他出货单: [1, 2]" +} +``` + +--- + ## 通过生产订单查询销售品 查询与指定生产订单(PrintingOrder)关联的所有销售品(SalesItem)。 diff --git a/flower/settings.py b/flower/settings.py index bd998a9..8540e33 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -408,6 +408,7 @@ PLATE_ORDER_DEFAULT_PROCESS_ID = 2 # 默认开版流程ID # 指定从哪个流程节点获取数量参数,单位固定为"米" # 如果该节点未完成或参数不存在,则不创建销售品 # ------------------------------------------------------------------------------ +AUTO_CREATE_SALESITEM_FROM_PRINT_ORDER = env.bool('AUTO_CREATE_SALESITEM_FROM_PRINT_ORDER', default=False) PRINTING_SALES_ITEM_SOURCE_STATE_ID = env.int('PRINTING_SALES_ITEM_SOURCE_STATE_ID') # 必须在 .env 中配置 PRINTING_SALES_ITEM_QUANTITY_KEY = '米数' # 数量参数的 key diff --git a/printing/apps.py b/printing/apps.py index 9fac506..a841eff 100644 --- a/printing/apps.py +++ b/printing/apps.py @@ -8,7 +8,7 @@ class PrintingConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'printing' verbose_name = '印染管理' - + def ready(self): """注册信号处理函数""" from stateflow.signals import process_completed diff --git a/printing/handlers.py b/printing/handlers.py index 89d280f..a844f82 100644 --- a/printing/handlers.py +++ b/printing/handlers.py @@ -20,6 +20,7 @@ def on_printing_job_process_completed(sender, **kwargs): 数量和单位从指定的流程节点参数中获取(通过 settings 配置)。 配置项: + AUTO_CREATE_SALESITEM_FROM_PRINT_ORDER: 是否启用自动创建销售品(默认 True) PRINTING_SALES_ITEM_SOURCE_STATE_ID: 指定从哪个 State 获取参数 PRINTING_SALES_ITEM_QUANTITY_KEY: 数量参数的 key PRINTING_SALES_ITEM_UNIT_KEY: 单位参数的 key @@ -31,6 +32,11 @@ def on_printing_job_process_completed(sender, **kwargs): last_completed_by: 最后步骤操作人 其他参数见 stateflow.signals.process_completed """ + # 检查是否启用自动创建销售品 + if not getattr(settings, 'AUTO_CREATE_SALESITEM_FROM_PRINT_ORDER', True): + logger.debug('[printing.handlers] AUTO_CREATE_SALESITEM_FROM_PRINT_ORDER=False,跳过销售品创建') + return + from shipment.models import SalesItem, UnitChoices logger.info( diff --git a/printing/migrations/0030_add_view_all_permissions.py b/printing/migrations/0030_add_view_all_permissions.py new file mode 100644 index 0000000..6ecc94f --- /dev/null +++ b/printing/migrations/0030_add_view_all_permissions.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.8 on 2026-01-14 08:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('printing', '0029_add_merchant_to_models'), + ] + + operations = [ + migrations.AlterModelOptions( + name='plateorder', + options={'ordering': ['-created_at'], 'permissions': [('can_invalidate_plateorder', '可以作废开版订单'), ('can_activate_plateorder', '可以恢复开版订单'), ('view_all_plateorders', '查看所有开版订单(突破客户可见性限制)')], 'verbose_name': '开版订单', 'verbose_name_plural': '开版订单'}, + ), + migrations.AlterModelOptions( + name='printingorder', + options={'permissions': [('can_invalidate_printingorder', '可以作废印染订单'), ('can_activate_printingorder', '可以恢复印染订单'), ('view_all_printingorders', '查看所有印染订单(突破客户可见性限制)')], 'verbose_name': '印染订单', 'verbose_name_plural': '印染订单'}, + ), + ] diff --git a/printing/models.py b/printing/models.py index 0e3210e..e142722 100644 --- a/printing/models.py +++ b/printing/models.py @@ -185,6 +185,7 @@ class PlateOrder(ModelBase): permissions = [ ('can_invalidate_plateorder', '可以作废开版订单'), ('can_activate_plateorder', '可以恢复开版订单'), + ('view_all_plateorders', '查看所有开版订单(突破客户可见性限制)'), ] @property @@ -318,6 +319,7 @@ class PrintingOrder(ModelBase): permissions = [ ('can_invalidate_printingorder', '可以作废印染订单'), ('can_activate_printingorder', '可以恢复印染订单'), + ('view_all_printingorders', '查看所有印染订单(突破客户可见性限制)'), ] @property diff --git a/shipment/services.py b/shipment/services.py index 4578b69..3bde80a 100644 --- a/shipment/services.py +++ b/shipment/services.py @@ -3,9 +3,12 @@ Shipment 模块业务逻辑层 """ from __future__ import annotations +from typing import List + +from django.db import transaction from django.db.models import QuerySet -from shipment.models import SalesItem +from shipment.models import SalesItem, Shipment def get_sales_items_by_printing_order( @@ -37,3 +40,66 @@ def get_sales_items_by_printing_order( queryset = queryset.filter(shipment__isnull=True) return queryset.select_related('shipment').order_by('id') + + +@transaction.atomic +def create_shipment( + customer_id: int, + shipment_date, + sales_item_ids: List[int], + created_by, + remark: str = '', +) -> Shipment: + """ + 创建出货单并关联销售品 + + Args: + customer_id: 客户ID + shipment_date: 出货日期 + sales_item_ids: 要关联的销售品ID列表 + created_by: 创建人 + remark: 备注 + + Returns: + 创建的 Shipment 实例 + + Raises: + ValueError: 如果销售品不存在或已被关联到其他出货单 + """ + from basic_info.models import Customer + + # 验证客户存在 + try: + customer = Customer.objects.get(id=customer_id) + except Customer.DoesNotExist: + raise ValueError(f'客户 {customer_id} 不存在') + + # 验证销售品 + if sales_item_ids: + # 查询销售品 + sales_items = SalesItem.objects.filter(id__in=sales_item_ids) + found_ids = set(sales_items.values_list('id', flat=True)) + missing_ids = set(sales_item_ids) - found_ids + + if missing_ids: + raise ValueError(f'以下销售品不存在: {list(missing_ids)}') + + # 检查是否有已关联出货单的销售品 + already_shipped = sales_items.filter(shipment__isnull=False) + if already_shipped.exists(): + shipped_ids = list(already_shipped.values_list('id', flat=True)) + raise ValueError(f'以下销售品已关联到其他出货单: {shipped_ids}') + + # 创建出货单 + shipment = Shipment.objects.create( + customer=customer, + shipment_date=shipment_date, + remark=remark, + created_by=created_by, + ) + + # 关联销售品 + if sales_item_ids: + SalesItem.objects.filter(id__in=sales_item_ids).update(shipment=shipment) + + return shipment