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 序列化器
"""
import json
from rest_framework import serializers
from rest_framework.fields import empty
from api_v1.models import UploadedFile
from api_v1.utils.media import build_public_media_url
@@ -52,8 +54,6 @@ def _build_plate_image_payload(items, request_user):
return []
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}
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
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):
file_id = serializers.IntegerField(min_value=1, help_text='上传文件的 ID')
name = serializers.CharField(
@@ -85,6 +127,9 @@ class PlateImageInputSerializer(serializers.Serializer):
help_text='可选的图片名称,默认使用文件原始名称'
)
class Meta:
list_serializer_class = PlateImageInputListSerializer
class PrintingOrderListSerializer(serializers.ModelSerializer):
"""印染订单列表序列化器"""
@@ -374,6 +419,7 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria
salesperson_name = serializers.CharField(source="salesperson.name", read_only=True)
merchandiser_name = serializers.CharField(source="merchandiser.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)
progress_percentage = serializers.IntegerField(read_only=True)
process_name = serializers.SerializerMethodField()
@@ -401,6 +447,7 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria
'process', 'process_name',
'status', 'status_id', 'is_completed', 'has_started', 'last_completed_state',
'progress_percentage', 'business_object_id', 'content_type_id',
'created_by',
'created_at', 'updated_at'
]
read_only_fields = [
@@ -443,6 +490,7 @@ class PlateOrderDetailSerializer(PlateOrderDesignCodeMixin, serializers.ModelSer
salesperson_name = serializers.CharField(source="salesperson.name", read_only=True)
merchandiser_name = serializers.CharField(source="merchandiser.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_id = serializers.IntegerField(read_only=True)
is_completed = serializers.BooleanField(read_only=True)
@@ -472,6 +520,7 @@ class PlateOrderDetailSerializer(PlateOrderDesignCodeMixin, serializers.ModelSer
'process', 'process_name',
'status', 'status_id', 'is_completed', 'has_started',
'progress_percentage', 'business_object_id', 'last_completed_state',
'created_by',
'created_at', 'updated_at'
]
read_only_fields = [
@@ -508,6 +557,7 @@ class PlateOrderCreateUpdateSerializer(serializers.ModelSerializer):
allow_null=True,
help_text="开版图片列表,需提供已上传的 file_id可选 name 字段"
)
created_by = serializers.IntegerField(source='created_by_id', read_only=True)
class Meta:
model = models.PlateOrder
@@ -523,9 +573,10 @@ class PlateOrderCreateUpdateSerializer(serializers.ModelSerializer):
"sample_meter", "required_sample_meters",
"required_completion_date", "completion_date",
"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):
return value or []

View File

@@ -1,6 +1,7 @@
"""
PlateOrder API 测试
"""
import json
from django.core.files.uploadedfile import SimpleUploadedFile
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
@@ -136,9 +137,13 @@ class PlateOrderAPITestCase(TestCase):
self.assertEqual(response.data['image_name'], 'sample.png')
self.assertEqual(len(response.data['plate_image']), 1)
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())
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):
"""测试创建开版订单时未提供客户"""
@@ -165,6 +170,8 @@ class PlateOrderAPITestCase(TestCase):
response = self.client.post('/api/v1/plate-orders/', data, format='json')
# CharField 不会验证内容,所以应该成功
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):
data = {
@@ -176,6 +183,45 @@ class PlateOrderAPITestCase(TestCase):
response = self.client.post('/api/v1/plate-orders/', data, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
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):
"""测试获取开版订单列表"""
@@ -231,6 +277,7 @@ class PlateOrderAPITestCase(TestCase):
if collection:
self.assertIn('plate_image', collection[0])
self.assertIsInstance(collection[0]['plate_image'], list)
self.assertIn('created_by', collection[0])
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}/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
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['customer'], self.customer.id)
self.assertIn('customer_name', response.data)

View File

@@ -357,6 +357,12 @@ class PrintingJobViewSet(viewsets.ModelViewSet):
# 调用 stateflow 统一服务进行状态流转
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(
job.business_object, request.user, **parameters
)
@@ -641,6 +647,12 @@ class PlateOrderViewSet(viewsets.ModelViewSet):
{'detail': '开版订单不支持删除操作,请使用作废功能'},
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'])
def invalidate(self, request, pk=None):
@@ -739,6 +751,12 @@ class PlateOrderViewSet(viewsets.ModelViewSet):
# 调用 stateflow 统一服务进行状态流转
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(
plate_order.business_object, request.user, **parameters
)

View File

@@ -352,6 +352,15 @@ class PrintingJobBatchAdvanceV2APITest(TestCase):
# stateflow两条 business_object 都应生成 1 条完成日志,且完成的是 state1
self.job1.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.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)
@@ -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.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)
self.bo = stateflow_models.BusinessObject.objects.create(
name='BO',
process=self.process,
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):
new_object_id = 222
new_object_id = self.process_dst.id
resp = self.client.post(
'/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.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):
resp = self.client.post(
'/api/v2/stateflow/business-objects/clone/',
@@ -601,6 +685,8 @@ class PlateOrderByProcessNodeV2APITest(TestCase):
# 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_new.id])
@@ -671,3 +757,203 @@ class PlateOrderByProcessNodeV2APITest(TestCase):
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.data['count'], 2)
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,
PrintingJobBatchAdvanceSubmitView,
PlateOrderByProcessNodeView,
PlateOrderByProcessView,
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/', PrintingJobBatchAdvanceSubmitView.as_view(), name='api_v2_printing_job_batch_advance_submit'),
path('plate-orders/by-process-node/', PlateOrderByProcessNodeView.as_view(), name='api_v2_plate_order_by_process_node'),
path('plate-orders/by-process/', PlateOrderByProcessView.as_view(), name='api_v2_plate_order_by_process'),
path('stateflow/business-objects/clone/', BusinessObjectCloneView.as_view(), name='api_v2_stateflow_business_object_clone'),
]

View File

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

View File

@@ -338,6 +338,12 @@ class PrintingJobBatchAdvanceSubmitView(APIView):
# 逐个复用单条推进逻辑
last_message = None
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(
job.business_object,
request.user,
@@ -372,6 +378,7 @@ class PlateOrderByProcessNodeSerializer(serializers.ModelSerializer):
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_parameters = serializers.SerializerMethodField()
class Meta:
@@ -385,6 +392,7 @@ class PlateOrderByProcessNodeSerializer(serializers.ModelSerializer):
'urgency_level',
'is_invalid',
'business_object_id',
'created_by',
'process_parameters',
'created_at',
'updated_at',
@@ -604,3 +612,277 @@ class PlateOrderByProcessNodeView(APIView):
'previous': paginator.get_previous_link() if page is not None else None,
'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.response import Response
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 services as stateflow_services
@@ -49,11 +51,48 @@ class BusinessObjectCloneView(APIView):
return Response({'detail': 'business_object 不存在'}, status=status.HTTP_404_NOT_FOUND)
try:
cloned = stateflow_services.clone_business_object(
bo,
new_object_id=new_object_id,
expected_content_type_id=expected_content_type_id,
)
with transaction.atomic():
cloned = stateflow_services.clone_business_object(
bo,
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:
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
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.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
# {
# 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
# },
# {
# 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
# },
]

View File

@@ -121,6 +121,12 @@ class PlateOrderAdmin(admin.ModelAdmin):
messages.append(f"{obj.id}: 没有关联的流程实例,无法推进")
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)
if success:
success_count += 1
@@ -375,6 +381,12 @@ class PrintingJobAdmin(admin.ModelAdmin):
messages.append(f"PrintingJob #{obj.id}: 没有关联的业务对象,无法推进")
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)
if success:
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='关联流程')
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(
stateflow_models.BusinessObject,
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 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}'
})
# 移除临时字段

View File

@@ -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