1
0
forked from erp-dev/erp

fix: business.object_id maybe null, missing parameters when plate-Order cloned

This commit is contained in:
2025-12-17 11:02:07 +08:00
parent 639efe8421
commit 51fad953c3
24 changed files with 13247 additions and 47 deletions

5
.cursor/worktrees.json Normal file
View File

@@ -0,0 +1,5 @@
{
"setup-worktree": [
"npm install"
]
}

View File

@@ -1,7 +1,9 @@
""" """
Printing API 序列化器 Printing API 序列化器
""" """
import json
from rest_framework import serializers from rest_framework import serializers
from rest_framework.fields import empty
from api_v1.models import UploadedFile from api_v1.models import UploadedFile
from api_v1.utils.media import build_public_media_url from api_v1.utils.media import build_public_media_url
@@ -52,8 +54,6 @@ def _build_plate_image_payload(items, request_user):
return [] return []
queryset = UploadedFile.objects.filter(id__in=file_ids, is_deleted=False) queryset = UploadedFile.objects.filter(id__in=file_ids, is_deleted=False)
if request_user and request_user.is_authenticated:
queryset = queryset.filter(owner=request_user)
files_map = {file.id: file for file in queryset} files_map = {file.id: file for file in queryset}
missing = [str(fid) for fid in file_ids if fid not in files_map] missing = [str(fid) for fid in file_ids if fid not in files_map]
@@ -76,6 +76,48 @@ def _build_plate_image_payload(items, request_user):
return payload return payload
class PlateImageInputListSerializer(serializers.ListSerializer):
"""
兼容 plate_image 的多种入参形态(尤其是 multipart/form-data 场景):
- JSON直接传 array
- multipart常见会把数组作为 JSON 字符串传入(例如 '[{"file_id": 1}]'
"""
def to_internal_value(self, data):
# allow_null=True 时None 会先到这里
if data is None:
return []
# multipart/form-data 下,前端常把数组序列化成字符串
if isinstance(data, str):
raw = data.strip()
if not raw:
return []
try:
data = json.loads(raw)
except json.JSONDecodeError:
raise serializers.ValidationError('plate_image 必须是 JSON 数组或可解析为数组的 JSON 字符串')
# 兼容单个对象
if isinstance(data, dict):
data = [data]
return super().to_internal_value(data)
def get_value(self, dictionary):
"""
DRF 在 multipart/form-data 下会优先用“HTML list”解析期望 plate_image[0][file_id] 这类键)。
但前端常见做法是直接传一个字段 plate_image='[{"file_id":1}]'JSON 字符串)。
这里做一次兜底:若 HTML list 未解析到值,则回退读取原始键值。
"""
value = super().get_value(dictionary)
if value is empty and hasattr(dictionary, 'get'):
raw = dictionary.get(self.field_name, empty)
if raw is not empty:
return raw
return value
class PlateImageInputSerializer(serializers.Serializer): class PlateImageInputSerializer(serializers.Serializer):
file_id = serializers.IntegerField(min_value=1, help_text='上传文件的 ID') file_id = serializers.IntegerField(min_value=1, help_text='上传文件的 ID')
name = serializers.CharField( name = serializers.CharField(
@@ -85,6 +127,9 @@ class PlateImageInputSerializer(serializers.Serializer):
help_text='可选的图片名称,默认使用文件原始名称' help_text='可选的图片名称,默认使用文件原始名称'
) )
class Meta:
list_serializer_class = PlateImageInputListSerializer
class PrintingOrderListSerializer(serializers.ModelSerializer): class PrintingOrderListSerializer(serializers.ModelSerializer):
"""印染订单列表序列化器""" """印染订单列表序列化器"""
@@ -374,6 +419,7 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria
salesperson_name = serializers.CharField(source="salesperson.name", read_only=True) salesperson_name = serializers.CharField(source="salesperson.name", read_only=True)
merchandiser_name = serializers.CharField(source="merchandiser.name", read_only=True) merchandiser_name = serializers.CharField(source="merchandiser.name", read_only=True)
designer_name = serializers.CharField(source="designer.name", read_only=True) designer_name = serializers.CharField(source="designer.name", read_only=True)
created_by = serializers.IntegerField(source='created_by_id', read_only=True)
status = serializers.CharField(read_only=True) status = serializers.CharField(read_only=True)
progress_percentage = serializers.IntegerField(read_only=True) progress_percentage = serializers.IntegerField(read_only=True)
process_name = serializers.SerializerMethodField() process_name = serializers.SerializerMethodField()
@@ -401,6 +447,7 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria
'process', 'process_name', 'process', 'process_name',
'status', 'status_id', 'is_completed', 'has_started', 'last_completed_state', 'status', 'status_id', 'is_completed', 'has_started', 'last_completed_state',
'progress_percentage', 'business_object_id', 'content_type_id', 'progress_percentage', 'business_object_id', 'content_type_id',
'created_by',
'created_at', 'updated_at' 'created_at', 'updated_at'
] ]
read_only_fields = [ read_only_fields = [
@@ -443,6 +490,7 @@ class PlateOrderDetailSerializer(PlateOrderDesignCodeMixin, serializers.ModelSer
salesperson_name = serializers.CharField(source="salesperson.name", read_only=True) salesperson_name = serializers.CharField(source="salesperson.name", read_only=True)
merchandiser_name = serializers.CharField(source="merchandiser.name", read_only=True) merchandiser_name = serializers.CharField(source="merchandiser.name", read_only=True)
designer_name = serializers.CharField(source="designer.name", read_only=True) designer_name = serializers.CharField(source="designer.name", read_only=True)
created_by = serializers.IntegerField(source='created_by_id', read_only=True)
status = serializers.CharField(read_only=True) status = serializers.CharField(read_only=True)
status_id = serializers.IntegerField(read_only=True) status_id = serializers.IntegerField(read_only=True)
is_completed = serializers.BooleanField(read_only=True) is_completed = serializers.BooleanField(read_only=True)
@@ -472,6 +520,7 @@ class PlateOrderDetailSerializer(PlateOrderDesignCodeMixin, serializers.ModelSer
'process', 'process_name', 'process', 'process_name',
'status', 'status_id', 'is_completed', 'has_started', 'status', 'status_id', 'is_completed', 'has_started',
'progress_percentage', 'business_object_id', 'last_completed_state', 'progress_percentage', 'business_object_id', 'last_completed_state',
'created_by',
'created_at', 'updated_at' 'created_at', 'updated_at'
] ]
read_only_fields = [ read_only_fields = [
@@ -508,6 +557,7 @@ class PlateOrderCreateUpdateSerializer(serializers.ModelSerializer):
allow_null=True, allow_null=True,
help_text="开版图片列表,需提供已上传的 file_id可选 name 字段" help_text="开版图片列表,需提供已上传的 file_id可选 name 字段"
) )
created_by = serializers.IntegerField(source='created_by_id', read_only=True)
class Meta: class Meta:
model = models.PlateOrder model = models.PlateOrder
@@ -523,9 +573,10 @@ class PlateOrderCreateUpdateSerializer(serializers.ModelSerializer):
"sample_meter", "required_sample_meters", "sample_meter", "required_sample_meters",
"required_completion_date", "completion_date", "required_completion_date", "completion_date",
"approval_result", "is_ordered", "customer_feedback", "approval_result", "is_ordered", "customer_feedback",
"process" "process",
"created_by",
] ]
read_only_fields = ["id"] read_only_fields = ["id", "created_by"]
def validate_plate_image(self, value): def validate_plate_image(self, value):
return value or [] return value or []

View File

