diff --git a/api_v2/tests.py b/api_v2/tests.py index 3db720b..fef87c8 100644 --- a/api_v2/tests.py +++ b/api_v2/tests.py @@ -479,3 +479,195 @@ class BusinessObjectCloneV2APITest(TestCase): format='json' ) self.assertEqual(resp.status_code, 401) + + +class PlateOrderByProcessNodeV2APITest(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='plate_factory_user', password='pass12345') + 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='客户P', + created_by=None, + ) + + # stateflow:流程 + 3个节点 + self.state1 = stateflow_models.State.objects.create(name='节点1') + self.state2 = stateflow_models.State.objects.create(name='节点2') + self.state3 = stateflow_models.State.objects.create(name='节点3') + self.param2 = stateflow_models.StateParameter.objects.create(key='temperature', value='25') + self.state2.parameters.add(self.param2) + + self.process = stateflow_models.Process.objects.create(name='开版流程') + 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:构造不同“下一待执行节点” + self.po_state1 = printing_models.PlateOrder.objects.create( + customer=self.customer, + process=self.process.id, + design_code='PO-A', + style_name='款式A', + ) + + self.po_state2_old = printing_models.PlateOrder.objects.create( + customer=self.customer, + process=self.process.id, + design_code='CODE-X', # 不包含数字,便于测试 search=id + style_name='款式B', + ) + stateflow_models.StateFlowRecord.objects.create( + business_object=self.po_state2_old.business_object, + state=self.state1, + completed_by=self.user, + is_cancelled=False, + ) + # 订单维度参数:模拟该订单曾在 state2 提交过参数,但该状态记录已撤销(因此当前仍处于 state2 待执行) + cancelled_log = stateflow_models.StateFlowRecord.objects.create( + business_object=self.po_state2_old.business_object, + state=self.state2, + completed_by=self.user, + is_cancelled=True, + ) + stateflow_models.StateLogParameterRecord.objects.create( + state_log=cancelled_log, + parameters={'temperature': '30'}, + ) + + self.po_state2_new = printing_models.PlateOrder.objects.create( + customer=self.customer, + process=self.process.id, + design_code='ABC-999', + style_name='款式C', + ) + stateflow_models.StateFlowRecord.objects.create( + business_object=self.po_state2_new.business_object, + state=self.state1, + completed_by=self.user, + is_cancelled=False, + ) + + self.po_state3 = printing_models.PlateOrder.objects.create( + customer=self.customer, + process=self.process.id, + design_code='PO-D', + style_name='款式D', + ) + stateflow_models.StateFlowRecord.objects.create( + business_object=self.po_state3.business_object, + state=self.state1, + completed_by=self.user, + is_cancelled=False, + ) + stateflow_models.StateFlowRecord.objects.create( + business_object=self.po_state3.business_object, + state=self.state2, + completed_by=self.user, + is_cancelled=False, + ) + + # 固定 created_at 用于排序断言 + tz = timezone.get_default_timezone() + t_old = timezone.make_aware(datetime.datetime(2025, 12, 1, 10, 0, 0), tz) + t_new = timezone.make_aware(datetime.datetime(2025, 12, 2, 10, 0, 0), tz) + printing_models.PlateOrder.objects.filter(id=self.po_state2_old.id).update(created_at=t_old) + printing_models.PlateOrder.objects.filter(id=self.po_state2_new.id).update(created_at=t_new) + + self.url = '/api/v2/plate-orders/by-process-node/' + + def test_filter_by_process_node_returns_only_matching_next_pending(self): + resp = self.client.get(self.url, {'process_node_id': self.node2.id}) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data['process_node']['id'], self.node2.id) + self.assertEqual(resp.data['process_node']['state_id'], self.state2.id) + self.assertTrue(any(p['key'] == 'temperature' and p['value'] == '25' for p in resp.data['parameters'])) + + ids = [item['id'] for item in resp.data['results']] + self.assertEqual(set(ids), {self.po_state2_old.id, self.po_state2_new.id}) + + # results 必须包含“订单维度”的参数值(同一节点下不同订单可不同) + by_id = {item['id']: item for item in resp.data['results']} + self.assertIn('process_parameters', by_id[self.po_state2_old.id]) + self.assertIn('process_parameters', by_id[self.po_state2_new.id]) + + # po_state2_old:来自已撤销的 state2 提交记录 + self.assertTrue( + any(p['key'] == 'temperature' and p['value'] == '30' for p in by_id[self.po_state2_old.id]['process_parameters']) + ) + # po_state2_new:从未提交过 state2 参数,value 应为 null + self.assertTrue( + any(p['key'] == 'temperature' and p['value'] is None for p in by_id[self.po_state2_new.id]['process_parameters']) + ) + + def test_process_parameters_schema_and_keys_align_with_template(self): + resp = self.client.get(self.url, {'process_node_id': self.node2.id}) + self.assertEqual(resp.status_code, 200) + + template_keys = [p['key'] for p in resp.data['parameters']] + self.assertGreater(len(template_keys), 0) + + for item in resp.data['results']: + self.assertIn('process_parameters', item) + self.assertIsInstance(item['process_parameters'], list) + + keys = [p.get('key') for p in item['process_parameters']] + self.assertEqual(keys, template_keys) + + # 严格 schema:每项只能有 key/value 两个字段(便于前端做稳定类型定义) + for p in item['process_parameters']: + self.assertEqual(set(p.keys()), {'key', 'value'}) + + def test_when_state_has_no_parameters_returns_empty_process_parameters(self): + # node1 对应 state1:该测试 setUp 中未配置任何参数 + resp = self.client.get(self.url, {'process_node_id': self.node1.id}) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data['parameters'], []) + self.assertEqual(len(resp.data['results']), 1) + self.assertEqual(resp.data['results'][0]['id'], self.po_state1.id) + self.assertEqual(resp.data['results'][0]['process_parameters'], []) + + def test_default_ordering_is_minus_created_at(self): + resp = self.client.get(self.url, {'process_node_id': self.node2.id}) + self.assertEqual(resp.status_code, 200) + ids = [item['id'] for item in resp.data['results']] + self.assertEqual(ids[0], self.po_state2_new.id) + self.assertEqual(ids[1], self.po_state2_old.id) + + def test_ordering_param_created_at_asc(self): + resp = self.client.get(self.url, {'process_node_id': self.node2.id, 'ordering': 'created_at'}) + self.assertEqual(resp.status_code, 200) + ids = [item['id'] for item in resp.data['results']] + self.assertEqual(ids[0], self.po_state2_old.id) + self.assertEqual(ids[1], self.po_state2_new.id) + + def test_search_supports_design_code_icontains(self): + resp = self.client.get(self.url, {'process_node_id': self.node2.id, 'search': 'abc'}) + self.assertEqual(resp.status_code, 200) + ids = [item['id'] for item in resp.data['results']] + self.assertEqual(ids, [self.po_state2_new.id]) + + def test_search_supports_pk_without_extra_param(self): + resp = self.client.get(self.url, {'process_node_id': self.node2.id, 'search': str(self.po_state2_old.id)}) + self.assertEqual(resp.status_code, 200) + ids = [item['id'] for item in resp.data['results']] + self.assertEqual(ids, [self.po_state2_old.id]) + + def test_pagination_limit_offset(self): + resp = self.client.get(self.url, {'process_node_id': self.node2.id, 'limit': 1, 'offset': 0}) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data['count'], 2) + self.assertEqual(len(resp.data['results']), 1) diff --git a/api_v2/urls.py b/api_v2/urls.py index 3beadc5..2d7af7d 100644 --- a/api_v2/urls.py +++ b/api_v2/urls.py @@ -5,6 +5,7 @@ from api_v2.views import ( PrintingJobByCustomerView, PrintingJobBatchAdvancePreviewView, PrintingJobBatchAdvanceSubmitView, + PlateOrderByProcessNodeView, BusinessObjectCloneView, ) @@ -13,6 +14,7 @@ urlpatterns = [ 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('plate-orders/by-process-node/', PlateOrderByProcessNodeView.as_view(), name='api_v2_plate_order_by_process_node'), path('stateflow/business-objects/clone/', BusinessObjectCloneView.as_view(), name='api_v2_stateflow_business_object_clone'), ] diff --git a/api_v2/views/__init__.py b/api_v2/views/__init__.py index c37cde3..00a8554 100644 --- a/api_v2/views/__init__.py +++ b/api_v2/views/__init__.py @@ -8,6 +8,7 @@ from .printing import ( PrintingJobV2Serializer, PrintingJobBatchAdvancePreviewView, PrintingJobBatchAdvanceSubmitView, + PlateOrderByProcessNodeView, ) from .stateflow import BusinessObjectCloneView @@ -17,6 +18,7 @@ __all__ = [ 'PrintingJobV2Serializer', 'PrintingJobBatchAdvancePreviewView', 'PrintingJobBatchAdvanceSubmitView', + 'PlateOrderByProcessNodeView', 'BusinessObjectCloneView', ] diff --git a/api_v2/views/printing.py b/api_v2/views/printing.py index ff3d853..ad1e9e8 100644 --- a/api_v2/views/printing.py +++ b/api_v2/views/printing.py @@ -2,13 +2,17 @@ import datetime from django.utils import timezone from django.db import transaction +from django.db.models import Q, Count, CharField, Prefetch +from django.db.models.functions import Cast, Coalesce from rest_framework import serializers, status, permissions +from rest_framework.pagination import LimitOffsetPagination 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 +from stateflow import models as stateflow_models class IsPrintingFactory(permissions.BasePermission): @@ -360,3 +364,243 @@ class PrintingJobBatchAdvanceSubmitView(APIView): 'printing_job_ids': [j.id for j in data['jobs']], 'jobs': job_srz.data, }) + + +class PlateOrderByProcessNodeSerializer(serializers.ModelSerializer): + """按流程节点筛选 PlateOrder 的列表序列化(尽量保持轻量)""" + + design_code = serializers.SerializerMethodField() + customer_name = serializers.CharField(source='customer.name', read_only=True) + business_object_id = serializers.SerializerMethodField() + process_parameters = serializers.SerializerMethodField() + + class Meta: + model = printing_models.PlateOrder + fields = [ + 'id', + 'design_code', + 'customer', + 'customer_name', + 'style_name', + 'urgency_level', + 'is_invalid', + 'business_object_id', + 'process_parameters', + '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_parameters(self, obj: printing_models.PlateOrder) -> list[dict]: + """ + 返回“订单维度”的工艺参数 key/value(当前 process_node 对应 state 的参数)。 + + 取值来源: + - 优先取该订单 business_object 在目标 state 的**最新一次 StateFlowRecord**(可能是已撤销记录) + 对应的 StateLogParameterRecord 汇总(后提交覆盖先提交)。 + - 若从未提交过该 state 的参数:value 为 null(由前端自行用顶层 parameters 的默认值做兜底/占位) + """ + keys: list[str] = self.context.get('target_parameter_keys') or [] + target_state_id: int | None = self.context.get('target_state_id') + if not keys or not target_state_id: + return [{'key': k, 'value': None} for k in keys] + + bo = getattr(obj, 'business_object', None) + if not bo: + return [{'key': k, 'value': None} for k in keys] + + logs = getattr(bo, '_prefetched_target_state_logs', None) + if logs is None: + # fallback:极少数情况下未预取 + logs = list( + bo.state_logs.filter(state_id=target_state_id).order_by('-completed_at', '-id')[:1] + ) + + latest_log = logs[0] if logs else None + summary: dict = {} + if latest_log: + param_records = getattr(latest_log, '_prefetched_parameter_records', None) + if param_records is None: + param_records = list(latest_log.parameter_records.all().order_by('created_at', 'id')) + for rec in param_records: + summary.update(rec.parameters or {}) + + return [{'key': k, 'value': summary.get(k)} for k in keys] + + +class PlateOrderByProcessNodeView(APIView): + """ + 按 process_node_id 查询“当前处于该节点(NEXT 模式:下一个待执行节点)”的 PlateOrder 列表。 + + GET /api/v2/plate-orders/by-process-node/ + + Query 参数: + - process_node_id: 必填,ProcessNode.id + - search: 可选。支持: + - 纯数字:同时匹配 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_node_id = qp.get('process_node_id') + if not process_node_id: + return Response({'detail': 'process_node_id 为必填参数'}, status=status.HTTP_400_BAD_REQUEST) + + try: + process_node_id_int = int(process_node_id) + except (TypeError, ValueError): + return Response({'detail': 'process_node_id 必须为数字'}, status=status.HTTP_400_BAD_REQUEST) + + try: + process_node = ( + stateflow_models.ProcessNode.objects + .select_related('process', 'state') + .get(id=process_node_id_int) + ) + except stateflow_models.ProcessNode.DoesNotExist: + return Response({'detail': 'process_node 不存在'}, status=status.HTTP_404_NOT_FOUND) + + # 目标节点信息 + target_state_id = process_node.state_id + target_order = process_node.order + process_id = process_node.process_id + + # 目标节点之前的所有 state_id(distinct,便于计数) + prev_state_ids = list( + stateflow_models.ProcessNode.objects + .filter(process_id=process_id, order__lt=target_order) + .order_by('order', 'id') + .values_list('state_id', flat=True) + .distinct() + ) + + queryset = ( + printing_models.PlateOrder.objects + .select_related('customer', 'business_object') + .filter(business_object__isnull=False, business_object__process_id=process_id) + ) + + # NEXT 模式判定:前置节点都已完成(未撤销) + 目标节点尚未完成(未撤销) + if prev_state_ids: + queryset = queryset.annotate( + completed_prev_states=Count( + 'business_object__state_logs__state_id', + filter=Q( + business_object__state_logs__is_cancelled=False, + business_object__state_logs__state_id__in=prev_state_ids, + ), + distinct=True, + ) + ).filter(completed_prev_states=len(prev_state_ids)) + + queryset = queryset.annotate( + completed_target=Count( + 'business_object__state_logs__id', + filter=Q( + business_object__state_logs__is_cancelled=False, + business_object__state_logs__state_id=target_state_id, + ), + distinct=True, + ) + ).filter(completed_target=0) + + # search:同时支持主键与 design_code icontains(不新增额外参数) + search = (qp.get('search') or '').strip() + if search: + if search.isdigit(): + try: + search_id = int(search) + except (TypeError, ValueError): + search_id = None + cond = Q(design_code__icontains=search) + if search_id is not None: + cond = cond | Q(id=search_id) + queryset = queryset.filter(cond) + else: + queryset = queryset.filter(design_code__icontains=search) + + # 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, + ) + + # 为 design_code 排序提供兜底:为空时按主键字符串 + 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') + + # 该节点参数模板(仅 key/value),同时用于 results[*].process_parameters 的 key 集合 + params = list(process_node.state.parameters.order_by('id').values('key', 'value')) + target_keys = [p['key'] for p in params] + + # 预取:目标 state 的最新日志及其参数记录(用于拼装“订单维度”的参数值,避免 N+1) + param_records_prefetch = Prefetch( + 'parameter_records', + queryset=stateflow_models.StateLogParameterRecord.objects.order_by('created_at', 'id'), + to_attr='_prefetched_parameter_records', + ) + target_state_logs_qs = ( + stateflow_models.StateFlowRecord.objects + .filter(state_id=target_state_id) + .order_by('-completed_at', '-id') + .prefetch_related(param_records_prefetch) + ) + queryset = queryset.prefetch_related( + Prefetch( + 'business_object__state_logs', + queryset=target_state_logs_qs, + to_attr='_prefetched_target_state_logs', + ) + ) + + # 分页(limit 默认 20) + 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 = PlateOrderByProcessNodeSerializer( + results, + many=True, + context={ + 'request': request, + 'target_state_id': target_state_id, + 'target_parameter_keys': target_keys, + }, + ) + + return Response({ + 'process_node': { + 'id': process_node.id, + 'process_id': process_id, + 'state_id': target_state_id, + 'state_name': process_node.state.name, + 'order': target_order, + }, + 'parameters': params, + '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, + }) diff --git a/celerybeat-schedule-shm b/celerybeat-schedule-shm index 48ab8d6..29215fd 100644 Binary files a/celerybeat-schedule-shm and b/celerybeat-schedule-shm differ diff --git a/celerybeat-schedule-wal b/celerybeat-schedule-wal index 19143b7..55290f3 100644 Binary files a/celerybeat-schedule-wal and b/celerybeat-schedule-wal differ diff --git a/docs/api_v2_plate_orders_by_process_node.md b/docs/api_v2_plate_orders_by_process_node.md new file mode 100644 index 0000000..6fc6e84 --- /dev/null +++ b/docs/api_v2_plate_orders_by_process_node.md @@ -0,0 +1,119 @@ +## api_v2:按流程节点查询 PlateOrder 列表(含该节点工艺参数 key/value) + +### 目标 +- **给前端提供**:按指定 `process_node_id` 拉取“当前处于该流程节点”的 `PlateOrder` 列表。 +- **同时返回**: + - 该节点(`State`)关联的工艺参数**模板默认值**(key/value) + - **每个订单在该节点上的参数值**(key/value,订单维度;若从未提交则为 null) + +### “处于该节点”的判定语义(与 stateflow 现有实现保持一致) +- 本接口采用 **NEXT 语义**:`process_node` 对应的 `state` 是该 `PlateOrder.business_object` 的 **下一个待执行节点**。 +- 等价规则(忽略已撤销记录 `is_cancelled=True`): + - 该节点之前(同一 `process`、`order` 更小)的所有节点均已完成(存在未撤销的 `StateFlowRecord`) + - 该节点本身尚未完成(不存在未撤销的 `StateFlowRecord`) +- 仅查询满足以下条件的订单: + - `PlateOrder.business_object` 存在 + - `PlateOrder.business_object.process_id == process_node.process_id` + +### 接口信息 +- **Method**:GET +- **Path**:`/api/v2/plate-orders/by-process-node/` +- **认证**:JWT(`IsAuthenticated`) +- **权限**:仅允许印染/工厂侧用户(与 v2 printing 其它接口一致,`IsPrintingFactory`) + +### Query 参数 +| 参数 | 必填 | 类型 | 默认值 | 说明 | +|---|---:|---|---|---| +| `process_node_id` | 是 | int | - | `stateflow.ProcessNode.id` | +| `search` | 否 | string | - | 单一搜索参数:同时支持主键与设计编号(见“查询规则”) | +| `ordering` | 否 | string | `-created_at` | 排序字段(见“排序规则”) | +| `limit` | 否 | int | `20` | 分页大小(LimitOffsetPagination) | +| `offset` | 否 | int | `0` | 分页偏移(LimitOffsetPagination) | + +### 查询规则(search) +- 当 `search` 为**纯数字**: + - 匹配 `PlateOrder.id == int(search)` **或** + - 匹配 `PlateOrder.design_code icontains search` +- 当 `search` 为**非纯数字**: + - 仅匹配 `PlateOrder.design_code icontains search` + +### 排序规则(ordering) +- 默认:`-created_at` +- 支持字段:`id` / `created_at` / `updated_at` / `design_code` +- `design_code` 排序规则: + - 当 `design_code` 为空时,使用 `id` 的字符串作为兜底值参与排序(保证排序稳定、可预测) +- 传入不支持的 `ordering`:返回 **400** + +### 返回值(200) +响应为分页结构,并额外附带节点信息与该节点工艺参数 key/value。 + +#### 顶层字段 +| 字段 | 类型 | 说明 | +|---|---|---| +| `process_node` | object | 目标流程节点信息(见下) | +| `parameters` | array[object] | 该节点 `State.parameters` 的 key/value(**模板默认值**;不是订单提交值) | +| `count` | int | 满足条件的总数 | +| `next` | string\|null | 下一页链接 | +| `previous` | string\|null | 上一页链接 | +| `results` | array[object] | 当前页的 `PlateOrder` 列表(见下) | + +#### `process_node` 字段 +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int | `ProcessNode.id` | +| `process_id` | int | `Process.id` | +| `state_id` | int | `State.id` | +| `state_name` | string | `State.name` | +| `order` | int | 节点顺序号 | + +#### `parameters` 元素字段(仅 key/value) +| 字段 | 类型 | 说明 | +|---|---|---| +| `key` | string | 参数键 | +| `value` | string\|null | 参数默认值(如有) | + +#### `process_parameters` 元素字段(订单维度:严格 schema) +> 该字段存在于 `results[*].process_parameters`,用于“每个订单在当前节点上的参数值”展示/回显。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `key` | string | 参数键;**与顶层 `parameters[*].key` 一致** | +| `value` | JSONValue\|null | 该订单在此节点的最新已提交参数值;若从未提交过该节点参数,则为 null | + +**JSONValue 定义**(用于前端类型定义): +- `string` \| `number` \| `boolean` \| `object` \| `array` \| `null` + +**不变性约束(前端可依赖)**: +- 对任意返回的 `PlateOrder`: + - `process_parameters` **一定存在**,类型恒为 `array` + - `process_parameters` 的 **key 集合与顺序**与顶层 `parameters` **完全一致** + - 若该节点无任何参数:`parameters=[]` 且 `process_parameters=[]` + +**取值规则(value 来源)**: +- 取该订单 `business_object` 在目标 `state` 的**最新一条** `StateFlowRecord`(按 `completed_at` 倒序,`id` 倒序)关联的参数提交汇总;同一条状态日志下多次补充参数时,后提交覆盖先提交。 +- 若该节点当前为“待执行”(NEXT),通常不会有未撤销的完成日志;当订单曾完成过该节点但后续回退时,会存在已撤销的日志,此时仍可能带有历史参数值用于回显。 + +#### `results` 元素字段(PlateOrder 列表项) +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int | PlateOrder 主键 | +| `design_code` | string\|null | 设计编号;若为空则返回 `id` 的字符串兜底值 | +| `customer` | int | 客户 ID | +| `customer_name` | string | 客户名称 | +| `style_name` | string\|null | 款号名称 | +| `urgency_level` | string | 紧急程度 | +| `is_invalid` | bool | 是否作废 | +| `business_object_id` | int\|null | 关联流程实例 ID | +| `process_parameters` | array[object] | **订单维度**的该节点参数 key/value(与顶层 `parameters` 的 key 集合一致;若该订单从未提交过该节点参数,则对应 value 为 null) | +| `created_at` | string | 创建时间(ISO 8601) | +| `updated_at` | string | 更新时间(ISO 8601) | + +### 常见错误码 +| HTTP 状态码 | 场景 | 返回 `detail` | +|---:|---|---| +| 400 | 缺少/非法参数(如 `process_node_id` 非数字、`ordering` 不支持) | 错误原因文本 | +| 401 | 未认证(缺少/无效 JWT) | DRF 默认 | +| 403 | 已认证但无权限(非工厂用户) | `您没有访问印染订单的权限` | +| 404 | `process_node_id` 不存在 | `process_node 不存在` | + +