1
0
forked from erp-dev/erp

feat: added plate order clone

This commit is contained in:
2025-12-16 08:42:53 +08:00
parent 4c9b8e6a42
commit 0638dd1ad3
25 changed files with 46174 additions and 8 deletions

View File

@@ -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):
"""开版订单详情序列化器""" """开版订单详情序列化器"""

View File

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

View File

@@ -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):
"""测试推进到下一个状态""" """测试推进到下一个状态"""

View File

@@ -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):
"""测试任务详情包含状态字段""" """测试任务详情包含状态字段"""

View File

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

View File

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

View File

@@ -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'),
] ]

View File

@@ -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',
] ]

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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]
}
```
### Response200
返回下一待执行节点(统一)及其工艺参数定义:
```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`)。
### Response200
```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后端在批量记录表上做去重

View 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 400content_type 不一致
当请求体 `content_type` 与源对象的 `content_type_id` 不一致:
- 返回 **400**
```json
{ "detail": "content_type 与源对象不一致,拒绝克隆" }
```
### 4.3 400object_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_typeobject_id 必须为空" }
```
---
## 5. 克隆范围与语义(后端实现约定)
后端通过 `stateflow.services.clone_business_object(...)` 实现克隆,核心语义:
- **克隆 BusinessObject 的基础字段**`name / process / description / content_type`
- **克隆后的 object_id 使用请求体传入的 `object_id`**
- **复制所有 StateFlowRecordstate_logs**
- 包含已撤销记录(`is_cancelled=True`
- 保持 `completed_at / cancelled_at` 等关键字段一致
- **复制每条 StateFlowRecord 的 StateLogParameterRecordparameter_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例如新订单、复制出来的订单等

View 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` 的批量推进接口创建审计记录。

View File

@@ -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'])

View 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': '印染任务批量推进记录',
},
),
]

View File

@@ -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 = '印染任务批量推进记录'

View File

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

View 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,
)