forked from erp-dev/erp
fix: business.object_id maybe null, missing parameters when plate-Order cloned
This commit is contained in:
290
api_v2/tests.py
290
api_v2/tests.py
@@ -352,6 +352,15 @@ class PrintingJobBatchAdvanceV2APITest(TestCase):
|
||||
# stateflow:两条 business_object 都应生成 1 条完成日志,且完成的是 state1
|
||||
self.job1.refresh_from_db()
|
||||
self.job2.refresh_from_db()
|
||||
# 同时应修复绑定:BusinessObject.content_type/object_id 指向 PrintingJob
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
ct_job = ContentType.objects.get_for_model(printing_models.PrintingJob)
|
||||
self.job1.business_object.refresh_from_db()
|
||||
self.job2.business_object.refresh_from_db()
|
||||
self.assertEqual(self.job1.business_object.content_type_id, ct_job.id)
|
||||
self.assertEqual(self.job1.business_object.object_id, self.job1.id)
|
||||
self.assertEqual(self.job2.business_object.content_type_id, ct_job.id)
|
||||
self.assertEqual(self.job2.business_object.object_id, self.job2.id)
|
||||
self.assertEqual(self.job1.business_object.state_logs.filter(is_cancelled=False).count(), 1)
|
||||
self.assertEqual(self.job2.business_object.state_logs.filter(is_cancelled=False).count(), 1)
|
||||
self.assertEqual(self.job1.business_object.state_logs.first().state_id, self.state1.id)
|
||||
@@ -395,12 +404,15 @@ class BusinessObjectCloneV2APITest(TestCase):
|
||||
stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state1, order=0)
|
||||
stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state2, order=1)
|
||||
|
||||
self.process_src = stateflow_models.Process.objects.create(name='P-src')
|
||||
self.process_dst = stateflow_models.Process.objects.create(name='P-dst')
|
||||
|
||||
ct = ContentType.objects.get_for_model(stateflow_models.Process)
|
||||
self.bo = stateflow_models.BusinessObject.objects.create(
|
||||
name='BO',
|
||||
process=self.process,
|
||||
content_type=ct,
|
||||
object_id=111,
|
||||
object_id=self.process_src.id,
|
||||
)
|
||||
|
||||
# 创建一条日志 + 参数记录,确保“克隆包含工艺参数”
|
||||
@@ -416,7 +428,7 @@ class BusinessObjectCloneV2APITest(TestCase):
|
||||
)
|
||||
|
||||
def test_clone_business_object_api_returns_new_id(self):
|
||||
new_object_id = 222
|
||||
new_object_id = self.process_dst.id
|
||||
resp = self.client.post(
|
||||
'/api/v2/stateflow/business-objects/clone/',
|
||||
{
|
||||
@@ -438,6 +450,78 @@ class BusinessObjectCloneV2APITest(TestCase):
|
||||
self.assertEqual(cloned.content_type_id, self.bo.content_type_id)
|
||||
self.assertEqual(cloned.object_id, new_object_id)
|
||||
|
||||
def test_clone_business_object_api_binds_to_plate_order_business_object(self):
|
||||
"""
|
||||
printing 工作流依赖:新建 PlateOrder 后通过 clone API 复制流程进度,
|
||||
后端应自动把克隆后的 BusinessObject 绑定到目标 PlateOrder.business_object。
|
||||
"""
|
||||
merchant = basic_models.Merchant.objects.create(
|
||||
name='M1',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
customer = basic_models.Customer.objects.create(
|
||||
merchant=merchant,
|
||||
name='C1',
|
||||
created_by=None,
|
||||
)
|
||||
|
||||
# source PlateOrder(会自动创建 business_object)
|
||||
source_po = printing_models.PlateOrder.objects.create(
|
||||
customer=customer,
|
||||
process=self.process.id,
|
||||
design_code='SRC',
|
||||
style_name='S',
|
||||
)
|
||||
self.assertIsNotNone(source_po.business_object_id)
|
||||
|
||||
# 给 source 写一条日志 + 参数,确保克隆后能看到“节点与参数”
|
||||
log = stateflow_models.StateFlowRecord.objects.create(
|
||||
business_object=source_po.business_object,
|
||||
state=self.state1,
|
||||
completed_by=self.user,
|
||||
)
|
||||
stateflow_models.StateLogParameterRecord.objects.create(
|
||||
state_log=log,
|
||||
parameters={'temperature': '25.5'},
|
||||
remark='r1',
|
||||
)
|
||||
|
||||
# target PlateOrder(创建时也会自动创建一个“空 business_object”)
|
||||
target_po = printing_models.PlateOrder.objects.create(
|
||||
customer=customer,
|
||||
process=self.process.id,
|
||||
design_code='DST',
|
||||
style_name='D',
|
||||
)
|
||||
self.assertIsNotNone(target_po.business_object_id)
|
||||
old_target_bo_id = target_po.business_object_id
|
||||
|
||||
ct_po = ContentType.objects.get_for_model(printing_models.PlateOrder)
|
||||
resp = self.client.post(
|
||||
'/api/v2/stateflow/business-objects/clone/',
|
||||
{
|
||||
'business_object_id': source_po.business_object_id,
|
||||
'content_type': ct_po.id,
|
||||
'object_id': target_po.id,
|
||||
},
|
||||
format='json'
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200, resp.data)
|
||||
new_bo_id = resp.data['business_object_id']
|
||||
|
||||
target_po.refresh_from_db()
|
||||
self.assertEqual(target_po.business_object_id, new_bo_id)
|
||||
|
||||
# 目标 PlateOrder 的旧“空 BO”应被安全清理(避免残留多份绑定)
|
||||
self.assertFalse(stateflow_models.BusinessObject.objects.filter(id=old_target_bo_id).exists())
|
||||
|
||||
# 克隆后的日志与参数应存在
|
||||
self.assertEqual(target_po.business_object.state_logs.count(), 1)
|
||||
cloned_log = target_po.business_object.state_logs.first()
|
||||
self.assertEqual(cloned_log.state_id, self.state1.id)
|
||||
self.assertEqual(cloned_log.parameter_records.count(), 1)
|
||||
self.assertEqual(cloned_log.parameter_records.first().parameters.get('temperature'), '25.5')
|
||||
|
||||
def test_clone_business_object_api_404(self):
|
||||
resp = self.client.post(
|
||||
'/api/v2/stateflow/business-objects/clone/',
|
||||
@@ -601,6 +685,8 @@ class PlateOrderByProcessNodeV2APITest(TestCase):
|
||||
|
||||
# results 必须包含“订单维度”的参数值(同一节点下不同订单可不同)
|
||||
by_id = {item['id']: item for item in resp.data['results']}
|
||||
self.assertIn('created_by', by_id[self.po_state2_old.id])
|
||||
self.assertIn('created_by', by_id[self.po_state2_new.id])
|
||||
self.assertIn('process_parameters', by_id[self.po_state2_old.id])
|
||||
self.assertIn('process_parameters', by_id[self.po_state2_new.id])
|
||||
|
||||
@@ -671,3 +757,203 @@ class PlateOrderByProcessNodeV2APITest(TestCase):
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.data['count'], 2)
|
||||
self.assertEqual(len(resp.data['results']), 1)
|
||||
|
||||
|
||||
class PlateOrderByProcessV2APITest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
|
||||
# 工厂用户(满足 IsPrintingFactory)
|
||||
self.merchant = basic_models.Merchant.objects.create(
|
||||
name='开版工厂2',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
self.user = get_user_model().objects.create_user(username='plate_process_user', password='pass12345')
|
||||
basic_models.Employee.objects.create(
|
||||
merchant=self.merchant,
|
||||
sys_user=self.user,
|
||||
name='开版员工2',
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.customer = basic_models.Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='客户PP',
|
||||
created_by=None,
|
||||
)
|
||||
|
||||
# stateflow:流程 + 3个节点(其中 2 个节点有参数,1 个无参数)
|
||||
self.state1 = stateflow_models.State.objects.create(name='画图')
|
||||
self.state2 = stateflow_models.State.objects.create(name='调色')
|
||||
self.state3 = stateflow_models.State.objects.create(name='套样')
|
||||
self.param_len = stateflow_models.StateParameter.objects.create(key='length', value=None)
|
||||
self.param_temp = stateflow_models.StateParameter.objects.create(key='temperature', value=None)
|
||||
self.state1.parameters.add(self.param_len)
|
||||
self.state2.parameters.add(self.param_temp)
|
||||
|
||||
self.process = stateflow_models.Process.objects.create(name='开版流程2')
|
||||
self.node1 = stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state1, order=0)
|
||||
self.node2 = stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state2, order=1)
|
||||
self.node3 = stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state3, order=2)
|
||||
|
||||
# PlateOrder:同一个 process,不同 created_at
|
||||
self.po_no_exec = printing_models.PlateOrder.objects.create(
|
||||
customer=self.customer,
|
||||
process=self.process.id,
|
||||
design_code='CODE-X', # 不包含数字,便于测试 plate_order=pk
|
||||
style_name='款式1',
|
||||
)
|
||||
self.po_exec = printing_models.PlateOrder.objects.create(
|
||||
customer=self.customer,
|
||||
process=self.process.id,
|
||||
design_code='PO-EXEC',
|
||||
style_name='款式2',
|
||||
)
|
||||
# 执行 state1(非撤销)并提交参数
|
||||
log1 = stateflow_models.StateFlowRecord.objects.create(
|
||||
business_object=self.po_exec.business_object,
|
||||
state=self.state1,
|
||||
completed_by=self.user,
|
||||
is_cancelled=False,
|
||||
)
|
||||
stateflow_models.StateLogParameterRecord.objects.create(
|
||||
state_log=log1,
|
||||
parameters={'length': '5米'},
|
||||
)
|
||||
# 执行 state2(撤销)并提交参数(默认不带撤销参数 -> 应视为未执行)
|
||||
cancelled_log2 = stateflow_models.StateFlowRecord.objects.create(
|
||||
business_object=self.po_exec.business_object,
|
||||
state=self.state2,
|
||||
completed_by=self.user,
|
||||
is_cancelled=True,
|
||||
)
|
||||
stateflow_models.StateLogParameterRecord.objects.create(
|
||||
state_log=cancelled_log2,
|
||||
parameters={'temperature': '30'},
|
||||
)
|
||||
|
||||
self.po_out_of_range = printing_models.PlateOrder.objects.create(
|
||||
customer=self.customer,
|
||||
process=self.process.id,
|
||||
design_code='OUT',
|
||||
style_name='款式3',
|
||||
)
|
||||
|
||||
# 固定 created_at
|
||||
tz = timezone.get_default_timezone()
|
||||
t1 = timezone.make_aware(datetime.datetime(2025, 12, 1, 10, 0, 0), tz)
|
||||
t2 = timezone.make_aware(datetime.datetime(2025, 12, 2, 10, 0, 0), tz)
|
||||
t0 = timezone.make_aware(datetime.datetime(2025, 11, 20, 10, 0, 0), tz)
|
||||
printing_models.PlateOrder.objects.filter(id=self.po_no_exec.id).update(created_at=t1)
|
||||
printing_models.PlateOrder.objects.filter(id=self.po_exec.id).update(created_at=t2)
|
||||
printing_models.PlateOrder.objects.filter(id=self.po_out_of_range.id).update(created_at=t0)
|
||||
|
||||
self.url = '/api/v2/plate-orders/by-process/'
|
||||
|
||||
def test_list_by_process_and_date_range(self):
|
||||
resp = self.client.get(
|
||||
self.url,
|
||||
{
|
||||
'process_id': self.process.id,
|
||||
'date_from': '2025-12-01',
|
||||
'date_to': '2025-12-02',
|
||||
}
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.data['process']['id'], self.process.id)
|
||||
ids = [item['id'] for item in resp.data['results']]
|
||||
self.assertEqual(set(ids), {self.po_no_exec.id, self.po_exec.id})
|
||||
# 序列化器字段:created_by 必须存在(允许为 null)
|
||||
for item in resp.data['results']:
|
||||
self.assertIn('created_by', item)
|
||||
|
||||
def test_default_ordering_is_minus_created_at(self):
|
||||
resp = self.client.get(
|
||||
self.url,
|
||||
{
|
||||
'process_id': self.process.id,
|
||||
'date_from': '2025-12-01',
|
||||
'date_to': '2025-12-02',
|
||||
}
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
ids = [item['id'] for item in resp.data['results']]
|
||||
self.assertEqual(ids[0], self.po_exec.id)
|
||||
self.assertEqual(ids[1], self.po_no_exec.id)
|
||||
|
||||
def test_plate_order_param_supports_design_code_icontains(self):
|
||||
resp = self.client.get(
|
||||
self.url,
|
||||
{
|
||||
'process_id': self.process.id,
|
||||
'date_from': '2025-12-01',
|
||||
'date_to': '2025-12-02',
|
||||
'plate_order': 'exec',
|
||||
}
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
ids = [item['id'] for item in resp.data['results']]
|
||||
self.assertEqual(ids, [self.po_exec.id])
|
||||
|
||||
def test_plate_order_param_supports_pk_without_extra_param(self):
|
||||
resp = self.client.get(
|
||||
self.url,
|
||||
{
|
||||
'process_id': self.process.id,
|
||||
'date_from': '2025-12-01',
|
||||
'date_to': '2025-12-02',
|
||||
'plate_order': str(self.po_no_exec.id),
|
||||
}
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
ids = [item['id'] for item in resp.data['results']]
|
||||
self.assertEqual(ids, [self.po_no_exec.id])
|
||||
|
||||
def test_process_params_includes_all_nodes_and_is_executed(self):
|
||||
resp = self.client.get(
|
||||
self.url,
|
||||
{
|
||||
'process_id': self.process.id,
|
||||
'date_from': '2025-12-01',
|
||||
'date_to': '2025-12-02',
|
||||
}
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
by_id = {item['id']: item for item in resp.data['results']}
|
||||
po_exec = by_id[self.po_exec.id]
|
||||
po_no_exec = by_id[self.po_no_exec.id]
|
||||
|
||||
# 每条 PlateOrder 都必须有 process_params,且包含所有节点(按 order)
|
||||
self.assertIn('process_params', po_exec)
|
||||
self.assertEqual([n['order'] for n in po_exec['process_params']], [0, 1, 2])
|
||||
self.assertEqual([n['node_name'] for n in po_exec['process_params']], ['画图', '调色', '套样'])
|
||||
|
||||
# po_exec:state1 已执行;state2 只有撤销记录 -> 默认视为未执行;state3 未执行
|
||||
n1, n2, n3 = po_exec['process_params']
|
||||
self.assertTrue(n1['is_executed'])
|
||||
self.assertFalse(n2['is_executed'])
|
||||
self.assertFalse(n3['is_executed'])
|
||||
|
||||
self.assertEqual([p['key'] for p in n1['params']], ['length'])
|
||||
self.assertEqual(n1['params'][0]['value'], '5米')
|
||||
self.assertEqual([p['key'] for p in n2['params']], ['temperature'])
|
||||
self.assertIsNone(n2['params'][0]['value'])
|
||||
self.assertEqual(n3['params'], [])
|
||||
|
||||
# po_no_exec:全部未执行,参数 value 全为 null
|
||||
self.assertEqual([n['order'] for n in po_no_exec['process_params']], [0, 1, 2])
|
||||
self.assertFalse(po_no_exec['process_params'][0]['is_executed'])
|
||||
self.assertIsNone(po_no_exec['process_params'][0]['params'][0]['value'])
|
||||
|
||||
def test_invalid_ordering_returns_400(self):
|
||||
resp = self.client.get(
|
||||
self.url,
|
||||
{
|
||||
'process_id': self.process.id,
|
||||
'date_from': '2025-12-01',
|
||||
'date_to': '2025-12-02',
|
||||
'ordering': 'unknown',
|
||||
}
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
@@ -6,6 +6,7 @@ from api_v2.views import (
|
||||
PrintingJobBatchAdvancePreviewView,
|
||||
PrintingJobBatchAdvanceSubmitView,
|
||||
PlateOrderByProcessNodeView,
|
||||
PlateOrderByProcessView,
|
||||
BusinessObjectCloneView,
|
||||
)
|
||||
|
||||
@@ -15,6 +16,7 @@ urlpatterns = [
|
||||
path('printing-jobs/batch-advance/preview/', PrintingJobBatchAdvancePreviewView.as_view(), name='api_v2_printing_job_batch_advance_preview'),
|
||||
path('printing-jobs/batch-advance/', PrintingJobBatchAdvanceSubmitView.as_view(), name='api_v2_printing_job_batch_advance_submit'),
|
||||
path('plate-orders/by-process-node/', PlateOrderByProcessNodeView.as_view(), name='api_v2_plate_order_by_process_node'),
|
||||
path('plate-orders/by-process/', PlateOrderByProcessView.as_view(), name='api_v2_plate_order_by_process'),
|
||||
path('stateflow/business-objects/clone/', BusinessObjectCloneView.as_view(), name='api_v2_stateflow_business_object_clone'),
|
||||
]
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from .printing import (
|
||||
PrintingJobBatchAdvancePreviewView,
|
||||
PrintingJobBatchAdvanceSubmitView,
|
||||
PlateOrderByProcessNodeView,
|
||||
PlateOrderByProcessView,
|
||||
)
|
||||
from .stateflow import BusinessObjectCloneView
|
||||
|
||||
@@ -19,6 +20,7 @@ __all__ = [
|
||||
'PrintingJobBatchAdvancePreviewView',
|
||||
'PrintingJobBatchAdvanceSubmitView',
|
||||
'PlateOrderByProcessNodeView',
|
||||
'PlateOrderByProcessView',
|
||||
'BusinessObjectCloneView',
|
||||
]
|
||||
|
||||
|
||||
@@ -338,6 +338,12 @@ class PrintingJobBatchAdvanceSubmitView(APIView):
|
||||
# 逐个复用单条推进逻辑
|
||||
last_message = None
|
||||
for job in data['jobs']:
|
||||
# 修复历史/异常数据:确保该 job 的流程实例正确绑定到 job(避免 BusinessObject.object_id/content_type 为空)
|
||||
stateflow_services.ensure_business_object_bound_to_instance(
|
||||
job.business_object,
|
||||
job,
|
||||
default_name=f"PrintingJob-{job.id}",
|
||||
)
|
||||
ok, msg, _state_log = stateflow_services.advance_to_next_state(
|
||||
job.business_object,
|
||||
request.user,
|
||||
@@ -372,6 +378,7 @@ class PlateOrderByProcessNodeSerializer(serializers.ModelSerializer):
|
||||
design_code = serializers.SerializerMethodField()
|
||||
customer_name = serializers.CharField(source='customer.name', read_only=True)
|
||||
business_object_id = serializers.SerializerMethodField()
|
||||
created_by = serializers.IntegerField(source='created_by_id', read_only=True)
|
||||
process_parameters = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
@@ -385,6 +392,7 @@ class PlateOrderByProcessNodeSerializer(serializers.ModelSerializer):
|
||||
'urgency_level',
|
||||
'is_invalid',
|
||||
'business_object_id',
|
||||
'created_by',
|
||||
'process_parameters',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
@@ -604,3 +612,277 @@ class PlateOrderByProcessNodeView(APIView):
|
||||
'previous': paginator.get_previous_link() if page is not None else None,
|
||||
'results': srz.data,
|
||||
})
|
||||
|
||||
|
||||
class PlateOrderByProcessSerializer(serializers.ModelSerializer):
|
||||
"""按流程(process_id)查询 PlateOrder,附带所有节点的参数汇总(订单维度)"""
|
||||
|
||||
design_code = serializers.SerializerMethodField()
|
||||
customer_name = serializers.CharField(source='customer.name', read_only=True)
|
||||
business_object_id = serializers.SerializerMethodField()
|
||||
created_by = serializers.IntegerField(source='created_by_id', read_only=True)
|
||||
process_params = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = printing_models.PlateOrder
|
||||
fields = [
|
||||
'id',
|
||||
'design_code',
|
||||
'customer',
|
||||
'customer_name',
|
||||
'style_name',
|
||||
'urgency_level',
|
||||
'is_invalid',
|
||||
'business_object_id',
|
||||
'created_by',
|
||||
'process_params',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_design_code(self, obj: printing_models.PlateOrder) -> str | None:
|
||||
return obj.design_code or (str(obj.id) if obj.id else None)
|
||||
|
||||
def get_business_object_id(self, obj: printing_models.PlateOrder) -> int | None:
|
||||
return obj.business_object_id
|
||||
|
||||
def get_process_params(self, obj: printing_models.PlateOrder) -> list[dict]:
|
||||
"""
|
||||
返回所有流程节点的参数视图(订单维度)。
|
||||
|
||||
约束(前端可依赖):
|
||||
- 一定包含 process 的全部节点(按 order 升序)
|
||||
- 每个节点包含 is_executed(默认不含撤销记录)
|
||||
- params 的 key/顺序与该节点 State.parameters 的 key/顺序一致
|
||||
- 未执行或未提交的参数 value 为 null
|
||||
"""
|
||||
nodes_info: list[dict] = self.context.get('process_nodes_info') or []
|
||||
if not nodes_info:
|
||||
return []
|
||||
|
||||
bo = getattr(obj, 'business_object', None)
|
||||
if not bo:
|
||||
# 没有关联流程实例:全部视为未执行
|
||||
result = []
|
||||
for node in nodes_info:
|
||||
result.append({
|
||||
'process_node_id': node['process_node_id'],
|
||||
'state_id': node['state_id'],
|
||||
'node_name': node['node_name'],
|
||||
'order': node['order'],
|
||||
'is_executed': False,
|
||||
'params': [{'key': k, 'value': None} for k in (node.get('keys') or [])],
|
||||
})
|
||||
return result
|
||||
|
||||
logs = getattr(bo, '_prefetched_state_logs_for_process_params', None)
|
||||
if logs is None:
|
||||
state_ids = [n['state_id'] for n in nodes_info]
|
||||
logs = list(
|
||||
bo.state_logs.filter(is_cancelled=False, state_id__in=state_ids)
|
||||
.order_by('-completed_at', '-id')
|
||||
.prefetch_related('parameter_records')
|
||||
)
|
||||
|
||||
# logs 已按时间倒序:第一次出现的 state_id 即“最新一次非撤销执行记录”
|
||||
latest_log_by_state: dict[int, stateflow_models.StateFlowRecord] = {}
|
||||
for log in logs:
|
||||
if log.state_id not in latest_log_by_state:
|
||||
latest_log_by_state[log.state_id] = log
|
||||
|
||||
result = []
|
||||
for node in nodes_info:
|
||||
state_id = node['state_id']
|
||||
keys = node.get('keys') or []
|
||||
log = latest_log_by_state.get(state_id)
|
||||
is_executed = log is not None
|
||||
|
||||
summary: dict = {}
|
||||
if log is not None:
|
||||
param_records = getattr(log, '_prefetched_parameter_records', None)
|
||||
if param_records is None:
|
||||
param_records = list(log.parameter_records.all().order_by('created_at', 'id'))
|
||||
for rec in param_records:
|
||||
summary.update(rec.parameters or {})
|
||||
|
||||
result.append({
|
||||
'process_node_id': node['process_node_id'],
|
||||
'state_id': state_id,
|
||||
'node_name': node['node_name'],
|
||||
'order': node['order'],
|
||||
'is_executed': is_executed,
|
||||
'params': [{'key': k, 'value': summary.get(k) if is_executed else None} for k in keys],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
class PlateOrderByProcessView(APIView):
|
||||
"""
|
||||
按 process_id 查询 PlateOrder 列表,并在每条 PlateOrder 中返回 process 的所有节点参数结构。
|
||||
|
||||
GET /api/v2/plate-orders/by-process/
|
||||
|
||||
Query 参数:
|
||||
- process_id: 必填,Process.id
|
||||
- date_from/date_to: 必填,YYYY-MM-DD(按 PlateOrder.created_at 闭区间过滤)
|
||||
- plate_order: 可选。支持:
|
||||
- 纯数字:同时匹配 id 精确 + design_code icontains
|
||||
- 非纯数字:design_code icontains
|
||||
- ordering: 可选,默认 -created_at,支持: id / created_at / updated_at / design_code
|
||||
- limit/offset: 分页(limit 默认 20)
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated, IsPrintingFactory]
|
||||
|
||||
_ORDERING_FIELDS = {'id', 'created_at', 'updated_at', 'design_code'}
|
||||
|
||||
def get(self, request):
|
||||
qp = request.query_params
|
||||
|
||||
process_id = qp.get('process_id')
|
||||
if not process_id:
|
||||
return Response({'detail': 'process_id 为必填参数'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
process_id_int = int(process_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response({'detail': 'process_id 必须为数字'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
date_from = qp.get('date_from')
|
||||
date_to = qp.get('date_to')
|
||||
if not date_from or not date_to:
|
||||
return Response({'detail': 'date_from 与 date_to 为必填参数'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
start_date = datetime.datetime.strptime(date_from, '%Y-%m-%d').date()
|
||||
end_date = datetime.datetime.strptime(date_to, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
return Response({'detail': '日期格式需为 YYYY-MM-DD'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# 闭区间:包含当日 00:00:00 和 23:59:59.999999
|
||||
start_dt = datetime.datetime.combine(start_date, datetime.time.min)
|
||||
end_dt = datetime.datetime.combine(end_date, datetime.time.max)
|
||||
if timezone.is_naive(start_dt):
|
||||
start_dt = timezone.make_aware(start_dt, timezone.get_default_timezone())
|
||||
if timezone.is_naive(end_dt):
|
||||
end_dt = timezone.make_aware(end_dt, timezone.get_default_timezone())
|
||||
|
||||
try:
|
||||
process = stateflow_models.Process.objects.get(id=process_id_int)
|
||||
except stateflow_models.Process.DoesNotExist:
|
||||
return Response({'detail': 'process 不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# 该 process 的所有节点(含参数定义 key 顺序)
|
||||
params_prefetch = Prefetch(
|
||||
'state__parameters',
|
||||
queryset=stateflow_models.StateParameter.objects.order_by('id'),
|
||||
)
|
||||
process_nodes = list(
|
||||
stateflow_models.ProcessNode.objects
|
||||
.filter(process_id=process_id_int)
|
||||
.select_related('state')
|
||||
.prefetch_related(params_prefetch)
|
||||
.order_by('order', 'id')
|
||||
)
|
||||
process_nodes_info = []
|
||||
state_ids = []
|
||||
for pn in process_nodes:
|
||||
keys = [p.key for p in pn.state.parameters.all()]
|
||||
process_nodes_info.append({
|
||||
'process_node_id': pn.id,
|
||||
'state_id': pn.state_id,
|
||||
'node_name': pn.state.name,
|
||||
'order': pn.order,
|
||||
'keys': keys,
|
||||
})
|
||||
state_ids.append(pn.state_id)
|
||||
|
||||
queryset = (
|
||||
printing_models.PlateOrder.objects
|
||||
.select_related('customer', 'business_object')
|
||||
.filter(
|
||||
process=process_id_int,
|
||||
created_at__gte=start_dt,
|
||||
created_at__lte=end_dt,
|
||||
)
|
||||
)
|
||||
|
||||
# plate_order:同时支持主键与 design_code icontains(不新增额外参数)
|
||||
plate_order = (qp.get('plate_order') or '').strip()
|
||||
if plate_order:
|
||||
if plate_order.isdigit():
|
||||
try:
|
||||
pid = int(plate_order)
|
||||
except (TypeError, ValueError):
|
||||
pid = None
|
||||
cond = Q(design_code__icontains=plate_order)
|
||||
if pid is not None:
|
||||
cond = cond | Q(id=pid)
|
||||
queryset = queryset.filter(cond)
|
||||
else:
|
||||
queryset = queryset.filter(design_code__icontains=plate_order)
|
||||
|
||||
# ordering:默认 -created_at
|
||||
ordering = (qp.get('ordering') or '-created_at').strip() or '-created_at'
|
||||
direction = '-' if ordering.startswith('-') else ''
|
||||
field = ordering[1:] if ordering.startswith('-') else ordering
|
||||
if field not in self._ORDERING_FIELDS:
|
||||
return Response(
|
||||
{'detail': f'ordering 不支持: {ordering}(可选: {", ".join(sorted(self._ORDERING_FIELDS))})'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if field == 'design_code':
|
||||
queryset = queryset.annotate(
|
||||
design_code_normalized=Coalesce('design_code', Cast('id', output_field=CharField()))
|
||||
).order_by(f'{direction}design_code_normalized', 'id')
|
||||
else:
|
||||
queryset = queryset.order_by(f'{direction}{field}', 'id')
|
||||
|
||||
# 预取:非撤销的状态日志及其参数记录(用于计算 is_executed + value)
|
||||
state_ids_unique = sorted(set(state_ids))
|
||||
param_records_prefetch = Prefetch(
|
||||
'parameter_records',
|
||||
queryset=stateflow_models.StateLogParameterRecord.objects.order_by('created_at', 'id'),
|
||||
to_attr='_prefetched_parameter_records',
|
||||
)
|
||||
logs_qs = (
|
||||
stateflow_models.StateFlowRecord.objects
|
||||
.filter(is_cancelled=False)
|
||||
.order_by('-completed_at', '-id')
|
||||
.prefetch_related(param_records_prefetch)
|
||||
)
|
||||
if state_ids_unique:
|
||||
logs_qs = logs_qs.filter(state_id__in=state_ids_unique)
|
||||
queryset = queryset.prefetch_related(
|
||||
Prefetch(
|
||||
'business_object__state_logs',
|
||||
queryset=logs_qs,
|
||||
to_attr='_prefetched_state_logs_for_process_params',
|
||||
)
|
||||
)
|
||||
|
||||
paginator = LimitOffsetPagination()
|
||||
paginator.default_limit = 20
|
||||
page = paginator.paginate_queryset(queryset, request, view=self)
|
||||
results = page if page is not None else list(queryset)
|
||||
|
||||
srz = PlateOrderByProcessSerializer(
|
||||
results,
|
||||
many=True,
|
||||
context={
|
||||
'request': request,
|
||||
'process_nodes_info': process_nodes_info,
|
||||
},
|
||||
)
|
||||
|
||||
return Response({
|
||||
'process': {
|
||||
'id': process.id,
|
||||
'name': process.name,
|
||||
'node_count': len(process_nodes_info),
|
||||
},
|
||||
'count': getattr(paginator, 'count', len(results)),
|
||||
'next': paginator.get_next_link() if page is not None else None,
|
||||
'previous': paginator.get_previous_link() if page is not None else None,
|
||||
'results': srz.data,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from rest_framework import serializers, status, permissions
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
from django.db import transaction
|
||||
|
||||
from stateflow import models as stateflow_models
|
||||
from stateflow import services as stateflow_services
|
||||
@@ -49,11 +51,48 @@ class BusinessObjectCloneView(APIView):
|
||||
return Response({'detail': 'business_object 不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
try:
|
||||
cloned = stateflow_services.clone_business_object(
|
||||
bo,
|
||||
new_object_id=new_object_id,
|
||||
expected_content_type_id=expected_content_type_id,
|
||||
)
|
||||
with transaction.atomic():
|
||||
cloned = stateflow_services.clone_business_object(
|
||||
bo,
|
||||
new_object_id=new_object_id,
|
||||
expected_content_type_id=expected_content_type_id,
|
||||
)
|
||||
|
||||
# 兼容 printing 的“克隆=新建后复制流程进度”工作流:
|
||||
# 目标业务对象(PlateOrder/PrintingJob)本身持有 business_object 字段,
|
||||
# 需要把克隆后的 BusinessObject 回写到目标对象上,否则前端看不到节点/参数历史。
|
||||
try:
|
||||
target_obj = cloned.content_type.get_object_for_this_type(pk=cloned.object_id)
|
||||
except Exception:
|
||||
target_obj = None
|
||||
|
||||
if target_obj is not None:
|
||||
try:
|
||||
bo_field = target_obj._meta.get_field('business_object')
|
||||
except FieldDoesNotExist:
|
||||
bo_field = None
|
||||
|
||||
if bo_field is not None and bo_field.related_model is stateflow_models.BusinessObject:
|
||||
old_bo_id = getattr(target_obj, 'business_object_id', None)
|
||||
target_obj.business_object = cloned
|
||||
target_obj.save(update_fields=['business_object'])
|
||||
|
||||
# 如果目标对象在创建时自动生成了一个“空 business_object”,这里做安全清理,
|
||||
# 避免同一个业务对象存在多个 BusinessObject 绑定导致后续审计/排查混乱。
|
||||
if old_bo_id and old_bo_id != cloned.id:
|
||||
old_bo = (
|
||||
stateflow_models.BusinessObject.objects
|
||||
.filter(id=old_bo_id)
|
||||
.select_related('content_type')
|
||||
.first()
|
||||
)
|
||||
if (
|
||||
old_bo
|
||||
and old_bo.state_logs.count() == 0
|
||||
and old_bo.content_type_id == cloned.content_type_id
|
||||
and old_bo.object_id == cloned.object_id
|
||||
):
|
||||
old_bo.delete()
|
||||
except ValueError as e:
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user