forked from erp-dev/erp
feat: added plate order clone
This commit is contained in:
@@ -186,6 +186,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
|
|||||||
work_state_display = serializers.SerializerMethodField()
|
work_state_display = serializers.SerializerMethodField()
|
||||||
is_completed = serializers.BooleanField(read_only=True)
|
is_completed = serializers.BooleanField(read_only=True)
|
||||||
business_object_id = serializers.SerializerMethodField()
|
business_object_id = serializers.SerializerMethodField()
|
||||||
|
batch_advance_records = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PrintingJob
|
model = models.PrintingJob
|
||||||
@@ -194,6 +195,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
|
|||||||
'quantity', 'unit', 'size', 'pieces', 'description',
|
'quantity', 'unit', 'size', 'pieces', 'description',
|
||||||
'work_state', 'work_state_display',
|
'work_state', 'work_state_display',
|
||||||
'status', 'is_completed', 'business_object_id',
|
'status', 'is_completed', 'business_object_id',
|
||||||
|
'batch_advance_records',
|
||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', 'created_at', 'updated_at', 'status', 'is_completed', 'business_object_id']
|
read_only_fields = ['id', 'created_at', 'updated_at', 'status', 'is_completed', 'business_object_id']
|
||||||
@@ -205,6 +207,17 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
|
|||||||
def get_work_state_display(self, obj):
|
def get_work_state_display(self, obj):
|
||||||
return obj.get_work_state_display()
|
return obj.get_work_state_display()
|
||||||
|
|
||||||
|
def get_batch_advance_records(self, obj):
|
||||||
|
"""
|
||||||
|
批量推进记录(稳定输出 key)
|
||||||
|
|
||||||
|
- 如果没有批量记录:返回 []
|
||||||
|
- 仅返回与该 job 相关的批量推进审计记录(按 created_at 倒序)
|
||||||
|
"""
|
||||||
|
# 注意:这里不要链式调用 select_related/order_by,否则会绕开 viewset 的 prefetch 缓存,导致 N+1 查询。
|
||||||
|
records = list(obj.batch_advance_records.all())
|
||||||
|
return PrintingJobBatchAdvanceRecordSerializer(records, many=True).data
|
||||||
|
|
||||||
|
|
||||||
class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
||||||
"""印染款式明细详情序列化器"""
|
"""印染款式明细详情序列化器"""
|
||||||
@@ -217,6 +230,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
|||||||
is_completed = serializers.BooleanField(read_only=True)
|
is_completed = serializers.BooleanField(read_only=True)
|
||||||
has_started = serializers.BooleanField(read_only=True)
|
has_started = serializers.BooleanField(read_only=True)
|
||||||
business_object_id = serializers.SerializerMethodField()
|
business_object_id = serializers.SerializerMethodField()
|
||||||
|
batch_advance_records = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PrintingJob
|
model = models.PrintingJob
|
||||||
@@ -225,6 +239,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
|||||||
'quantity', 'unit', 'size', 'pieces', 'description',
|
'quantity', 'unit', 'size', 'pieces', 'description',
|
||||||
'work_state', 'work_state_display',
|
'work_state', 'work_state_display',
|
||||||
'status', 'status_id', 'is_completed', 'has_started', 'business_object_id',
|
'status', 'status_id', 'is_completed', 'has_started', 'business_object_id',
|
||||||
|
'batch_advance_records',
|
||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
@@ -239,15 +254,22 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
|||||||
def get_work_state_display(self, obj):
|
def get_work_state_display(self, obj):
|
||||||
return obj.get_work_state_display()
|
return obj.get_work_state_display()
|
||||||
|
|
||||||
|
def get_batch_advance_records(self, obj):
|
||||||
|
"""见 list serializer,同样稳定输出 key"""
|
||||||
|
records = list(obj.batch_advance_records.all())
|
||||||
|
return PrintingJobBatchAdvanceRecordSerializer(records, many=True).data
|
||||||
|
|
||||||
|
|
||||||
class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer):
|
class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer):
|
||||||
"""印染款式明细创建/更新序列化器"""
|
"""印染款式明细创建/更新序列化器"""
|
||||||
|
batch_advance_records = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PrintingJob
|
model = models.PrintingJob
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'printing_order', 'product', 'quantity', 'unit', 'size', 'pieces', 'description',
|
'id', 'printing_order', 'product', 'quantity', 'unit', 'size', 'pieces', 'description',
|
||||||
'work_state',
|
'work_state',
|
||||||
|
'batch_advance_records',
|
||||||
]
|
]
|
||||||
read_only_fields = ['id']
|
read_only_fields = ['id']
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
@@ -290,6 +312,43 @@ class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer):
|
|||||||
raise serializers.ValidationError(message)
|
raise serializers.ValidationError(message)
|
||||||
return updated_instance
|
return updated_instance
|
||||||
|
|
||||||
|
def get_batch_advance_records(self, obj):
|
||||||
|
"""创建/更新接口也稳定输出该 key(通常为空)"""
|
||||||
|
records = list(obj.batch_advance_records.all())
|
||||||
|
return PrintingJobBatchAdvanceRecordSerializer(records, many=True).data
|
||||||
|
|
||||||
|
|
||||||
|
class PrintingJobBatchAdvanceRecordSerializer(serializers.ModelSerializer):
|
||||||
|
"""印染任务批量推进记录(用于嵌入 PrintingJob 的序列化结果)"""
|
||||||
|
|
||||||
|
state_id = serializers.IntegerField(source='state.id', read_only=True)
|
||||||
|
state_name = serializers.CharField(source='state.name', read_only=True)
|
||||||
|
created_by_username = serializers.CharField(source='created_by.username', read_only=True)
|
||||||
|
created_by_name = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.PrintingJobBatchAdvanceRecord
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'printing_order',
|
||||||
|
'state',
|
||||||
|
'state_id',
|
||||||
|
'state_name',
|
||||||
|
'created_by',
|
||||||
|
'created_by_username',
|
||||||
|
'created_by_name',
|
||||||
|
'parameters',
|
||||||
|
'created_at',
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
def get_created_by_name(self, obj):
|
||||||
|
user = getattr(obj, 'created_by', None)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
emp = getattr(user, 'employee', None)
|
||||||
|
return getattr(emp, 'name', None)
|
||||||
|
|
||||||
|
|
||||||
class PlateOrderDesignCodeMixin:
|
class PlateOrderDesignCodeMixin:
|
||||||
"""确保 design_code 为空时使用主键"""
|
"""确保 design_code 为空时使用主键"""
|
||||||
@@ -320,6 +379,7 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria
|
|||||||
process_name = serializers.SerializerMethodField()
|
process_name = serializers.SerializerMethodField()
|
||||||
plate_image_url = serializers.SerializerMethodField()
|
plate_image_url = serializers.SerializerMethodField()
|
||||||
last_completed_state = serializers.CharField(read_only=True)
|
last_completed_state = serializers.CharField(read_only=True)
|
||||||
|
content_type_id = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PlateOrder
|
model = models.PlateOrder
|
||||||
@@ -340,7 +400,7 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria
|
|||||||
'approval_result', 'is_ordered', 'customer_feedback', 'print_count',
|
'approval_result', 'is_ordered', 'customer_feedback', 'print_count',
|
||||||
'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',
|
'progress_percentage', 'business_object_id', 'content_type_id',
|
||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
@@ -364,6 +424,17 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria
|
|||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_content_type_id(self, obj):
|
||||||
|
"""
|
||||||
|
返回关联流程实例(business_object)的 content_type_id。
|
||||||
|
|
||||||
|
- 无 business_object:返回 None
|
||||||
|
- business_object 存在但未绑定关联对象:返回 None
|
||||||
|
"""
|
||||||
|
if not getattr(obj, 'business_object_id', None):
|
||||||
|
return None
|
||||||
|
return obj.business_object.content_type_id
|
||||||
|
|
||||||
|
|
||||||
class PlateOrderDetailSerializer(PlateOrderDesignCodeMixin, serializers.ModelSerializer):
|
class PlateOrderDetailSerializer(PlateOrderDesignCodeMixin, serializers.ModelSerializer):
|
||||||
"""开版订单详情序列化器"""
|
"""开版订单详情序列化器"""
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Printing module business logic services
|
|||||||
from typing import Dict, Any, Tuple
|
from typing import Dict, Any, Tuple
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from printing import models as printing_models
|
from printing import models as printing_models
|
||||||
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
|
||||||
@@ -126,10 +127,14 @@ class PrintingJobService:
|
|||||||
|
|
||||||
# 如果 PrintingOrder 有关联的流程,创建 BusinessObject
|
# 如果 PrintingOrder 有关联的流程,创建 BusinessObject
|
||||||
if printing_order and printing_order.process:
|
if printing_order and printing_order.process:
|
||||||
|
# 将流程实例绑定到该 PrintingJob(便于跨模块定位与审计)
|
||||||
|
ct = ContentType.objects.get_for_model(printing_models.PrintingJob)
|
||||||
business_object = stateflow_models.BusinessObject.objects.create(
|
business_object = stateflow_models.BusinessObject.objects.create(
|
||||||
name=f"PrintingJob-{job.id}",
|
name=f"PrintingJob-{job.id}",
|
||||||
process=printing_order.process,
|
process=printing_order.process,
|
||||||
description=f"印染任务 {job.id} 的流程实例",
|
description=f"印染任务 {job.id} 的流程实例",
|
||||||
|
content_type=ct,
|
||||||
|
object_id=job.id,
|
||||||
)
|
)
|
||||||
job.business_object = business_object
|
job.business_object = business_object
|
||||||
job.save()
|
job.save()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
PlateOrder API 测试
|
PlateOrder API 测试
|
||||||
"""
|
"""
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@@ -294,6 +295,35 @@ class PlateOrderAPITestCase(TestCase):
|
|||||||
results = response.data['results'] if isinstance(response.data, dict) else response.data
|
results = response.data['results'] if isinstance(response.data, dict) else response.data
|
||||||
self.assertEqual(results[0]['design_code'], str(results[0]['id']))
|
self.assertEqual(results[0]['design_code'], str(results[0]['id']))
|
||||||
|
|
||||||
|
def test_plate_order_list_includes_content_type_id(self):
|
||||||
|
"""列表接口应包含 content_type_id(来源 business_object.content_type_id)"""
|
||||||
|
process = stateflow_models.Process.objects.create(name='P-ct')
|
||||||
|
ct = ContentType.objects.get_for_model(stateflow_models.Process)
|
||||||
|
|
||||||
|
bo = stateflow_models.BusinessObject.objects.create(
|
||||||
|
name='BO-ct',
|
||||||
|
process=process,
|
||||||
|
content_type=ct,
|
||||||
|
object_id=123,
|
||||||
|
)
|
||||||
|
plate_order = printing_models.PlateOrder.objects.create(
|
||||||
|
customer=self.customer,
|
||||||
|
design_code='DESIGN-CT',
|
||||||
|
plate_type='圆网',
|
||||||
|
style_name='款式-ct',
|
||||||
|
fabric='棉布',
|
||||||
|
process=process.id,
|
||||||
|
business_object=bo,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get('/api/v1/plate-orders/')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
results = response.data['results'] if isinstance(response.data, dict) else response.data
|
||||||
|
|
||||||
|
target = next(item for item in results if item['id'] == plate_order.id)
|
||||||
|
self.assertIn('content_type_id', target)
|
||||||
|
self.assertEqual(target['content_type_id'], ct.id)
|
||||||
|
|
||||||
def test_fabric_source_field_in_detail(self):
|
def test_fabric_source_field_in_detail(self):
|
||||||
"""验证布料来源字段在返回中存在"""
|
"""验证布料来源字段在返回中存在"""
|
||||||
plate_order = printing_models.PlateOrder.objects.create(
|
plate_order = printing_models.PlateOrder.objects.create(
|
||||||
@@ -687,6 +717,12 @@ class PlateOrderAPITestCase(TestCase):
|
|||||||
|
|
||||||
self.assertIsNotNone(plate_order.business_object)
|
self.assertIsNotNone(plate_order.business_object)
|
||||||
self.assertEqual(plate_order.business_object.process.id, self.process.id)
|
self.assertEqual(plate_order.business_object.process.id, self.process.id)
|
||||||
|
# 新逻辑:BusinessObject 绑定回 PlateOrder
|
||||||
|
self.assertEqual(
|
||||||
|
plate_order.business_object.content_type_id,
|
||||||
|
ContentType.objects.get_for_model(printing_models.PlateOrder).id
|
||||||
|
)
|
||||||
|
self.assertEqual(plate_order.business_object.object_id, plate_order.id)
|
||||||
|
|
||||||
def test_advance_to_next_state(self):
|
def test_advance_to_next_state(self):
|
||||||
"""测试推进到下一个状态"""
|
"""测试推进到下一个状态"""
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from rest_framework.test import APIClient
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from basic_info import models as basic_models
|
from basic_info import models as basic_models
|
||||||
from printing import models as printing_models
|
from printing import models as printing_models
|
||||||
from stateflow import models as stateflow_models
|
from stateflow import models as stateflow_models
|
||||||
@@ -108,6 +109,11 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
response = self.client.post('/api/v1/printing-jobs/', data, format='json')
|
response = self.client.post('/api/v1/printing-jobs/', data, format='json')
|
||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
# 新增字段:批量推进记录(稳定输出 key,默认空数组)
|
||||||
|
self.assertIn('batch_advance_records', response.data)
|
||||||
|
self.assertIsInstance(response.data['batch_advance_records'], list)
|
||||||
|
self.assertEqual(len(response.data['batch_advance_records']), 0)
|
||||||
|
|
||||||
# 验证创建成功
|
# 验证创建成功
|
||||||
job = printing_models.PrintingJob.objects.filter(
|
job = printing_models.PrintingJob.objects.filter(
|
||||||
printing_order=self.printing_order,
|
printing_order=self.printing_order,
|
||||||
@@ -121,7 +127,7 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
def test_list_printing_jobs(self):
|
def test_list_printing_jobs(self):
|
||||||
"""测试获取款式明细列表"""
|
"""测试获取款式明细列表"""
|
||||||
# 创建测试数据
|
# 创建测试数据
|
||||||
printing_models.PrintingJob.objects.create(
|
job1 = printing_models.PrintingJob.objects.create(
|
||||||
printing_order=self.printing_order,
|
printing_order=self.printing_order,
|
||||||
product=self.product,
|
product=self.product,
|
||||||
quantity=100,
|
quantity=100,
|
||||||
@@ -129,7 +135,7 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
size='50*60',
|
size='50*60',
|
||||||
pieces=10
|
pieces=10
|
||||||
)
|
)
|
||||||
printing_models.PrintingJob.objects.create(
|
job2 = printing_models.PrintingJob.objects.create(
|
||||||
printing_order=self.printing_order,
|
printing_order=self.printing_order,
|
||||||
product=self.product,
|
product=self.product,
|
||||||
quantity=200,
|
quantity=200,
|
||||||
@@ -141,6 +147,12 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
response = self.client.get('/api/v1/printing-jobs/')
|
response = self.client.get('/api/v1/printing-jobs/')
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(len(response.data), 2)
|
self.assertEqual(len(response.data), 2)
|
||||||
|
|
||||||
|
# 新增字段:批量推进记录(稳定输出 key)
|
||||||
|
for item in response.data:
|
||||||
|
self.assertIn('batch_advance_records', item)
|
||||||
|
self.assertIsInstance(item['batch_advance_records'], list)
|
||||||
|
self.assertEqual(len(item['batch_advance_records']), 0)
|
||||||
|
|
||||||
def test_retrieve_printing_job(self):
|
def test_retrieve_printing_job(self):
|
||||||
"""测试获取款式明细详情"""
|
"""测试获取款式明细详情"""
|
||||||
@@ -160,6 +172,58 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
self.assertEqual(response.data['unit'], '米')
|
self.assertEqual(response.data['unit'], '米')
|
||||||
self.assertIn('product_name', response.data)
|
self.assertIn('product_name', response.data)
|
||||||
self.assertEqual(response.data['product_name'], self.product.name)
|
self.assertEqual(response.data['product_name'], self.product.name)
|
||||||
|
|
||||||
|
# 新增字段:批量推进记录(稳定输出 key)
|
||||||
|
self.assertIn('batch_advance_records', response.data)
|
||||||
|
self.assertIsInstance(response.data['batch_advance_records'], list)
|
||||||
|
self.assertEqual(len(response.data['batch_advance_records']), 0)
|
||||||
|
|
||||||
|
def test_batch_advance_records_in_list_and_detail(self):
|
||||||
|
"""测试 PrintingJob 序列化输出包含批量推进记录(有记录时返回明细,无记录返回空)"""
|
||||||
|
job = printing_models.PrintingJob.objects.create(
|
||||||
|
printing_order=self.printing_order,
|
||||||
|
product=self.product,
|
||||||
|
quantity=100,
|
||||||
|
unit='米',
|
||||||
|
size='50*60',
|
||||||
|
pieces=10,
|
||||||
|
description='批量记录测试'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建一条批量推进记录并关联该 job
|
||||||
|
record = printing_models.PrintingJobBatchAdvanceRecord.objects.create(
|
||||||
|
printing_order=self.printing_order,
|
||||||
|
state=self.state1,
|
||||||
|
created_by=self.user,
|
||||||
|
parameters={'temperature': '25.5', 'operator': '张三'},
|
||||||
|
)
|
||||||
|
record.printing_jobs.add(job)
|
||||||
|
|
||||||
|
# list: 应包含 batch_advance_records
|
||||||
|
response = self.client.get('/api/v1/printing-jobs/')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
target = next(item for item in response.data if item['id'] == job.id)
|
||||||
|
|
||||||
|
self.assertIn('batch_advance_records', target)
|
||||||
|
self.assertEqual(len(target['batch_advance_records']), 1)
|
||||||
|
|
||||||
|
rec = target['batch_advance_records'][0]
|
||||||
|
self.assertEqual(rec['id'], record.id)
|
||||||
|
self.assertEqual(rec['printing_order'], self.printing_order.id)
|
||||||
|
self.assertEqual(rec['state'], self.state1.id)
|
||||||
|
self.assertEqual(rec['state_id'], self.state1.id)
|
||||||
|
self.assertEqual(rec['state_name'], self.state1.name)
|
||||||
|
self.assertEqual(rec['created_by'], self.user.id)
|
||||||
|
self.assertEqual(rec['created_by_username'], self.user.username)
|
||||||
|
self.assertEqual(rec['created_by_name'], self.employee.name)
|
||||||
|
self.assertEqual(rec['parameters']['temperature'], '25.5')
|
||||||
|
self.assertEqual(rec['parameters']['operator'], '张三')
|
||||||
|
|
||||||
|
# detail: 同样应包含 batch_advance_records
|
||||||
|
response = self.client.get(f'/api/v1/printing-jobs/{job.id}/')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertIn('batch_advance_records', response.data)
|
||||||
|
self.assertEqual(len(response.data['batch_advance_records']), 1)
|
||||||
|
|
||||||
def test_update_printing_job(self):
|
def test_update_printing_job(self):
|
||||||
"""测试更新款式明细"""
|
"""测试更新款式明细"""
|
||||||
@@ -453,6 +517,9 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
job = printing_models.PrintingJob.objects.get(id=response.data['id'])
|
job = printing_models.PrintingJob.objects.get(id=response.data['id'])
|
||||||
self.assertIsNotNone(job.business_object)
|
self.assertIsNotNone(job.business_object)
|
||||||
self.assertEqual(job.business_object.process, self.process)
|
self.assertEqual(job.business_object.process, self.process)
|
||||||
|
# 新逻辑:BusinessObject 绑定回 PrintingJob
|
||||||
|
self.assertEqual(job.business_object.content_type_id, ContentType.objects.get_for_model(printing_models.PrintingJob).id)
|
||||||
|
self.assertEqual(job.business_object.object_id, job.id)
|
||||||
|
|
||||||
def test_job_status_in_detail(self):
|
def test_job_status_in_detail(self):
|
||||||
"""测试任务详情包含状态字段"""
|
"""测试任务详情包含状态字段"""
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from rest_framework.permissions import BasePermission
|
|||||||
from rest_framework.pagination import LimitOffsetPagination
|
from rest_framework.pagination import LimitOffsetPagination
|
||||||
from rest_framework.permissions import DjangoModelPermissions
|
from rest_framework.permissions import DjangoModelPermissions
|
||||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||||
from django.db.models import CharField
|
from django.db.models import CharField, Prefetch
|
||||||
from django.db.models.functions import Cast, Coalesce
|
from django.db.models.functions import Cast, Coalesce
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from django_filters import rest_framework as django_filters
|
from django_filters import rest_framework as django_filters
|
||||||
@@ -306,6 +306,14 @@ class PrintingJobViewSet(viewsets.ModelViewSet):
|
|||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
if self.action in ['list', 'retrieve']:
|
if self.action in ['list', 'retrieve']:
|
||||||
queryset = queryset.select_related('printing_order', 'product')
|
queryset = queryset.select_related('printing_order', 'product')
|
||||||
|
# 预取批量推进记录(避免 serializer 产生 N+1)
|
||||||
|
queryset = queryset.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
'batch_advance_records',
|
||||||
|
queryset=models.PrintingJobBatchAdvanceRecord.objects.select_related('state', 'created_by')
|
||||||
|
.order_by('-created_at', '-id'),
|
||||||
|
)
|
||||||
|
)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
|||||||
274
api_v2/tests.py
274
api_v2/tests.py
@@ -10,6 +10,8 @@ from basic_info import models as basic_models
|
|||||||
from printing import models as printing_models
|
from printing import models as printing_models
|
||||||
from business import models as business_models
|
from business import models as business_models
|
||||||
from api_v2.views.printing import PrintingJobByCustomerView
|
from api_v2.views.printing import PrintingJobByCustomerView
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from stateflow import models as stateflow_models
|
||||||
|
|
||||||
|
|
||||||
class QuickCreateEmployeeUserAPITest(TestCase):
|
class QuickCreateEmployeeUserAPITest(TestCase):
|
||||||
@@ -205,5 +207,275 @@ class PrintingJobByCustomerAPITest(TestCase):
|
|||||||
})
|
})
|
||||||
self.assertEqual(len(resp.data), 1)
|
self.assertEqual(len(resp.data), 1)
|
||||||
data = resp.data[0]
|
data = resp.data[0]
|
||||||
self.assertEqual(data['product'], self.product.id)
|
self.assertEqual(data['product']['id'], self.product.id)
|
||||||
self.assertEqual(data['billed_quantity'], '20.00')
|
self.assertEqual(data['billed_quantity'], '20.00')
|
||||||
|
|
||||||
|
|
||||||
|
class PrintingJobBatchAdvanceV2APITest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
# 工厂用户(满足 IsPrintingFactory)
|
||||||
|
self.merchant = basic_models.Merchant.objects.create(
|
||||||
|
name='印染工厂',
|
||||||
|
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||||
|
)
|
||||||
|
self.user = get_user_model().objects.create_user(username='factory_user', password='pass12345')
|
||||||
|
self.employee = basic_models.Employee.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
sys_user=self.user,
|
||||||
|
name='工厂员工',
|
||||||
|
)
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
# 基础客户/产品
|
||||||
|
self.customer = basic_models.Customer.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='客户A',
|
||||||
|
created_by=None,
|
||||||
|
)
|
||||||
|
category = basic_models.ProductCategory.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='品类',
|
||||||
|
product_prefix='FAB',
|
||||||
|
)
|
||||||
|
self.product = basic_models.Product.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
category=category,
|
||||||
|
name='产品A',
|
||||||
|
human_id='FAB-100',
|
||||||
|
width_size=Decimal('150.00'),
|
||||||
|
color='红色',
|
||||||
|
unit=basic_models.ProductUnitEnum.METER,
|
||||||
|
)
|
||||||
|
|
||||||
|
# stateflow:流程 + 3个节点 + 节点参数(第一步必填)
|
||||||
|
self.state1 = stateflow_models.State.objects.create(name='待处理1', description='第一个节点')
|
||||||
|
self.state2 = stateflow_models.State.objects.create(name='待处理2', description='第二个节点')
|
||||||
|
self.state3 = stateflow_models.State.objects.create(name='待处理3', description='第三个节点')
|
||||||
|
|
||||||
|
self.param_required = stateflow_models.StateParameter.objects.create(
|
||||||
|
key='temperature',
|
||||||
|
value='',
|
||||||
|
is_required=True,
|
||||||
|
description='温度(必填)',
|
||||||
|
)
|
||||||
|
self.state1.parameters.add(self.param_required)
|
||||||
|
|
||||||
|
self.process = stateflow_models.Process.objects.create(name='印染流程')
|
||||||
|
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.state3, order=2)
|
||||||
|
|
||||||
|
# printing:订单 + 两条明细(每条明细都有 business_object)
|
||||||
|
self.printing_order = printing_models.PrintingOrder.objects.create(
|
||||||
|
customer=self.customer,
|
||||||
|
fabric='棉',
|
||||||
|
width='150cm',
|
||||||
|
process=self.process,
|
||||||
|
)
|
||||||
|
bo1 = stateflow_models.BusinessObject.objects.create(
|
||||||
|
name='BO-1',
|
||||||
|
process=self.process,
|
||||||
|
)
|
||||||
|
bo2 = stateflow_models.BusinessObject.objects.create(
|
||||||
|
name='BO-2',
|
||||||
|
process=self.process,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.job1 = printing_models.PrintingJob.objects.create(
|
||||||
|
printing_order=self.printing_order,
|
||||||
|
product=self.product,
|
||||||
|
quantity=10,
|
||||||
|
unit='米',
|
||||||
|
business_object=bo1,
|
||||||
|
)
|
||||||
|
self.job2 = printing_models.PrintingJob.objects.create(
|
||||||
|
printing_order=self.printing_order,
|
||||||
|
product=self.product,
|
||||||
|
quantity=20,
|
||||||
|
unit='米',
|
||||||
|
business_object=bo2,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_preview_success_returns_next_state_and_parameters(self):
|
||||||
|
resp = self.client.post(
|
||||||
|
'/api/v2/printing-jobs/batch-advance/preview/',
|
||||||
|
{'printing_job_ids': [self.job1.id, self.job2.id]},
|
||||||
|
format='json'
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertEqual(resp.data['printing_order_id'], self.printing_order.id)
|
||||||
|
self.assertEqual(set(resp.data['printing_job_ids']), {self.job1.id, self.job2.id})
|
||||||
|
|
||||||
|
next_state = resp.data['next_state']
|
||||||
|
self.assertEqual(next_state['id'], self.state1.id)
|
||||||
|
self.assertEqual(next_state['order'], 0)
|
||||||
|
self.assertTrue(any(p['key'] == 'temperature' and p['is_required'] for p in next_state['parameters']))
|
||||||
|
|
||||||
|
def test_preview_fails_when_jobs_not_same_printing_order(self):
|
||||||
|
other_order = printing_models.PrintingOrder.objects.create(
|
||||||
|
customer=self.customer,
|
||||||
|
fabric='麻',
|
||||||
|
width='160cm',
|
||||||
|
process=self.process,
|
||||||
|
)
|
||||||
|
bo3 = stateflow_models.BusinessObject.objects.create(name='BO-3', process=self.process)
|
||||||
|
job3 = printing_models.PrintingJob.objects.create(
|
||||||
|
printing_order=other_order,
|
||||||
|
product=self.product,
|
||||||
|
quantity=5,
|
||||||
|
unit='米',
|
||||||
|
business_object=bo3,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = self.client.post(
|
||||||
|
'/api/v2/printing-jobs/batch-advance/preview/',
|
||||||
|
{'printing_job_ids': [self.job1.id, job3.id]},
|
||||||
|
format='json'
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 400)
|
||||||
|
self.assertIn('printing_order', resp.data.get('detail', ''))
|
||||||
|
|
||||||
|
def test_submit_success_advances_all_and_creates_batch_record(self):
|
||||||
|
resp = self.client.post(
|
||||||
|
'/api/v2/printing-jobs/batch-advance/',
|
||||||
|
{
|
||||||
|
'printing_job_ids': [self.job1.id, self.job2.id],
|
||||||
|
'parameters': {'temperature': '25.5'}
|
||||||
|
},
|
||||||
|
format='json'
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertIn('batch_id', resp.data)
|
||||||
|
|
||||||
|
# stateflow:两条 business_object 都应生成 1 条完成日志,且完成的是 state1
|
||||||
|
self.job1.refresh_from_db()
|
||||||
|
self.job2.refresh_from_db()
|
||||||
|
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)
|
||||||
|
self.assertEqual(self.job2.business_object.state_logs.first().state_id, self.state1.id)
|
||||||
|
|
||||||
|
# printing:批量记录应存在且关联 jobs
|
||||||
|
record = printing_models.PrintingJobBatchAdvanceRecord.objects.get(id=resp.data['batch_id'])
|
||||||
|
self.assertEqual(record.created_by_id, self.user.id)
|
||||||
|
self.assertEqual(record.printing_order_id, self.printing_order.id)
|
||||||
|
self.assertEqual(record.state_id, self.state1.id)
|
||||||
|
self.assertEqual(record.parameters.get('temperature'), '25.5')
|
||||||
|
self.assertEqual(set(record.printing_jobs.values_list('id', flat=True)), {self.job1.id, self.job2.id})
|
||||||
|
|
||||||
|
def test_submit_fails_when_missing_required_parameter(self):
|
||||||
|
resp = self.client.post(
|
||||||
|
'/api/v2/printing-jobs/batch-advance/',
|
||||||
|
{
|
||||||
|
'printing_job_ids': [self.job1.id, self.job2.id],
|
||||||
|
'parameters': {} # 缺少 temperature
|
||||||
|
},
|
||||||
|
format='json'
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 400)
|
||||||
|
self.assertIn('缺失必填参数', resp.data.get('detail', ''))
|
||||||
|
|
||||||
|
# 未产生任何日志/批量记录
|
||||||
|
self.assertEqual(self.job1.business_object.state_logs.count(), 0)
|
||||||
|
self.assertEqual(self.job2.business_object.state_logs.count(), 0)
|
||||||
|
self.assertEqual(printing_models.PrintingJobBatchAdvanceRecord.objects.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessObjectCloneV2APITest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.user = get_user_model().objects.create_user(username='u1', password='pass12345')
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
self.state1 = stateflow_models.State.objects.create(name='S1')
|
||||||
|
self.state2 = stateflow_models.State.objects.create(name='S2')
|
||||||
|
self.process = stateflow_models.Process.objects.create(name='P1')
|
||||||
|
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)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建一条日志 + 参数记录,确保“克隆包含工艺参数”
|
||||||
|
log = stateflow_models.StateFlowRecord.objects.create(
|
||||||
|
business_object=self.bo,
|
||||||
|
state=self.state1,
|
||||||
|
completed_by=self.user,
|
||||||
|
)
|
||||||
|
stateflow_models.StateLogParameterRecord.objects.create(
|
||||||
|
state_log=log,
|
||||||
|
parameters={'temperature': '25.5'},
|
||||||
|
remark='r1',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_clone_business_object_api_returns_new_id(self):
|
||||||
|
new_object_id = 222
|
||||||
|
resp = self.client.post(
|
||||||
|
'/api/v2/stateflow/business-objects/clone/',
|
||||||
|
{
|
||||||
|
'business_object_id': self.bo.id,
|
||||||
|
'content_type': self.bo.content_type_id,
|
||||||
|
'object_id': new_object_id,
|
||||||
|
},
|
||||||
|
format='json'
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertIn('business_object_id', resp.data)
|
||||||
|
|
||||||
|
new_id = resp.data['business_object_id']
|
||||||
|
self.assertNotEqual(new_id, self.bo.id)
|
||||||
|
|
||||||
|
cloned = stateflow_models.BusinessObject.objects.get(id=new_id)
|
||||||
|
self.assertEqual(cloned.process_id, self.bo.process_id)
|
||||||
|
self.assertEqual(cloned.state_logs.count(), self.bo.state_logs.count())
|
||||||
|
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_404(self):
|
||||||
|
resp = self.client.post(
|
||||||
|
'/api/v2/stateflow/business-objects/clone/',
|
||||||
|
{
|
||||||
|
'business_object_id': 999999,
|
||||||
|
'content_type': self.bo.content_type_id,
|
||||||
|
'object_id': 222,
|
||||||
|
},
|
||||||
|
format='json'
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 404)
|
||||||
|
|
||||||
|
def test_clone_business_object_api_rejects_mismatched_content_type(self):
|
||||||
|
# 用另一个 ContentType(比如 State)
|
||||||
|
ct_state = ContentType.objects.get_for_model(stateflow_models.State)
|
||||||
|
self.assertNotEqual(ct_state.id, self.bo.content_type_id)
|
||||||
|
|
||||||
|
resp = self.client.post(
|
||||||
|
'/api/v2/stateflow/business-objects/clone/',
|
||||||
|
{
|
||||||
|
'business_object_id': self.bo.id,
|
||||||
|
'content_type': ct_state.id,
|
||||||
|
'object_id': 222,
|
||||||
|
},
|
||||||
|
format='json'
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 400)
|
||||||
|
self.assertIn('content_type', resp.data.get('detail', ''))
|
||||||
|
|
||||||
|
def test_clone_business_object_api_unauthorized(self):
|
||||||
|
client = APIClient()
|
||||||
|
resp = client.post(
|
||||||
|
'/api/v2/stateflow/business-objects/clone/',
|
||||||
|
{
|
||||||
|
'business_object_id': self.bo.id,
|
||||||
|
'content_type': self.bo.content_type_id,
|
||||||
|
'object_id': 222,
|
||||||
|
},
|
||||||
|
format='json'
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 401)
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from api_v2.views import QuickCreateEmployeeUserView, PrintingJobByCustomerView
|
from api_v2.views import (
|
||||||
|
QuickCreateEmployeeUserView,
|
||||||
|
PrintingJobByCustomerView,
|
||||||
|
PrintingJobBatchAdvancePreviewView,
|
||||||
|
PrintingJobBatchAdvanceSubmitView,
|
||||||
|
BusinessObjectCloneView,
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('users/quick-create/', QuickCreateEmployeeUserView.as_view(), name='api_v2_user_quick_create'),
|
path('users/quick-create/', QuickCreateEmployeeUserView.as_view(), name='api_v2_user_quick_create'),
|
||||||
path('printing-jobs/by-customer/', PrintingJobByCustomerView.as_view(), name='api_v2_printing_job_by_customer'),
|
path('printing-jobs/by-customer/', PrintingJobByCustomerView.as_view(), name='api_v2_printing_job_by_customer'),
|
||||||
|
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('stateflow/business-objects/clone/', BusinessObjectCloneView.as_view(), name='api_v2_stateflow_business_object_clone'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,20 @@ api_v2 视图包。
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .users import QuickCreateEmployeeUserView
|
from .users import QuickCreateEmployeeUserView
|
||||||
from .printing import PrintingJobByCustomerView, PrintingJobV2Serializer
|
from .printing import (
|
||||||
|
PrintingJobByCustomerView,
|
||||||
|
PrintingJobV2Serializer,
|
||||||
|
PrintingJobBatchAdvancePreviewView,
|
||||||
|
PrintingJobBatchAdvanceSubmitView,
|
||||||
|
)
|
||||||
|
from .stateflow import BusinessObjectCloneView
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'QuickCreateEmployeeUserView',
|
'QuickCreateEmployeeUserView',
|
||||||
'PrintingJobByCustomerView',
|
'PrintingJobByCustomerView',
|
||||||
'PrintingJobV2Serializer',
|
'PrintingJobV2Serializer',
|
||||||
|
'PrintingJobBatchAdvancePreviewView',
|
||||||
|
'PrintingJobBatchAdvanceSubmitView',
|
||||||
|
'BusinessObjectCloneView',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.db import transaction
|
||||||
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 basic_info import models as basic_models
|
||||||
from printing import models as printing_models
|
from printing import models as printing_models
|
||||||
from api_man.serializers import ProductSerializer
|
from api_man.serializers import ProductSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class IsPrintingFactory(permissions.BasePermission):
|
||||||
|
"""仅允许印染工厂用户访问(与 api_v1 逻辑保持一致)"""
|
||||||
|
|
||||||
|
message = '您没有访问印染订单的权限'
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if not request.user or not request.user.is_authenticated:
|
||||||
|
return False
|
||||||
|
if hasattr(request.user, 'employee'):
|
||||||
|
return request.user.employee.merchant.type == basic_models.MerchantTypeEnum.FACTORY
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class PrintingJobV2Serializer(serializers.ModelSerializer):
|
class PrintingJobV2Serializer(serializers.ModelSerializer):
|
||||||
"""v2 独立的印染任务序列化器,包含开单数量"""
|
"""v2 独立的印染任务序列化器,包含开单数量"""
|
||||||
|
|
||||||
@@ -138,3 +153,210 @@ class PrintingJobByCustomerView(APIView):
|
|||||||
|
|
||||||
serializer = self.serializer_class(queryset, many=True)
|
serializer = self.serializer_class(queryset, many=True)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class PrintingJobBatchAdvancePreviewRequestSerializer(serializers.Serializer):
|
||||||
|
"""批量推进:预览/校验请求"""
|
||||||
|
|
||||||
|
printing_job_ids = serializers.ListField(
|
||||||
|
child=serializers.IntegerField(min_value=1),
|
||||||
|
allow_empty=False,
|
||||||
|
help_text='需要批量推进的 printing_job id 列表',
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_printing_job_ids(self, value):
|
||||||
|
# 去重保持稳定性(前端可能重复传)
|
||||||
|
deduped = list(dict.fromkeys(value))
|
||||||
|
if not deduped:
|
||||||
|
raise serializers.ValidationError('printing_job_ids 不能为空')
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
|
||||||
|
class PrintingJobBatchAdvanceSubmitRequestSerializer(PrintingJobBatchAdvancePreviewRequestSerializer):
|
||||||
|
"""批量推进:提交请求"""
|
||||||
|
|
||||||
|
parameters = serializers.DictField(
|
||||||
|
child=serializers.JSONField(),
|
||||||
|
required=False,
|
||||||
|
default=dict,
|
||||||
|
help_text='与单条推进接口一致的工艺参数(将作为 **kwargs 传给 stateflow)',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_jobs_for_batch_advance(printing_job_ids: list[int]):
|
||||||
|
"""
|
||||||
|
批量推进的核心一致性校验(preview 与 submit 共用)
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1) 所有 id 都存在
|
||||||
|
2) 全部属于同一个 printing_order
|
||||||
|
3) 全部存在 business_object(流程实例)
|
||||||
|
4) 全部具有相同的 next_pending_state(下一待执行节点),否则不允许批量
|
||||||
|
|
||||||
|
注意:这里不做“竞态”处理(preview 后 submit 前状态变化),submit 时会再次调用该函数重新校验。
|
||||||
|
后续如需增强,可在 preview 返回 snapshot token,在 submit 校验 token 以提升用户体验。
|
||||||
|
"""
|
||||||
|
# 查询并校验存在性
|
||||||
|
qs = (
|
||||||
|
printing_models.PrintingJob.objects
|
||||||
|
.select_related('printing_order', 'business_object', 'business_object__process')
|
||||||
|
.filter(id__in=printing_job_ids)
|
||||||
|
)
|
||||||
|
jobs = list(qs)
|
||||||
|
found_ids = {j.id for j in jobs}
|
||||||
|
missing_ids = [str(i) for i in printing_job_ids if i not in found_ids]
|
||||||
|
if missing_ids:
|
||||||
|
raise serializers.ValidationError({'detail': f'以下 printing_job 不存在: {", ".join(missing_ids)}'})
|
||||||
|
|
||||||
|
# 同一订单
|
||||||
|
order_ids = {j.printing_order_id for j in jobs}
|
||||||
|
if len(order_ids) != 1:
|
||||||
|
raise serializers.ValidationError({'detail': '所选明细不属于同一个 printing_order,无法批量推进'})
|
||||||
|
printing_order_id = next(iter(order_ids))
|
||||||
|
printing_order = jobs[0].printing_order
|
||||||
|
|
||||||
|
# 必须有关联流程实例
|
||||||
|
no_bo = [str(j.id) for j in jobs if not j.business_object_id]
|
||||||
|
if no_bo:
|
||||||
|
raise serializers.ValidationError({'detail': f'以下 printing_job 未关联流程实例(business_object),无法推进: {", ".join(no_bo)}'})
|
||||||
|
|
||||||
|
# 计算并校验 next_pending_state 一致
|
||||||
|
from stateflow import services as stateflow_services
|
||||||
|
|
||||||
|
next_infos = []
|
||||||
|
for j in jobs:
|
||||||
|
info = stateflow_services.get_next_pending_state(j.business_object, include_parameters=True)
|
||||||
|
if info is None:
|
||||||
|
next_infos.append((j.id, None))
|
||||||
|
else:
|
||||||
|
next_infos.append((j.id, info))
|
||||||
|
|
||||||
|
# 不能包含“无待执行节点”(流程已完成或无节点)
|
||||||
|
cannot_advance = [str(job_id) for job_id, info in next_infos if info is None]
|
||||||
|
if cannot_advance:
|
||||||
|
raise serializers.ValidationError({'detail': f'以下 printing_job 没有待执行节点(流程已完成或无节点),无法批量推进: {", ".join(cannot_advance)}'})
|
||||||
|
|
||||||
|
# 比对 state_id
|
||||||
|
first_info = next_infos[0][1]
|
||||||
|
target_state = first_info['state']
|
||||||
|
target_order = first_info['order']
|
||||||
|
target_state_id = target_state.id
|
||||||
|
|
||||||
|
diff_jobs = []
|
||||||
|
for job_id, info in next_infos:
|
||||||
|
if info['state'].id != target_state_id:
|
||||||
|
diff_jobs.append(str(job_id))
|
||||||
|
if diff_jobs:
|
||||||
|
raise serializers.ValidationError({'detail': f'所选明细当前待执行节点不一致,无法批量推进(不同节点的 jobs: {", ".join(diff_jobs)})'})
|
||||||
|
|
||||||
|
# 参数定义取目标节点(所有一致)
|
||||||
|
target_parameters = first_info.get('parameters', []) or []
|
||||||
|
|
||||||
|
return {
|
||||||
|
'printing_order': printing_order,
|
||||||
|
'printing_order_id': printing_order_id,
|
||||||
|
'jobs': jobs,
|
||||||
|
'target_state': target_state,
|
||||||
|
'target_order': target_order,
|
||||||
|
'target_parameters': target_parameters,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PrintingJobBatchAdvancePreviewView(APIView):
|
||||||
|
"""
|
||||||
|
批量推进:预览
|
||||||
|
|
||||||
|
作用:
|
||||||
|
- 校验 printing_job_ids 是否可批量推进(同订单/同待执行节点)
|
||||||
|
- 返回“下一步待执行节点”的信息及其工艺参数定义,供前端生成批量表单
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [permissions.IsAuthenticated, IsPrintingFactory]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
srz = PrintingJobBatchAdvancePreviewRequestSerializer(data=request.data)
|
||||||
|
srz.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
data = _validate_jobs_for_batch_advance(srz.validated_data['printing_job_ids'])
|
||||||
|
|
||||||
|
from stateflow.serializers import StateParameterSerializer
|
||||||
|
|
||||||
|
params_srz = StateParameterSerializer(
|
||||||
|
data['target_parameters'],
|
||||||
|
many=True,
|
||||||
|
context={'request': request},
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'printing_order_id': data['printing_order_id'],
|
||||||
|
'printing_job_ids': [j.id for j in data['jobs']],
|
||||||
|
'next_state': {
|
||||||
|
'id': data['target_state'].id,
|
||||||
|
'name': data['target_state'].name,
|
||||||
|
'description': data['target_state'].description,
|
||||||
|
'order': data['target_order'],
|
||||||
|
'parameters': params_srz.data,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class PrintingJobBatchAdvanceSubmitView(APIView):
|
||||||
|
"""
|
||||||
|
批量推进:提交
|
||||||
|
|
||||||
|
规则:全成功/全失败
|
||||||
|
- 任意一个 job 推进失败:整体回滚(不产生任何 stateflow 日志,也不产生批量推进记录)
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [permissions.IsAuthenticated, IsPrintingFactory]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
srz = PrintingJobBatchAdvanceSubmitRequestSerializer(data=request.data)
|
||||||
|
srz.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
payload = srz.validated_data
|
||||||
|
parameters = payload.get('parameters') or {}
|
||||||
|
|
||||||
|
data = _validate_jobs_for_batch_advance(payload['printing_job_ids'])
|
||||||
|
|
||||||
|
from stateflow import services as stateflow_services
|
||||||
|
|
||||||
|
# 全成功/全失败:用事务包住整个批量推进
|
||||||
|
with transaction.atomic():
|
||||||
|
record = printing_models.PrintingJobBatchAdvanceRecord.objects.create(
|
||||||
|
printing_order=data['printing_order'],
|
||||||
|
state=data['target_state'],
|
||||||
|
created_by=request.user,
|
||||||
|
parameters=parameters,
|
||||||
|
)
|
||||||
|
record.printing_jobs.set(data['jobs'])
|
||||||
|
|
||||||
|
# 逐个复用单条推进逻辑
|
||||||
|
last_message = None
|
||||||
|
for job in data['jobs']:
|
||||||
|
ok, msg, _state_log = stateflow_services.advance_to_next_state(
|
||||||
|
job.business_object,
|
||||||
|
request.user,
|
||||||
|
**parameters
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
# 抛异常触发事务回滚,保证“全部失败”
|
||||||
|
raise serializers.ValidationError({'detail': msg})
|
||||||
|
last_message = msg
|
||||||
|
|
||||||
|
# 返回最新的 job 列表(可用于前端刷新)
|
||||||
|
refreshed_jobs = (
|
||||||
|
printing_models.PrintingJob.objects
|
||||||
|
.select_related('printing_order', 'product')
|
||||||
|
.filter(id__in=[j.id for j in data['jobs']])
|
||||||
|
.order_by('id')
|
||||||
|
)
|
||||||
|
job_srz = PrintingJobV2Serializer(refreshed_jobs, many=True)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'detail': last_message or '批量推进成功',
|
||||||
|
'batch_id': record.id,
|
||||||
|
'printing_order_id': data['printing_order_id'],
|
||||||
|
'printing_job_ids': [j.id for j in data['jobs']],
|
||||||
|
'jobs': job_srz.data,
|
||||||
|
})
|
||||||
|
|||||||
62
api_v2/views/stateflow.py
Normal file
62
api_v2/views/stateflow.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from rest_framework import serializers, status, permissions
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from stateflow import models as stateflow_models
|
||||||
|
from stateflow import services as stateflow_services
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessObjectCloneRequestSerializer(serializers.Serializer):
|
||||||
|
business_object_id = serializers.IntegerField(min_value=1)
|
||||||
|
# 调用方必须显式传入 content_type 与 object_id:
|
||||||
|
# - content_type:仅用于校验与源对象一致(不做额外校验)
|
||||||
|
# - object_id:克隆后的新业务对象 ID(必须与源对象不同)
|
||||||
|
content_type = serializers.IntegerField(min_value=1, allow_null=True)
|
||||||
|
object_id = serializers.IntegerField(min_value=1, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessObjectCloneView(APIView):
|
||||||
|
"""
|
||||||
|
克隆 BusinessObject(含所有状态流转记录与工艺参数记录)
|
||||||
|
|
||||||
|
POST /api/v2/stateflow/business-objects/clone/
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
{
|
||||||
|
"business_object_id": 123,
|
||||||
|
"content_type": 9,
|
||||||
|
"object_id": 456
|
||||||
|
}
|
||||||
|
|
||||||
|
返回:
|
||||||
|
{
|
||||||
|
"business_object_id": 456
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
srz = BusinessObjectCloneRequestSerializer(data=request.data)
|
||||||
|
srz.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
bo_id = srz.validated_data['business_object_id']
|
||||||
|
expected_content_type_id = srz.validated_data.get('content_type')
|
||||||
|
new_object_id = srz.validated_data.get('object_id')
|
||||||
|
try:
|
||||||
|
bo = stateflow_models.BusinessObject.objects.get(id=bo_id)
|
||||||
|
except stateflow_models.BusinessObject.DoesNotExist:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
return Response({'business_object_id': cloned.id})
|
||||||
|
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
10869
data-bak/db-backup-20251212-190001.sql
Normal file
10869
data-bak/db-backup-20251212-190001.sql
Normal file
File diff suppressed because it is too large
Load Diff
10953
data-bak/db-backup-20251213-190000.sql
Normal file
10953
data-bak/db-backup-20251213-190000.sql
Normal file
File diff suppressed because it is too large
Load Diff
10978
data-bak/db-backup-20251214-190000.sql
Normal file
10978
data-bak/db-backup-20251214-190000.sql
Normal file
File diff suppressed because it is too large
Load Diff
11740
data-bak/db-backup-20251215-190000.sql
Normal file
11740
data-bak/db-backup-20251215-190000.sql
Normal file
File diff suppressed because it is too large
Load Diff
160
docs/api_v2_advance_printing_jobs_api.md
Normal file
160
docs/api_v2_advance_printing_jobs_api.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
### 文档说明
|
||||||
|
本文档描述 `api_v2` 中 **PrintingJob 批量推进流程状态**的两个接口(preview + submit),用于前端生成批量表单与提交批量推进。
|
||||||
|
|
||||||
|
### 背景与术语
|
||||||
|
- **PrintingJob**:概念上等同于“订单明细对象”,归属一个 `PrintingOrder`。
|
||||||
|
- **Stateflow**:PrintingJob 通过 `business_object`(`stateflow.BusinessObject`)承载流程实例;推进逻辑复用 `stateflow.services.advance_to_next_state(...)`。
|
||||||
|
- **批量推进**:对多个 PrintingJob(要求同一订单、同一待执行节点)使用同一份工艺参数推进一个节点。
|
||||||
|
|
||||||
|
### 权限与鉴权
|
||||||
|
- **鉴权**:需要登录(`IsAuthenticated`),使用系统统一 JWT 认证。
|
||||||
|
- **业务权限**:需要印染工厂身份(`IsPrintingFactory`),即 `request.user.employee.merchant.type == FACTORY`。
|
||||||
|
|
||||||
|
### 路由与代码位置
|
||||||
|
- **Base**:`/api/v2/`
|
||||||
|
- **实现文件**:`api_v2/views/printing.py`
|
||||||
|
- **路由定义**:`api_v2/urls.py`
|
||||||
|
|
||||||
|
### 统一校验规则(preview 与 submit 共用)
|
||||||
|
批量推进(或预览)必须同时满足:
|
||||||
|
- **ID 存在**:`printing_job_ids` 全部存在,否则 400。
|
||||||
|
- **同一订单**:所有 job 必须属于同一个 `printing_order`,否则 400。
|
||||||
|
- **必须绑定流程实例**:每个 job 必须有 `business_object`,否则 400。
|
||||||
|
- **同一待执行节点**:所有 job 的 `next_pending_state` 必须一致,否则 400。
|
||||||
|
- **可推进**:任一 job 的 `next_pending_state` 为 `None`(流程已完成或无节点)则 400。
|
||||||
|
|
||||||
|
> 备注:当前 **不做 preview→submit 竞态处理**(例如 preview 通过后,期间有人推进了其中部分 job)。submit 会重新校验;如需更好的用户体验,后续可引入 snapshot token / 幂等键。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 批量推进预览(用于生成批量表单)
|
||||||
|
### Endpoint
|
||||||
|
- **POST** `api/v2/printing-jobs/batch-advance/preview/`
|
||||||
|
|
||||||
|
### Request Body
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"printing_job_ids": [101, 102, 103]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response(200)
|
||||||
|
返回下一待执行节点(统一)及其工艺参数定义:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"printing_order_id": 55,
|
||||||
|
"printing_job_ids": [101, 102, 103],
|
||||||
|
"next_state": {
|
||||||
|
"id": 7,
|
||||||
|
"name": "染色",
|
||||||
|
"description": "染色工序",
|
||||||
|
"order": 0,
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"key": "temperature",
|
||||||
|
"value": "",
|
||||||
|
"attachment": null,
|
||||||
|
"attachment_url": null,
|
||||||
|
"description": "温度(必填)",
|
||||||
|
"is_required": true,
|
||||||
|
"is_image_path": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 典型错误(400)
|
||||||
|
返回 `{"detail": "<原因>"}` 或字段级错误,例如:
|
||||||
|
- 不同订单:`所选明细不属于同一个 printing_order,无法批量推进`
|
||||||
|
- 缺 business_object:`以下 printing_job 未关联流程实例(business_object),无法推进: ...`
|
||||||
|
- next_state 不一致:`所选明细当前待执行节点不一致,无法批量推进...`
|
||||||
|
- 已完成/无节点:`没有待执行节点(流程已完成或无节点)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 批量推进提交(全成功/全失败)
|
||||||
|
### Endpoint
|
||||||
|
- **POST** `api/v2/printing-jobs/batch-advance/`
|
||||||
|
|
||||||
|
### 语义(重要)
|
||||||
|
- **事务全成功/全失败**:接口使用数据库事务包裹:
|
||||||
|
- 创建批量推进记录(审计表)
|
||||||
|
- 逐个调用 `stateflow.services.advance_to_next_state(...)` 推进每个 job 的 `business_object`
|
||||||
|
- 任意一个 job 推进失败:**整体回滚**(不会产生任何 stateflow 日志,也不会产生批量记录)
|
||||||
|
|
||||||
|
### Request Body
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"printing_job_ids": [101, 102, 103],
|
||||||
|
"parameters": {
|
||||||
|
"temperature": "25.5",
|
||||||
|
"operator": "张三"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
说明:
|
||||||
|
- `parameters` 会作为 `**kwargs` 传入 `stateflow.services.advance_to_next_state(...)`。
|
||||||
|
- 若下一节点存在必填参数(`is_required=true`),则必须提供对应 key,否则返回 400(错误信息形如 `缺失必填参数: temperature`)。
|
||||||
|
|
||||||
|
### Response(200)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "已完成状态: 染色",
|
||||||
|
"batch_id": 9,
|
||||||
|
"printing_order_id": 55,
|
||||||
|
"printing_job_ids": [101, 102, 103],
|
||||||
|
"jobs": [
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"printing_order": 55,
|
||||||
|
"product": { "id": 1, "...": "..." },
|
||||||
|
"work_state": 0,
|
||||||
|
"quantity": 10,
|
||||||
|
"width": "150cm",
|
||||||
|
"fabric": "棉",
|
||||||
|
"unit": "米",
|
||||||
|
"size": null,
|
||||||
|
"pieces": null,
|
||||||
|
"description": null,
|
||||||
|
"business_object_id": 888,
|
||||||
|
"created_at": "2025-12-12T10:00:00+08:00",
|
||||||
|
"updated_at": "2025-12-12T10:01:00+08:00",
|
||||||
|
"billed_quantity": "0.00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
说明:
|
||||||
|
- `jobs` 使用 `PrintingJobV2Serializer`:其中 `product` 为嵌套对象(不是纯 ID)。
|
||||||
|
|
||||||
|
### 典型错误(400)
|
||||||
|
- 参数缺失:`{"detail": "缺失必填参数: temperature"}`
|
||||||
|
- 不满足一致性校验:同 preview 的错误列表。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 批量推进审计表:PrintingJobBatchAdvanceRecord
|
||||||
|
### 模型
|
||||||
|
`printing.models.PrintingJobBatchAdvanceRecord`
|
||||||
|
|
||||||
|
### 字段(核心)
|
||||||
|
- `created_at` / `updated_at`:来自 `ModelBase`
|
||||||
|
- `created_by`:操作人(可空,删除用户后保留记录)
|
||||||
|
- `printing_order`:所属主订单
|
||||||
|
- `state`:本次被“完成”的流程节点(推进时的 next_pending_state)
|
||||||
|
- `parameters`:本次批量推进提交的参数(JSON)
|
||||||
|
- `printing_jobs`:本次批量推进涉及的明细集合(ManyToMany)
|
||||||
|
|
||||||
|
### 迁移
|
||||||
|
- `printing/migrations/0024_printingjobbatchadvancerecord.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前端联调建议
|
||||||
|
- 先调用 preview 获取 `next_state.parameters` 生成批量表单(必填项根据 `is_required`)。
|
||||||
|
- 表单提交调用 submit;如返回 400,提示 `detail` 并建议用户刷新列表后重试。
|
||||||
|
- 当前未实现竞态 token/幂等键;如需要防重复提交,建议后续加 `Idempotency-Key`(前端生成 UUID,后端在批量记录表上做去重)。
|
||||||
|
|
||||||
|
|
||||||
138
docs/api_v2_stateflow_business_object_clone.md
Normal file
138
docs/api_v2_stateflow_business_object_clone.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
### 文档说明
|
||||||
|
本文档描述 `api_v2` 的 **BusinessObject 克隆**接口与约束,用于在不影响原对象的情况下,克隆一个“流程实例数据完全一致”的新对象(包含日志与工艺参数历史),同时要求绑定到新的业务对象(`content_type + object_id`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目的
|
||||||
|
`stateflow.BusinessObject` 通过:
|
||||||
|
- `process` 指定流程模板;
|
||||||
|
- `state_logs (StateFlowRecord)` 记录每一步完成情况;
|
||||||
|
- `StateLogParameterRecord` 记录每次提交的工艺参数 JSON;
|
||||||
|
- `content_type + object_id`(GenericForeignKey)可选关联到“订单/工单/任务”等业务对象。
|
||||||
|
|
||||||
|
克隆的目的:
|
||||||
|
- **完整复制流程实例的历史数据**(含已撤销记录、含参数记录)用于“另起一份流程实例继续/重跑/复盘”;
|
||||||
|
- **不影响原对象**;
|
||||||
|
- **必须绑定新的业务对象 ID**,否则克隆出的对象与原对象在业务关联上无差异,容易造成误用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. API 概览
|
||||||
|
### 2.1 Endpoint
|
||||||
|
- **POST** `/api/v2/stateflow/business-objects/clone/`
|
||||||
|
|
||||||
|
### 2.2 权限
|
||||||
|
- 需要登录:`IsAuthenticated`
|
||||||
|
- 鉴权方式:项目统一 JWT(同其他 api_v2 端点)
|
||||||
|
|
||||||
|
### 2.3 请求参数(必须)
|
||||||
|
请求体 JSON:
|
||||||
|
- `business_object_id`:源 BusinessObject 的 ID
|
||||||
|
- `content_type`:源对象的 `content_type_id`(仅用于一致性校验)
|
||||||
|
- `object_id`:克隆后新对象要绑定的业务对象 ID(必须是新的)
|
||||||
|
|
||||||
|
> 说明:
|
||||||
|
> - **content_type 仅做 “与源对象是否一致” 校验**,不做额外业务校验。
|
||||||
|
> - **object_id 必须与源对象不同**;否则克隆没有业务意义,后端会拒绝。
|
||||||
|
|
||||||
|
### 2.4 响应
|
||||||
|
成功:
|
||||||
|
- 返回 `business_object_id`(克隆后新对象 ID)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 请求/响应示例
|
||||||
|
### 3.1 成功克隆
|
||||||
|
请求:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"business_object_id": 123,
|
||||||
|
"content_type": 9,
|
||||||
|
"object_id": 456
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应(200):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"business_object_id": 789
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
含义:
|
||||||
|
- `123` 是源流程实例
|
||||||
|
- `789` 是新的流程实例(日志与参数完整复制)
|
||||||
|
- 新实例会绑定到新的业务对象:`content_type_id=9, object_id=456`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 重要校验规则与错误码
|
||||||
|
### 4.1 404:源对象不存在
|
||||||
|
当 `business_object_id` 不存在时:
|
||||||
|
- 返回 **404**
|
||||||
|
```json
|
||||||
|
{ "detail": "business_object 不存在" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 400:content_type 不一致
|
||||||
|
当请求体 `content_type` 与源对象的 `content_type_id` 不一致:
|
||||||
|
- 返回 **400**
|
||||||
|
```json
|
||||||
|
{ "detail": "content_type 与源对象不一致,拒绝克隆" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 400:object_id 相关校验失败
|
||||||
|
当源对象存在 `content_type_id`(即已绑定业务对象)时:
|
||||||
|
- **必须提供** `object_id`;
|
||||||
|
- 且 `object_id` **必须与源对象不同**。
|
||||||
|
|
||||||
|
可能返回的 400:
|
||||||
|
```json
|
||||||
|
{ "detail": "必须提供新的 object_id" }
|
||||||
|
```
|
||||||
|
或
|
||||||
|
```json
|
||||||
|
{ "detail": "object_id 必须与源对象不同" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 400:源对象未绑定业务对象但传了 object_id
|
||||||
|
当源对象 `content_type_id` 为 `null` 时,要求 `object_id` 也为 `null`:
|
||||||
|
```json
|
||||||
|
{ "detail": "源对象未绑定 content_type,object_id 必须为空" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 克隆范围与语义(后端实现约定)
|
||||||
|
后端通过 `stateflow.services.clone_business_object(...)` 实现克隆,核心语义:
|
||||||
|
- **克隆 BusinessObject 的基础字段**:`name / process / description / content_type`;
|
||||||
|
- **克隆后的 object_id 使用请求体传入的 `object_id`**;
|
||||||
|
- **复制所有 StateFlowRecord(state_logs)**:
|
||||||
|
- 包含已撤销记录(`is_cancelled=True`)
|
||||||
|
- 保持 `completed_at / cancelled_at` 等关键字段一致
|
||||||
|
- **复制每条 StateFlowRecord 的 StateLogParameterRecord(parameter_records)**:
|
||||||
|
- 复制 `parameters`(深拷贝 JSON)
|
||||||
|
- 复制 `remark`
|
||||||
|
- 保持 `created_at/updated_at` 一致
|
||||||
|
- **时间戳一致性(严格)**:
|
||||||
|
- `BusinessObject.created_at/updated_at` 也会被同步为源对象值(通过 update 绕过 auto_now/auto_now_add)
|
||||||
|
- **互不影响**:
|
||||||
|
- 修改克隆对象的参数记录/日志不会影响源对象(复制的是新记录)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 与测试的对应关系
|
||||||
|
强校验与数据一致性主要由 **service 层严格测试**保证:
|
||||||
|
- `stateflow/tests/test_clone_business_object.py`
|
||||||
|
|
||||||
|
API 层仅做轻量测试:
|
||||||
|
- `api_v2/tests.py`(成功/404/401/ct不一致)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 变更提示(给前端/调用方)
|
||||||
|
如果你之前使用旧版接口仅传 `business_object_id`:
|
||||||
|
- 现在必须补齐 `content_type` 与新的 `object_id`;
|
||||||
|
- 并且 `object_id` 必须是“新业务对象”的 id(例如新订单、复制出来的订单等)。
|
||||||
|
|
||||||
|
|
||||||
77
docs/batch_submit_state_params.md
Normal file
77
docs/batch_submit_state_params.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
### 背景
|
||||||
|
`PrintingJob` 在业务上等同于“印染订单明细”。当使用批量推进接口(例如 `api_v2` 的批量推进)对多个明细用同一份参数推进流程时,需要在后端保留一条**批量推进审计记录**,并在 `api_v1` 的 PrintingJob 相关接口返回该记录,方便前端展示“这条明细曾经参与过哪些批量推进、当时提交了哪些工艺参数”。
|
||||||
|
|
||||||
|
### 变更点概述(api_v1)
|
||||||
|
在 `api_v1` 的 PrintingJob 相关序列化输出中新增字段:
|
||||||
|
- **`batch_advance_records`**:始终存在(key 永远输出)
|
||||||
|
- 若该 job 没有批量推进记录:返回 `[]`
|
||||||
|
- 若存在记录:返回记录数组(按创建时间倒序)
|
||||||
|
|
||||||
|
> 该字段是新增字段,不会影响老客户端;对前端而言可以直接依赖该 key 的存在性,无需做 “undefined/null” 兼容。
|
||||||
|
|
||||||
|
### 返回字段结构:batch_advance_records
|
||||||
|
每条记录(PrintingJobBatchAdvanceRecord)包含:
|
||||||
|
- `id`: 批量推进记录 ID
|
||||||
|
- `printing_order`: 主订单 ID
|
||||||
|
- `state`: 本次批量推进“完成”的流程节点 ID(即推进时的 next_pending_state)
|
||||||
|
- `state_id`: 同 `state`(便于前端直接取用)
|
||||||
|
- `state_name`: 节点名称
|
||||||
|
- `created_by`: 操作人用户 ID(可能为 null)
|
||||||
|
- `created_by_username`: 操作人用户名(可能为 null)
|
||||||
|
- `created_by_name`: 操作人员工姓名(若用户有 employee,则返回 employee.name,否则 null)
|
||||||
|
- `parameters`: 本次批量推进提交的参数 JSON(与推进接口 `parameters` 完全一致)
|
||||||
|
- `created_at`: 记录创建时间(ISO8601)
|
||||||
|
|
||||||
|
### 示例(无记录)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"printing_order": 55,
|
||||||
|
"product": 1,
|
||||||
|
"quantity": 10,
|
||||||
|
"unit": "米",
|
||||||
|
"batch_advance_records": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例(有记录)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"printing_order": 55,
|
||||||
|
"product": 1,
|
||||||
|
"quantity": 10,
|
||||||
|
"unit": "米",
|
||||||
|
"batch_advance_records": [
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"printing_order": 55,
|
||||||
|
"state": 7,
|
||||||
|
"state_id": 7,
|
||||||
|
"state_name": "染色",
|
||||||
|
"created_by": 1001,
|
||||||
|
"created_by_username": "factory_user",
|
||||||
|
"created_by_name": "张三",
|
||||||
|
"parameters": {
|
||||||
|
"temperature": "25.5",
|
||||||
|
"operator": "张三"
|
||||||
|
},
|
||||||
|
"created_at": "2025-12-14T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 相关实现位置
|
||||||
|
- **serializer 增字段**:`api_v1/views/printing/serializers.py`
|
||||||
|
- `PrintingJobListSerializer.batch_advance_records`
|
||||||
|
- `PrintingJobDetailSerializer.batch_advance_records`
|
||||||
|
- `PrintingJobCreateUpdateSerializer.batch_advance_records`
|
||||||
|
- `PrintingJobBatchAdvanceRecordSerializer`
|
||||||
|
- **避免 N+1**:`api_v1/views/printing/views.py`(`PrintingJobViewSet.get_queryset()` 预取 `batch_advance_records`)
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
- 记录中的 `parameters` 为**批量提交时的原始参数**,不会自动与 stateflow 的参数历史做合并;若需要查看某次推进在 stateflow 中落地的参数记录,请以 stateflow 日志为准。
|
||||||
|
- 当前文档只描述 `api_v1` 输出字段;批量推进行为本身由 `api_v2` 的批量推进接口创建审计记录。
|
||||||
|
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.admin import action
|
from django.contrib.admin import action
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.forms import BaseInlineFormSet
|
from django.forms import BaseInlineFormSet
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from . import models
|
from . import models
|
||||||
@@ -195,6 +196,7 @@ class PrintingOrderAdmin(admin.ModelAdmin):
|
|||||||
# 为所有新创建且没有 business_object 的 job 创建 BusinessObject
|
# 为所有新创建且没有 business_object 的 job 创建 BusinessObject
|
||||||
if isinstance(formset.model, type) and issubclass(formset.model, models.PrintingJob):
|
if isinstance(formset.model, type) and issubclass(formset.model, models.PrintingJob):
|
||||||
order = form.instance
|
order = form.instance
|
||||||
|
ct = ContentType.objects.get_for_model(models.PrintingJob)
|
||||||
for job in order.printing_jobs.all():
|
for job in order.printing_jobs.all():
|
||||||
if not job.business_object and order.process:
|
if not job.business_object and order.process:
|
||||||
from stateflow.models import BusinessObject
|
from stateflow.models import BusinessObject
|
||||||
@@ -202,6 +204,8 @@ class PrintingOrderAdmin(admin.ModelAdmin):
|
|||||||
name=f"PrintingJob-{job.id}",
|
name=f"PrintingJob-{job.id}",
|
||||||
process=order.process,
|
process=order.process,
|
||||||
description=f"印染任务 {job.id} 的流程实例",
|
description=f"印染任务 {job.id} 的流程实例",
|
||||||
|
content_type=ct,
|
||||||
|
object_id=job.id,
|
||||||
)
|
)
|
||||||
job.business_object = business_object
|
job.business_object = business_object
|
||||||
job.save(update_fields=['business_object'])
|
job.save(update_fields=['business_object'])
|
||||||
|
|||||||
76
printing/migrations/0024_printingjobbatchadvancerecord.py
Normal file
76
printing/migrations/0024_printingjobbatchadvancerecord.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-12
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('stateflow', '0021_statelogparameterrecord'),
|
||||||
|
('printing', '0023_alter_printingjob_work_state'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PrintingJobBatchAdvanceRecord',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||||
|
(
|
||||||
|
'parameters',
|
||||||
|
models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
default=dict,
|
||||||
|
help_text='批量推进时提交的参数(与单条推进一致)',
|
||||||
|
verbose_name='工艺参数',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'created_by',
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name='printing_job_batch_advance_records',
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name='创建人',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'printing_order',
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name='batch_advance_records',
|
||||||
|
to='printing.printingorder',
|
||||||
|
verbose_name='印染订单',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'state',
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name='printing_job_batch_advance_records',
|
||||||
|
to='stateflow.state',
|
||||||
|
verbose_name='完成的流程节点',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'printing_jobs',
|
||||||
|
models.ManyToManyField(
|
||||||
|
related_name='batch_advance_records',
|
||||||
|
to='printing.printingjob',
|
||||||
|
verbose_name='印染明细',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '印染任务批量推进记录',
|
||||||
|
'verbose_name_plural': '印染任务批量推进记录',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from flower.common import ModelBase
|
from flower.common import ModelBase
|
||||||
from basic_info import models as basic_models
|
from basic_info import models as basic_models
|
||||||
@@ -139,7 +140,11 @@ class PlateOrder(ModelBase):
|
|||||||
|
|
||||||
# 创建 BusinessObject
|
# 创建 BusinessObject
|
||||||
business_object = stateflow_models.BusinessObject.objects.create(
|
business_object = stateflow_models.BusinessObject.objects.create(
|
||||||
process=process_obj
|
name=f"PlateOrder-{self.id}",
|
||||||
|
process=process_obj,
|
||||||
|
description=f"开版订单 {self.id} 的流程实例",
|
||||||
|
content_type=ContentType.objects.get_for_model(PlateOrder),
|
||||||
|
object_id=self.id,
|
||||||
)
|
)
|
||||||
self.business_object = business_object
|
self.business_object = business_object
|
||||||
# 再次保存以更新 business_object 字段
|
# 再次保存以更新 business_object 字段
|
||||||
@@ -403,3 +408,52 @@ class PrintingJob(ModelBase):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = '印染款式明细'
|
verbose_name = '印染款式明细'
|
||||||
verbose_name_plural = '印染款式明细'
|
verbose_name_plural = '印染款式明细'
|
||||||
|
|
||||||
|
|
||||||
|
class PrintingJobBatchAdvanceRecord(ModelBase):
|
||||||
|
"""
|
||||||
|
印染任务批量推进记录(审计)
|
||||||
|
|
||||||
|
记录一次“对多个 PrintingJob 用同一组参数推进同一步流程节点”的行为:
|
||||||
|
- created_by/created_at:谁在何时做了批量推进
|
||||||
|
- parameters:本次推进提交的工艺参数(JSON)
|
||||||
|
- state:本次被“完成”的流程节点(即推进时的 next_pending_state)
|
||||||
|
- printing_jobs:本次批量推进涉及的所有明细
|
||||||
|
"""
|
||||||
|
|
||||||
|
printing_order = models.ForeignKey(
|
||||||
|
PrintingOrder,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='batch_advance_records',
|
||||||
|
verbose_name='印染订单',
|
||||||
|
)
|
||||||
|
state = models.ForeignKey(
|
||||||
|
stateflow_models.State,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='printing_job_batch_advance_records',
|
||||||
|
verbose_name='完成的流程节点',
|
||||||
|
)
|
||||||
|
created_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='printing_job_batch_advance_records',
|
||||||
|
verbose_name='创建人',
|
||||||
|
)
|
||||||
|
parameters = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='工艺参数',
|
||||||
|
help_text='批量推进时提交的参数(与单条推进一致)',
|
||||||
|
)
|
||||||
|
printing_jobs = models.ManyToManyField(
|
||||||
|
PrintingJob,
|
||||||
|
related_name='batch_advance_records',
|
||||||
|
verbose_name='印染明细',
|
||||||
|
blank=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '印染任务批量推进记录'
|
||||||
|
verbose_name_plural = '印染任务批量推进记录'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Stateflow业务逻辑服务层
|
Stateflow业务逻辑服务层
|
||||||
"""
|
"""
|
||||||
|
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.db import transaction
|
from django.db import transaction
|
||||||
@@ -570,3 +571,122 @@ def get_process_nodes(business_object: 'models.BusinessObject') -> List[dict]:
|
|||||||
}
|
}
|
||||||
for node in process_nodes
|
for node in process_nodes
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def clone_business_object(
|
||||||
|
source: 'models.BusinessObject',
|
||||||
|
*,
|
||||||
|
new_object_id: int,
|
||||||
|
expected_content_type_id: int,
|
||||||
|
) -> 'models.BusinessObject':
|
||||||
|
"""
|
||||||
|
克隆一个 BusinessObject(含所有状态流转记录与工艺参数记录)
|
||||||
|
|
||||||
|
目标:
|
||||||
|
- 不影响原对象
|
||||||
|
- 克隆出“业务数据完全一致”的新对象(process/描述/关联对象/日志/参数等)
|
||||||
|
- 返回新的 BusinessObject 实例
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 会克隆 source.state_logs 的所有记录(含已撤销记录)
|
||||||
|
- 会克隆每条 StateFlowRecord 下的所有 StateLogParameterRecord(含参数 JSON 与 remark)
|
||||||
|
- 会尽量保持 completed_at / cancelled_at / 参数记录 created_at 与原对象一致
|
||||||
|
- created_at/updated_at 等 ModelBase 字段也会被同步(使用 update 绕过 auto_now*)
|
||||||
|
- **重要**:克隆必须提供新的 object_id(当 source.content_type 非空时),否则克隆与业务绑定无差异,容易造成误用
|
||||||
|
- expected_content_type_id 仅用于校验调用方意图:必须与 source.content_type_id 一致,否则拒绝克隆
|
||||||
|
"""
|
||||||
|
if source is None:
|
||||||
|
raise ValueError("source 不能为空")
|
||||||
|
|
||||||
|
# 重新加载 source,确保拿到完整关系(避免调用方未预取导致 N+1)
|
||||||
|
source = (
|
||||||
|
models.BusinessObject.objects
|
||||||
|
.select_related('process', 'content_type')
|
||||||
|
.prefetch_related(
|
||||||
|
'state_logs__state',
|
||||||
|
'state_logs__completed_by',
|
||||||
|
'state_logs__parameter_records',
|
||||||
|
)
|
||||||
|
.get(id=source.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("实际对象不存在,拒绝克隆")
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
cloned = models.BusinessObject.objects.create(
|
||||||
|
name=source.name,
|
||||||
|
process=source.process,
|
||||||
|
description=source.description,
|
||||||
|
content_type=source.content_type,
|
||||||
|
object_id=new_object_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 同步 BusinessObject 的时间戳字段(保持一致性)
|
||||||
|
models.BusinessObject.objects.filter(id=cloned.id).update(
|
||||||
|
created_at=source.created_at,
|
||||||
|
updated_at=source.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 复制 state_logs(按完成时间排序,保证克隆后的序列稳定)
|
||||||
|
source_logs = sorted(
|
||||||
|
list(source.state_logs.all()),
|
||||||
|
key=lambda log: (log.completed_at, log.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
for src_log in source_logs:
|
||||||
|
new_log = models.StateFlowRecord.objects.create(
|
||||||
|
business_object=cloned,
|
||||||
|
state=src_log.state,
|
||||||
|
completed_by=src_log.completed_by,
|
||||||
|
is_cancelled=src_log.is_cancelled,
|
||||||
|
cancelled_at=src_log.cancelled_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 同步 StateFlowRecord 的时间戳字段(含 completed_at)
|
||||||
|
models.StateFlowRecord.objects.filter(id=new_log.id).update(
|
||||||
|
completed_at=src_log.completed_at,
|
||||||
|
created_at=src_log.created_at,
|
||||||
|
updated_at=src_log.updated_at,
|
||||||
|
cancelled_at=src_log.cancelled_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 复制参数记录(按 created_at 排序)
|
||||||
|
src_param_records = sorted(
|
||||||
|
list(src_log.parameter_records.all()),
|
||||||
|
key=lambda rec: (rec.created_at, rec.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
for src_rec in src_param_records:
|
||||||
|
new_rec = models.StateLogParameterRecord.objects.create(
|
||||||
|
state_log=new_log,
|
||||||
|
parameters=copy.deepcopy(src_rec.parameters),
|
||||||
|
remark=src_rec.remark,
|
||||||
|
)
|
||||||
|
models.StateLogParameterRecord.objects.filter(id=new_rec.id).update(
|
||||||
|
created_at=src_rec.created_at,
|
||||||
|
updated_at=src_rec.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 重新加载,确保返回对象的字段与 DB 一致(尤其是时间戳)
|
||||||
|
cloned.refresh_from_db()
|
||||||
|
actual_object.refresh_from_db()
|
||||||
|
|
||||||
|
actual_object.business_object_id = cloned.id
|
||||||
|
actual_object.save()
|
||||||
|
return cloned
|
||||||
|
|||||||
236
stateflow/tests/test_clone_business_object.py
Normal file
236
stateflow/tests/test_clone_business_object.py
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
"""
|
||||||
|
BusinessObject 克隆服务测试
|
||||||
|
|
||||||
|
重点验证:
|
||||||
|
- 克隆后新对象与源对象业务数据一致(process/描述/关联对象/日志/参数)
|
||||||
|
- 时间戳(completed_at/cancelled_at/参数记录 created_at 等)保持一致
|
||||||
|
- 包含已撤销记录
|
||||||
|
- 克隆对象与原对象互不影响
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from stateflow import models, services
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class CloneBusinessObjectServiceTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user1 = User.objects.create_user(username='u1', password='pass')
|
||||||
|
self.user2 = User.objects.create_user(username='u2', password='pass')
|
||||||
|
|
||||||
|
self.state1 = models.State.objects.create(name='状态1', description='第一个节点')
|
||||||
|
self.state2 = models.State.objects.create(name='状态2', description='第二个节点')
|
||||||
|
|
||||||
|
self.process = models.Process.objects.create(name='流程A', description='流程描述')
|
||||||
|
models.ProcessNode.objects.create(process=self.process, state=self.state1, order=0)
|
||||||
|
models.ProcessNode.objects.create(process=self.process, state=self.state2, order=1)
|
||||||
|
|
||||||
|
ct = ContentType.objects.get_for_model(models.Process)
|
||||||
|
self.business_object = models.BusinessObject.objects.create(
|
||||||
|
name='BO-1',
|
||||||
|
process=self.process,
|
||||||
|
description='BO 描述',
|
||||||
|
content_type=ct,
|
||||||
|
object_id=self.process.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 固定时间点
|
||||||
|
tz = timezone.get_default_timezone()
|
||||||
|
self.t1 = timezone.make_aware(datetime.datetime(2025, 12, 1, 10, 0, 0), tz)
|
||||||
|
self.t1a = timezone.make_aware(datetime.datetime(2025, 12, 1, 10, 1, 0), tz)
|
||||||
|
self.t1b = timezone.make_aware(datetime.datetime(2025, 12, 1, 10, 2, 0), tz)
|
||||||
|
self.t2 = timezone.make_aware(datetime.datetime(2025, 12, 2, 9, 0, 0), tz)
|
||||||
|
self.t2a = timezone.make_aware(datetime.datetime(2025, 12, 2, 9, 1, 0), tz)
|
||||||
|
self.t2_cancel = timezone.make_aware(datetime.datetime(2025, 12, 2, 9, 30, 0), tz)
|
||||||
|
|
||||||
|
# 创建两条状态日志:一条正常,一条已撤销
|
||||||
|
log1 = models.StateFlowRecord.objects.create(
|
||||||
|
business_object=self.business_object,
|
||||||
|
state=self.state1,
|
||||||
|
completed_by=self.user1,
|
||||||
|
is_cancelled=False,
|
||||||
|
)
|
||||||
|
models.StateFlowRecord.objects.filter(id=log1.id).update(
|
||||||
|
completed_at=self.t1,
|
||||||
|
created_at=self.t1,
|
||||||
|
updated_at=self.t1,
|
||||||
|
)
|
||||||
|
|
||||||
|
rec1 = models.StateLogParameterRecord.objects.create(
|
||||||
|
state_log=log1,
|
||||||
|
parameters={'temperature': '25.5'},
|
||||||
|
remark='初次测量',
|
||||||
|
)
|
||||||
|
models.StateLogParameterRecord.objects.filter(id=rec1.id).update(
|
||||||
|
created_at=self.t1a,
|
||||||
|
updated_at=self.t1a,
|
||||||
|
)
|
||||||
|
|
||||||
|
rec2 = models.StateLogParameterRecord.objects.create(
|
||||||
|
state_log=log1,
|
||||||
|
parameters={'temperature': '26.0', 'operator': '张三'},
|
||||||
|
remark='复测',
|
||||||
|
)
|
||||||
|
models.StateLogParameterRecord.objects.filter(id=rec2.id).update(
|
||||||
|
created_at=self.t1b,
|
||||||
|
updated_at=self.t1b,
|
||||||
|
)
|
||||||
|
|
||||||
|
log2 = models.StateFlowRecord.objects.create(
|
||||||
|
business_object=self.business_object,
|
||||||
|
state=self.state2,
|
||||||
|
completed_by=self.user2,
|
||||||
|
is_cancelled=True,
|
||||||
|
cancelled_at=self.t2_cancel,
|
||||||
|
)
|
||||||
|
models.StateFlowRecord.objects.filter(id=log2.id).update(
|
||||||
|
completed_at=self.t2,
|
||||||
|
created_at=self.t2,
|
||||||
|
updated_at=self.t2,
|
||||||
|
cancelled_at=self.t2_cancel,
|
||||||
|
)
|
||||||
|
|
||||||
|
rec3 = models.StateLogParameterRecord.objects.create(
|
||||||
|
state_log=log2,
|
||||||
|
parameters={'humidity': '60%'},
|
||||||
|
remark='记录湿度',
|
||||||
|
)
|
||||||
|
models.StateLogParameterRecord.objects.filter(id=rec3.id).update(
|
||||||
|
created_at=self.t2a,
|
||||||
|
updated_at=self.t2a,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 重新加载,确保测试数据时间戳生效
|
||||||
|
self.business_object.refresh_from_db()
|
||||||
|
|
||||||
|
def test_clone_business_object_copies_all_records_and_parameters(self):
|
||||||
|
new_object_id = (self.business_object.object_id or 0) + 1000
|
||||||
|
cloned = services.clone_business_object(
|
||||||
|
self.business_object,
|
||||||
|
new_object_id=new_object_id,
|
||||||
|
expected_content_type_id=self.business_object.content_type_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 基本字段一致
|
||||||
|
self.assertNotEqual(cloned.id, self.business_object.id)
|
||||||
|
self.assertEqual(cloned.name, self.business_object.name)
|
||||||
|
self.assertEqual(cloned.description, self.business_object.description)
|
||||||
|
self.assertEqual(cloned.process_id, self.business_object.process_id)
|
||||||
|
self.assertEqual(cloned.content_type_id, self.business_object.content_type_id)
|
||||||
|
self.assertEqual(cloned.object_id, new_object_id)
|
||||||
|
self.assertNotEqual(cloned.object_id, self.business_object.object_id)
|
||||||
|
|
||||||
|
# ModelBase 时间戳字段同步(严格一致)
|
||||||
|
self.assertEqual(cloned.created_at, self.business_object.created_at)
|
||||||
|
self.assertEqual(cloned.updated_at, self.business_object.updated_at)
|
||||||
|
|
||||||
|
src_logs = list(self.business_object.state_logs.all().order_by('completed_at', 'id'))
|
||||||
|
new_logs = list(cloned.state_logs.all().order_by('completed_at', 'id'))
|
||||||
|
self.assertEqual(len(new_logs), len(src_logs))
|
||||||
|
|
||||||
|
for src, new in zip(src_logs, new_logs):
|
||||||
|
self.assertNotEqual(src.id, new.id)
|
||||||
|
self.assertEqual(new.state_id, src.state_id)
|
||||||
|
self.assertEqual(new.completed_by_id, src.completed_by_id)
|
||||||
|
self.assertEqual(new.is_cancelled, src.is_cancelled)
|
||||||
|
self.assertEqual(new.cancelled_at, src.cancelled_at)
|
||||||
|
self.assertEqual(new.completed_at, src.completed_at)
|
||||||
|
self.assertEqual(new.created_at, src.created_at)
|
||||||
|
self.assertEqual(new.updated_at, src.updated_at)
|
||||||
|
|
||||||
|
src_params = list(src.parameter_records.all().order_by('created_at', 'id'))
|
||||||
|
new_params = list(new.parameter_records.all().order_by('created_at', 'id'))
|
||||||
|
self.assertEqual(len(new_params), len(src_params))
|
||||||
|
|
||||||
|
for src_rec, new_rec in zip(src_params, new_params):
|
||||||
|
self.assertNotEqual(src_rec.id, new_rec.id)
|
||||||
|
self.assertEqual(new_rec.parameters, src_rec.parameters)
|
||||||
|
self.assertEqual(new_rec.remark, src_rec.remark)
|
||||||
|
self.assertEqual(new_rec.created_at, src_rec.created_at)
|
||||||
|
self.assertEqual(new_rec.updated_at, src_rec.updated_at)
|
||||||
|
|
||||||
|
# 已撤销记录的参数默认不可见(克隆也应一致)
|
||||||
|
src_cancelled = src_logs[1]
|
||||||
|
new_cancelled = new_logs[1]
|
||||||
|
self.assertTrue(src_cancelled.is_cancelled)
|
||||||
|
self.assertTrue(new_cancelled.is_cancelled)
|
||||||
|
self.assertEqual(src_cancelled.get_all_parameters_summary(), {})
|
||||||
|
self.assertEqual(new_cancelled.get_all_parameters_summary(), {})
|
||||||
|
self.assertEqual(
|
||||||
|
new_cancelled.get_all_parameters_summary(include_cancelled=True),
|
||||||
|
src_cancelled.get_all_parameters_summary(include_cancelled=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_clone_is_independent_from_source(self):
|
||||||
|
cloned = services.clone_business_object(
|
||||||
|
self.business_object,
|
||||||
|
new_object_id=(self.business_object.object_id or 0) + 1000,
|
||||||
|
expected_content_type_id=self.business_object.content_type_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 修改克隆对象的参数记录,不应影响源对象
|
||||||
|
cloned_first_log = cloned.state_logs.order_by('completed_at', 'id').first()
|
||||||
|
cloned_first_param = cloned_first_log.parameter_records.order_by('created_at', 'id').first()
|
||||||
|
|
||||||
|
cloned_first_param.parameters['temperature'] = '99.9'
|
||||||
|
cloned_first_param.save(update_fields=['parameters'])
|
||||||
|
|
||||||
|
src_first_log = self.business_object.state_logs.order_by('completed_at', 'id').first()
|
||||||
|
src_first_param = src_first_log.parameter_records.order_by('created_at', 'id').first()
|
||||||
|
self.assertEqual(src_first_param.parameters['temperature'], '25.5')
|
||||||
|
|
||||||
|
def test_clone_empty_business_object(self):
|
||||||
|
bo = models.BusinessObject.objects.create(
|
||||||
|
name='BO-empty',
|
||||||
|
process=self.process,
|
||||||
|
description='empty',
|
||||||
|
)
|
||||||
|
cloned = services.clone_business_object(
|
||||||
|
bo,
|
||||||
|
new_object_id=None,
|
||||||
|
expected_content_type_id=None,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(cloned.id, bo.id)
|
||||||
|
self.assertIsNone(cloned.content_type_id)
|
||||||
|
self.assertIsNone(cloned.object_id)
|
||||||
|
self.assertEqual(cloned.state_logs.count(), 0)
|
||||||
|
|
||||||
|
def test_clone_rejects_mismatched_content_type(self):
|
||||||
|
"""content_type 不一致应拒绝克隆"""
|
||||||
|
# 用另一个 ContentType(比如 State)
|
||||||
|
ct_state = ContentType.objects.get_for_model(models.State)
|
||||||
|
self.assertNotEqual(ct_state.id, self.business_object.content_type_id)
|
||||||
|
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
services.clone_business_object(
|
||||||
|
self.business_object,
|
||||||
|
new_object_id=(self.business_object.object_id or 0) + 1000,
|
||||||
|
expected_content_type_id=ct_state.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_clone_requires_new_object_id_when_content_type_present(self):
|
||||||
|
"""源对象有 content_type 时,必须提供新的 object_id"""
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
services.clone_business_object(
|
||||||
|
self.business_object,
|
||||||
|
new_object_id=None,
|
||||||
|
expected_content_type_id=self.business_object.content_type_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_clone_rejects_same_object_id(self):
|
||||||
|
"""object_id 与源对象相同应拒绝克隆"""
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
services.clone_business_object(
|
||||||
|
self.business_object,
|
||||||
|
new_object_id=self.business_object.object_id,
|
||||||
|
expected_content_type_id=self.business_object.content_type_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user