@@ -1,6 +1,7 @@
""" """
PlateOrder API 测试 PlateOrder API 测试
""" """
import json
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import TestCase from django.test import TestCase
@@ -136,9 +137,13 @@ class PlateOrderAPITestCase(TestCase):
self.assertEqual(response.data['image_name'], 'sample.png') self.assertEqual(response.data['image_name'], 'sample.png')
self.assertEqual(len(response.data['plate_image']), 1) self.assertEqual(len(response.data['plate_image']), 1)
self.assertEqual(response.data['plate_image'][0]['file_id'], self.upload_file_primary.id) self.assertEqual(response.data['plate_image'][0]['file_id'], self.upload_file_primary.id)
self.assertIn('created_by', response.data)
self.assertEqual(response.data['created_by'], self.user.id)
# 验证数据库中创建了记录 # 验证数据库中创建了记录
self.assertTrue(printing_models.PlateOrder.objects.filter(design_code='DESIGN001').exists()) self.assertTrue(printing_models.PlateOrder.objects.filter(design_code='DESIGN001').exists())
created = printing_models.PlateOrder.objects.get(design_code='DESIGN001')
self.assertEqual(created.created_by_id, self.user.id)
def test_create_plate_order_without_customer(self): def test_create_plate_order_without_customer(self):
"""测试创建开版订单时未提供客户""" """测试创建开版订单时未提供客户"""
@@ -165,6 +170,8 @@ class PlateOrderAPITestCase(TestCase):
response = self.client.post('/api/v1/plate-orders/', data, format='json') response = self.client.post('/api/v1/plate-orders/', data, format='json')
# CharField 不会验证内容,所以应该成功 # CharField 不会验证内容,所以应该成功
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertIn('created_by', response.data)
self.assertEqual(response.data['created_by'], self.user.id)
def test_create_plate_order_with_invalid_plate_image_file(self): def test_create_plate_order_with_invalid_plate_image_file(self):
data = { data = {
@@ -176,6 +183,45 @@ class PlateOrderAPITestCase(TestCase):
response = self.client.post('/api/v1/plate-orders/', data, format='json') response = self.client.post('/api/v1/plate-orders/', data, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('plate_image', response.data) self.assertIn('plate_image', response.data)
def test_create_plate_order_with_plate_image_json_string_in_multipart(self):
"""兼容前端使用 multipart/form-data 时 plate_image 以 JSON 字符串传入"""
data = {
'customer': str(self.customer.id),
'design_code': 'DESIGN_MULTIPART_IMG',
'plate_type': '圆网',
'plate_image': json.dumps([{'file_id': self.upload_file_primary.id, 'name': '主图'}]),
}
response = self.client.post('/api/v1/plate-orders/', data, format='multipart')
self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data)
self.assertIn('plate_image', response.data)
self.assertEqual(len(response.data['plate_image']), 1)
self.assertEqual(response.data['plate_image'][0]['file_id'], self.upload_file_primary.id)
def test_create_plate_order_can_reference_uploaded_file_owned_by_other_user(self):
"""克隆/协作场景:允许引用非本人上传的 UploadedFile只要文件存在且未删除"""
other_user = User.objects.create_user(
username='otheruser',
password='pass123',
email='other@example.com',
)
other_file = api_models.UploadedFile.objects.create(
owner=other_user,
path=SimpleUploadedFile('other.jpg', b'test-image', content_type='image/jpeg'),
original_filename='other.jpg',
file_size=9,
content_type='image/jpeg',
)
data = {
'customer': self.customer.id,
'design_code': 'DESIGN_OTHER_OWNER_IMG',
'plate_type': '圆网',
'plate_image': [{'file_id': other_file.id, 'name': '协作图片'}],
}
response = self.client.post('/api/v1/plate-orders/', data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data)
self.assertEqual(response.data['plate_image'][0]['file_id'], other_file.id)
def test_list_plate_orders(self): def test_list_plate_orders(self):
"""测试获取开版订单列表""" """测试获取开版订单列表"""
@@ -231,6 +277,7 @@ class PlateOrderAPITestCase(TestCase):
if collection: if collection:
self.assertIn('plate_image', collection[0]) self.assertIn('plate_image', collection[0])
self.assertIsInstance(collection[0]['plate_image'], list) self.assertIsInstance(collection[0]['plate_image'], list)
self.assertIn('created_by', collection[0])
def test_retrieve_plate_order(self): def test_retrieve_plate_order(self):
"""测试获取单个开版订单详情""" """测试获取单个开版订单详情"""
@@ -258,6 +305,8 @@ class PlateOrderAPITestCase(TestCase):
response = self.client.get(f'/api/v1/plate-orders/{plate_order.id}/') response = self.client.get(f'/api/v1/plate-orders/{plate_order.id}/')
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['design_code'], 'DESIGN001') self.assertEqual(response.data['design_code'], 'DESIGN001')
self.assertIn('created_by', response.data)
self.assertIsNone(response.data['created_by'])
self.assertEqual(response.data['urgency_level'], '紧急') self.assertEqual(response.data['urgency_level'], '紧急')
self.assertEqual(response.data['customer'], self.customer.id) self.assertEqual(response.data['customer'], self.customer.id)
self.assertIn('customer_name', response.data) self.assertIn('customer_name', response.data)

View File

@@ -357,6 +357,12 @@ class PrintingJobViewSet(viewsets.ModelViewSet):
# 调用 stateflow 统一服务进行状态流转 # 调用 stateflow 统一服务进行状态流转
from stateflow import services as stateflow_services from stateflow import services as stateflow_services
# 修复历史/异常数据:确保该 job 的流程实例正确绑定到 job避免 BusinessObject.object_id/content_type 为空)
stateflow_services.ensure_business_object_bound_to_instance(
job.business_object,
job,
default_name=f"PrintingJob-{job.id}",
)
success, message, state_log = stateflow_services.advance_to_next_state( success, message, state_log = stateflow_services.advance_to_next_state(
job.business_object, request.user, **parameters job.business_object, request.user, **parameters
) )
@@ -641,6 +647,12 @@ class PlateOrderViewSet(viewsets.ModelViewSet):
{'detail': '开版订单不支持删除操作,请使用作废功能'}, {'detail': '开版订单不支持删除操作,请使用作废功能'},
status=status.HTTP_405_METHOD_NOT_ALLOWED status=status.HTTP_405_METHOD_NOT_ALLOWED
) )
def perform_create(self, serializer):
"""
创建时自动绑定创建人created_by不允许前端传参控制。
"""
serializer.save(created_by=self.request.user)
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def invalidate(self, request, pk=None): def invalidate(self, request, pk=None):
@@ -739,6 +751,12 @@ class PlateOrderViewSet(viewsets.ModelViewSet):
# 调用 stateflow 统一服务进行状态流转 # 调用 stateflow 统一服务进行状态流转
from stateflow import services as stateflow_services from stateflow import services as stateflow_services
# 修复历史/异常数据:确保该 plate_order 的流程实例正确绑定到 plate_order避免 BusinessObject.object_id/content_type 为空)
stateflow_services.ensure_business_object_bound_to_instance(
plate_order.business_object,
plate_order,
default_name=f"PlateOrder-{plate_order.id}",
)
success, message, state_log = stateflow_services.advance_to_next_state( success, message, state_log = stateflow_services.advance_to_next_state(
plate_order.business_object, request.user, **parameters plate_order.business_object, request.user, **parameters
) )

View File

