From ea9f378de9aa449be49befd093d6b4ddbaae558a Mon Sep 17 00:00:00 2001 From: colaftc Date: Thu, 15 Jan 2026 16:35:46 +0800 Subject: [PATCH] fix: process id change when plate_order and printing_order update --- api_v1/views/printing/serializers.py | 27 ++++++++ api_v1/views/printing/services.py | 32 ++++++++- api_v1/views/printing/test_api.py | 48 +++++++++++++ api_v1/views/printing/test_plate_order_api.py | 56 ++++++++++++++- .../views/printing/test_printing_job_api.py | 34 ++++++++++ docs/2026-01-14_summary.md | 33 +++++++-- docs/2026-01-15_summary.md | 35 ++++++++++ docs/business_object_relink_fix.md | 68 +++++++++++++++++++ stateflow/services.py | 59 ++++++++++++++++ 9 files changed, 384 insertions(+), 8 deletions(-) create mode 100644 docs/2026-01-15_summary.md create mode 100644 docs/business_object_relink_fix.md diff --git a/api_v1/views/printing/serializers.py b/api_v1/views/printing/serializers.py index e890715..9035702 100644 --- a/api_v1/views/printing/serializers.py +++ b/api_v1/views/printing/serializers.py @@ -475,6 +475,12 @@ class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): """更新任务,使用 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 success, message, updated_instance = PrintingJobService.update_printing_job( instance, validated_data, user @@ -772,6 +778,27 @@ class PlateOrderCreateUpdateSerializer(serializers.ModelSerializer): return super().create(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) if plate_images is not None: validated_data['plate_image'] = _build_plate_image_payload(plate_images, self._request_user) diff --git a/api_v1/views/printing/services.py b/api_v1/views/printing/services.py index 82a50ce..b0ee0b3 100644 --- a/api_v1/views/printing/services.py +++ b/api_v1/views/printing/services.py @@ -70,10 +70,40 @@ class PrintingOrderService: Returns: (success, message, updated_order) """ - # 如果要修改 process,需要验证 + # 如果要修改 process,需要验证并同步 relink 所有 jobs 的 BusinessObject 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(): 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(): diff --git a/api_v1/views/printing/test_api.py b/api_v1/views/printing/test_api.py index 8029fb1..f3720fa 100644 --- a/api_v1/views/printing/test_api.py +++ b/api_v1/views/printing/test_api.py @@ -514,6 +514,54 @@ class PrintingOrderAPITestCase(TestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) order.refresh_from_db() 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): """测试有已开始的任务时不能修改流程""" diff --git a/api_v1/views/printing/test_plate_order_api.py b/api_v1/views/printing/test_plate_order_api.py index ced5f64..c2829dd 100644 --- a/api_v1/views/printing/test_plate_order_api.py +++ b/api_v1/views/printing/test_plate_order_api.py @@ -35,6 +35,14 @@ class PlateOrderAPITestCase(TestCase): password='testpass123', 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( @@ -72,6 +80,7 @@ class PlateOrderAPITestCase(TestCase): # 创建客户 self.customer = basic_models.Customer.objects.create( merchant=self.merchant, + created_by=self.employee, name='测试客户', mobile='13900139000', area='测试地区' @@ -930,7 +939,7 @@ class PlateOrderAPITestCase(TestCase): # 获取原来的 business_object old_business_object_id = plate_order.business_object.id if plate_order.business_object else None - # 更新流程(注意:这只会更新 process 字段,不会重新创建 business_object) + # 更新流程:应重建并重新绑定 business_object(若存在未撤销进度则应拒绝) data = { 'customer': self.customer.id, 'design_code': 'DESIGN105', @@ -943,10 +952,40 @@ class PlateOrderAPITestCase(TestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['process'], process2.id) - # 验证 business_object 没有改变(因为已经存在) + # 验证 business_object 已重建并绑定到新流程 plate_order.refresh_from_db() 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): """测试 BusinessObject 自动创建逻辑""" @@ -1163,6 +1202,7 @@ class PlateOrderInvalidateAPITestCase(TestCase): # 创建客户 self.customer = basic_models.Customer.objects.create( merchant=self.merchant, + created_by=self.employee, name='测试客户', mobile='13900139000', area='测试地区' @@ -1188,6 +1228,7 @@ class PlateOrderInvalidateAPITestCase(TestCase): def test_invalidate_plate_order_without_permission(self): """测试无权限作废开版订单""" plate_order = printing_models.PlateOrder.objects.create( + merchant=self.merchant, customer=self.customer, design_code='DESIGN001', plate_type='圆网', @@ -1208,6 +1249,7 @@ class PlateOrderInvalidateAPITestCase(TestCase): self.user.user_permissions.add(invalidate_perm) plate_order = printing_models.PlateOrder.objects.create( + merchant=self.merchant, customer=self.customer, design_code='DESIGN001', plate_type='圆网', @@ -1229,6 +1271,7 @@ class PlateOrderInvalidateAPITestCase(TestCase): self.user.user_permissions.add(invalidate_perm) plate_order = printing_models.PlateOrder.objects.create( + merchant=self.merchant, customer=self.customer, design_code='DESIGN001', plate_type='圆网', @@ -1243,6 +1286,7 @@ class PlateOrderInvalidateAPITestCase(TestCase): def test_activate_plate_order_without_permission(self): """测试无权限恢复开版订单""" plate_order = printing_models.PlateOrder.objects.create( + merchant=self.merchant, customer=self.customer, design_code='DESIGN001', plate_type='圆网', @@ -1264,6 +1308,7 @@ class PlateOrderInvalidateAPITestCase(TestCase): self.user.user_permissions.add(activate_perm) plate_order = printing_models.PlateOrder.objects.create( + merchant=self.merchant, customer=self.customer, design_code='DESIGN001', plate_type='圆网', @@ -1286,6 +1331,7 @@ class PlateOrderInvalidateAPITestCase(TestCase): self.user.user_permissions.add(activate_perm) plate_order = printing_models.PlateOrder.objects.create( + merchant=self.merchant, customer=self.customer, design_code='DESIGN001', plate_type='圆网', @@ -1336,6 +1382,7 @@ class PlateOrderInvalidateAPITestCase(TestCase): def test_update_plate_order_designer(self): """测试更新开版订单的设计师""" plate_order = printing_models.PlateOrder.objects.create( + merchant=self.merchant, customer=self.customer, design_code='DESIGN_UPDATE_DESIGNER', plate_type='圆网', @@ -1358,6 +1405,7 @@ class PlateOrderInvalidateAPITestCase(TestCase): def test_list_plate_orders_includes_designer_name(self): """测试列表接口包含设计师字段""" printing_models.PlateOrder.objects.create( + merchant=self.merchant, customer=self.customer, design_code='DESIGN_LIST_1', plate_type='圆网', @@ -1365,6 +1413,7 @@ class PlateOrderInvalidateAPITestCase(TestCase): designer=self.designer, ) printing_models.PlateOrder.objects.create( + merchant=self.merchant, customer=self.customer, design_code='DESIGN_LIST_2', plate_type='平网', @@ -1382,6 +1431,7 @@ class PlateOrderInvalidateAPITestCase(TestCase): def test_retrieve_plate_order_includes_designer(self): """测试详情接口包含设计师信息""" plate_order = printing_models.PlateOrder.objects.create( + merchant=self.merchant, customer=self.customer, design_code='DESIGN_RETRIEVE', plate_type='圆网', diff --git a/api_v1/views/printing/test_printing_job_api.py b/api_v1/views/printing/test_printing_job_api.py index ba831a0..42fc752 100644 --- a/api_v1/views/printing/test_printing_job_api.py +++ b/api_v1/views/printing/test_printing_job_api.py @@ -33,6 +33,10 @@ class PrintingJobAPITestCase(TestCase): password='testpass123', 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( @@ -257,6 +261,36 @@ class PrintingJobAPITestCase(TestCase): self.assertEqual(job.quantity, 200) self.assertEqual(job.unit, '码') 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): """测试部分更新款式明细""" diff --git a/docs/2026-01-14_summary.md b/docs/2026-01-14_summary.md index f6c327f..ea5c26b 100644 --- a/docs/2026-01-14_summary.md +++ b/docs/2026-01-14_summary.md @@ -93,7 +93,7 @@ python manage.py backfill_merchant 在 `settings.py` 中添加 `AUTO_CREATE_SALESITEM_FROM_PRINT_ORDER` 配置项: -- **默认值**: `True`(保持现有行为) +- **默认值**: `False`(默认关闭,避免未确认流程就自动落库) - **环境变量**: `AUTO_CREATE_SALESITEM_FROM_PRINT_ORDER` - **作用**: 当设为 `False` 时,`printing/handlers.py` 中的流程完成信号处理器将不再自动创建销售品 @@ -103,6 +103,15 @@ python manage.py backfill_merchant --- +### 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 模块客户可见性过滤 实现了基于客户可见性的订单查询过滤功能,确保员工只能看到自己负责的客户的订单。 @@ -152,7 +161,15 @@ python manage.py backfill_merchant - `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 强制归属(非空) - 为 `Shipment` 与 `SalesItem` 增加 `merchant` 外键且不允许为空 - 创建时从 `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 文档 @@ -197,4 +222,4 @@ python manage.py backfill_merchant - API 独立于 printing 模块,避免影响现有功能 - 完整的测试覆盖:正常查询、包含已出货、数据格式、404 错误、空结果、未认证 -- printing 和 shipment 模块全部测试通过(共 13 个测试用例) \ No newline at end of file +- printing 和 shipment 模块相关测试通过(`api_v1.views.shipment.test_api` 共 17 个用例) diff --git a/docs/2026-01-15_summary.md b/docs/2026-01-15_summary.md new file mode 100644 index 0000000..cb7af0d --- /dev/null +++ b/docs/2026-01-15_summary.md @@ -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`(背景、风险、修正案与规则) + diff --git a/docs/business_object_relink_fix.md b/docs/business_object_relink_fix.md new file mode 100644 index 0000000..0377a96 --- /dev/null +++ b/docs/business_object_relink_fix.md @@ -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 \ No newline at end of file diff --git a/stateflow/services.py b/stateflow/services.py index 150239f..c929764 100644 --- a/stateflow/services.py +++ b/stateflow/services.py @@ -14,6 +14,65 @@ from . import models logger = logging.getLogger(__name__) 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( business_object: 'models.BusinessObject', instance,