forked from erp-dev/erp
fix: business.object_id maybe null, missing parameters when plate-Order cloned
This commit is contained in:
5
.cursor/worktrees.json
Normal file
5
.cursor/worktrees.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"setup-worktree": [
|
||||||
|
"npm install"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
290
api_v2/tests.py
290
api_v2/tests.py
@@ -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_exec:state1 已执行;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)
|
||||||
|
|||||||
@@ -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'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
|||||||
@@ -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.
12007
data-bak/db-backup-20251216-190000.sql
Normal file
12007
data-bak/db-backup-20251216-190000.sql
Normal file
File diff suppressed because it is too large
Load Diff
107
docs/api_v2_plate_orders_by_process.md
Normal file
107
docs/api_v2_plate_orders_by_process.md
Normal 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 不存在` |
|
||||||
|
|
||||||
|
|
||||||
104
docs/bug-fix/business_object_object_id_null.md
Normal file
104
docs/bug-fix/business_object_object_id_null.md
Normal 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 必须是已绑定状态
|
||||||
|
- 批量推进/单条推进在遇到历史脏数据时应给出明确错误或被自愈修复(取决于调用方策略)
|
||||||
|
|
||||||
|
|
||||||
@@ -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',
|
||||||
},
|
# },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
21
printing/migrations/0025_plateorder_created_by.py
Normal file
21
printing/migrations/0025_plateorder_created_by.py
Normal 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='创建人'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
|||||||
3
stateflow/management/__init__.py
Normal file
3
stateflow/management/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Package marker for Django management commands.
|
||||||
|
|
||||||
|
|
||||||
3
stateflow/management/commands/__init__.py
Normal file
3
stateflow/management/commands/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Package marker for Django management commands.
|
||||||
|
|
||||||
|
|
||||||
124
stateflow/management/commands/repair_business_object_bindings.py
Normal file
124
stateflow/management/commands/repair_business_object_bindings.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from printing import models as printing_models
|
||||||
|
from stateflow import services as stateflow_services
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
"修复 printing 相关模型(PrintingJob/PlateOrder)所关联的 BusinessObject 的 "
|
||||||
|
"content_type/object_id/name 绑定(用于历史数据回填与一致性修复)。"
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--dry-run',
|
||||||
|
action='store_true',
|
||||||
|
help='只输出统计,不写入数据库',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--limit',
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help='最多处理多少条记录(用于分批回填)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--only',
|
||||||
|
choices=['printingjob', 'plateorder', 'all'],
|
||||||
|
default='all',
|
||||||
|
help='仅修复指定类型(默认 all)',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
dry_run: bool = options['dry_run']
|
||||||
|
limit: int | None = options['limit']
|
||||||
|
only: str = options['only']
|
||||||
|
|
||||||
|
# 提前确保 ContentType 可用(也用于输出校验信息)
|
||||||
|
ct_job = ContentType.objects.get_for_model(printing_models.PrintingJob)
|
||||||
|
ct_po = ContentType.objects.get_for_model(printing_models.PlateOrder)
|
||||||
|
|
||||||
|
self.stdout.write(f"dry_run={dry_run} only={only} limit={limit}")
|
||||||
|
self.stdout.write(f"ContentType: PrintingJob={ct_job.id}, PlateOrder={ct_po.id}")
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'printingjob_total': 0,
|
||||||
|
'printingjob_updated': 0,
|
||||||
|
'plateorder_total': 0,
|
||||||
|
'plateorder_updated': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _iter_qs(qs):
|
||||||
|
if limit:
|
||||||
|
return qs.order_by('id')[:limit]
|
||||||
|
return qs.order_by('id')
|
||||||
|
|
||||||
|
# 用事务包住,避免部分更新(dry-run 仍使用事务,最后标记回滚)
|
||||||
|
with transaction.atomic():
|
||||||
|
if only in ('printingjob', 'all'):
|
||||||
|
qs = (
|
||||||
|
printing_models.PrintingJob.objects
|
||||||
|
.select_related('business_object')
|
||||||
|
.filter(business_object__isnull=False)
|
||||||
|
)
|
||||||
|
for job in _iter_qs(qs):
|
||||||
|
stats['printingjob_total'] += 1
|
||||||
|
bo = job.business_object
|
||||||
|
should_fix = (
|
||||||
|
bo.content_type_id != ct_job.id
|
||||||
|
or bo.object_id != job.id
|
||||||
|
or not (bo.name or '').strip()
|
||||||
|
)
|
||||||
|
if not should_fix:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
stats['printingjob_updated'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if stateflow_services.ensure_business_object_bound_to_instance(
|
||||||
|
bo,
|
||||||
|
job,
|
||||||
|
default_name=f"PrintingJob-{job.id}",
|
||||||
|
):
|
||||||
|
stats['printingjob_updated'] += 1
|
||||||
|
|
||||||
|
if only in ('plateorder', 'all'):
|
||||||
|
qs = (
|
||||||
|
printing_models.PlateOrder.objects
|
||||||
|
.select_related('business_object')
|
||||||
|
.filter(business_object__isnull=False)
|
||||||
|
)
|
||||||
|
for po in _iter_qs(qs):
|
||||||
|
stats['plateorder_total'] += 1
|
||||||
|
bo = po.business_object
|
||||||
|
should_fix = (
|
||||||
|
bo.content_type_id != ct_po.id
|
||||||
|
or bo.object_id != po.id
|
||||||
|
or not (bo.name or '').strip()
|
||||||
|
)
|
||||||
|
if not should_fix:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
stats['plateorder_updated'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if stateflow_services.ensure_business_object_bound_to_instance(
|
||||||
|
bo,
|
||||||
|
po,
|
||||||
|
default_name=f"PlateOrder-{po.id}",
|
||||||
|
):
|
||||||
|
stats['plateorder_updated'] += 1
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
# dry-run:显式回滚(即便未来误加了写逻辑,也不会落库)
|
||||||
|
transaction.set_rollback(True)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS("Done."))
|
||||||
|
for k in sorted(stats.keys()):
|
||||||
|
self.stdout.write(f"{k}={stats[k]}")
|
||||||
|
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@ Stateflow 序列化器
|
|||||||
from rest_framework import serializers
|
from 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}'
|
||||||
})
|
})
|
||||||
|
|
||||||
# 移除临时字段
|
# 移除临时字段
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user