@@ -352,6 +352,15 @@ class PrintingJobBatchAdvanceV2APITest(TestCase):
# stateflow两条 business_object 都应生成 1 条完成日志,且完成的是 state1 # stateflow两条 business_object 都应生成 1 条完成日志,且完成的是 state1
self.job1.refresh_from_db() self.job1.refresh_from_db()
self.job2.refresh_from_db() self.job2.refresh_from_db()
# 同时应修复绑定BusinessObject.content_type/object_id 指向 PrintingJob
from django.contrib.contenttypes.models import ContentType
ct_job = ContentType.objects.get_for_model(printing_models.PrintingJob)
self.job1.business_object.refresh_from_db()
self.job2.business_object.refresh_from_db()
self.assertEqual(self.job1.business_object.content_type_id, ct_job.id)
self.assertEqual(self.job1.business_object.object_id, self.job1.id)
self.assertEqual(self.job2.business_object.content_type_id, ct_job.id)
self.assertEqual(self.job2.business_object.object_id, self.job2.id)
self.assertEqual(self.job1.business_object.state_logs.filter(is_cancelled=False).count(), 1) self.assertEqual(self.job1.business_object.state_logs.filter(is_cancelled=False).count(), 1)
self.assertEqual(self.job2.business_object.state_logs.filter(is_cancelled=False).count(), 1) self.assertEqual(self.job2.business_object.state_logs.filter(is_cancelled=False).count(), 1)
self.assertEqual(self.job1.business_object.state_logs.first().state_id, self.state1.id) self.assertEqual(self.job1.business_object.state_logs.first().state_id, self.state1.id)
@@ -395,12 +404,15 @@ class BusinessObjectCloneV2APITest(TestCase):
stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state1, order=0) stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state1, order=0)
stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state2, order=1) stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state2, order=1)
self.process_src = stateflow_models.Process.objects.create(name='P-src')
self.process_dst = stateflow_models.Process.objects.create(name='P-dst')
ct = ContentType.objects.get_for_model(stateflow_models.Process) ct = ContentType.objects.get_for_model(stateflow_models.Process)
self.bo = stateflow_models.BusinessObject.objects.create( self.bo = stateflow_models.BusinessObject.objects.create(
name='BO', name='BO',
process=self.process, process=self.process,
content_type=ct, content_type=ct,
object_id=111, object_id=self.process_src.id,
) )
# 创建一条日志 + 参数记录,确保“克隆包含工艺参数” # 创建一条日志 + 参数记录,确保“克隆包含工艺参数”
@@ -416,7 +428,7 @@ class BusinessObjectCloneV2APITest(TestCase):
) )
def test_clone_business_object_api_returns_new_id(self): def test_clone_business_object_api_returns_new_id(self):
new_object_id = 222 new_object_id = self.process_dst.id
resp = self.client.post( resp = self.client.post(
'/api/v2/stateflow/business-objects/clone/', '/api/v2/stateflow/business-objects/clone/',
{ {
@@ -438,6 +450,78 @@ class BusinessObjectCloneV2APITest(TestCase):
self.assertEqual(cloned.content_type_id, self.bo.content_type_id) self.assertEqual(cloned.content_type_id, self.bo.content_type_id)
self.assertEqual(cloned.object_id, new_object_id) self.assertEqual(cloned.object_id, new_object_id)
def test_clone_business_object_api_binds_to_plate_order_business_object(self):
"""
printing 工作流依赖:新建 PlateOrder 后通过 clone API 复制流程进度,
后端应自动把克隆后的 BusinessObject 绑定到目标 PlateOrder.business_object。
"""
merchant = basic_models.Merchant.objects.create(
name='M1',
type=basic_models.MerchantTypeEnum.FACTORY,
)
customer = basic_models.Customer.objects.create(
merchant=merchant,
name='C1',
created_by=None,
)
# source PlateOrder会自动创建 business_object
source_po = printing_models.PlateOrder.objects.create(
customer=customer,
process=self.process.id,
design_code='SRC',
style_name='S',
)
self.assertIsNotNone(source_po.business_object_id)
# 给 source 写一条日志 + 参数,确保克隆后能看到“节点与参数”
log = stateflow_models.StateFlowRecord.objects.create(
business_object=source_po.business_object,
state=self.state1,
completed_by=self.user,
)
stateflow_models.StateLogParameterRecord.objects.create(
state_log=log,
parameters={'temperature': '25.5'},
remark='r1',
)
# target PlateOrder创建时也会自动创建一个“空 business_object”
target_po = printing_models.PlateOrder.objects.create(
customer=customer,
process=self.process.id,
design_code='DST',
style_name='D',
)
self.assertIsNotNone(target_po.business_object_id)
old_target_bo_id = target_po.business_object_id
ct_po = ContentType.objects.get_for_model(printing_models.PlateOrder)
resp = self.client.post(
'/api/v2/stateflow/business-objects/clone/',
{
'business_object_id': source_po.business_object_id,
'content_type': ct_po.id,
'object_id': target_po.id,
},
format='json'
)
self.assertEqual(resp.status_code, 200, resp.data)
new_bo_id = resp.data['business_object_id']
target_po.refresh_from_db()
self.assertEqual(target_po.business_object_id, new_bo_id)
# 目标 PlateOrder 的旧“空 BO”应被安全清理避免残留多份绑定
self.assertFalse(stateflow_models.BusinessObject.objects.filter(id=old_target_bo_id).exists())
# 克隆后的日志与参数应存在
self.assertEqual(target_po.business_object.state_logs.count(), 1)
cloned_log = target_po.business_object.state_logs.first()
self.assertEqual(cloned_log.state_id, self.state1.id)
self.assertEqual(cloned_log.parameter_records.count(), 1)
self.assertEqual(cloned_log.parameter_records.first().parameters.get('temperature'), '25.5')
def test_clone_business_object_api_404(self): def test_clone_business_object_api_404(self):
resp = self.client.post( resp = self.client.post(
'/api/v2/stateflow/business-objects/clone/', '/api/v2/stateflow/business-objects/clone/',
@@ -601,6 +685,8 @@ class PlateOrderByProcessNodeV2APITest(TestCase):
# results 必须包含“订单维度”的参数值(同一节点下不同订单可不同) # results 必须包含“订单维度”的参数值(同一节点下不同订单可不同)
by_id = {item['id']: item for item in resp.data['results']} by_id = {item['id']: item for item in resp.data['results']}
self.assertIn('created_by', by_id[self.po_state2_old.id])
self.assertIn('created_by', by_id[self.po_state2_new.id])
self.assertIn('process_parameters', by_id[self.po_state2_old.id]) self.assertIn('process_parameters', by_id[self.po_state2_old.id])
self.assertIn('process_parameters', by_id[self.po_state2_new.id]) self.assertIn('process_parameters', by_id[self.po_state2_new.id])
@@ -671,3 +757,203 @@ class PlateOrderByProcessNodeV2APITest(TestCase):
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.data['count'], 2) self.assertEqual(resp.data['count'], 2)
self.assertEqual(len(resp.data['results']), 1) self.assertEqual(len(resp.data['results']), 1)
class PlateOrderByProcessV2APITest(TestCase):
def setUp(self):
self.client = APIClient()
# 工厂用户(满足 IsPrintingFactory
self.merchant = basic_models.Merchant.objects.create(
name='开版工厂2',
type=basic_models.MerchantTypeEnum.FACTORY,
)
self.user = get_user_model().objects.create_user(username='plate_process_user', password='pass12345')
basic_models.Employee.objects.create(
merchant=self.merchant,
sys_user=self.user,
name='开版员工2',
)
self.client.force_authenticate(user=self.user)
self.customer = basic_models.Customer.objects.create(
merchant=self.merchant,
name='客户PP',
created_by=None,
)
# stateflow流程 + 3个节点其中 2 个节点有参数1 个无参数)
self.state1 = stateflow_models.State.objects.create(name='画图')
self.state2 = stateflow_models.State.objects.create(name='调色')
self.state3 = stateflow_models.State.objects.create(name='套样')
self.param_len = stateflow_models.StateParameter.objects.create(key='length', value=None)
self.param_temp = stateflow_models.StateParameter.objects.create(key='temperature', value=None)
self.state1.parameters.add(self.param_len)
self.state2.parameters.add(self.param_temp)
self.process = stateflow_models.Process.objects.create(name='开版流程2')
self.node1 = stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state1, order=0)
self.node2 = stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state2, order=1)
self.node3 = stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state3, order=2)
# PlateOrder同一个 process不同 created_at
self.po_no_exec = printing_models.PlateOrder.objects.create(
customer=self.customer,
process=self.process.id,
design_code='CODE-X', # 不包含数字,便于测试 plate_order=pk
style_name='款式1',
)
self.po_exec = printing_models.PlateOrder.objects.create(
customer=self.customer,
process=self.process.id,
design_code='PO-EXEC',
style_name='款式2',
)
# 执行 state1非撤销并提交参数
log1 = stateflow_models.StateFlowRecord.objects.create(
business_object=self.po_exec.business_object,
state=self.state1,
completed_by=self.user,
is_cancelled=False,
)
stateflow_models.StateLogParameterRecord.objects.create(
state_log=log1,
parameters={'length': '5米'},
)
# 执行 state2撤销并提交参数默认不带撤销参数 -> 应视为未执行)
cancelled_log2 = stateflow_models.StateFlowRecord.objects.create(
business_object=self.po_exec.business_object,
state=self.state2,
completed_by=self.user,
is_cancelled=True,
)
stateflow_models.StateLogParameterRecord.objects.create(
state_log=cancelled_log2,
parameters={'temperature': '30'},
)
self.po_out_of_range = printing_models.PlateOrder.objects.create(
customer=self.customer,
process=self.process.id,
design_code='OUT',
style_name='款式3',
)
# 固定 created_at
tz = timezone.get_default_timezone()
t1 = timezone.make_aware(datetime.datetime(2025, 12, 1, 10, 0, 0), tz)
t2 = timezone.make_aware(datetime.datetime(2025, 12, 2, 10, 0, 0), tz)
t0 = timezone.make_aware(datetime.datetime(2025, 11, 20, 10, 0, 0), tz)
printing_models.PlateOrder.objects.filter(id=self.po_no_exec.id).update(created_at=t1)
printing_models.PlateOrder.objects.filter(id=self.po_exec.id).update(created_at=t2)
printing_models.PlateOrder.objects.filter(id=self.po_out_of_range.id).update(created_at=t0)
self.url = '/api/v2/plate-orders/by-process/'
def test_list_by_process_and_date_range(self):
resp = self.client.get(
self.url,
{
'process_id': self.process.id,
'date_from': '2025-12-01',
'date_to': '2025-12-02',
}
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.data['process']['id'], self.process.id)
ids = [item['id'] for item in resp.data['results']]
self.assertEqual(set(ids), {self.po_no_exec.id, self.po_exec.id})
# 序列化器字段created_by 必须存在(允许为 null
for item in resp.data['results']:
self.assertIn('created_by', item)
def test_default_ordering_is_minus_created_at(self):
resp = self.client.get(
self.url,
{
'process_id': self.process.id,
'date_from': '2025-12-01',
'date_to': '2025-12-02',
}
)
self.assertEqual(resp.status_code, 200)
ids = [item['id'] for item in resp.data['results']]
self.assertEqual(ids[0], self.po_exec.id)
self.assertEqual(ids[1], self.po_no_exec.id)
def test_plate_order_param_supports_design_code_icontains(self):
resp = self.client.get(
self.url,
{
'process_id': self.process.id,
'date_from': '2025-12-01',
'date_to': '2025-12-02',
'plate_order': 'exec',
}
)
self.assertEqual(resp.status_code, 200)
ids = [item['id'] for item in resp.data['results']]
self.assertEqual(ids, [self.po_exec.id])
def test_plate_order_param_supports_pk_without_extra_param(self):
resp = self.client.get(
self.url,
{
'process_id': self.process.id,
'date_from': '2025-12-01',
'date_to': '2025-12-02',
'plate_order': str(self.po_no_exec.id),
}
)
self.assertEqual(resp.status_code, 200)
ids = [item['id'] for item in resp.data['results']]
self.assertEqual(ids, [self.po_no_exec.id])
def test_process_params_includes_all_nodes_and_is_executed(self):
resp = self.client.get(
self.url,
{
'process_id': self.process.id,
'date_from': '2025-12-01',
'date_to': '2025-12-02',
}
)
self.assertEqual(resp.status_code, 200)
by_id = {item['id']: item for item in resp.data['results']}
po_exec = by_id[self.po_exec.id]
po_no_exec = by_id[self.po_no_exec.id]
# 每条 PlateOrder 都必须有 process_params且包含所有节点按 order
self.assertIn('process_params', po_exec)
self.assertEqual([n['order'] for n in po_exec['process_params']], [0, 1, 2])
self.assertEqual([n['node_name'] for n in po_exec['process_params']], ['画图', '调色', '套样'])
# po_execstate1 已执行state2 只有撤销记录 -> 默认视为未执行state3 未执行
n1, n2, n3 = po_exec['process_params']
self.assertTrue(n1['is_executed'])
self.assertFalse(n2['is_executed'])
self.assertFalse(n3['is_executed'])
self.assertEqual([p['key'] for p in n1['params']], ['length'])
self.assertEqual(n1['params'][0]['value'], '5米')
self.assertEqual([p['key'] for p in n2['params']], ['temperature'])
self.assertIsNone(n2['params'][0]['value'])
self.assertEqual(n3['params'], [])
# po_no_exec全部未执行参数 value 全为 null
self.assertEqual([n['order'] for n in po_no_exec['process_params']], [0, 1, 2])
self.assertFalse(po_no_exec['process_params'][0]['is_executed'])
self.assertIsNone(po_no_exec['process_params'][0]['params'][0]['value'])
def test_invalid_ordering_returns_400(self):
resp = self.client.get(
self.url,
{
'process_id': self.process.id,
'date_from': '2025-12-01',
'date_to': '2025-12-02',
'ordering': 'unknown',
}
)
self.assertEqual(resp.status_code, 400)

View File

@@ -6,6 +6,7 @@ from api_v2.views import (
PrintingJobBatchAdvancePreviewView, PrintingJobBatchAdvancePreviewView,
PrintingJobBatchAdvanceSubmitView, PrintingJobBatchAdvanceSubmitView,
PlateOrderByProcessNodeView, PlateOrderByProcessNodeView,
PlateOrderByProcessView,
BusinessObjectCloneView, BusinessObjectCloneView,
) )
@@ -15,6 +16,7 @@ urlpatterns = [
path('printing-jobs/batch-advance/preview/', PrintingJobBatchAdvancePreviewView.as_view(), name='api_v2_printing_job_batch_advance_preview'), path('printing-jobs/batch-advance/preview/', PrintingJobBatchAdvancePreviewView.as_view(), name='api_v2_printing_job_batch_advance_preview'),
path('printing-jobs/batch-advance/', PrintingJobBatchAdvanceSubmitView.as_view(), name='api_v2_printing_job_batch_advance_submit'), 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-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('stateflow/business-objects/clone/', BusinessObjectCloneView.as_view(), name='api_v2_stateflow_business_object_clone'), path('stateflow/business-objects/clone/', BusinessObjectCloneView.as_view(), name='api_v2_stateflow_business_object_clone'),
] ]

