forked from erp-dev/erp
fix: business.object_id maybe null, missing parameters when plate-Order cloned
This commit is contained in:
3
stateflow/management/__init__.py
Normal file
3
stateflow/management/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# Package marker for Django management commands.
|
||||
|
||||
|
||||
3
stateflow/management/commands/__init__.py
Normal file
3
stateflow/management/commands/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# Package marker for Django management commands.
|
||||
|
||||
|
||||
124
stateflow/management/commands/repair_business_object_bindings.py
Normal file
124
stateflow/management/commands/repair_business_object_bindings.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from printing import models as printing_models
|
||||
from stateflow import services as stateflow_services
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"修复 printing 相关模型(PrintingJob/PlateOrder)所关联的 BusinessObject 的 "
|
||||
"content_type/object_id/name 绑定(用于历史数据回填与一致性修复)。"
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='只输出统计,不写入数据库',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--limit',
|
||||
type=int,
|
||||
default=None,
|
||||
help='最多处理多少条记录(用于分批回填)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--only',
|
||||
choices=['printingjob', 'plateorder', 'all'],
|
||||
default='all',
|
||||
help='仅修复指定类型(默认 all)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run: bool = options['dry_run']
|
||||
limit: int | None = options['limit']
|
||||
only: str = options['only']
|
||||
|
||||
# 提前确保 ContentType 可用(也用于输出校验信息)
|
||||
ct_job = ContentType.objects.get_for_model(printing_models.PrintingJob)
|
||||
ct_po = ContentType.objects.get_for_model(printing_models.PlateOrder)
|
||||
|
||||
self.stdout.write(f"dry_run={dry_run} only={only} limit={limit}")
|
||||
self.stdout.write(f"ContentType: PrintingJob={ct_job.id}, PlateOrder={ct_po.id}")
|
||||
|
||||
stats = {
|
||||
'printingjob_total': 0,
|
||||
'printingjob_updated': 0,
|
||||
'plateorder_total': 0,
|
||||
'plateorder_updated': 0,
|
||||
}
|
||||
|
||||
def _iter_qs(qs):
|
||||
if limit:
|
||||
return qs.order_by('id')[:limit]
|
||||
return qs.order_by('id')
|
||||
|
||||
# 用事务包住,避免部分更新(dry-run 仍使用事务,最后标记回滚)
|
||||
with transaction.atomic():
|
||||
if only in ('printingjob', 'all'):
|
||||
qs = (
|
||||
printing_models.PrintingJob.objects
|
||||
.select_related('business_object')
|
||||
.filter(business_object__isnull=False)
|
||||
)
|
||||
for job in _iter_qs(qs):
|
||||
stats['printingjob_total'] += 1
|
||||
bo = job.business_object
|
||||
should_fix = (
|
||||
bo.content_type_id != ct_job.id
|
||||
or bo.object_id != job.id
|
||||
or not (bo.name or '').strip()
|
||||
)
|
||||
if not should_fix:
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
stats['printingjob_updated'] += 1
|
||||
continue
|
||||
|
||||
if stateflow_services.ensure_business_object_bound_to_instance(
|
||||
bo,
|
||||
job,
|
||||
default_name=f"PrintingJob-{job.id}",
|
||||
):
|
||||
stats['printingjob_updated'] += 1
|
||||
|
||||
if only in ('plateorder', 'all'):
|
||||
qs = (
|
||||
printing_models.PlateOrder.objects
|
||||
.select_related('business_object')
|
||||
.filter(business_object__isnull=False)
|
||||
)
|
||||
for po in _iter_qs(qs):
|
||||
stats['plateorder_total'] += 1
|
||||
bo = po.business_object
|
||||
should_fix = (
|
||||
bo.content_type_id != ct_po.id
|
||||
or bo.object_id != po.id
|
||||
or not (bo.name or '').strip()
|
||||
)
|
||||
if not should_fix:
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
stats['plateorder_updated'] += 1
|
||||
continue
|
||||
|
||||
if stateflow_services.ensure_business_object_bound_to_instance(
|
||||
bo,
|
||||
po,
|
||||
default_name=f"PlateOrder-{po.id}",
|
||||
):
|
||||
stats['plateorder_updated'] += 1
|
||||
|
||||
if dry_run:
|
||||
# dry-run:显式回滚(即便未来误加了写逻辑,也不会落库)
|
||||
transaction.set_rollback(True)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Done."))
|
||||
for k in sorted(stats.keys()):
|
||||
self.stdout.write(f"{k}={stats[k]}")
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Stateflow 序列化器
|
||||
from rest_framework import serializers
|
||||
from django.db import transaction
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from stateflow import models
|
||||
|
||||
|
||||
@@ -356,7 +357,14 @@ class BusinessObjectCreateUpdateSerializer(serializers.ModelSerializer):
|
||||
}
|
||||
|
||||
def validate(self, attrs):
|
||||
"""验证 content_type 和 object_id 的一致性"""
|
||||
"""
|
||||
业务约束:BusinessObject 必须绑定到一个“真实业务对象”。
|
||||
|
||||
背景:
|
||||
- DB 允许 content_type/object_id 为空仅为历史兼容;
|
||||
- 但业务逻辑要求 BusinessObject 可通过 (content_type, object_id) 追溯到被追踪对象;
|
||||
否则整个流程实例将失去意义,并可能导致下游逻辑崩溃。
|
||||
"""
|
||||
content_type = attrs.get('content_type')
|
||||
content_type_str = attrs.get('content_type_str')
|
||||
object_id = attrs.get('object_id')
|
||||
@@ -371,17 +379,26 @@ class BusinessObjectCreateUpdateSerializer(serializers.ModelSerializer):
|
||||
raise serializers.ValidationError({
|
||||
'content_type_str': f'无效的 content_type 格式: {content_type_str}'
|
||||
})
|
||||
|
||||
# 验证:如果提供了 content_type,必须提供 object_id
|
||||
if content_type and not object_id:
|
||||
|
||||
# 以“最终值”做校验:partial_update 未传字段时要以 instance 的现有值为准
|
||||
if self.instance is not None:
|
||||
if 'content_type' not in attrs:
|
||||
content_type = self.instance.content_type
|
||||
if 'object_id' not in attrs:
|
||||
object_id = self.instance.object_id
|
||||
|
||||
# 强制绑定:content_type/object_id 均不可为空
|
||||
if content_type is None or object_id is None:
|
||||
raise serializers.ValidationError({
|
||||
'object_id': '提供了关联对象类型,必须同时提供对象ID'
|
||||
'detail': 'BusinessObject 必须绑定关联对象(content_type 与 object_id 均为必填且不可为空)'
|
||||
})
|
||||
|
||||
# 验证:如果提供了 object_id,必须提供 content_type
|
||||
if object_id and not content_type:
|
||||
|
||||
# 强制要求绑定对象存在(避免产生“不可追溯”的悬空绑定)
|
||||
try:
|
||||
content_type.get_object_for_this_type(pk=object_id)
|
||||
except ObjectDoesNotExist:
|
||||
raise serializers.ValidationError({
|
||||
'content_type': '提供了对象ID,必须同时提供关联对象类型'
|
||||
'object_id': f'关联对象不存在:{content_type.app_label}.{content_type.model} #{object_id}'
|
||||
})
|
||||
|
||||
# 移除临时字段
|
||||
|
||||
@@ -4,11 +4,58 @@ Stateflow业务逻辑服务层
|
||||
import copy
|
||||
from typing import List, Optional, Tuple
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import transaction
|
||||
from . import models
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
def ensure_business_object_bound_to_instance(
|
||||
business_object: 'models.BusinessObject',
|
||||
instance,
|
||||
*,
|
||||
default_name: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
确保 BusinessObject 的 GenericForeignKey 绑定到指定实例。
|
||||
|
||||
说明:
|
||||
- 这是一个“自愈”辅助函数,用于修复历史数据/非标准创建路径导致的 content_type/object_id 为空或不一致。
|
||||
- 仅在调用方明确知道该 BusinessObject 应当绑定到 instance 时使用(例如 PrintingJob.business_object 一对一)。
|
||||
|
||||
返回:
|
||||
- True:本次发生了更新并保存
|
||||
- False:无需更新或参数不足(business_object/instance/instance.pk 为空)
|
||||
"""
|
||||
if not business_object or instance is None:
|
||||
return False
|
||||
|
||||
instance_id = getattr(instance, 'pk', None)
|
||||
if not instance_id:
|
||||
return False
|
||||
|
||||
ct = ContentType.objects.get_for_model(instance.__class__)
|
||||
update_fields: list[str] = []
|
||||
|
||||
if business_object.content_type_id != ct.id:
|
||||
business_object.content_type = ct
|
||||
update_fields.append('content_type')
|
||||
|
||||
if business_object.object_id != instance_id:
|
||||
business_object.object_id = instance_id
|
||||
update_fields.append('object_id')
|
||||
|
||||
if default_name is not None and not (business_object.name or '').strip():
|
||||
business_object.name = default_name
|
||||
update_fields.append('name')
|
||||
|
||||
if not update_fields:
|
||||
return False
|
||||
|
||||
business_object.save(update_fields=update_fields)
|
||||
return True
|
||||
|
||||
|
||||
def get_last_completed_state(business_object: 'models.BusinessObject') -> Optional['models.State']:
|
||||
"""
|
||||
@@ -127,6 +174,17 @@ def advance_to_next_state(business_object: 'models.BusinessObject', user, **para
|
||||
- message: 提示信息
|
||||
- state_log: 创建的状态日志(成功时)
|
||||
"""
|
||||
# 业务约束:BusinessObject 必须能追溯到真实业务对象,否则状态流转记录没有业务意义。
|
||||
# DB 允许为空仅为历史兼容;但新增数据一律禁止。
|
||||
if business_object.content_type_id is None or business_object.object_id is None:
|
||||
return False, "业务对象未绑定关联对象(content_type/object_id),禁止推进", None
|
||||
if business_object.content_object is None:
|
||||
return (
|
||||
False,
|
||||
f"业务对象绑定的关联对象不存在:{business_object.content_type.app_label}.{business_object.content_type.model} #{business_object.object_id}",
|
||||
None,
|
||||
)
|
||||
|
||||
can_advance, reason = can_advance_to_next_state(business_object)
|
||||
if not can_advance:
|
||||
return False, reason, None
|
||||
@@ -598,6 +656,12 @@ def clone_business_object(
|
||||
if source is None:
|
||||
raise ValueError("source 不能为空")
|
||||
|
||||
# 新增约束:禁止克隆“未绑定”的 BusinessObject(会产生新的不可追溯流程实例)
|
||||
if source.content_type_id is None or source.object_id is None:
|
||||
raise ValueError("源对象未绑定关联对象(content_type/object_id),禁止克隆")
|
||||
if expected_content_type_id is None:
|
||||
raise ValueError("必须提供 expected_content_type_id")
|
||||
|
||||
# 重新加载 source,确保拿到完整关系(避免调用方未预取导致 N+1)
|
||||
source = (
|
||||
models.BusinessObject.objects
|
||||
@@ -610,23 +674,23 @@ def clone_business_object(
|
||||
.get(id=source.id)
|
||||
)
|
||||
|
||||
# 校验 content_type 一致性(不做额外校验,仅比对 id)
|
||||
# 校验 content_type 一致性(仅比对 id,不做额外校验)
|
||||
if source.content_type_id != expected_content_type_id:
|
||||
raise ValueError("content_type 与源对象不一致,拒绝克隆")
|
||||
|
||||
# 如果源对象未绑定 content_type,则拒绝克隆
|
||||
if source.content_type_id is None:
|
||||
return ValueError("源对象未绑定 content_type,拒绝克隆")
|
||||
else:
|
||||
if new_object_id is None:
|
||||
raise ValueError("必须提供新的 object_id")
|
||||
if source.object_id == new_object_id:
|
||||
raise ValueError("object_id 必须与源对象不同")
|
||||
|
||||
content_type = models.ContentType.objects.get(id=source.content_type_id)
|
||||
actual_object = content_type.model_class().objects.get(id=new_object_id)
|
||||
if actual_object is None:
|
||||
raise ValueError("实际对象不存在,拒绝克隆")
|
||||
# 绑定规则(强制):
|
||||
# - 必须提供新的 object_id,且与源对象不同
|
||||
if new_object_id is None:
|
||||
raise ValueError("必须提供新的 object_id")
|
||||
if source.object_id == new_object_id:
|
||||
raise ValueError("object_id 必须与源对象不同")
|
||||
# 目标对象必须存在(避免产生悬空绑定)
|
||||
try:
|
||||
source.content_type.get_object_for_this_type(pk=new_object_id)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValueError(
|
||||
f"目标关联对象不存在:{source.content_type.app_label}.{source.content_type.model} #{new_object_id}"
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
cloned = models.BusinessObject.objects.create(
|
||||
@@ -685,8 +749,4 @@ def clone_business_object(
|
||||
|
||||
# 重新加载,确保返回对象的字段与 DB 一致(尤其是时间戳)
|
||||
cloned.refresh_from_db()
|
||||
actual_object.refresh_from_db()
|
||||
|
||||
actual_object.business_object_id = cloned.id
|
||||
actual_object.save()
|
||||
return cloned
|
||||
|
||||
Reference in New Issue
Block a user