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()
|
||||
is_completed = serializers.BooleanField(read_only=True)
|
||||
business_object_id = serializers.SerializerMethodField()
|
||||
batch_advance_records = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.PrintingJob
|
||||
@@ -194,6 +195,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
|
||||
'quantity', 'unit', 'size', 'pieces', 'description',
|
||||
'work_state', 'work_state_display',
|
||||
'status', 'is_completed', 'business_object_id',
|
||||
'batch_advance_records',
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
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):
|
||||
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):
|
||||
"""印染款式明细详情序列化器"""
|
||||
@@ -217,6 +230,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
||||
is_completed = serializers.BooleanField(read_only=True)
|
||||
has_started = serializers.BooleanField(read_only=True)
|
||||
business_object_id = serializers.SerializerMethodField()
|
||||
batch_advance_records = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.PrintingJob
|
||||
@@ -225,6 +239,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
||||
'quantity', 'unit', 'size', 'pieces', 'description',
|
||||
'work_state', 'work_state_display',
|
||||
'status', 'status_id', 'is_completed', 'has_started', 'business_object_id',
|
||||
'batch_advance_records',
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = [
|
||||
@@ -239,15 +254,22 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
||||
def get_work_state_display(self, obj):
|
||||
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):
|
||||
"""印染款式明细创建/更新序列化器"""
|
||||
batch_advance_records = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.PrintingJob
|
||||
fields = [
|
||||
'id', 'printing_order', 'product', 'quantity', 'unit', 'size', 'pieces', 'description',
|
||||
'work_state',
|
||||
'batch_advance_records',
|
||||
]
|
||||
read_only_fields = ['id']
|
||||
extra_kwargs = {
|
||||
@@ -290,6 +312,43 @@ class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer):
|
||||
raise serializers.ValidationError(message)
|
||||
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:
|
||||
"""确保 design_code 为空时使用主键"""
|
||||
@@ -320,6 +379,7 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria
|
||||
process_name = serializers.SerializerMethodField()
|
||||
plate_image_url = serializers.SerializerMethodField()
|
||||
last_completed_state = serializers.CharField(read_only=True)
|
||||
content_type_id = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.PlateOrder
|
||||
@@ -340,7 +400,7 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria
|
||||
'approval_result', 'is_ordered', 'customer_feedback', 'print_count',
|
||||
'process', 'process_name',
|
||||
'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'
|
||||
]
|
||||
read_only_fields = [
|
||||
@@ -364,6 +424,17 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria
|
||||
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):
|
||||
"""开版订单详情序列化器"""
|
||||
|
||||
@@ -4,6 +4,7 @@ Printing module business logic services
|
||||
from typing import Dict, Any, Tuple
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from printing import models as printing_models
|
||||
from stateflow import models as stateflow_models
|
||||
from stateflow import services as stateflow_services
|
||||
@@ -126,10 +127,14 @@ class PrintingJobService:
|
||||
|
||||
# 如果 PrintingOrder 有关联的流程,创建 BusinessObject
|
||||
if printing_order and printing_order.process:
|
||||
# 将流程实例绑定到该 PrintingJob(便于跨模块定位与审计)
|
||||
ct = ContentType.objects.get_for_model(printing_models.PrintingJob)
|
||||
business_object = stateflow_models.BusinessObject.objects.create(
|
||||
name=f"PrintingJob-{job.id}",
|
||||
process=printing_order.process,
|
||||
description=f"印染任务 {job.id} 的流程实例",
|
||||
content_type=ct,
|
||||
object_id=job.id,
|
||||
)
|
||||
job.business_object = business_object
|
||||
job.save()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
PlateOrder API 测试
|
||||
"""
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
@@ -294,6 +295,35 @@ class PlateOrderAPITestCase(TestCase):
|
||||
results = response.data['results'] if isinstance(response.data, dict) else response.data
|
||||
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):
|
||||
"""验证布料来源字段在返回中存在"""
|
||||
plate_order = printing_models.PlateOrder.objects.create(
|
||||
@@ -687,6 +717,12 @@ class PlateOrderAPITestCase(TestCase):
|
||||
|
||||
self.assertIsNotNone(plate_order.business_object)
|
||||
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):
|
||||
"""测试推进到下一个状态"""
|
||||
|
||||
@@ -7,6 +7,7 @@ from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from basic_info import models as basic_models
|
||||
from printing import models as printing_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')
|
||||
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(
|
||||
printing_order=self.printing_order,
|
||||
@@ -121,7 +127,7 @@ class PrintingJobAPITestCase(TestCase):
|
||||
def test_list_printing_jobs(self):
|
||||
"""测试获取款式明细列表"""
|
||||
# 创建测试数据
|
||||
printing_models.PrintingJob.objects.create(
|
||||
job1 = printing_models.PrintingJob.objects.create(
|
||||
printing_order=self.printing_order,
|
||||
product=self.product,
|
||||
quantity=100,
|
||||
@@ -129,7 +135,7 @@ class PrintingJobAPITestCase(TestCase):
|
||||
size='50*60',
|
||||
pieces=10
|
||||
)
|
||||
printing_models.PrintingJob.objects.create(
|
||||
job2 = printing_models.PrintingJob.objects.create(
|
||||
printing_order=self.printing_order,
|
||||
product=self.product,
|
||||
quantity=200,
|
||||
@@ -141,6 +147,12 @@ class PrintingJobAPITestCase(TestCase):
|
||||
response = self.client.get('/api/v1/printing-jobs/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
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):
|
||||
"""测试获取款式明细详情"""
|
||||
@@ -160,6 +172,58 @@ class PrintingJobAPITestCase(TestCase):
|
||||
self.assertEqual(response.data['unit'], '米')
|
||||
self.assertIn('product_name', response.data)
|
||||
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):
|
||||
"""测试更新款式明细"""
|
||||
@@ -453,6 +517,9 @@ class PrintingJobAPITestCase(TestCase):
|
||||
job = printing_models.PrintingJob.objects.get(id=response.data['id'])
|
||||
self.assertIsNotNone(job.business_object)
|
||||
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):
|
||||
"""测试任务详情包含状态字段"""
|
||||
|
||||
@@ -8,7 +8,7 @@ from rest_framework.permissions import BasePermission
|
||||
from rest_framework.pagination import LimitOffsetPagination
|
||||
from rest_framework.permissions import DjangoModelPermissions
|
||||
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_filters.rest_framework import DjangoFilterBackend
|
||||
from django_filters import rest_framework as django_filters
|
||||
@@ -306,6 +306,14 @@ class PrintingJobViewSet(viewsets.ModelViewSet):
|
||||
queryset = super().get_queryset()
|
||||
if self.action in ['list', 'retrieve']:
|
||||
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
|
||||
|
||||
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 business import models as business_models
|
||||
from api_v2.views.printing import PrintingJobByCustomerView
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from stateflow import models as stateflow_models
|
||||
|
||||
|
||||
class QuickCreateEmployeeUserAPITest(TestCase):
|
||||
@@ -205,5 +207,275 @@ class PrintingJobByCustomerAPITest(TestCase):
|
||||
})
|
||||
self.assertEqual(len(resp.data), 1)
|
||||
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')
|
||||
|
||||
|
||||
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 api_v2.views import QuickCreateEmployeeUserView, PrintingJobByCustomerView
|
||||
from api_v2.views import (
|
||||
QuickCreateEmployeeUserView,
|
||||
PrintingJobByCustomerView,
|
||||
PrintingJobBatchAdvancePreviewView,
|
||||
PrintingJobBatchAdvanceSubmitView,
|
||||
BusinessObjectCloneView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
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/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 .printing import PrintingJobByCustomerView, PrintingJobV2Serializer
|
||||
from .printing import (
|
||||
PrintingJobByCustomerView,
|
||||
PrintingJobV2Serializer,
|
||||
PrintingJobBatchAdvancePreviewView,
|
||||
PrintingJobBatchAdvanceSubmitView,
|
||||
)
|
||||
from .stateflow import BusinessObjectCloneView
|
||||
|
||||
__all__ = [
|
||||
'QuickCreateEmployeeUserView',
|
||||
'PrintingJobByCustomerView',
|
||||
'PrintingJobV2Serializer',
|
||||
'PrintingJobBatchAdvancePreviewView',
|
||||
'PrintingJobBatchAdvanceSubmitView',
|
||||
'BusinessObjectCloneView',
|
||||
]
|
||||
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
from rest_framework import serializers, status, permissions
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from basic_info import models as basic_models
|
||||
from printing import models as printing_models
|
||||
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):
|
||||
"""v2 独立的印染任务序列化器,包含开单数量"""
|
||||
|
||||
@@ -138,3 +153,210 @@ class PrintingJobByCustomerView(APIView):
|
||||
|
||||
serializer = self.serializer_class(queryset, many=True)
|
||||
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.admin import action
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.forms import BaseInlineFormSet
|
||||
from django.utils.html import format_html
|
||||
from . import models
|
||||
@@ -195,6 +196,7 @@ class PrintingOrderAdmin(admin.ModelAdmin):
|
||||
# 为所有新创建且没有 business_object 的 job 创建 BusinessObject
|
||||
if isinstance(formset.model, type) and issubclass(formset.model, models.PrintingJob):
|
||||
order = form.instance
|
||||
ct = ContentType.objects.get_for_model(models.PrintingJob)
|
||||
for job in order.printing_jobs.all():
|
||||
if not job.business_object and order.process:
|
||||
from stateflow.models import BusinessObject
|
||||
@@ -202,6 +204,8 @@ class PrintingOrderAdmin(admin.ModelAdmin):
|
||||
name=f"PrintingJob-{job.id}",
|
||||
process=order.process,
|
||||
description=f"印染任务 {job.id} 的流程实例",
|
||||
content_type=ct,
|
||||
object_id=job.id,
|
||||
)
|
||||
job.business_object = 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 django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from flower.common import ModelBase
|
||||
from basic_info import models as basic_models
|
||||
@@ -139,7 +140,11 @@ class PlateOrder(ModelBase):
|
||||
|
||||
# 创建 BusinessObject
|
||||
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
|
||||
# 再次保存以更新 business_object 字段
|
||||
@@ -403,3 +408,52 @@ class PrintingJob(ModelBase):
|
||||
class Meta:
|
||||
verbose_name = '印染款式明细'
|
||||
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业务逻辑服务层
|
||||
"""
|
||||
import copy
|
||||
from typing import List, Optional, Tuple
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
@@ -570,3 +571,122 @@ def get_process_nodes(business_object: 'models.BusinessObject') -> List[dict]:
|
||||
}
|
||||
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