View File

@@ -9,6 +9,7 @@ from .printing import (
PrintingJobBatchAdvancePreviewView, PrintingJobBatchAdvancePreviewView,
PrintingJobBatchAdvanceSubmitView, PrintingJobBatchAdvanceSubmitView,
PlateOrderByProcessNodeView, PlateOrderByProcessNodeView,
PlateOrderByProcessView,
) )
from .stateflow import BusinessObjectCloneView from .stateflow import BusinessObjectCloneView
@@ -19,6 +20,7 @@ __all__ = [
'PrintingJobBatchAdvancePreviewView', 'PrintingJobBatchAdvancePreviewView',
'PrintingJobBatchAdvanceSubmitView', 'PrintingJobBatchAdvanceSubmitView',
'PlateOrderByProcessNodeView', 'PlateOrderByProcessNodeView',
'PlateOrderByProcessView',
'BusinessObjectCloneView', 'BusinessObjectCloneView',
] ]

View File

@@ -338,6 +338,12 @@ class PrintingJobBatchAdvanceSubmitView(APIView):
# 逐个复用单条推进逻辑 # 逐个复用单条推进逻辑
last_message = None last_message = None
for job in data['jobs']: for job in data['jobs']:
# 修复历史/异常数据:确保该 job 的流程实例正确绑定到 job避免 BusinessObject.object_id/content_type 为空)
stateflow_services.ensure_business_object_bound_to_instance(
job.business_object,
job,
default_name=f"PrintingJob-{job.id}",
)
ok, msg, _state_log = stateflow_services.advance_to_next_state( ok, msg, _state_log = stateflow_services.advance_to_next_state(
job.business_object, job.business_object,
request.user, request.user,
@@ -372,6 +378,7 @@ class PlateOrderByProcessNodeSerializer(serializers.ModelSerializer):
design_code = serializers.SerializerMethodField() design_code = serializers.SerializerMethodField()
customer_name = serializers.CharField(source='customer.name', read_only=True) customer_name = serializers.CharField(source='customer.name', read_only=True)
business_object_id = serializers.SerializerMethodField() business_object_id = serializers.SerializerMethodField()
created_by = serializers.IntegerField(source='created_by_id', read_only=True)
process_parameters = serializers.SerializerMethodField() process_parameters = serializers.SerializerMethodField()
class Meta: class Meta:
@@ -385,6 +392,7 @@ class PlateOrderByProcessNodeSerializer(serializers.ModelSerializer):
'urgency_level', 'urgency_level',
'is_invalid', 'is_invalid',
'business_object_id', 'business_object_id',
'created_by',
'process_parameters', 'process_parameters',
'created_at', 'created_at',
'updated_at', 'updated_at',
@@ -604,3 +612,277 @@ class PlateOrderByProcessNodeView(APIView):
'previous': paginator.get_previous_link() if page is not None else None, 'previous': paginator.get_previous_link() if page is not None else None,
'results': srz.data, 'results': srz.data,
}) })
class PlateOrderByProcessSerializer(serializers.ModelSerializer):
"""按流程process_id查询 PlateOrder附带所有节点的参数汇总订单维度"""
design_code = serializers.SerializerMethodField()
customer_name = serializers.CharField(source='customer.name', read_only=True)
business_object_id = serializers.SerializerMethodField()
created_by = serializers.IntegerField(source='created_by_id', read_only=True)
process_params = serializers.SerializerMethodField()
class Meta:
model = printing_models.PlateOrder
fields = [
'id',
'design_code',
'customer',
'customer_name',
'style_name',
'urgency_level',
'is_invalid',
'business_object_id',
'created_by',
'process_params',
'created_at',
'updated_at',
]
read_only_fields = fields
def get_design_code(self, obj: printing_models.PlateOrder) -> str | None:
return obj.design_code or (str(obj.id) if obj.id else None)
def get_business_object_id(self, obj: printing_models.PlateOrder) -> int | None:
return obj.business_object_id
def get_process_params(self, obj: printing_models.PlateOrder) -> list[dict]:
"""
返回所有流程节点的参数视图(订单维度)。
约束(前端可依赖):
- 一定包含 process 的全部节点(按 order 升序)
- 每个节点包含 is_executed默认不含撤销记录
- params 的 key/顺序与该节点 State.parameters 的 key/顺序一致
- 未执行或未提交的参数 value 为 null
"""
nodes_info: list[dict] = self.context.get('process_nodes_info') or []
if not nodes_info:
return []
bo = getattr(obj, 'business_object', None)
if not bo:
# 没有关联流程实例:全部视为未执行
result = []
for node in nodes_info:
result.append({
'process_node_id': node['process_node_id'],
'state_id': node['state_id'],
'node_name': node['node_name'],
'order': node['order'],
'is_executed': False,
'params': [{'key': k, 'value': None} for k in (node.get('keys') or [])],
})
return result
logs = getattr(bo, '_prefetched_state_logs_for_process_params', None)
if logs is None:
state_ids = [n['state_id'] for n in nodes_info]
logs = list(
bo.state_logs.filter(is_cancelled=False, state_id__in=state_ids)
.order_by('-completed_at', '-id')
.prefetch_related('parameter_records')
)
# logs 已按时间倒序:第一次出现的 state_id 即“最新一次非撤销执行记录”
latest_log_by_state: dict[int, stateflow_models.StateFlowRecord] = {}
for log in logs:
if log.state_id not in latest_log_by_state:
latest_log_by_state[log.state_id] = log
result = []
for node in nodes_info:
state_id = node['state_id']
keys = node.get('keys') or []
log = latest_log_by_state.get(state_id)
is_executed = log is not None
summary: dict = {}
if log is not None:
param_records = getattr(log, '_prefetched_parameter_records', None)
if param_records is None:
param_records = list(log.parameter_records.all().order_by('created_at', 'id'))
for rec in param_records:
summary.update(rec.parameters or {})
result.append({
'process_node_id': node['process_node_id'],
'state_id': state_id,
'node_name': node['node_name'],
'order': node['order'],
'is_executed': is_executed,
'params': [{'key': k, 'value': summary.get(k) if is_executed else None} for k in keys],
})
return result
class PlateOrderByProcessView(APIView):
"""
按 process_id 查询 PlateOrder 列表,并在每条 PlateOrder 中返回 process 的所有节点参数结构。
GET /api/v2/plate-orders/by-process/
Query 参数:
- process_id: 必填Process.id
- date_from/date_to: 必填YYYY-MM-DD按 PlateOrder.created_at 闭区间过滤)
- plate_order: 可选。支持:
- 纯数字:同时匹配 id 精确 + design_code icontains
- 非纯数字design_code icontains
- ordering: 可选,默认 -created_at支持: id / created_at / updated_at / design_code
- limit/offset: 分页limit 默认 20
"""
permission_classes = [permissions.IsAuthenticated, IsPrintingFactory]
_ORDERING_FIELDS = {'id', 'created_at', 'updated_at', 'design_code'}
def get(self, request):
qp = request.query_params
process_id = qp.get('process_id')
if not process_id:
return Response({'detail': 'process_id 为必填参数'}, status=status.HTTP_400_BAD_REQUEST)
try:
process_id_int = int(process_id)
except (TypeError, ValueError):
return Response({'detail': 'process_id 必须为数字'}, status=status.HTTP_400_BAD_REQUEST)
date_from = qp.get('date_from')
date_to = qp.get('date_to')
if not date_from or not date_to:
return Response({'detail': 'date_from 与 date_to 为必填参数'}, status=status.HTTP_400_BAD_REQUEST)
try:
start_date = datetime.datetime.strptime(date_from, '%Y-%m-%d').date()
end_date = datetime.datetime.strptime(date_to, '%Y-%m-%d').date()
except ValueError:
return Response({'detail': '日期格式需为 YYYY-MM-DD'}, status=status.HTTP_400_BAD_REQUEST)
# 闭区间:包含当日 00:00:00 和 23:59:59.999999
start_dt = datetime.datetime.combine(start_date, datetime.time.min)
end_dt = datetime.datetime.combine(end_date, datetime.time.max)
if timezone.is_naive(start_dt):
start_dt = timezone.make_aware(start_dt, timezone.get_default_timezone())
if timezone.is_naive(end_dt):
end_dt = timezone.make_aware(end_dt, timezone.get_default_timezone())
try:
process = stateflow_models.Process.objects.get(id=process_id_int)
except stateflow_models.Process.DoesNotExist:
return Response({'detail': 'process 不存在'}, status=status.HTTP_404_NOT_FOUND)
# 该 process 的所有节点(含参数定义 key 顺序)
params_prefetch = Prefetch(
'state__parameters',
queryset=stateflow_models.StateParameter.objects.order_by('id'),
)
process_nodes = list(
stateflow_models.ProcessNode.objects
.filter(process_id=process_id_int)
.select_related('state')
.prefetch_related(params_prefetch)
.order_by('order', 'id')
)
process_nodes_info = []
state_ids = []
for pn in process_nodes:
keys = [p.key for p in pn.state.parameters.all()]
process_nodes_info.append({
'process_node_id': pn.id,
'state_id': pn.state_id,
'node_name': pn.state.name,
'order': pn.order,
'keys': keys,
})
state_ids.append(pn.state_id)
queryset = (
printing_models.PlateOrder.objects
.select_related('customer', 'business_object')
.filter(
process=process_id_int,
created_at__gte=start_dt,
created_at__lte=end_dt,
)
)
# plate_order同时支持主键与 design_code icontains不新增额外参数
plate_order = (qp.get('plate_order') or '').strip()
if plate_order:
if plate_order.isdigit():
try:
pid = int(plate_order)
except (TypeError, ValueError):
pid = None
cond = Q(design_code__icontains=plate_order)
if pid is not None:
cond = cond | Q(id=pid)
queryset = queryset.filter(cond)
else:
queryset = queryset.filter(design_code__icontains=plate_order)
# ordering默认 -created_at
ordering = (qp.get('ordering') or '-created_at').strip() or '-created_at'
direction = '-' if ordering.startswith('-') else ''
field = ordering[1:] if ordering.startswith('-') else ordering
if field not in self._ORDERING_FIELDS:
return Response(
{'detail': f'ordering 不支持: {ordering}(可选: {", ".join(sorted(self._ORDERING_FIELDS))}'},
status=status.HTTP_400_BAD_REQUEST,
)
if field == 'design_code':
queryset = queryset.annotate(
design_code_normalized=Coalesce('design_code', Cast('id', output_field=CharField()))
).order_by(f'{direction}design_code_normalized', 'id')
else:
queryset = queryset.order_by(f'{direction}{field}', 'id')
# 预取:非撤销的状态日志及其参数记录(用于计算 is_executed + value
state_ids_unique = sorted(set(state_ids))
param_records_prefetch = Prefetch(
'parameter_records',
queryset=stateflow_models.StateLogParameterRecord.objects.order_by('created_at', 'id'),
to_attr='_prefetched_parameter_records',
)
logs_qs = (
stateflow_models.StateFlowRecord.objects
.filter(is_cancelled=False)
.order_by('-completed_at', '-id')
.prefetch_related(param_records_prefetch)
)
if state_ids_unique:
logs_qs = logs_qs.filter(state_id__in=state_ids_unique)
queryset = queryset.prefetch_related(
Prefetch(
'business_object__state_logs',
queryset=logs_qs,
to_attr='_prefetched_state_logs_for_process_params',
)
)
paginator = LimitOffsetPagination()
paginator.default_limit = 20
page = paginator.paginate_queryset(queryset, request, view=self)
results = page if page is not None else list(queryset)
srz = PlateOrderByProcessSerializer(
results,
many=True,
context={
'request': request,
'process_nodes_info': process_nodes_info,
},
)
return Response({
'process': {
'id': process.id,
'name': process.name,
'node_count': len(process_nodes_info),
},
'count': getattr(paginator, 'count', len(results)),
'next': paginator.get_next_link() if page is not None else None,
'previous': paginator.get_previous_link() if page is not None else None,
'results': srz.data,
})

