1
0
forked from erp-dev/erp

fix: added permission for plate_order and printing_order api

This commit is contained in:
2026-01-14 18:14:22 +08:00
parent fae667c965
commit cae0f8434e
17 changed files with 867 additions and 15 deletions

View File

@@ -25,7 +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 from .views.shipment import SalesItemByPrintingOrderView, ShipmentCreateView
# 创建 DRF Router for Stateflow # 创建 DRF Router for Stateflow
stateflow_router = DefaultRouter() stateflow_router = DefaultRouter()
@@ -100,6 +100,11 @@ urlpatterns = [
path('stateflow/', include(stateflow_router.urls)), path('stateflow/', include(stateflow_router.urls)),
# Shipment API # Shipment API
path(
'shipment/shipments/',
ShipmentCreateView.as_view(),
name='shipment_create'
),
path( path(
'shipment/sales-items/by-printing-order/<int:printing_order_id>/', 'shipment/sales-items/by-printing-order/<int:printing_order_id>/',
SalesItemByPrintingOrderView.as_view(), SalesItemByPrintingOrderView.as_view(),

View File

@@ -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

View File

@@ -46,12 +46,13 @@ class PrintingOrderAPITestCase(TestCase):
status=basic_models.EmployeeStatusEnum.ACTIVE status=basic_models.EmployeeStatusEnum.ACTIVE
) )
# 创建客户 # 创建客户(设置 created_by 以便客户可见性过滤)
self.customer = basic_models.Customer.objects.create( self.customer = basic_models.Customer.objects.create(
merchant=self.merchant, merchant=self.merchant,
name='测试客户', name='测试客户',
mobile='13900139000', mobile='13900139000',
area='测试地区' area='测试地区',
created_by=self.employee
) )
# 创建流程 # 创建流程
@@ -72,7 +73,9 @@ class PrintingOrderAPITestCase(TestCase):
view_perm = Permission.objects.get(codename='view_printingorder') view_perm = Permission.objects.get(codename='view_printingorder')
add_perm = Permission.objects.get(codename='add_printingorder') add_perm = Permission.objects.get(codename='add_printingorder')
change_perm = Permission.objects.get(codename='change_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): def test_create_printing_order(self):
"""测试创建印染订单""" """测试创建印染订单"""
@@ -113,11 +116,13 @@ class PrintingOrderAPITestCase(TestCase):
"""测试获取订单列表""" """测试获取订单列表"""
# 创建测试订单 # 创建测试订单
printing_models.PrintingOrder.objects.create( printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='布料1', fabric='布料1',
width='150cm' width='150cm'
) )
printing_models.PrintingOrder.objects.create( printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='布料2', fabric='布料2',
width='160cm' width='160cm'
@@ -131,6 +136,7 @@ class PrintingOrderAPITestCase(TestCase):
def test_retrieve_printing_order(self): def test_retrieve_printing_order(self):
"""测试获取订单详情""" """测试获取订单详情"""
order = printing_models.PrintingOrder.objects.create( order = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='测试布料', fabric='测试布料',
width='150cm', width='150cm',
@@ -148,6 +154,7 @@ class PrintingOrderAPITestCase(TestCase):
def test_update_printing_order(self): def test_update_printing_order(self):
"""测试更新订单""" """测试更新订单"""
order = printing_models.PrintingOrder.objects.create( order = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='旧布料', fabric='旧布料',
width='150cm' width='150cm'
@@ -176,6 +183,7 @@ class PrintingOrderAPITestCase(TestCase):
def test_partial_update_printing_order(self): def test_partial_update_printing_order(self):
"""测试部分更新订单""" """测试部分更新订单"""
order = printing_models.PrintingOrder.objects.create( order = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='原布料', fabric='原布料',
width='150cm', width='150cm',
@@ -206,6 +214,7 @@ class PrintingOrderAPITestCase(TestCase):
self.user.user_permissions.add(delete_perm) self.user.user_permissions.add(delete_perm)
order = printing_models.PrintingOrder.objects.create( order = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='测试布料', fabric='测试布料',
width='150cm' width='150cm'
@@ -227,6 +236,7 @@ class PrintingOrderAPITestCase(TestCase):
self.user.user_permissions.add(invalidate_perm) self.user.user_permissions.add(invalidate_perm)
order = printing_models.PrintingOrder.objects.create( order = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='测试布料', fabric='测试布料',
width='150cm', width='150cm',
@@ -243,6 +253,7 @@ class PrintingOrderAPITestCase(TestCase):
def test_invalidate_without_permission(self): def test_invalidate_without_permission(self):
"""测试没有权限时作废订单失败""" """测试没有权限时作废订单失败"""
order = printing_models.PrintingOrder.objects.create( order = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='测试布料', fabric='测试布料',
width='150cm' width='150cm'
@@ -259,6 +270,7 @@ class PrintingOrderAPITestCase(TestCase):
self.user.user_permissions.add(activate_perm) self.user.user_permissions.add(activate_perm)
order = printing_models.PrintingOrder.objects.create( order = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='测试布料', fabric='测试布料',
width='150cm', width='150cm',
@@ -275,6 +287,7 @@ class PrintingOrderAPITestCase(TestCase):
def test_activate_without_permission(self): def test_activate_without_permission(self):
"""测试没有权限时恢复订单失败""" """测试没有权限时恢复订单失败"""
order = printing_models.PrintingOrder.objects.create( order = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='测试布料', fabric='测试布料',
width='150cm', width='150cm',
@@ -288,6 +301,7 @@ class PrintingOrderAPITestCase(TestCase):
def test_mark_fabric_received(self): def test_mark_fabric_received(self):
"""测试标记布料已收""" """测试标记布料已收"""
order = printing_models.PrintingOrder.objects.create( order = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='测试布料', fabric='测试布料',
width='150cm', width='150cm',
@@ -304,6 +318,7 @@ class PrintingOrderAPITestCase(TestCase):
def test_mark_fabric_received_already_received(self): def test_mark_fabric_received_already_received(self):
"""测试重复标记布料已收""" """测试重复标记布料已收"""
order = printing_models.PrintingOrder.objects.create( order = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='测试布料', fabric='测试布料',
width='150cm', width='150cm',
@@ -319,15 +334,18 @@ class PrintingOrderAPITestCase(TestCase):
customer2 = basic_models.Customer.objects.create( customer2 = basic_models.Customer.objects.create(
merchant=self.merchant, merchant=self.merchant,
name='客户2', name='客户2',
mobile='13900139001' mobile='13900139001',
created_by=self.employee
) )
printing_models.PrintingOrder.objects.create( printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='布料1', fabric='布料1',
width='150cm' width='150cm'
) )
printing_models.PrintingOrder.objects.create( printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=customer2, customer=customer2,
fabric='布料2', fabric='布料2',
width='160cm' width='160cm'
@@ -340,12 +358,14 @@ class PrintingOrderAPITestCase(TestCase):
def test_filter_by_urgent(self): def test_filter_by_urgent(self):
"""测试按紧急状态过滤""" """测试按紧急状态过滤"""
printing_models.PrintingOrder.objects.create( printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='布料1', fabric='布料1',
width='150cm', width='150cm',
is_urgent=True is_urgent=True
) )
printing_models.PrintingOrder.objects.create( printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='布料2', fabric='布料2',
width='160cm', width='160cm',
@@ -360,11 +380,13 @@ class PrintingOrderAPITestCase(TestCase):
def test_search_by_fabric(self): def test_search_by_fabric(self):
"""测试按布料搜索""" """测试按布料搜索"""
printing_models.PrintingOrder.objects.create( printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='纯棉布料', fabric='纯棉布料',
width='150cm' width='150cm'
) )
printing_models.PrintingOrder.objects.create( printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='涤纶布料', fabric='涤纶布料',
width='160cm' width='160cm'
@@ -378,11 +400,13 @@ class PrintingOrderAPITestCase(TestCase):
def test_ordering(self): def test_ordering(self):
"""测试排序""" """测试排序"""
order1 = printing_models.PrintingOrder.objects.create( order1 = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='布料1', fabric='布料1',
width='150cm' width='150cm'
) )
order2 = printing_models.PrintingOrder.objects.create( order2 = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='布料2', fabric='布料2',
width='160cm' width='160cm'
@@ -406,6 +430,7 @@ class PrintingOrderAPITestCase(TestCase):
from datetime import date from datetime import date
order = printing_models.PrintingOrder.objects.create( order = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='测试布料', fabric='测试布料',
width='150cm' width='150cm'
@@ -452,6 +477,7 @@ class PrintingOrderAPITestCase(TestCase):
def test_order_progress_in_list(self): def test_order_progress_in_list(self):
"""测试订单列表包含进度字段""" """测试订单列表包含进度字段"""
order = printing_models.PrintingOrder.objects.create( order = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='测试布料', fabric='测试布料',
width='150cm', width='150cm',
@@ -466,6 +492,7 @@ class PrintingOrderAPITestCase(TestCase):
def test_update_process_when_no_jobs(self): def test_update_process_when_no_jobs(self):
"""测试没有任务时可以修改流程""" """测试没有任务时可以修改流程"""
order = printing_models.PrintingOrder.objects.create( order = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='测试布料', fabric='测试布料',
width='150cm', width='150cm',
@@ -491,6 +518,7 @@ class PrintingOrderAPITestCase(TestCase):
def test_cannot_update_process_when_job_started(self): def test_cannot_update_process_when_job_started(self):
"""测试有已开始的任务时不能修改流程""" """测试有已开始的任务时不能修改流程"""
order = printing_models.PrintingOrder.objects.create( order = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='测试布料', fabric='测试布料',
width='150cm', width='150cm',
@@ -549,6 +577,7 @@ class PrintingOrderAPITestCase(TestCase):
"""测试订单列表包含 jobs_status_summary 字段PrintingJob 状态汇总)""" """测试订单列表包含 jobs_status_summary 字段PrintingJob 状态汇总)"""
# 创建订单 # 创建订单
order = printing_models.PrintingOrder.objects.create( order = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='测试布料', fabric='测试布料',
width='150cm', width='150cm',
@@ -622,6 +651,7 @@ class PrintingOrderAPITestCase(TestCase):
def test_jobs_status_summary_empty_when_no_jobs(self): def test_jobs_status_summary_empty_when_no_jobs(self):
"""测试没有任务时 jobs_status_summary 为空列表""" """测试没有任务时 jobs_status_summary 为空列表"""
order = printing_models.PrintingOrder.objects.create( order = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer, customer=self.customer,
fabric='无任务订单', fabric='无任务订单',
width='150cm', width='150cm',
@@ -640,3 +670,174 @@ class PrintingOrderAPITestCase(TestCase):
summary = response.data['results'][0]['jobs_status_summary'] summary = response.data['results'][0]['jobs_status_summary']
self.assertEqual(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)

View File

@@ -28,6 +28,7 @@ from .serializers import (
PrintingJobDetailSerializer, PrintingJobDetailSerializer,
PrintingJobCreateUpdateSerializer, PrintingJobCreateUpdateSerializer,
) )
from .mixins import CustomerVisibilityFilterMixin
class IsPrintingFactory(BasePermission): class IsPrintingFactory(BasePermission):
@@ -96,7 +97,7 @@ class PrintingOrderFilterSet(django_filters.FilterSet):
@method_decorator(cache_page(20), name='list') @method_decorator(cache_page(20), name='list')
class PrintingOrderViewSet(LimitedModelViewSet): class PrintingOrderViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
""" """
印染订单 ViewSet 印染订单 ViewSet
@@ -129,8 +130,14 @@ class PrintingOrderViewSet(LimitedModelViewSet):
格式: [{"state_name": "进度一", "state_id": 1, "count": 3}, ...] 格式: [{"state_name": "进度一", "state_id": 1, "count": 3}, ...]
- jobs_last_status_summary: 订单下所有印染任务按最后完成状态分组的数量汇总 - jobs_last_status_summary: 订单下所有印染任务按最后完成状态分组的数量汇总
格式: [{"state_name": "进度一", "count": 3}, ...] 格式: [{"state_name": "进度一", "count": 3}, ...]
权限控制:
- 默认按客户可见性过滤(员工只能看到自己负责的客户的订单)
- 拥有 printing.view_all_printingorders 权限可突破此限制
""" """
queryset = models.PrintingOrder.objects.all() queryset = models.PrintingOrder.objects.all()
customer_field = 'customer'
view_all_permission = 'printing.view_all_printingorders'
permission_classes = [DjangoModelPermissions] permission_classes = [DjangoModelPermissions]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_class = PrintingOrderFilterSet filterset_class = PrintingOrderFilterSet
@@ -286,7 +293,7 @@ class PrintingJobFilterSet(django_filters.FilterSet):
fields = ['printing_order', 'product'] fields = ['printing_order', 'product']
class PrintingJobViewSet(LimitedModelViewSet): class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
""" """
印染款式明细 ViewSet 印染款式明细 ViewSet
@@ -318,8 +325,14 @@ class PrintingJobViewSet(LimitedModelViewSet):
- pieces_max: 最大件数 - pieces_max: 最大件数
- search: 全文搜索(产品名称、单位、尺寸、备注) - search: 全文搜索(产品名称、单位、尺寸、备注)
- ordering: 排序字段 - ordering: 排序字段
权限控制:
- 默认按客户可见性过滤(通过 printing_order.customer
- 拥有 printing.view_all_printingorders 权限可突破此限制
""" """
queryset = models.PrintingJob.objects.all() queryset = models.PrintingJob.objects.all()
customer_field = 'printing_order__customer'
view_all_permission = 'printing.view_all_printingorders'
permission_classes = [DjangoModelPermissions, IsPrintingFactory] permission_classes = [DjangoModelPermissions, IsPrintingFactory]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_class = PrintingJobFilterSet 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') # @method_decorator(condition(last_modified_func=_plate_order_last_modified), name='list')
class PlateOrderViewSet(LimitedModelViewSet): class PlateOrderViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
""" """
开版订单 ViewSet 开版订单 ViewSet
@@ -677,8 +690,14 @@ class PlateOrderViewSet(LimitedModelViewSet):
- created_date_to: 创建日期结束 - created_date_to: 创建日期结束
- search: 全文搜索(设计编号、款式名称、客户名称、面料) - search: 全文搜索(设计编号、款式名称、客户名称、面料)
- ordering: 排序字段 - ordering: 排序字段
权限控制:
- 默认按客户可见性过滤(员工只能看到自己负责的客户的订单)
- 拥有 printing.view_all_plateorders 权限可突破此限制
""" """
queryset = models.PlateOrder.objects.all() queryset = models.PlateOrder.objects.all()
customer_field = 'customer'
view_all_permission = 'printing.view_all_plateorders'
permission_classes = [DjangoModelPermissions] permission_classes = [DjangoModelPermissions]
parser_classes = [MultiPartParser, FormParser, JSONParser] # 支持文件上传 parser_classes = [MultiPartParser, FormParser, JSONParser] # 支持文件上传
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]

View File

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

View File

@@ -3,6 +3,57 @@ Shipment API 序列化器
""" """
from rest_framework import serializers 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): class SalesItemSerializer(serializers.Serializer):
""" """

View File

@@ -264,3 +264,165 @@ class SalesItemByPrintingOrderAPITestCase(TestCase):
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) 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)

View File

@@ -6,7 +6,63 @@ from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated 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): class SalesItemByPrintingOrderView(APIView):

View File

@@ -67,6 +67,71 @@ python manage.py backfill_merchant <merchant_id>
--- ---
### 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 <merchant_id>
- API 独立于 printing 模块,避免影响现有功能 - API 独立于 printing 模块,避免影响现有功能
- 完整的测试覆盖正常查询、包含已出货、数据格式、404 错误、空结果、未认证 - 完整的测试覆盖正常查询、包含已出货、数据格式、404 错误、空结果、未认证
- printing 和 shipment 模块全部测试通过(31个测试用例) - printing 和 shipment 模块全部测试通过(共 13 个测试用例)

View File

@@ -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 查询与指定生产订单PrintingOrder关联的所有销售品SalesItem

View File

@@ -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_SOURCE_STATE_ID = env.int('PRINTING_SALES_ITEM_SOURCE_STATE_ID') # 必须在 .env 中配置
PRINTING_SALES_ITEM_QUANTITY_KEY = '米数' # 数量参数的 key PRINTING_SALES_ITEM_QUANTITY_KEY = '米数' # 数量参数的 key

View File

@@ -20,6 +20,7 @@ def on_printing_job_process_completed(sender, **kwargs):
数量和单位从指定的流程节点参数中获取(通过 settings 配置)。 数量和单位从指定的流程节点参数中获取(通过 settings 配置)。
配置项: 配置项:
AUTO_CREATE_SALESITEM_FROM_PRINT_ORDER: 是否启用自动创建销售品(默认 True
PRINTING_SALES_ITEM_SOURCE_STATE_ID: 指定从哪个 State 获取参数 PRINTING_SALES_ITEM_SOURCE_STATE_ID: 指定从哪个 State 获取参数
PRINTING_SALES_ITEM_QUANTITY_KEY: 数量参数的 key PRINTING_SALES_ITEM_QUANTITY_KEY: 数量参数的 key
PRINTING_SALES_ITEM_UNIT_KEY: 单位参数的 key PRINTING_SALES_ITEM_UNIT_KEY: 单位参数的 key
@@ -31,6 +32,11 @@ def on_printing_job_process_completed(sender, **kwargs):
last_completed_by: 最后步骤操作人 last_completed_by: 最后步骤操作人
其他参数见 stateflow.signals.process_completed 其他参数见 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 from shipment.models import SalesItem, UnitChoices
logger.info( logger.info(

View File

@@ -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': '印染订单'},
),
]

View File

@@ -185,6 +185,7 @@ class PlateOrder(ModelBase):
permissions = [ permissions = [
('can_invalidate_plateorder', '可以作废开版订单'), ('can_invalidate_plateorder', '可以作废开版订单'),
('can_activate_plateorder', '可以恢复开版订单'), ('can_activate_plateorder', '可以恢复开版订单'),
('view_all_plateorders', '查看所有开版订单(突破客户可见性限制)'),
] ]
@property @property
@@ -318,6 +319,7 @@ class PrintingOrder(ModelBase):
permissions = [ permissions = [
('can_invalidate_printingorder', '可以作废印染订单'), ('can_invalidate_printingorder', '可以作废印染订单'),
('can_activate_printingorder', '可以恢复印染订单'), ('can_activate_printingorder', '可以恢复印染订单'),
('view_all_printingorders', '查看所有印染订单(突破客户可见性限制)'),
] ]
@property @property

View File

@@ -3,9 +3,12 @@ Shipment 模块业务逻辑层
""" """
from __future__ import annotations from __future__ import annotations
from typing import List
from django.db import transaction
from django.db.models import QuerySet from django.db.models import QuerySet
from shipment.models import SalesItem from shipment.models import SalesItem, Shipment
def get_sales_items_by_printing_order( def get_sales_items_by_printing_order(
@@ -37,3 +40,66 @@ def get_sales_items_by_printing_order(
queryset = queryset.filter(shipment__isnull=True) queryset = queryset.filter(shipment__isnull=True)
return queryset.select_related('shipment').order_by('id') 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