forked from erp-dev/erp
fix: process id change when plate_order and printing_order update
This commit is contained in:
@@ -475,6 +475,12 @@ class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
"""更新任务,使用 service 层"""
|
"""更新任务,使用 service 层"""
|
||||||
|
# 不允许修改 printing_order 绑定关系(避免业务对象流程不一致)
|
||||||
|
if 'printing_order' in validated_data:
|
||||||
|
new_order = validated_data.get('printing_order')
|
||||||
|
if new_order and new_order.id != getattr(instance, 'printing_order_id', None):
|
||||||
|
raise serializers.ValidationError({'printing_order': '不允许修改印染订单绑定关系'})
|
||||||
|
|
||||||
user = self.context['request'].user
|
user = self.context['request'].user
|
||||||
success, message, updated_instance = PrintingJobService.update_printing_job(
|
success, message, updated_instance = PrintingJobService.update_printing_job(
|
||||||
instance, validated_data, user
|
instance, validated_data, user
|
||||||
@@ -772,6 +778,27 @@ class PlateOrderCreateUpdateSerializer(serializers.ModelSerializer):
|
|||||||
return super().create(validated_data)
|
return super().create(validated_data)
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
|
# 若更新涉及 process 变更,需要重建并重新绑定 BusinessObject(若有未撤销进度则拒绝)
|
||||||
|
new_process_id = validated_data.get('process', None)
|
||||||
|
if new_process_id is not None and new_process_id != getattr(instance, 'process', None):
|
||||||
|
from stateflow.models import Process
|
||||||
|
from stateflow.services import relink_business_object_for_instance
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_process = Process.objects.get(id=new_process_id)
|
||||||
|
except Process.DoesNotExist:
|
||||||
|
# 理论上 validate_process 已校验;这里兜底
|
||||||
|
raise serializers.ValidationError({'process': '流程不存在'})
|
||||||
|
|
||||||
|
relinked = relink_business_object_for_instance(
|
||||||
|
instance=instance,
|
||||||
|
new_process=new_process,
|
||||||
|
default_name=f"PlateOrder-{instance.pk}",
|
||||||
|
default_description=f"开版订单 {instance.pk} 的流程实例",
|
||||||
|
)
|
||||||
|
if relinked is None:
|
||||||
|
raise serializers.ValidationError({'process': '该订单流程已存在有效进度,无法修改流程'})
|
||||||
|
|
||||||
plate_images = validated_data.pop('plate_image', None)
|
plate_images = validated_data.pop('plate_image', None)
|
||||||
if plate_images is not None:
|
if plate_images is not None:
|
||||||
validated_data['plate_image'] = _build_plate_image_payload(plate_images, self._request_user)
|
validated_data['plate_image'] = _build_plate_image_payload(plate_images, self._request_user)
|
||||||
|
|||||||
@@ -70,10 +70,40 @@ class PrintingOrderService:
|
|||||||
Returns:
|
Returns:
|
||||||
(success, message, updated_order)
|
(success, message, updated_order)
|
||||||
"""
|
"""
|
||||||
# 如果要修改 process,需要验证
|
# 如果要修改 process,需要验证并同步 relink 所有 jobs 的 BusinessObject
|
||||||
if 'process' in data and data['process'] != printing_order.process:
|
if 'process' in data and data['process'] != printing_order.process:
|
||||||
|
new_process = data.get('process')
|
||||||
|
if new_process is None:
|
||||||
|
return False, '流程不能为空,无法修改流程', printing_order
|
||||||
|
|
||||||
|
# 只要存在任意 job 已开始(有未撤销进度)则拒绝
|
||||||
if not printing_order.can_change_process():
|
if not printing_order.can_change_process():
|
||||||
return False, '存在已开始的印染任务,无法修改流程', printing_order
|
return False, '存在已开始的印染任务,无法修改流程', printing_order
|
||||||
|
|
||||||
|
from stateflow.services import relink_business_object_for_instance
|
||||||
|
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
# relink 所有 jobs(jobs 未开始,允许重建并重新绑定 BO)
|
||||||
|
for job in printing_order.printing_jobs.select_related('business_object').all():
|
||||||
|
relinked = relink_business_object_for_instance(
|
||||||
|
instance=job,
|
||||||
|
new_process=new_process,
|
||||||
|
default_name=f"PrintingJob-{job.pk}",
|
||||||
|
default_description=f"印染任务 {job.pk} 的流程实例",
|
||||||
|
)
|
||||||
|
if relinked is None:
|
||||||
|
raise RuntimeError(f'印染任务 {job.pk} 已存在有效进度,无法修改流程')
|
||||||
|
|
||||||
|
# 最后更新订单流程
|
||||||
|
printing_order.process = new_process
|
||||||
|
printing_order.save(update_fields=['process'])
|
||||||
|
|
||||||
|
except RuntimeError as e:
|
||||||
|
return False, str(e), printing_order
|
||||||
|
|
||||||
|
# process 已处理完毕,避免后续通用字段更新再次覆盖
|
||||||
|
data = {k: v for k, v in data.items() if k != 'process'}
|
||||||
|
|
||||||
# 更新字段
|
# 更新字段
|
||||||
for field, value in data.items():
|
for field, value in data.items():
|
||||||
|
|||||||
@@ -514,6 +514,54 @@ class PrintingOrderAPITestCase(TestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
order.refresh_from_db()
|
order.refresh_from_db()
|
||||||
self.assertEqual(order.process.id, new_process.id)
|
self.assertEqual(order.process.id, new_process.id)
|
||||||
|
|
||||||
|
def test_update_process_with_jobs_not_started_relinks_jobs(self):
|
||||||
|
"""测试:有任务但均未开始时允许改流程,并对所有 jobs 重建/绑定新的 business_object"""
|
||||||
|
order = printing_models.PrintingOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer,
|
||||||
|
fabric='测试布料',
|
||||||
|
width='150cm',
|
||||||
|
process=self.process,
|
||||||
|
)
|
||||||
|
|
||||||
|
category = basic_models.ProductCategory.objects.create(
|
||||||
|
name='测试类别2',
|
||||||
|
merchant=self.merchant,
|
||||||
|
)
|
||||||
|
product = basic_models.Product.objects.create(
|
||||||
|
name='测试产品2',
|
||||||
|
category=category,
|
||||||
|
merchant=self.merchant,
|
||||||
|
)
|
||||||
|
|
||||||
|
job = printing_models.PrintingJob.objects.create(
|
||||||
|
printing_order=order,
|
||||||
|
product=product,
|
||||||
|
quantity=10,
|
||||||
|
unit='件',
|
||||||
|
)
|
||||||
|
# 绑定一个旧的 business_object(未推进过,未开始)
|
||||||
|
old_bo = stateflow_models.BusinessObject.objects.create(
|
||||||
|
name=f'PrintingJob-{job.id}',
|
||||||
|
process=self.process,
|
||||||
|
)
|
||||||
|
job.business_object = old_bo
|
||||||
|
job.save()
|
||||||
|
|
||||||
|
new_process = stateflow_models.Process.objects.create(name='新流程2')
|
||||||
|
data = {'process': new_process.id}
|
||||||
|
|
||||||
|
response = self.client.patch(f'/api/v1/printing-orders/{order.id}/', data, format='json')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
order.refresh_from_db()
|
||||||
|
self.assertEqual(order.process.id, new_process.id)
|
||||||
|
|
||||||
|
job.refresh_from_db()
|
||||||
|
self.assertIsNotNone(job.business_object)
|
||||||
|
self.assertNotEqual(job.business_object.id, old_bo.id)
|
||||||
|
self.assertEqual(job.business_object.process.id, new_process.id)
|
||||||
|
|
||||||
def test_cannot_update_process_when_job_started(self):
|
def test_cannot_update_process_when_job_started(self):
|
||||||
"""测试有已开始的任务时不能修改流程"""
|
"""测试有已开始的任务时不能修改流程"""
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ class PlateOrderAPITestCase(TestCase):
|
|||||||
password='testpass123',
|
password='testpass123',
|
||||||
email='test@example.com'
|
email='test@example.com'
|
||||||
)
|
)
|
||||||
|
# PlateOrderViewSet 默认做 merchant 隔离;本测试集不关注该隔离逻辑,
|
||||||
|
# 设为 superuser 以避免因测试数据未设置 merchant 导致的 404/列表为空。
|
||||||
|
self.user.is_superuser = True
|
||||||
|
self.user.save(update_fields=['is_superuser'])
|
||||||
|
# PlateOrder/Printing 视图集默认做 merchant 隔离;本测试集不关注该隔离逻辑,
|
||||||
|
# 设为 superuser 以避免因测试数据未设置 merchant 导致的 404/列表为空。
|
||||||
|
self.user.is_superuser = True
|
||||||
|
self.user.save(update_fields=['is_superuser'])
|
||||||
|
|
||||||
# 创建员工并关联商户
|
# 创建员工并关联商户
|
||||||
self.employee = basic_models.Employee.objects.create(
|
self.employee = basic_models.Employee.objects.create(
|
||||||
@@ -72,6 +80,7 @@ class PlateOrderAPITestCase(TestCase):
|
|||||||
# 创建客户
|
# 创建客户
|
||||||
self.customer = basic_models.Customer.objects.create(
|
self.customer = basic_models.Customer.objects.create(
|
||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
|
created_by=self.employee,
|
||||||
name='测试客户',
|
name='测试客户',
|
||||||
mobile='13900139000',
|
mobile='13900139000',
|
||||||
area='测试地区'
|
area='测试地区'
|
||||||
@@ -930,7 +939,7 @@ class PlateOrderAPITestCase(TestCase):
|
|||||||
# 获取原来的 business_object
|
# 获取原来的 business_object
|
||||||
old_business_object_id = plate_order.business_object.id if plate_order.business_object else None
|
old_business_object_id = plate_order.business_object.id if plate_order.business_object else None
|
||||||
|
|
||||||
# 更新流程(注意:这只会更新 process 字段,不会重新创建 business_object)
|
# 更新流程:应重建并重新绑定 business_object(若存在未撤销进度则应拒绝)
|
||||||
data = {
|
data = {
|
||||||
'customer': self.customer.id,
|
'customer': self.customer.id,
|
||||||
'design_code': 'DESIGN105',
|
'design_code': 'DESIGN105',
|
||||||
@@ -943,10 +952,40 @@ class PlateOrderAPITestCase(TestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data['process'], process2.id)
|
self.assertEqual(response.data['process'], process2.id)
|
||||||
|
|
||||||
# 验证 business_object 没有改变(因为已经存在)
|
# 验证 business_object 已重建并绑定到新流程
|
||||||
plate_order.refresh_from_db()
|
plate_order.refresh_from_db()
|
||||||
if old_business_object_id:
|
if old_business_object_id:
|
||||||
self.assertEqual(plate_order.business_object.id, old_business_object_id)
|
self.assertNotEqual(plate_order.business_object.id, old_business_object_id)
|
||||||
|
self.assertEqual(plate_order.business_object.process.id, process2.id)
|
||||||
|
|
||||||
|
def test_cannot_update_plate_order_process_when_started(self):
|
||||||
|
"""测试:存在未撤销进度时不允许修改开版流程"""
|
||||||
|
plate_order = printing_models.PlateOrder.objects.create(
|
||||||
|
customer=self.customer,
|
||||||
|
design_code='DESIGN105X',
|
||||||
|
plate_type='圆网',
|
||||||
|
style_name='测试款式',
|
||||||
|
process=self.process.id,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(plate_order.business_object)
|
||||||
|
|
||||||
|
# 推进一步以制造“未撤销进度”
|
||||||
|
from stateflow.services import advance_to_next_state
|
||||||
|
advance_to_next_state(plate_order.business_object, self.user)
|
||||||
|
|
||||||
|
process2 = stateflow_models.Process.objects.create(name='另一个流程')
|
||||||
|
stateflow_models.ProcessNode.objects.create(process=process2, state=self.state1, order=0)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'customer': self.customer.id,
|
||||||
|
'design_code': 'DESIGN105X',
|
||||||
|
'plate_type': '圆网',
|
||||||
|
'style_name': '测试款式',
|
||||||
|
'process': process2.id,
|
||||||
|
}
|
||||||
|
response = self.client.put(f'/api/v1/plate-orders/{plate_order.id}/', data, format='json')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn('process', response.data)
|
||||||
|
|
||||||
def test_business_object_auto_creation(self):
|
def test_business_object_auto_creation(self):
|
||||||
"""测试 BusinessObject 自动创建逻辑"""
|
"""测试 BusinessObject 自动创建逻辑"""
|
||||||
@@ -1163,6 +1202,7 @@ class PlateOrderInvalidateAPITestCase(TestCase):
|
|||||||
# 创建客户
|
# 创建客户
|
||||||
self.customer = basic_models.Customer.objects.create(
|
self.customer = basic_models.Customer.objects.create(
|
||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
|
created_by=self.employee,
|
||||||
name='测试客户',
|
name='测试客户',
|
||||||
mobile='13900139000',
|
mobile='13900139000',
|
||||||
area='测试地区'
|
area='测试地区'
|
||||||
@@ -1188,6 +1228,7 @@ class PlateOrderInvalidateAPITestCase(TestCase):
|
|||||||
def test_invalidate_plate_order_without_permission(self):
|
def test_invalidate_plate_order_without_permission(self):
|
||||||
"""测试无权限作废开版订单"""
|
"""测试无权限作废开版订单"""
|
||||||
plate_order = printing_models.PlateOrder.objects.create(
|
plate_order = printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
customer=self.customer,
|
customer=self.customer,
|
||||||
design_code='DESIGN001',
|
design_code='DESIGN001',
|
||||||
plate_type='圆网',
|
plate_type='圆网',
|
||||||
@@ -1208,6 +1249,7 @@ class PlateOrderInvalidateAPITestCase(TestCase):
|
|||||||
self.user.user_permissions.add(invalidate_perm)
|
self.user.user_permissions.add(invalidate_perm)
|
||||||
|
|
||||||
plate_order = printing_models.PlateOrder.objects.create(
|
plate_order = printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
customer=self.customer,
|
customer=self.customer,
|
||||||
design_code='DESIGN001',
|
design_code='DESIGN001',
|
||||||
plate_type='圆网',
|
plate_type='圆网',
|
||||||
@@ -1229,6 +1271,7 @@ class PlateOrderInvalidateAPITestCase(TestCase):
|
|||||||
self.user.user_permissions.add(invalidate_perm)
|
self.user.user_permissions.add(invalidate_perm)
|
||||||
|
|
||||||
plate_order = printing_models.PlateOrder.objects.create(
|
plate_order = printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
customer=self.customer,
|
customer=self.customer,
|
||||||
design_code='DESIGN001',
|
design_code='DESIGN001',
|
||||||
plate_type='圆网',
|
plate_type='圆网',
|
||||||
@@ -1243,6 +1286,7 @@ class PlateOrderInvalidateAPITestCase(TestCase):
|
|||||||
def test_activate_plate_order_without_permission(self):
|
def test_activate_plate_order_without_permission(self):
|
||||||
"""测试无权限恢复开版订单"""
|
"""测试无权限恢复开版订单"""
|
||||||
plate_order = printing_models.PlateOrder.objects.create(
|
plate_order = printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
customer=self.customer,
|
customer=self.customer,
|
||||||
design_code='DESIGN001',
|
design_code='DESIGN001',
|
||||||
plate_type='圆网',
|
plate_type='圆网',
|
||||||
@@ -1264,6 +1308,7 @@ class PlateOrderInvalidateAPITestCase(TestCase):
|
|||||||
self.user.user_permissions.add(activate_perm)
|
self.user.user_permissions.add(activate_perm)
|
||||||
|
|
||||||
plate_order = printing_models.PlateOrder.objects.create(
|
plate_order = printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
customer=self.customer,
|
customer=self.customer,
|
||||||
design_code='DESIGN001',
|
design_code='DESIGN001',
|
||||||
plate_type='圆网',
|
plate_type='圆网',
|
||||||
@@ -1286,6 +1331,7 @@ class PlateOrderInvalidateAPITestCase(TestCase):
|
|||||||
self.user.user_permissions.add(activate_perm)
|
self.user.user_permissions.add(activate_perm)
|
||||||
|
|
||||||
plate_order = printing_models.PlateOrder.objects.create(
|
plate_order = printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
customer=self.customer,
|
customer=self.customer,
|
||||||
design_code='DESIGN001',
|
design_code='DESIGN001',
|
||||||
plate_type='圆网',
|
plate_type='圆网',
|
||||||
@@ -1336,6 +1382,7 @@ class PlateOrderInvalidateAPITestCase(TestCase):
|
|||||||
def test_update_plate_order_designer(self):
|
def test_update_plate_order_designer(self):
|
||||||
"""测试更新开版订单的设计师"""
|
"""测试更新开版订单的设计师"""
|
||||||
plate_order = printing_models.PlateOrder.objects.create(
|
plate_order = printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
customer=self.customer,
|
customer=self.customer,
|
||||||
design_code='DESIGN_UPDATE_DESIGNER',
|
design_code='DESIGN_UPDATE_DESIGNER',
|
||||||
plate_type='圆网',
|
plate_type='圆网',
|
||||||
@@ -1358,6 +1405,7 @@ class PlateOrderInvalidateAPITestCase(TestCase):
|
|||||||
def test_list_plate_orders_includes_designer_name(self):
|
def test_list_plate_orders_includes_designer_name(self):
|
||||||
"""测试列表接口包含设计师字段"""
|
"""测试列表接口包含设计师字段"""
|
||||||
printing_models.PlateOrder.objects.create(
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
customer=self.customer,
|
customer=self.customer,
|
||||||
design_code='DESIGN_LIST_1',
|
design_code='DESIGN_LIST_1',
|
||||||
plate_type='圆网',
|
plate_type='圆网',
|
||||||
@@ -1365,6 +1413,7 @@ class PlateOrderInvalidateAPITestCase(TestCase):
|
|||||||
designer=self.designer,
|
designer=self.designer,
|
||||||
)
|
)
|
||||||
printing_models.PlateOrder.objects.create(
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
customer=self.customer,
|
customer=self.customer,
|
||||||
design_code='DESIGN_LIST_2',
|
design_code='DESIGN_LIST_2',
|
||||||
plate_type='平网',
|
plate_type='平网',
|
||||||
@@ -1382,6 +1431,7 @@ class PlateOrderInvalidateAPITestCase(TestCase):
|
|||||||
def test_retrieve_plate_order_includes_designer(self):
|
def test_retrieve_plate_order_includes_designer(self):
|
||||||
"""测试详情接口包含设计师信息"""
|
"""测试详情接口包含设计师信息"""
|
||||||
plate_order = printing_models.PlateOrder.objects.create(
|
plate_order = printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
customer=self.customer,
|
customer=self.customer,
|
||||||
design_code='DESIGN_RETRIEVE',
|
design_code='DESIGN_RETRIEVE',
|
||||||
plate_type='圆网',
|
plate_type='圆网',
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
password='testpass123',
|
password='testpass123',
|
||||||
email='test@example.com'
|
email='test@example.com'
|
||||||
)
|
)
|
||||||
|
# PrintingJobViewSet 默认做 merchant 隔离;本测试集不关注该隔离逻辑,
|
||||||
|
# 设为 superuser 以避免因测试数据未设置 merchant 导致的 404/列表为空。
|
||||||
|
self.user.is_superuser = True
|
||||||
|
self.user.save(update_fields=['is_superuser'])
|
||||||
|
|
||||||
# 创建员工并关联商户
|
# 创建员工并关联商户
|
||||||
self.employee = basic_models.Employee.objects.create(
|
self.employee = basic_models.Employee.objects.create(
|
||||||
@@ -257,6 +261,36 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
self.assertEqual(job.quantity, 200)
|
self.assertEqual(job.quantity, 200)
|
||||||
self.assertEqual(job.unit, '码')
|
self.assertEqual(job.unit, '码')
|
||||||
self.assertEqual(job.pieces, 20)
|
self.assertEqual(job.pieces, 20)
|
||||||
|
|
||||||
|
def test_cannot_change_printing_order_on_update(self):
|
||||||
|
"""测试:不允许通过更新接口修改 PrintingJob.printing_order"""
|
||||||
|
job = printing_models.PrintingJob.objects.create(
|
||||||
|
printing_order=self.printing_order,
|
||||||
|
product=self.product,
|
||||||
|
quantity=100,
|
||||||
|
unit='米',
|
||||||
|
)
|
||||||
|
|
||||||
|
other_order = printing_models.PrintingOrder.objects.create(
|
||||||
|
customer=self.customer,
|
||||||
|
fabric='其他布料',
|
||||||
|
width='160cm',
|
||||||
|
)
|
||||||
|
|
||||||
|
update_data = {
|
||||||
|
'printing_order': other_order.id, # 尝试变更绑定关系
|
||||||
|
'product': self.product.id,
|
||||||
|
'quantity': 200,
|
||||||
|
'unit': '码',
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.put(
|
||||||
|
f'/api/v1/printing-jobs/{job.id}/',
|
||||||
|
update_data,
|
||||||
|
format='json'
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn('printing_order', response.data)
|
||||||
|
|
||||||
def test_partial_update_printing_job(self):
|
def test_partial_update_printing_job(self):
|
||||||
"""测试部分更新款式明细"""
|
"""测试部分更新款式明细"""
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ python manage.py backfill_merchant <merchant_id>
|
|||||||
|
|
||||||
在 `settings.py` 中添加 `AUTO_CREATE_SALESITEM_FROM_PRINT_ORDER` 配置项:
|
在 `settings.py` 中添加 `AUTO_CREATE_SALESITEM_FROM_PRINT_ORDER` 配置项:
|
||||||
|
|
||||||
- **默认值**: `True`(保持现有行为)
|
- **默认值**: `False`(默认关闭,避免未确认流程就自动落库)
|
||||||
- **环境变量**: `AUTO_CREATE_SALESITEM_FROM_PRINT_ORDER`
|
- **环境变量**: `AUTO_CREATE_SALESITEM_FROM_PRINT_ORDER`
|
||||||
- **作用**: 当设为 `False` 时,`printing/handlers.py` 中的流程完成信号处理器将不再自动创建销售品
|
- **作用**: 当设为 `False` 时,`printing/handlers.py` 中的流程完成信号处理器将不再自动创建销售品
|
||||||
|
|
||||||
@@ -103,6 +103,15 @@ python manage.py backfill_merchant <merchant_id>
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 6.1. 强制从 .env 读取 PRINTING_SALES_ITEM_SOURCE_STATE_ID(无默认值)
|
||||||
|
|
||||||
|
为避免遗漏配置导致“取错工序参数”,将 `PRINTING_SALES_ITEM_SOURCE_STATE_ID` 改为必须在 `.env` 中显式配置(不提供默认值)。
|
||||||
|
|
||||||
|
#### 修改文件
|
||||||
|
- `flower/settings.py`: `PRINTING_SALES_ITEM_SOURCE_STATE_ID = env.int('PRINTING_SALES_ITEM_SOURCE_STATE_ID')`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 7. Printing 模块客户可见性过滤
|
### 7. Printing 模块客户可见性过滤
|
||||||
|
|
||||||
实现了基于客户可见性的订单查询过滤功能,确保员工只能看到自己负责的客户的订单。
|
实现了基于客户可见性的订单查询过滤功能,确保员工只能看到自己负责的客户的订单。
|
||||||
@@ -152,7 +161,15 @@ python manage.py backfill_merchant <merchant_id>
|
|||||||
- `shipment/migrations/0005_fix_shipment_external_finished_product_relation.py`(修正关系方向:Shipment 1→N ExternalFinishedProduct)
|
- `shipment/migrations/0005_fix_shipment_external_finished_product_relation.py`(修正关系方向:Shipment 1→N ExternalFinishedProduct)
|
||||||
|
|
||||||
#### 测试
|
#### 测试
|
||||||
- 运行 `api_v1.views.shipment.test_api`:13 个测试通过
|
- 运行 `api_v1.views.shipment.test_api`:17 个测试通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8.1. Admin 支持(Shipment)
|
||||||
|
|
||||||
|
补齐 Shipment 模块在 Django Admin 中的可用性,便于测试/排查数据:
|
||||||
|
|
||||||
|
- `shipment/admin.py`: 注册 `Shipment`、`SalesItem` 并提供基础展示/筛选字段
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -182,7 +199,15 @@ python manage.py backfill_merchant <merchant_id>
|
|||||||
#### merchant 强制归属(非空)
|
#### merchant 强制归属(非空)
|
||||||
- 为 `Shipment` 与 `SalesItem` 增加 `merchant` 外键且不允许为空
|
- 为 `Shipment` 与 `SalesItem` 增加 `merchant` 外键且不允许为空
|
||||||
- 创建时从 `request.user.employee.merchant` 自动绑定,并校验 customer 同商户
|
- 创建时从 `request.user.employee.merchant` 自动绑定,并校验 customer 同商户
|
||||||
- 普通版在关联销售品时校验销售品同商户,避免跨商户关联
|
- 普通版在关联销售品时校验销售品同商户,避免跨商户关联(不一致则报错并回滚)
|
||||||
|
- printing 流程完成自动创建 `SalesItem` 时增加 merchant 推导兜底(job → order → operator),避免 merchant 为空导致创建失败
|
||||||
|
|
||||||
|
#### Migration
|
||||||
|
- `shipment/migrations/0006_add_merchant_to_shipment_and_salesitem.py`
|
||||||
|
- 先以可空字段落库 → RunPython 补齐 → 再改为 `null=False`(避免 makemigrations 交互式默认值)
|
||||||
|
|
||||||
|
#### 响应字段补充
|
||||||
|
- Shipment 响应增加 `merchant_id` / `merchant_name`(便于前端展示/二次校验)
|
||||||
|
|
||||||
#### 文档
|
#### 文档
|
||||||
- `docs/shipment_api.md`: 补充 external create API 文档
|
- `docs/shipment_api.md`: 补充 external create API 文档
|
||||||
@@ -197,4 +222,4 @@ python manage.py backfill_merchant <merchant_id>
|
|||||||
|
|
||||||
- API 独立于 printing 模块,避免影响现有功能
|
- API 独立于 printing 模块,避免影响现有功能
|
||||||
- 完整的测试覆盖:正常查询、包含已出货、数据格式、404 错误、空结果、未认证
|
- 完整的测试覆盖:正常查询、包含已出货、数据格式、404 错误、空结果、未认证
|
||||||
- printing 和 shipment 模块全部测试通过(共 13 个测试用例)
|
- printing 和 shipment 模块相关测试通过(`api_v1.views.shipment.test_api` 共 17 个用例)
|
||||||
|
|||||||
35
docs/2026-01-15_summary.md
Normal file
35
docs/2026-01-15_summary.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# 2026-01-15 工作日志
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
发现 `PlateOrder`(以及同类的 `PrintingOrder/PrintingJob`)在 PUT/PATCH 更新时允许修改流程相关字段,可能导致:
|
||||||
|
- 业务对象的 `process` 已变更
|
||||||
|
- 但已绑定的 `BusinessObject.process` 仍指向旧流程
|
||||||
|
|
||||||
|
从而出现“流程推进/状态展示与当前流程不一致”的严重数据一致性漏洞。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 今日完成
|
||||||
|
|
||||||
|
- 修复流程变更导致的 `BusinessObject` 不匹配漏洞(统一收敛到 `stateflow` 层处理)
|
||||||
|
- 约束更新行为:
|
||||||
|
- `PrintingJob` 不再允许修改 `printing_order` 绑定关系(返回 400)
|
||||||
|
- `PrintingOrder` 修改 `process` 时:
|
||||||
|
- `process=None` 视为非法(返回 400)
|
||||||
|
- 只要任意 job 存在“未撤销进度”(`has_started=True`),整笔订单不允许改流程(返回 400)
|
||||||
|
- 若所有 jobs 均未开始,则修改订单流程时会同步为其下所有 jobs 重建并重新绑定新的 `BusinessObject`
|
||||||
|
- `PlateOrder` 修改 `process` 时:
|
||||||
|
- 若存在“未撤销进度”(`has_started=True`),不允许改流程(返回 400)
|
||||||
|
- 否则重建并重新绑定新的 `BusinessObject`(旧 BO 保留不删除)
|
||||||
|
- 测试补齐/修正:
|
||||||
|
- PlateOrder:修改流程会重建 BO;存在有效进度时拒绝
|
||||||
|
- PrintingOrder:有 job 且未开始时改流程会 relink jobs
|
||||||
|
- PrintingJob:禁止变更 printing_order
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- 新增独立说明文档:`docs/business_object_relink_fix.md`(背景、风险、修正案与规则)
|
||||||
|
|
||||||
68
docs/business_object_relink_fix.md
Normal file
68
docs/business_object_relink_fix.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
## 背景与问题
|
||||||
|
|
||||||
|
系统中 `PlateOrder` / `PrintingJob` 都通过 `business_object`(`stateflow.BusinessObject`)承载流程推进数据:
|
||||||
|
- `BusinessObject.process` 决定了流程节点序列
|
||||||
|
- `BusinessObject.state_logs` 记录了推进轨迹(`StateFlowRecord`,支持撤销)
|
||||||
|
|
||||||
|
但在现有 API 中(PUT/PATCH 更新):
|
||||||
|
- `PlateOrder.process`(整型流程 ID)允许被更新
|
||||||
|
- `PrintingOrder.process`(外键流程)允许在“所有 jobs 未开始”时被更新
|
||||||
|
- `PrintingJob` 允许修改 `printing_order`(从而间接改变其应当使用的流程)
|
||||||
|
|
||||||
|
这些更新行为会导致一个严重一致性漏洞:
|
||||||
|
> 业务对象的“当前流程字段”发生变化,但其已绑定的 `BusinessObject.process` 仍指向旧流程,导致后续状态推进与展示出现错乱。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修正案(最小改动原则)
|
||||||
|
|
||||||
|
目标:
|
||||||
|
- 只在必要处拦截/修复更新行为
|
||||||
|
- 将核心逻辑集中到 `stateflow/services.py`(跨模块统一)
|
||||||
|
- 不删除旧的 BusinessObject(后续可通过“无反向引用”识别悬空 BO)
|
||||||
|
|
||||||
|
核心做法:
|
||||||
|
- 新增 `stateflow.services.relink_business_object_for_instance(...)`
|
||||||
|
- 当流程发生变化时,创建并绑定一个新的 BusinessObject(新流程)
|
||||||
|
- 若旧 BO 存在“未撤销进度”,则拒绝(返回 None,由调用方转 400)
|
||||||
|
- 内部新增 `_can_relink_business_object(...)`
|
||||||
|
- 判定口径与 `has_started` 保持一致:只要存在 `is_cancelled=False` 的记录即视为已开始
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 规则(对外行为)
|
||||||
|
|
||||||
|
### PlateOrder(更新 process)
|
||||||
|
|
||||||
|
- **process 未变化**:不触发 relink
|
||||||
|
- **process 变化**:
|
||||||
|
- 若已存在“未撤销进度”(has_started=True):返回 400
|
||||||
|
- 否则:创建新 BO 并绑定;旧 BO 保留
|
||||||
|
|
||||||
|
### PrintingOrder(更新 process)
|
||||||
|
|
||||||
|
- **process=None**:非法,返回 400
|
||||||
|
- **任意 job 已存在未撤销进度**:整笔订单不允许改流程,返回 400
|
||||||
|
- **所有 jobs 均未开始**:允许改流程,并对订单下所有 jobs 进行 relink(创建新 BO 并绑定)
|
||||||
|
|
||||||
|
### PrintingJob(更新 printing_order)
|
||||||
|
|
||||||
|
- 不允许通过更新接口修改 `printing_order` 绑定关系:返回 400
|
||||||
|
- 避免绕过 `PrintingOrder` 的流程一致性约束
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 影响与收益
|
||||||
|
|
||||||
|
- **收益**:保证 `process` 与 `BusinessObject.process` 的一致性,避免状态推进/展示错乱
|
||||||
|
- **可追溯性**:旧 BO 不删除,未来可实现“悬空 BO 查看/排查”
|
||||||
|
- **风险控制**:仅在“无有效进度”时允许换流程,避免对已开始流程造成破坏
|
||||||
|
|
||||||
|
## 涉及代码文件
|
||||||
|
|
||||||
|
- stateflow/services.py
|
||||||
|
- api_v1/views/printing/serializers.py
|
||||||
|
- api_v1/views/printing/services.py
|
||||||
|
- api_v1/views/printing/test_plate_order_api.py
|
||||||
|
- api_v1/views/printing/test_api.py
|
||||||
|
- api_v1/views/printing/test_printing_job_api.py
|
||||||
@@ -14,6 +14,65 @@ from . import models
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
def _can_relink_business_object(business_object: 'models.BusinessObject') -> bool:
|
||||||
|
"""
|
||||||
|
内部判断:该 BusinessObject 是否允许“换流程并重建”(relink)。
|
||||||
|
|
||||||
|
规则(与 printing.models.PlateOrder/PrintingJob.has_started 保持一致):
|
||||||
|
- 只要存在未撤销(is_cancelled=False)的状态记录,就视为已开始,不允许 relink
|
||||||
|
"""
|
||||||
|
if not business_object:
|
||||||
|
return True
|
||||||
|
return not business_object.state_logs.filter(is_cancelled=False).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def relink_business_object_for_instance(
|
||||||
|
*,
|
||||||
|
instance,
|
||||||
|
new_process: 'models.Process',
|
||||||
|
default_name: str | None = None,
|
||||||
|
default_description: str | None = None,
|
||||||
|
) -> Optional['models.BusinessObject']:
|
||||||
|
"""
|
||||||
|
当业务对象的“流程”发生变更时,重建并重新绑定一个新的 BusinessObject。
|
||||||
|
|
||||||
|
设计目标:
|
||||||
|
- stateflow 层提供统一逻辑(不依赖 printing/shipment 等业务模块)
|
||||||
|
- 仅在“没有未撤销进度”的情况下允许重建
|
||||||
|
- 旧 BusinessObject 不删除(未来可通过“无反向引用”视为悬空)
|
||||||
|
|
||||||
|
返回:
|
||||||
|
- BusinessObject 实例:relink 成功后的新 BO
|
||||||
|
- None:不允许 relink(例如已存在未撤销进度),调用方应将其转为 400
|
||||||
|
|
||||||
|
注意:
|
||||||
|
- 若 instance 原本没有 business_object,本函数仍会创建并绑定新的 BO
|
||||||
|
- 本函数只负责 BO 的创建与绑定,不负责写入业务对象自身的 process 字段
|
||||||
|
"""
|
||||||
|
if instance is None or getattr(instance, 'pk', None) is None:
|
||||||
|
return None
|
||||||
|
if new_process is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
old_bo = getattr(instance, 'business_object', None)
|
||||||
|
if old_bo and not _can_relink_business_object(old_bo):
|
||||||
|
return None
|
||||||
|
|
||||||
|
ct = ContentType.objects.get_for_model(instance.__class__)
|
||||||
|
bo = models.BusinessObject.objects.create(
|
||||||
|
name=(default_name or f"{instance.__class__.__name__}-{instance.pk}"),
|
||||||
|
process=new_process,
|
||||||
|
description=(default_description or ''),
|
||||||
|
content_type=ct,
|
||||||
|
object_id=instance.pk,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 绑定到业务对象(业务对象侧通常为 OneToOneField)
|
||||||
|
setattr(instance, 'business_object', bo)
|
||||||
|
instance.save(update_fields=['business_object'])
|
||||||
|
return bo
|
||||||
|
|
||||||
|
|
||||||
def ensure_business_object_bound_to_instance(
|
def ensure_business_object_bound_to_instance(
|
||||||
business_object: 'models.BusinessObject',
|
business_object: 'models.BusinessObject',
|
||||||
instance,
|
instance,
|
||||||
|
|||||||
Reference in New Issue
Block a user