View File

@@ -1,6 +1,8 @@
from rest_framework import serializers, status, permissions from rest_framework import serializers, status, permissions
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from django.core.exceptions import FieldDoesNotExist
from django.db import transaction
from stateflow import models as stateflow_models from stateflow import models as stateflow_models
from stateflow import services as stateflow_services from stateflow import services as stateflow_services
@@ -49,11 +51,48 @@ class BusinessObjectCloneView(APIView):
return Response({'detail': 'business_object 不存在'}, status=status.HTTP_404_NOT_FOUND) return Response({'detail': 'business_object 不存在'}, status=status.HTTP_404_NOT_FOUND)
try: try:
cloned = stateflow_services.clone_business_object( with transaction.atomic():
bo, cloned = stateflow_services.clone_business_object(
new_object_id=new_object_id, bo,
expected_content_type_id=expected_content_type_id, new_object_id=new_object_id,
) expected_content_type_id=expected_content_type_id,
)
# 兼容 printing 的“克隆=新建后复制流程进度”工作流:
# 目标业务对象PlateOrder/PrintingJob本身持有 business_object 字段,
# 需要把克隆后的 BusinessObject 回写到目标对象上,否则前端看不到节点/参数历史。
try:
target_obj = cloned.content_type.get_object_for_this_type(pk=cloned.object_id)
except Exception:
target_obj = None
if target_obj is not None:
try:
bo_field = target_obj._meta.get_field('business_object')
except FieldDoesNotExist:
bo_field = None
if bo_field is not None and bo_field.related_model is stateflow_models.BusinessObject:
old_bo_id = getattr(target_obj, 'business_object_id', None)
target_obj.business_object = cloned
target_obj.save(update_fields=['business_object'])
# 如果目标对象在创建时自动生成了一个“空 business_object”这里做安全清理
# 避免同一个业务对象存在多个 BusinessObject 绑定导致后续审计/排查混乱。
if old_bo_id and old_bo_id != cloned.id:
old_bo = (
stateflow_models.BusinessObject.objects
.filter(id=old_bo_id)
.select_related('content_type')
.first()
)
if (
old_bo
and old_bo.state_logs.count() == 0
and old_bo.content_type_id == cloned.content_type_id
and old_bo.object_id == cloned.object_id
):
old_bo.delete()
except ValueError as e: except ValueError as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,107 @@
## api_v2按流程查询 PlateOrder 列表(返回所有节点参数 `process_params`
### 目标
-`process_id` + `created_at` 时间范围查询 `PlateOrder` 列表
- 每条 `PlateOrder` 额外返回 `process_params`**该流程的全部节点**,以及每个节点的 **是否已执行****参数 key/value订单维度**
### 接口信息
- **Method**GET
- **Path**`/api/v2/plate-orders/by-process/`
- **认证**JWT`IsAuthenticated`
- **权限**:仅允许印染/工厂侧用户(`IsPrintingFactory`
### Query 参数
| 参数 | 必填 | 类型 | 默认值 | 说明 |
|---|---:|---|---|---|
| `process_id` | 是 | int | - | `stateflow.Process.id` |
| `date_from` | 是 | string(date) | - | 起始日期 `YYYY-MM-DD`(按 `PlateOrder.created_at` 过滤,闭区间) |
| `date_to` | 是 | string(date) | - | 结束日期 `YYYY-MM-DD`(闭区间,包含当日 23:59:59.999999 |
| `plate_order` | 否 | string | - | 单一查询参数:同时支持主键与设计编号(见“查询规则”) |
| `ordering` | 否 | string | `-created_at` | 排序字段(见“排序规则”) |
| `limit` | 否 | int | `20` | 分页大小LimitOffsetPagination |
| `offset` | 否 | int | `0` | 分页偏移LimitOffsetPagination |
### 查询规则plate_order
-`plate_order` 为**纯数字**
- 匹配 `PlateOrder.id == int(plate_order)` **或**
- 匹配 `PlateOrder.design_code icontains plate_order`
-`plate_order` 为**非纯数字**
- 仅匹配 `PlateOrder.design_code icontains plate_order`
### 排序规则ordering
- 默认:`-created_at`
- 支持字段:`id` / `created_at` / `updated_at` / `design_code`
- `design_code` 排序规则:
-`design_code` 为空时,使用 `id` 的字符串作为兜底值参与排序
- 传入不支持的 `ordering`:返回 **400**
### 返回值200
响应为分页结构。
#### 顶层字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `process` | object | 流程信息(见下) |
| `count` | int | 总数 |
| `next` | string\|null | 下一页链接 |
| `previous` | string\|null | 上一页链接 |
| `results` | array[object] | `PlateOrder` 列表(见下) |
#### `process` 字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | `Process.id` |
| `name` | string | `Process.name` |
| `node_count` | int | 节点数量 |
#### `results` 元素字段PlateOrder 列表项)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | PlateOrder 主键 |
| `design_code` | string\|null | 设计编号;若为空则返回 `id` 的字符串兜底值 |
| `customer` | int | 客户 ID |
| `customer_name` | string | 客户名称 |
| `style_name` | string\|null | 款号名称 |
| `urgency_level` | string | 紧急程度 |
| `is_invalid` | bool | 是否作废 |
| `business_object_id` | int\|null | 关联流程实例 ID |
| `process_params` | array[object] | **流程全部节点**的参数结构(见下,严格 schema |
| `created_at` | string | 创建时间ISO 8601 |
| `updated_at` | string | 更新时间ISO 8601 |
#### `process_params` 元素字段(严格 schema
| 字段 | 类型 | 说明 |
|---|---|---|
| `process_node_id` | int | `ProcessNode.id` |
| `state_id` | int | `State.id` |
| `node_name` | string | 节点名称(`State.name` |
| `order` | int | 节点顺序号 |
| `is_executed` | bool | **是否已执行**:存在未撤销的 `StateFlowRecord` 则为 true仅有撤销记录视为 false |
| `params` | array[object] | 该节点参数 key/value订单维度见下 |
#### `params` 元素字段(订单维度:严格 schema
| 字段 | 类型 | 说明 |
|---|---|---|
| `key` | string | 参数键(来自该节点 `State.parameters` |
| `value` | JSONValue\|null | 订单在该节点的最新已提交参数值;未执行/未提交则为 null |
**JSONValue 定义**
- `string` \| `number` \| `boolean` \| `object` \| `array` \| `null`
**不变性约束(前端可依赖)**
- `process_params` **一定存在**,类型恒为 `array`
- `process_params` **一定包含该流程的全部节点**,按 `order` 升序
- 对每个节点:
- `params` **一定存在**,类型恒为 `array`
- `params`**key 集合与顺序**与该节点 `State.parameters` **完全一致**
- 若该节点没有任何参数:`params=[]`
### 常见错误码
| HTTP 状态码 | 场景 | 返回 `detail` |
|---:|---|---|
| 400 | 缺少/非法参数(如 `process_id` 非数字、`date_from/date_to` 缺失或格式错误、`ordering` 不支持) | 错误原因文本 |
| 401 | 未认证 | DRF 默认 |
| 403 | 无权限(非工厂用户) | `您没有访问印染订单的权限` |
| 404 | `process_id` 不存在 | `process 不存在` |

View File

@@ -0,0 +1,104 @@
## Bug 调研报告:`BusinessObject.content_type/object_id` 为空导致状态流转记录不可追溯
### 背景与结论摘要
- **问题**`stateflow.BusinessObject``content_type_id` / `object_id` 允许为空,导致部分流程实例无法追溯到真实业务对象;相关 `StateFlowRecord` / 参数记录即使存在,也无法再关联回订单/任务等业务数据。
- **结论**`object_id=NULL` 不是“默认 process 能自动补齐”的字段;它只能在创建/绑定 `BusinessObject` 时显式写入。历史/旁路创建路径只写了 `process/name`,且 services 层此前允许在未绑定的 `BusinessObject` 上继续写日志,最终放大成数据不可追溯问题。
- **处理目标**:在 **API + services 层永久性杜绝新增未绑定 BusinessObject**,并提供历史数据回填/隔离方案。
### 现象与影响
- **现象**
- `BusinessObject.content_type_id IS NULL``BusinessObject.object_id IS NULL`
- `StateFlowRecord.business_object_id` 通常不为空,但其指向的 `BusinessObject` 可能未绑定业务对象 → **日志“有记录但无归属”**
- **影响**
- 无法通过 `BusinessObject.content_object` 找到关联订单/任务GenericForeignKey 失效)
- 依赖“从流程实例回溯业务对象”的接口/审计/参数回显/报表逻辑可能崩溃或产生错误结果
- 一旦继续在未绑定对象上推进流程,会持续产生日志与参数记录,扩大脏数据
### 数据证据测试库抽样2025-12-17
> 注:以下数字来自本次调研时的测试库,仅用于说明问题形态与证据链;生产环境请用同样的统计口径核对。
- **表级统计BusinessObject**
- `BusinessObject.total = 119`
- `content_type_id IS NULL = 70`
- `object_id IS NULL = 65`
- `content_type_id IS NULL AND object_id IS NULL = 65`(典型“完全未绑定”)
- `content_type_id IS NULL AND object_id IS NOT NULL = 5`(“半绑定”异常,通常来自不完整回填/旧逻辑)
- **时间分布(出现明显分界)**
- 2025-11-18 ~ 2025-12-09新增的 `BusinessObject` 几乎全部为未绑定
- 2025-12-10 之后:新增 `BusinessObject` 基本为已绑定
- 这与“历史旁路/旧版本逻辑 → 后续修复上线”的典型形态一致
- **关键反证(排除‘当前 printing 正确路径’)**
- 当前库中 `PrintingJob.min_id = 16``id <= 15``PrintingJob` 已不存在
- 但仍存在多条 `BusinessObject.name = PrintingJob-1..15``content_type_id/object_id = NULL`
- 说明这些 `BusinessObject` 是“创建时就未绑定”,且后续对应业务对象被清理/重建GFK 不会级联清理 BusinessObject后遗留为孤儿
### 根因分析(为什么会产生 NULL
- **Schema 层允许为空(放行)**
- `stateflow/migrations/0012_alter_businessobject_content_type_and_more.py``content_type/object_id` 改为 `null=True, blank=True`
- `stateflow/models.py::BusinessObject` 仍保持字段可空(历史兼容需要)
- **创建入口存在“只创建流程实例、不绑定业务对象”的旁路(直接成因)**
- 旁路创建方式通常类似:`BusinessObject.objects.create(name=..., process=...)`(不传 `content_type/object_id`
- 该模式在仓库测试代码中可见(反映团队历史习惯/旧实现可能性),且与库中 `PrintingJob-*` 未绑定样本吻合
- **services 层此前缺少硬防线(放大器)**
- 若允许在未绑定的 `BusinessObject` 上执行 `advance_to_next_state`,则会不断生成 `StateFlowRecord` / 参数记录,但永远无法追溯业务对象
### 为什么“默认 process”不能保证 `object_id` 非空
- **`process_id` 只决定流程模板**,并不能推出“关联的业务对象是谁”
- 业务对象 ID 必须来自具体实例(如 `PlateOrder.id` / `PrintingJob.id`),而这个 ID 只有在业务对象入库后才确定
- 因此任何“先建 BusinessObject、后忘记补绑定”的路径都会产生 `object_id=NULL`,且 DB 允许该脏数据落库
### 处理方法(已落地)
#### 1) API 层:禁止创建/更新未绑定 BusinessObject永久杜绝新增
- **位置**`stateflow/serializers.py::BusinessObjectCreateUpdateSerializer`
- **规则**
- `content_type``object_id` **均为必填且不可为空**
- `object_id` 必须能在 `content_type` 指向的模型中查到真实对象(避免“悬空绑定”)
#### 2) Services 层:禁止在未绑定 BusinessObject 上推进/克隆(永久杜绝新增日志污染)
- **推进**`stateflow/services.py::advance_to_next_state`
-`content_type_id/object_id` 为空:直接返回失败(禁止推进)
-`content_object``None`(悬空绑定):直接返回失败(禁止推进)
- **克隆**`stateflow/services.py::clone_business_object`
- 若源对象未绑定:抛错禁止克隆
- 克隆目标 `new_object_id` 必填,且目标对象必须存在(避免生成新的悬空绑定)
#### 3) 历史数据修复:提供 printing 维度回填命令(修复“仍存在且可确定归属”的记录)
- **命令**`stateflow/management/commands/repair_business_object_bindings.py`
- **用途**:对 `PrintingJob.business_object` / `PlateOrder.business_object` 一对一绑定关系做一致性回填(`content_type/object_id/name`
- **用法**
- 仅统计不落库:`uv run manage.py repair_business_object_bindings --dry-run`
- 分类型:`--only printingjob|plateorder|all`
- 分批:`--limit N`
- **重要限制**
-`BusinessObject` 已成为孤儿(业务对象已被删除/不存在),则无法可靠回填,只能**隔离/清理**(见下节建议)
### 建议补齐的“永久性治理”(防止新旁路再次引入)
- **收敛创建入口(强烈建议)**
- 统一通过“创建并绑定”的工厂函数/服务创建 BusinessObject禁止散落的 `BusinessObject.objects.create(...)`
- 对 printing 等业务模型,创建时必须使用“先保存业务对象 → 再创建并绑定 BusinessObject”的顺序
- **全仓扫描与 CI 约束**
-`BusinessObject.objects.create(` 做静态扫描,若未同时出现 `content_type``object_id` 则在 CI 失败(或至少告警)
- **线上监控/告警**
- 增加周期性检查:若发现“近 X 分钟/小时新增的 BusinessObject 存在 NULL 绑定”立刻告警Sentry/日志/钉钉均可)
- 目的:让问题从“长期潜伏”变为“即时可见”
- **可选DB 级兜底**
- 若允许:用触发器/约束保证“新写入必须非空”,同时保留历史空值(更强的最后一道防线)
### 历史孤儿数据建议处置
- **识别**
- `content_type_id/object_id` 均为空的 BusinessObject
- 或者 `content_object is None` 的悬空绑定
- **处置选项**(需业务确认):
- **清理**:删除孤儿 BusinessObject 及其 state_logs若这些日志对业务无意义且不会被引用
- **隔离**:保留数据但在查询/报表中排除,并记录“不可追溯原因”(便于审计)
- **人工映射**:若能通过其它字段(如外部单号、备注)恢复归属,可进行一次性人工回填
### 验证与回归
- **新增防线验证点**
- 创建/更新 BusinessObject API缺失绑定应直接拒绝
- 推进/克隆:未绑定/悬空绑定应被禁止(不再产生新的 StateFlowRecord
- **建议回归覆盖**
- printing 创建 PlateOrder/PrintingJob 后,关联的 BusinessObject 必须是已绑定状态
- 批量推进/单条推进在遇到历史脏数据时应给出明确错误或被自愈修复(取决于调用方策略)

View File

@@ -199,18 +199,18 @@ DATABASES = {
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ # {
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
}, # },
{ {
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
}, },
{ # {
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', # 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
}, # },
{ # {
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', # 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
}, # },
] ]

View File

@@ -121,6 +121,12 @@ class PlateOrderAdmin(admin.ModelAdmin):
messages.append(f"{obj.id}: 没有关联的流程实例,无法推进") messages.append(f"{obj.id}: 没有关联的流程实例,无法推进")
continue continue
# 修复历史/异常数据:确保该订单的流程实例正确绑定到订单(避免 BusinessObject.object_id/content_type 为空)
services.ensure_business_object_bound_to_instance(
obj.business_object,
obj,
default_name=f"PlateOrder-{obj.id}",
)
success, message, state_log = services.advance_to_next_state(obj.business_object, request.user) success, message, state_log = services.advance_to_next_state(obj.business_object, request.user)
if success: if success:
success_count += 1 success_count += 1
@@ -375,6 +381,12 @@ class PrintingJobAdmin(admin.ModelAdmin):
messages.append(f"PrintingJob #{obj.id}: 没有关联的业务对象,无法推进") messages.append(f"PrintingJob #{obj.id}: 没有关联的业务对象,无法推进")
continue continue
# 修复历史/异常数据:确保该 job 的流程实例正确绑定到 job避免 BusinessObject.object_id/content_type 为空)
services.ensure_business_object_bound_to_instance(
obj.business_object,
obj,
default_name=f"PrintingJob-{obj.id}",
)
success, message, state_log = services.advance_to_next_state(obj.business_object, request.user) success, message, state_log = services.advance_to_next_state(obj.business_object, request.user)
if success: if success:
success_count += 1 success_count += 1

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.2.8 on 2025-12-16 11:52
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('printing', '0024_printingjobbatchadvancerecord'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='plateorder',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_plate_orders', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
]

View File

@@ -107,6 +107,14 @@ class PlateOrder(ModelBase):
# 流程管理 # 流程管理
process = models.IntegerField(default=settings.PLATE_ORDER_DEFAULT_PROCESS_ID, verbose_name='关联流程') process = models.IntegerField(default=settings.PLATE_ORDER_DEFAULT_PROCESS_ID, verbose_name='关联流程')
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_plate_orders',
verbose_name='创建人',
)
business_object = models.OneToOneField( business_object = models.OneToOneField(
stateflow_models.BusinessObject, stateflow_models.BusinessObject,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,

View File

@@ -0,0 +1,3 @@
# Package marker for Django management commands.

View File

@@ -0,0 +1,3 @@
# Package marker for Django management commands.

View 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]}")

View File

@@ -4,6 +4,7 @@ Stateflow 序列化器
from rest_framework import serializers from rest_framework import serializers
from django.db import transaction from django.db import transaction
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from stateflow import models from stateflow import models
@@ -356,7 +357,14 @@ class BusinessObjectCreateUpdateSerializer(serializers.ModelSerializer):
} }
def validate(self, attrs): 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 = attrs.get('content_type')
content_type_str = attrs.get('content_type_str') content_type_str = attrs.get('content_type_str')
object_id = attrs.get('object_id') object_id = attrs.get('object_id')
@@ -371,17 +379,26 @@ class BusinessObjectCreateUpdateSerializer(serializers.ModelSerializer):
raise serializers.ValidationError({ raise serializers.ValidationError({
'content_type_str': f'无效的 content_type 格式: {content_type_str}' 'content_type_str': f'无效的 content_type 格式: {content_type_str}'
}) })
# 验证:如果提供了 content_type必须提供 object_id # 以“最终值”做校验partial_update 未传字段时要以 instance 的现有值为准
if content_type and not object_id: 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({ 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({ raise serializers.ValidationError({
'content_type': '提供了对象ID必须同时提供关联对象类型' 'object_id': f'关联对象不存在:{content_type.app_label}.{content_type.model} #{object_id}'
}) })
# 移除临时字段 # 移除临时字段

View File

@@ -4,11 +4,58 @@ Stateflow业务逻辑服务层
import copy import copy
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
from django.contrib.auth import get_user_model 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 django.db import transaction
from . import models from . import models
User = get_user_model() 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']: 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: 提示信息 - message: 提示信息
- state_log: 创建的状态日志(成功时) - 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) can_advance, reason = can_advance_to_next_state(business_object)
if not can_advance: if not can_advance:
return False, reason, None return False, reason, None
@@ -598,6 +656,12 @@ def clone_business_object(
if source is None: if source is None:
raise ValueError("source 不能为空") 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确保拿到完整关系避免调用方未预取导致 N+1
source = ( source = (
models.BusinessObject.objects models.BusinessObject.objects
@@ -610,23 +674,23 @@ def clone_business_object(
.get(id=source.id) .get(id=source.id)
) )
# 校验 content_type 一致性(不做额外校验,仅比对 id # 校验 content_type 一致性(仅比对 id不做额外校验)
if source.content_type_id != expected_content_type_id: if source.content_type_id != expected_content_type_id:
raise ValueError("content_type 与源对象不一致,拒绝克隆") raise ValueError("content_type 与源对象不一致,拒绝克隆")
# 如果源对象未绑定 content_type则拒绝克隆 # 绑定规则(强制):
if source.content_type_id is None: # - 必须提供新的 object_id且与源对象不同
return ValueError("源对象未绑定 content_type拒绝克隆") if new_object_id is None:
else: raise ValueError("必须提供新的 object_id")
if new_object_id is None: if source.object_id == new_object_id:
raise ValueError("必须提供新的 object_id") 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)
content_type = models.ContentType.objects.get(id=source.content_type_id) except ObjectDoesNotExist:
actual_object = content_type.model_class().objects.get(id=new_object_id) raise ValueError(
if actual_object is None: f"目标关联对象不存在:{source.content_type.app_label}.{source.content_type.model} #{new_object_id}"
raise ValueError("实际对象不存在,拒绝克隆") )
with transaction.atomic(): with transaction.atomic():
cloned = models.BusinessObject.objects.create( cloned = models.BusinessObject.objects.create(
@@ -685,8 +749,4 @@ def clone_business_object(
# 重新加载,确保返回对象的字段与 DB 一致(尤其是时间戳) # 重新加载,确保返回对象的字段与 DB 一致(尤其是时间戳)
cloned.refresh_from_db() cloned.refresh_from_db()
actual_object.refresh_from_db()
actual_object.business_object_id = cloned.id
actual_object.save()
return cloned return cloned