forked from erp-dev/erp
feat: batch update for plate order
This commit is contained in:
116
api_v2/tests.py
116
api_v2/tests.py
@@ -2,6 +2,7 @@ from decimal import Decimal
|
||||
import datetime
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient, APIRequestFactory
|
||||
@@ -957,3 +958,118 @@ class PlateOrderByProcessV2APITest(TestCase):
|
||||
}
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
|
||||
class PlateOrderBatchUpdateV2APITest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
|
||||
# 工厂用户(满足 IsPrintingFactory)
|
||||
self.merchant = basic_models.Merchant.objects.create(
|
||||
name='印染工厂',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
self.user = get_user_model().objects.create_user(username='factory_user2', password='pass12345')
|
||||
basic_models.Employee.objects.create(
|
||||
merchant=self.merchant,
|
||||
sys_user=self.user,
|
||||
name='工厂员工2',
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
# 赋予 change 权限(批量更新必需)
|
||||
perm_change = Permission.objects.get(codename='change_plateorder')
|
||||
self.user.user_permissions.add(perm_change)
|
||||
|
||||
self.customer = basic_models.Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='客户A',
|
||||
created_by=None,
|
||||
)
|
||||
self.designer = basic_models.Employee.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='设计师',
|
||||
)
|
||||
|
||||
self.po1 = printing_models.PlateOrder.objects.create(
|
||||
customer=self.customer,
|
||||
design_code='D1',
|
||||
urgency_level='正常',
|
||||
style_name='S1',
|
||||
)
|
||||
self.po2 = printing_models.PlateOrder.objects.create(
|
||||
customer=self.customer,
|
||||
design_code='D2',
|
||||
urgency_level='正常',
|
||||
style_name='S2',
|
||||
)
|
||||
|
||||
def test_batch_update_success(self):
|
||||
resp = self.client.post(
|
||||
'/api/v2/plate-orders/batch-update/',
|
||||
{
|
||||
'plate_order_ids': [self.po1.id, self.po2.id],
|
||||
'data': {'urgency_level': '加急', 'designer': self.designer.id},
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.data['updated_count'], 2)
|
||||
|
||||
self.po1.refresh_from_db()
|
||||
self.po2.refresh_from_db()
|
||||
self.assertEqual(self.po1.urgency_level, '加急')
|
||||
self.assertEqual(self.po2.urgency_level, '加急')
|
||||
self.assertEqual(self.po1.designer_id, self.designer.id)
|
||||
self.assertEqual(self.po2.designer_id, self.designer.id)
|
||||
|
||||
def test_batch_update_fails_when_missing_ids(self):
|
||||
resp = self.client.post(
|
||||
'/api/v2/plate-orders/batch-update/',
|
||||
{
|
||||
'plate_order_ids': [self.po1.id, 999999],
|
||||
'data': {'urgency_level': '加急'},
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertIn('不存在', resp.data.get('detail', ''))
|
||||
|
||||
def test_batch_update_fails_when_unknown_field(self):
|
||||
resp = self.client.post(
|
||||
'/api/v2/plate-orders/batch-update/',
|
||||
{
|
||||
'plate_order_ids': [self.po1.id],
|
||||
'data': {'process': 123},
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertIn('不支持批量更新字段', resp.data.get('detail', ''))
|
||||
|
||||
def test_batch_update_is_invalid_requires_permission(self):
|
||||
# 未授予 can_invalidate_plateorder
|
||||
resp = self.client.post(
|
||||
'/api/v2/plate-orders/batch-update/',
|
||||
{
|
||||
'plate_order_ids': [self.po1.id],
|
||||
'data': {'is_invalid': True},
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
|
||||
perm_invalidate = Permission.objects.get(codename='can_invalidate_plateorder')
|
||||
self.user.user_permissions.add(perm_invalidate)
|
||||
|
||||
resp2 = self.client.post(
|
||||
'/api/v2/plate-orders/batch-update/',
|
||||
{
|
||||
'plate_order_ids': [self.po1.id],
|
||||
'data': {'is_invalid': True},
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(resp2.status_code, 200)
|
||||
self.po1.refresh_from_db()
|
||||
self.assertTrue(self.po1.is_invalid)
|
||||
|
||||
@@ -7,6 +7,7 @@ from api_v2.views import (
|
||||
PrintingJobBatchAdvanceSubmitView,
|
||||
PlateOrderByProcessNodeView,
|
||||
PlateOrderByProcessView,
|
||||
PlateOrderBatchUpdateView,
|
||||
BusinessObjectCloneView,
|
||||
)
|
||||
|
||||
@@ -17,6 +18,7 @@ urlpatterns = [
|
||||
path('printing-jobs/batch-advance/', PrintingJobBatchAdvanceSubmitView.as_view(), name='api_v2_printing_job_batch_advance_submit'),
|
||||
path('plate-orders/by-process-node/', PlateOrderByProcessNodeView.as_view(), name='api_v2_plate_order_by_process_node'),
|
||||
path('plate-orders/by-process/', PlateOrderByProcessView.as_view(), name='api_v2_plate_order_by_process'),
|
||||
path('plate-orders/batch-update/', PlateOrderBatchUpdateView.as_view(), name='api_v2_plate_order_batch_update'),
|
||||
path('stateflow/business-objects/clone/', BusinessObjectCloneView.as_view(), name='api_v2_stateflow_business_object_clone'),
|
||||
]
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from .printing import (
|
||||
PrintingJobBatchAdvanceSubmitView,
|
||||
PlateOrderByProcessNodeView,
|
||||
PlateOrderByProcessView,
|
||||
PlateOrderBatchUpdateView,
|
||||
)
|
||||
from .stateflow import BusinessObjectCloneView
|
||||
|
||||
@@ -21,6 +22,7 @@ __all__ = [
|
||||
'PrintingJobBatchAdvanceSubmitView',
|
||||
'PlateOrderByProcessNodeView',
|
||||
'PlateOrderByProcessView',
|
||||
'PlateOrderBatchUpdateView',
|
||||
'BusinessObjectCloneView',
|
||||
]
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
from django.db import models as django_models
|
||||
from django.db.models import Q, Count, CharField, Prefetch
|
||||
from django.db.models.functions import Cast, Coalesce
|
||||
from rest_framework import serializers, status, permissions
|
||||
@@ -41,6 +42,7 @@ class PrintingJobV2Serializer(serializers.ModelSerializer):
|
||||
model = printing_models.PrintingJob
|
||||
fields = [
|
||||
'id',
|
||||
'original_id',
|
||||
'printing_order',
|
||||
'product',
|
||||
'work_state',
|
||||
@@ -385,6 +387,7 @@ class PlateOrderByProcessNodeSerializer(serializers.ModelSerializer):
|
||||
model = printing_models.PlateOrder
|
||||
fields = [
|
||||
'id',
|
||||
'original_id',
|
||||
'design_code',
|
||||
'customer',
|
||||
'customer_name',
|
||||
@@ -627,6 +630,7 @@ class PlateOrderByProcessSerializer(serializers.ModelSerializer):
|
||||
model = printing_models.PlateOrder
|
||||
fields = [
|
||||
'id',
|
||||
'original_id',
|
||||
'design_code',
|
||||
'customer',
|
||||
'customer_name',
|
||||
@@ -886,3 +890,193 @@ class PlateOrderByProcessView(APIView):
|
||||
'previous': paginator.get_previous_link() if page is not None else None,
|
||||
'results': srz.data,
|
||||
})
|
||||
|
||||
|
||||
# 允许批量更新的字段白名单:只改这里即可增减
|
||||
PLATE_ORDER_BATCH_UPDATE_ALLOWED_FIELDS = [
|
||||
"original_id",
|
||||
"design_code",
|
||||
"plate_type",
|
||||
"plate_date",
|
||||
"plate_method",
|
||||
"image_name",
|
||||
"plate_notes",
|
||||
"reprint_reason",
|
||||
"urgency_level",
|
||||
"is_invalid",
|
||||
"customer",
|
||||
"area",
|
||||
"default_address",
|
||||
"salesperson",
|
||||
"merchandiser",
|
||||
"designer",
|
||||
"style_name",
|
||||
"fabric",
|
||||
"fabric_source",
|
||||
"width",
|
||||
"production_method",
|
||||
"is_mark_frame",
|
||||
"drawing_rating",
|
||||
"color_matching_rating",
|
||||
"sample_rating",
|
||||
"difficulty_rating",
|
||||
"sample_meter",
|
||||
"required_sample_meters",
|
||||
"required_completion_date",
|
||||
"completion_date",
|
||||
"approval_result",
|
||||
"is_ordered",
|
||||
"customer_feedback",
|
||||
]
|
||||
|
||||
|
||||
class PlateOrderBatchUpdateDataSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
PlateOrder 批量更新允许字段(白名单)。
|
||||
|
||||
说明:
|
||||
- 不允许更新 process/business_object/created_by 等会触发流程副作用或越权的字段
|
||||
- plate_image 属于结构化字段(且 v1 有额外转换逻辑),暂不纳入批量更新,避免前端误用
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = printing_models.PlateOrder
|
||||
fields = PLATE_ORDER_BATCH_UPDATE_ALLOWED_FIELDS
|
||||
|
||||
|
||||
class PlateOrderBatchUpdateRequestSerializer(serializers.Serializer):
|
||||
plate_order_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(min_value=1),
|
||||
allow_empty=False,
|
||||
help_text="需要批量更新的 PlateOrder id 列表(同一组 data 会应用到所有 id)",
|
||||
)
|
||||
data = serializers.DictField(
|
||||
child=serializers.JSONField(),
|
||||
allow_empty=False,
|
||||
help_text='需要更新的字段集合,例如 {"urgency_level": "加急", "designer": 123}',
|
||||
)
|
||||
dry_run = serializers.BooleanField(
|
||||
required=False,
|
||||
default=False,
|
||||
help_text="仅校验与预览,不实际写库",
|
||||
)
|
||||
|
||||
def validate_plate_order_ids(self, value):
|
||||
# 去重保持稳定性(前端可能重复传)
|
||||
deduped = list(dict.fromkeys(value))
|
||||
if not deduped:
|
||||
raise serializers.ValidationError("plate_order_ids 不能为空")
|
||||
return deduped
|
||||
|
||||
def validate_data(self, value):
|
||||
data_srz = PlateOrderBatchUpdateDataSerializer(data=value, partial=True)
|
||||
data_srz.is_valid(raise_exception=True)
|
||||
if not data_srz.validated_data:
|
||||
raise serializers.ValidationError({"detail": "data 不能为空"})
|
||||
return data_srz.validated_data
|
||||
|
||||
|
||||
class PlateOrderBatchUpdateView(APIView):
|
||||
"""
|
||||
PlateOrder 批量更新(同一份 data 应用到多个 plate_order)。
|
||||
|
||||
POST /api/v2/plate-orders/batch-update/
|
||||
Body:
|
||||
{
|
||||
"plate_order_ids": [1, 2, 3],
|
||||
"data": {"urgency_level": "加急", "designer": 10},
|
||||
"dry_run": false
|
||||
}
|
||||
|
||||
规则:
|
||||
- 全成功/全失败(任意 id 不存在直接 400,不做部分更新)
|
||||
- 需要 printing.change_plateorder 权限
|
||||
- 如包含 is_invalid:
|
||||
- is_invalid=true 需要 printing.can_invalidate_plateorder
|
||||
- is_invalid=false 需要 printing.can_activate_plateorder
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated, IsPrintingFactory]
|
||||
|
||||
def post(self, request):
|
||||
# 注意:Django 会缓存 has_perm 结果(user._perm_cache)。
|
||||
# 在测试中 APIClient.force_authenticate 会复用同一个 user 实例,
|
||||
# 可能导致“中途赋权后第二次请求仍判定无权限”的假阴性;这里主动清理缓存更稳妥。
|
||||
for cache_attr in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"):
|
||||
if hasattr(request.user, cache_attr):
|
||||
delattr(request.user, cache_attr)
|
||||
|
||||
if not request.user.has_perm("printing.change_plateorder"):
|
||||
return Response({"detail": "您没有权限批量更新开版订单"}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
# 先对 data 做“字段白名单”校验(保证错误输出为顶层 detail,便于前端/测试消费)
|
||||
raw_data = request.data.get("data") if isinstance(request.data, dict) else None
|
||||
if isinstance(raw_data, dict):
|
||||
allowed = set(PLATE_ORDER_BATCH_UPDATE_ALLOWED_FIELDS)
|
||||
unknown = sorted(set(raw_data.keys()) - allowed)
|
||||
if unknown:
|
||||
return Response(
|
||||
{"detail": f"不支持批量更新字段: {', '.join(unknown)}", "allowed_fields": sorted(allowed)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
srz = PlateOrderBatchUpdateRequestSerializer(data=request.data)
|
||||
srz.is_valid(raise_exception=True)
|
||||
payload = srz.validated_data
|
||||
|
||||
plate_order_ids: list[int] = payload["plate_order_ids"]
|
||||
data: dict = payload["data"]
|
||||
dry_run: bool = payload.get("dry_run", False)
|
||||
|
||||
# is_invalid 权限语义:沿用 v1 的作废/恢复权限
|
||||
if "is_invalid" in data:
|
||||
if data["is_invalid"] is True and not request.user.has_perm("printing.can_invalidate_plateorder"):
|
||||
return Response({"detail": "您没有权限作废开版订单"}, status=status.HTTP_403_FORBIDDEN)
|
||||
if data["is_invalid"] is False and not request.user.has_perm("printing.can_activate_plateorder"):
|
||||
return Response({"detail": "您没有权限恢复开版订单"}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
qs = printing_models.PlateOrder.objects.filter(id__in=plate_order_ids)
|
||||
found_ids = list(qs.values_list("id", flat=True))
|
||||
found_set = set(found_ids)
|
||||
missing_ids = [str(i) for i in plate_order_ids if i not in found_set]
|
||||
if missing_ids:
|
||||
return Response({"detail": f"以下 PlateOrder 不存在: {', '.join(missing_ids)}"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
# 返回/展示时保持与入参一致的顺序
|
||||
found_ids = [i for i in plate_order_ids if i in found_set]
|
||||
|
||||
# 将 validated_data 转换为 queryset.update 可用的 kwargs(处理 FK -> *_id)
|
||||
update_kwargs: dict = {}
|
||||
for k, v in data.items():
|
||||
try:
|
||||
field = printing_models.PlateOrder._meta.get_field(k)
|
||||
except Exception:
|
||||
update_kwargs[k] = v
|
||||
continue
|
||||
|
||||
if isinstance(field, django_models.ForeignKey):
|
||||
update_kwargs[f"{k}_id"] = v.pk if v is not None else None
|
||||
else:
|
||||
update_kwargs[k] = v
|
||||
|
||||
update_kwargs["updated_at"] = timezone.now()
|
||||
|
||||
if dry_run:
|
||||
return Response(
|
||||
{
|
||||
"dry_run": True,
|
||||
"plate_order_ids": found_ids,
|
||||
"matched_count": len(found_ids),
|
||||
"data": request.data.get("data") or {},
|
||||
}
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
updated_count = qs.update(**update_kwargs)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"detail": "批量更新成功",
|
||||
"updated_count": updated_count,
|
||||
"plate_order_ids": found_ids,
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user