From ff1ea2482df8cd193216db4469806aabb42028d4 Mon Sep 17 00:00:00 2001 From: colaftc Date: Thu, 25 Dec 2025 17:18:44 +0800 Subject: [PATCH] feat: new api for plate order (get_plate_order_by_state_status) --- .env | 2 +- .../commands/sync_mdy_plate_orders.py | 35 +++ api_v1/mdy_plate_order_sync.py | 69 +++-- api_v1/views/stateflow/process.py | 16 ++ api_v2/tests.py | 233 +++++++++++++++ api_v2/urls.py | 2 + api_v2/views/__init__.py | 2 + api_v2/views/printing.py | 269 +++++++++++++++++- celerybeat-schedule | Bin 12288 -> 12288 bytes celerybeat-schedule-shm | Bin 32768 -> 0 bytes celerybeat-schedule-wal | Bin 4120032 -> 0 bytes docs/api_v2_plate_orders_by_state_status.md | 112 ++++++++ stateflow/services.py | 56 +++- stateflow/tests/test_business_object_api.py | 29 +- ...st_business_object_crud_and_filters_api.py | 33 ++- stateflow/tests/test_clone_business_object.py | 23 +- stateflow/tests/test_parameters.py | 7 +- .../tests/test_process_list_node_count_api.py | 28 ++ stateflow/tests/test_services.py | 66 +++++ stateflow/tests/test_step_back.py | 6 +- stateflow/tests/test_step_back_api.py | 6 +- 21 files changed, 936 insertions(+), 58 deletions(-) delete mode 100644 celerybeat-schedule-shm delete mode 100644 celerybeat-schedule-wal create mode 100644 docs/api_v2_plate_orders_by_state_status.md diff --git a/.env b/.env index 2bd33ec..47734c6 100644 --- a/.env +++ b/.env @@ -2,7 +2,7 @@ SECRET_KEY=testing_$weak_secret_is_allowd DEBUG=True ALLOWED_HOSTS=yuwenerp.yuwen.cloud,localhost, DB_HOST=127.0.0.1 -DB_PORT=6432 +DB_PORT=5432 DB_NAME=flower DB_USER=postgres DB_PASSWORD=postgres \ No newline at end of file diff --git a/api_v1/management/commands/sync_mdy_plate_orders.py b/api_v1/management/commands/sync_mdy_plate_orders.py index 01c7fb2..f5a2427 100644 --- a/api_v1/management/commands/sync_mdy_plate_orders.py +++ b/api_v1/management/commands/sync_mdy_plate_orders.py @@ -22,6 +22,38 @@ class Command(BaseCommand): default=0.02, help='每次明道云请求之间的最小间隔(用于限流,50qps 建议 >= 0.02)', ) + parser.add_argument( + '--sort-direction', + choices=['asc', 'desc'], + default='asc', + help='按 ctime 升序(asc)或降序(desc)抓取', + ) + parser.add_argument( + '--use-checkpoint', + dest='use_checkpoint', + action='store_true', + default=True, + help='读取 DataSync 游标(默认开启)', + ) + parser.add_argument( + '--skip-checkpoint', + dest='use_checkpoint', + action='store_false', + help='忽略 DataSync 游标,从第一页开始', + ) + parser.add_argument( + '--update-checkpoint', + dest='update_checkpoint', + action='store_true', + default=True, + help='同步完成后写入 DataSync 记录(默认开启)', + ) + parser.add_argument( + '--skip-checkpoint-write', + dest='update_checkpoint', + action='store_false', + help='本次同步不写入 DataSync 记录', + ) def handle(self, *args, **options): payload = sync_mdy_plate_orders_to_staging( @@ -31,5 +63,8 @@ class Command(BaseCommand): with_related=options['with_related'], max_related_per_type=options['max_related_per_type'], request_interval_seconds=options['request_interval_seconds'], + use_checkpoint=options['use_checkpoint'], + update_checkpoint=options['update_checkpoint'], + sort_direction=options['sort_direction'], ) self.stdout.write(self.style.SUCCESS(str(payload))) diff --git a/api_v1/mdy_plate_order_sync.py b/api_v1/mdy_plate_order_sync.py index bcb0f21..8e6ddba 100644 --- a/api_v1/mdy_plate_order_sync.py +++ b/api_v1/mdy_plate_order_sync.py @@ -2,6 +2,7 @@ import asyncio import logging from datetime import datetime import time +from typing import Literal from django.utils import timezone @@ -93,6 +94,9 @@ def sync_mdy_plate_orders_to_staging( with_related: bool = True, max_related_per_type: int = 5, request_interval_seconds: float = 0.02, + use_checkpoint: bool = True, + update_checkpoint: bool = True, + sort_direction: Literal["asc", "desc"] = "asc", ) -> dict: """同步明道云开版数据表到暂存表(含可选跨表关联数据)。 @@ -104,16 +108,29 @@ def sync_mdy_plate_orders_to_staging( 说明: - max_pages 表示“单次任务最多处理多少页”(不是最大页码) - max_records 表示“单次任务最多处理多少条记录”(0/None 表示不限制) + - use_checkpoint 控制本次是否读取 DataSync 游标 + - update_checkpoint 控制本次是否写入 DataSync 记录 + - sort_direction 为 `asc`(默认)或 `desc`,决定按时间升/降序抓取 """ max_records = max_records or 0 + sort_direction = (sort_direction or "asc").lower() + if sort_direction not in {"asc", "desc"}: + raise ValueError("sort_direction must be 'asc' or 'desc'") + is_asc = sort_direction == "asc" - last_sync = api_models.DataSync.objects.filter( - table_name=api_models.DataSync.TableName.PLATE_ORDER - ).order_by("-created_at").first() + last_sync = None + if use_checkpoint: + last_sync = ( + api_models.DataSync.objects.filter( + table_name=api_models.DataSync.TableName.PLATE_ORDER + ) + .order_by("-created_at") + .first() + ) last_ctime = last_sync.last_ctime if last_sync else None last_rowid = last_sync.last_rowid if last_sync else "" - start_page_index = last_sync.page_index if last_sync else 1 + start_page_index = last_sync.page_index if last_sync and last_sync.page_index else 1 synced_rows = 0 page_index = max(1, start_page_index) @@ -133,7 +150,7 @@ def sync_mdy_plate_orders_to_staging( page=page_index, page_size=page_size, sort_id="ctime", - is_asc=True, + is_asc=is_asc, ) ) if request_interval_seconds: @@ -156,7 +173,7 @@ def sync_mdy_plate_orders_to_staging( record_ctime = _parse_mdy_datetime(row.get("ctime")) # 跳过已同步到的游标 - if last_ctime and record_ctime: + if use_checkpoint and last_ctime and record_ctime: if record_ctime < last_ctime: continue if record_ctime == last_ctime and last_rowid and rowid == last_rowid: @@ -192,11 +209,18 @@ def sync_mdy_plate_orders_to_staging( record_ctime = _parse_mdy_datetime(row.get("ctime")) if record_ctime: - if latest_ctime is None or record_ctime > latest_ctime: + if latest_ctime is None: latest_ctime = record_ctime latest_rowid = rowid - elif record_ctime == latest_ctime: - latest_rowid = rowid + else: + should_update = ( + record_ctime > latest_ctime if is_asc else record_ctime < latest_ctime + ) + if should_update: + latest_ctime = record_ctime + latest_rowid = rowid + elif record_ctime == latest_ctime: + latest_rowid = rowid pages_processed += 1 if hit_max_records: @@ -209,16 +233,20 @@ def sync_mdy_plate_orders_to_staging( record_last_ctime = latest_ctime or last_ctime record_last_rowid = latest_rowid or last_rowid - api_models.DataSync.objects.create( - table_name=api_models.DataSync.TableName.PLATE_ORDER, - page_index=page_index, - page_size=page_size, - synced_rows=synced_rows, - total_count=total_count, - last_ctime=record_last_ctime, - last_rowid=record_last_rowid, - note=f"asc scan; with_related={with_related}", - ) + if update_checkpoint: + api_models.DataSync.objects.create( + table_name=api_models.DataSync.TableName.PLATE_ORDER, + page_index=page_index, + page_size=page_size, + synced_rows=synced_rows, + total_count=total_count, + last_ctime=record_last_ctime, + last_rowid=record_last_rowid, + note=( + f"{sort_direction} scan; with_related={with_related}; " + f"use_checkpoint={use_checkpoint}" + ), + ) payload = { "synced_rows": synced_rows, @@ -227,6 +255,9 @@ def sync_mdy_plate_orders_to_staging( "total_count": total_count, "last_ctime": record_last_ctime.isoformat() if record_last_ctime else None, "with_related": with_related, + "sort_direction": sort_direction, + "use_checkpoint": use_checkpoint, + "update_checkpoint": update_checkpoint, } logger.info("明道云开版暂存同步完成: %s", payload) return payload diff --git a/api_v1/views/stateflow/process.py b/api_v1/views/stateflow/process.py index ae8d7d4..197bc42 100644 --- a/api_v1/views/stateflow/process.py +++ b/api_v1/views/stateflow/process.py @@ -2,6 +2,8 @@ Process API ViewSet """ from rest_framework import viewsets, filters +from rest_framework.decorators import action +from rest_framework.response import Response from rest_framework.pagination import LimitOffsetPagination from django_filters.rest_framework import DjangoFilterBackend from django.db.models import Count @@ -10,6 +12,7 @@ from stateflow.serializers import ( ProcessListSerializer, ProcessDetailSerializer, ProcessCreateUpdateSerializer, + StateListSerializer, ) @@ -57,3 +60,16 @@ class ProcessViewSet(viewsets.ModelViewSet): queryset = queryset.prefetch_related('process_nodes__state') return queryset + + @action(detail=True, methods=['get']) + def nodes(self, request, pk=None): + """返回指定流程的所有节点列表""" + process = self.get_object() + states = process.get_nodes() + data = StateListSerializer(states, many=True).data + return Response({ + 'process_id': process.id, + 'process_name': process.name, + 'count': len(data), + 'nodes': data, + }) diff --git a/api_v2/tests.py b/api_v2/tests.py index becfa31..c5fa1fe 100644 --- a/api_v2/tests.py +++ b/api_v2/tests.py @@ -960,6 +960,239 @@ class PlateOrderByProcessV2APITest(TestCase): self.assertEqual(resp.status_code, 400) +class PlateOrderByStateStatusV2APITest(TestCase): + def setUp(self): + self.client = APIClient() + + self.merchant = basic_models.Merchant.objects.create( + name='开版工厂3', + type=basic_models.MerchantTypeEnum.FACTORY, + ) + self.user = get_user_model().objects.create_user(username='state_status_user', password='pass12345') + basic_models.Employee.objects.create( + merchant=self.merchant, + sys_user=self.user, + name='开版员工3', + ) + self.client.force_authenticate(user=self.user) + + self.customer = basic_models.Customer.objects.create( + merchant=self.merchant, + name='客户SS', + created_by=None, + ) + + self.state_prepare = stateflow_models.State.objects.create(name='画图') + self.state_target = stateflow_models.State.objects.create(name='调色') + self.state_unlinked = stateflow_models.State.objects.create(name='未绑定') + self.param_temp = stateflow_models.StateParameter.objects.create(key='temperature', value='25') + self.state_target.parameters.add(self.param_temp) + + self.process = stateflow_models.Process.objects.create(name='主流程') + self.node_prepare = stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state_prepare, order=0) + self.node_target = stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state_target, order=1) + + self.other_process = stateflow_models.Process.objects.create(name='其他流程') + stateflow_models.ProcessNode.objects.create(process=self.other_process, state=self.state_target, order=0) + + self.po_not_started = printing_models.PlateOrder.objects.create( + customer=self.customer, + process=self.process.id, + design_code='PO-NOT', + style_name='款式N', + ) + self.po_no_logs = printing_models.PlateOrder.objects.create( + customer=self.customer, + process=self.process.id, + design_code='PO-ZERO', + style_name='款式Z', + ) + self.po_completed_old = printing_models.PlateOrder.objects.create( + customer=self.customer, + process=self.process.id, + design_code='PO-C-OLD', + style_name='款式CO', + ) + self.po_completed_new = printing_models.PlateOrder.objects.create( + customer=self.customer, + process=self.process.id, + design_code='PO-C-NEW', + style_name='款式CN', + ) + self.po_cancelled = printing_models.PlateOrder.objects.create( + customer=self.customer, + process=self.process.id, + design_code='PO-CAN', + style_name='款式CA', + ) + self.po_other_process = printing_models.PlateOrder.objects.create( + customer=self.customer, + process=self.other_process.id, + design_code='PO-OTHER', + style_name='款式OP', + ) + + self._complete_state(self.po_not_started, self.state_prepare) + self._complete_state(self.po_completed_old, self.state_prepare) + self._complete_state(self.po_completed_new, self.state_prepare) + self._complete_state(self.po_cancelled, self.state_prepare) + + self._complete_state(self.po_completed_old, self.state_target, parameters={'temperature': '28'}) + self._complete_state(self.po_completed_new, self.state_target, parameters={'temperature': '32'}) + self._complete_state(self.po_cancelled, self.state_target, is_cancelled=True, parameters={'temperature': '99'}) + self._complete_state(self.po_other_process, self.state_target, parameters={'temperature': '88'}) + + tz = timezone.get_default_timezone() + t_old = timezone.make_aware(datetime.datetime(2025, 12, 1, 9, 0, 0), tz) + t_new = timezone.make_aware(datetime.datetime(2025, 12, 3, 9, 0, 0), tz) + printing_models.PlateOrder.objects.filter(id=self.po_completed_old.id).update(created_at=t_old) + printing_models.PlateOrder.objects.filter(id=self.po_completed_new.id).update(created_at=t_new) + + self.url = '/api/v2/plate-orders/by-state-status/' + + def _complete_state(self, plate_order, state, *, is_cancelled=False, parameters=None): + log = stateflow_models.StateFlowRecord.objects.create( + business_object=plate_order.business_object, + state=state, + completed_by=self.user, + is_cancelled=is_cancelled, + ) + if parameters is not None: + stateflow_models.StateLogParameterRecord.objects.create( + state_log=log, + parameters=parameters, + ) + return log + + def test_completed_status_returns_sorted_plate_orders(self): + resp = self.client.get( + self.url, + { + 'process_id': self.process.id, + 'state_id': self.state_target.id, + 'status': 'completed', + } + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data['state']['id'], self.state_target.id) + self.assertEqual(resp.data['status'], 'completed') + ids = [item['id'] for item in resp.data['results']] + self.assertEqual(ids, [self.po_completed_new.id, self.po_completed_old.id]) + + template_keys = [p['key'] for p in resp.data['state_parameters']] + self.assertEqual(template_keys, ['temperature']) + + by_id = {item['id']: item for item in resp.data['results']} + self.assertEqual(by_id[self.po_completed_new.id]['state_parameters'][0]['value'], '32') + self.assertEqual(by_id[self.po_completed_old.id]['state_parameters'][0]['value'], '28') + self.assertIsNotNone(by_id[self.po_completed_new.id]['state_log']) + self.assertFalse(by_id[self.po_completed_new.id]['state_log']['is_cancelled']) + + def test_default_status_is_completed(self): + resp = self.client.get( + self.url, + { + 'process_id': self.process.id, + 'state_id': self.state_target.id, + } + ) + self.assertEqual(resp.status_code, 200) + ids = [item['id'] for item in resp.data['results']] + self.assertEqual(set(ids), {self.po_completed_new.id, self.po_completed_old.id}) + + def test_state_id_with_not_started_status_is_invalid(self): + resp = self.client.get( + self.url, + { + 'process_id': self.process.id, + 'state_id': self.state_target.id, + 'status': 'not_started', + } + ) + self.assertEqual(resp.status_code, 400) + + def test_cancelled_status_returns_orders_with_cancelled_logs(self): + resp = self.client.get( + self.url, + { + 'process_id': self.process.id, + 'state_id': self.state_target.id, + 'status': 'cancelled', + } + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data['results'][0]['id'], self.po_cancelled.id) + self.assertTrue(resp.data['results'][0]['state_log']['is_cancelled']) + + def test_invalid_process_state_pair_returns_400(self): + resp = self.client.get( + self.url, + { + 'process_id': self.process.id, + 'state_id': self.state_unlinked.id, + 'status': 'completed', + } + ) + self.assertEqual(resp.status_code, 400) + self.assertIn('state 不属于该 process', resp.data['detail']) + + def test_ordering_created_at_and_pagination(self): + resp = self.client.get( + self.url, + { + 'process_id': self.process.id, + 'state_id': self.state_target.id, + 'status': 'completed', + 'ordering': 'created_at', + 'limit': 1, + 'offset': 1, + } + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data['count'], 2) + self.assertEqual(len(resp.data['results']), 1) + self.assertEqual(resp.data['results'][0]['id'], self.po_completed_new.id) + + def test_other_process_records_are_excluded(self): + resp = self.client.get( + self.url, + { + 'process_id': self.process.id, + 'state_id': self.state_target.id, + 'status': 'completed', + } + ) + ids = [item['id'] for item in resp.data['results']] + self.assertNotIn(self.po_other_process.id, ids) + + def test_without_state_id_returns_orders_without_any_logs(self): + resp = self.client.get( + self.url, + { + 'process_id': self.process.id, + } + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data['status'], 'not_started') + self.assertIsNone(resp.data['state']) + self.assertEqual(resp.data['state_parameters'], []) + ids = [item['id'] for item in resp.data['results']] + self.assertEqual(ids, [self.po_no_logs.id]) + self.assertEqual(resp.data['results'][0]['state_parameters'], []) + self.assertIsNone(resp.data['results'][0]['state_log']) + + def test_without_state_id_only_allows_not_started_status(self): + resp = self.client.get( + self.url, + { + 'process_id': self.process.id, + 'status': 'completed', + } + ) + self.assertEqual(resp.status_code, 400) + self.assertIn('state_id 为空时仅支持 status=not_started', resp.data['detail']) + + class PlateOrderBatchUpdateV2APITest(TestCase): def setUp(self): self.client = APIClient() diff --git a/api_v2/urls.py b/api_v2/urls.py index 27e824a..351d9e7 100644 --- a/api_v2/urls.py +++ b/api_v2/urls.py @@ -7,6 +7,7 @@ from api_v2.views import ( PrintingJobBatchAdvanceSubmitView, PlateOrderByProcessNodeView, PlateOrderByProcessView, + PlateOrderByStateStatusView, PlateOrderBatchUpdateView, BusinessObjectCloneView, ) @@ -18,6 +19,7 @@ urlpatterns = [ 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('plate-orders/by-state-status/', PlateOrderByStateStatusView.as_view(), name='api_v2_plate_order_by_state_status'), path('plate-orders/batch-update/', PlateOrderBatchUpdateView.as_view(), name='api_v2_plate_order_batch_update'), 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 6f3c1c5..c81efea 100644 --- a/api_v2/views/__init__.py +++ b/api_v2/views/__init__.py @@ -10,6 +10,7 @@ from .printing import ( PrintingJobBatchAdvanceSubmitView, PlateOrderByProcessNodeView, PlateOrderByProcessView, + PlateOrderByStateStatusView, PlateOrderBatchUpdateView, ) from .stateflow import BusinessObjectCloneView @@ -22,6 +23,7 @@ __all__ = [ 'PrintingJobBatchAdvanceSubmitView', 'PlateOrderByProcessNodeView', 'PlateOrderByProcessView', + 'PlateOrderByStateStatusView', 'PlateOrderBatchUpdateView', 'BusinessObjectCloneView', ] diff --git a/api_v2/views/printing.py b/api_v2/views/printing.py index 006de9f..40e9ac1 100644 --- a/api_v2/views/printing.py +++ b/api_v2/views/printing.py @@ -3,7 +3,7 @@ import datetime from django.utils import timezone from django.db import transaction from django.db import models as django_models -from django.db.models import Q, Count, CharField, Prefetch +from django.db.models import Q, Count, CharField, Prefetch, Exists, OuterRef from django.db.models.functions import Cast, Coalesce from rest_framework import serializers, status, permissions from rest_framework.pagination import LimitOffsetPagination @@ -14,6 +14,7 @@ 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 +from stateflow import services as stateflow_services class IsPrintingFactory(permissions.BasePermission): @@ -721,6 +722,102 @@ class PlateOrderByProcessSerializer(serializers.ModelSerializer): return result +class PlateOrderByStateStatusSerializer(serializers.ModelSerializer): + """按单个节点状态过滤 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) + state_parameters = serializers.SerializerMethodField() + state_log = serializers.SerializerMethodField() + state_status = 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', + 'state_status', + 'state_parameters', + 'state_log', + '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_state_status(self, obj: printing_models.PlateOrder) -> str: + return self.context.get('requested_status', '') + + def _get_latest_state_log(self, obj: printing_models.PlateOrder): + bo = getattr(obj, 'business_object', None) + if not bo: + return None + logs = getattr(bo, '_prefetched_target_state_logs', None) + if logs: + return logs[0] + target_state_id = self.context.get('target_state_id') + if not target_state_id: + return None + return ( + bo.state_logs + .filter(state_id=target_state_id) + .order_by('-completed_at', '-id') + .select_related('completed_by') + .first() + ) + + def get_state_parameters(self, obj: printing_models.PlateOrder) -> list[dict]: + keys: list[str] = self.context.get('target_parameter_keys') or [] + if not keys: + return [] + + bo = getattr(obj, 'business_object', None) + if not bo: + return [{'key': k, 'value': None} for k in keys] + + latest_log = self._get_latest_state_log(obj) + if latest_log is None: + return [{'key': k, 'value': None} for k in keys] + + 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')) + + summary: dict = {} + for rec in param_records: + summary.update(rec.parameters or {}) + + return [{'key': k, 'value': summary.get(k)} for k in keys] + + def get_state_log(self, obj: printing_models.PlateOrder) -> dict | None: + log = self._get_latest_state_log(obj) + if log is None: + return None + completed_by = log.completed_by + return { + 'id': log.id, + 'state_id': log.state_id, + 'completed_at': log.completed_at, + 'completed_by': completed_by.id if completed_by else None, + 'completed_by_username': completed_by.username if completed_by else None, + 'is_cancelled': log.is_cancelled, + } + + class PlateOrderByProcessView(APIView): """ 按 process_id 查询 PlateOrder 列表,并在每条 PlateOrder 中返回 process 的所有节点参数结构。 @@ -892,6 +989,176 @@ class PlateOrderByProcessView(APIView): }) +class PlateOrderByStateStatusView(APIView): + """按 process_id + state_id + status 过滤 PlateOrder。""" + + permission_classes = [permissions.IsAuthenticated, IsPrintingFactory] + + _ORDERING_FIELDS = {'id', 'created_at', 'updated_at', 'design_code'} + _SUPPORTED_STATUS = {'completed', 'not_started', 'cancelled', 'in_progress'} + + 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) + + state_id_raw = qp.get('state_id') + state_id_int: int | None = None + if state_id_raw not in (None, ''): + try: + state_id_int = int(state_id_raw) + except (TypeError, ValueError): + return Response({'detail': 'state_id 必须为数字'}, status=status.HTTP_400_BAD_REQUEST) + + status_value = (qp.get('status') or 'completed').strip().lower() + if state_id_int is None: + if qp.get('status') and status_value != 'not_started': + return Response({'detail': 'state_id 为空时仅支持 status=not_started'}, status=status.HTTP_400_BAD_REQUEST) + status_value = 'not_started' + elif status_value == 'not_started': + return Response({'detail': 'state_id 不为空时不支持 status=not_started'}, status=status.HTTP_400_BAD_REQUEST) + elif status_value not in self._SUPPORTED_STATUS: + options = ', '.join(sorted(self._SUPPORTED_STATUS)) + return Response({'detail': f'status 不支持: {status_value}(可选: {options})'}, status=status.HTTP_400_BAD_REQUEST) + + 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) + + state = None + state_parameters_template: list[dict] = [] + target_keys: list[str] = [] + representative_order: int | None = None + process_node_ids: list[int] = [] + + base_bo = stateflow_models.BusinessObject.objects.filter( + process_id=process_id_int, + plate_order__isnull=False, + ) + + if state_id_int is None: + filtered_bo = base_bo.annotate( + has_any_log=Exists( + stateflow_models.StateFlowRecord.objects.filter(business_object_id=OuterRef('pk')) + ) + ).filter(has_any_log=False) + queryset = ( + printing_models.PlateOrder.objects + .select_related('customer', 'business_object', 'created_by') + .filter(process=process_id_int, business_object_id__in=filtered_bo.values('id')) + ) + else: + try: + state = stateflow_models.State.objects.get(id=state_id_int) + except stateflow_models.State.DoesNotExist: + return Response({'detail': 'state 不存在'}, status=status.HTTP_404_NOT_FOUND) + + process_nodes = list( + stateflow_models.ProcessNode.objects + .filter(process_id=process_id_int, state_id=state_id_int) + .order_by('order', 'id') + ) + if not process_nodes: + return Response({'detail': 'state 不属于该 process'}, status=status.HTTP_400_BAD_REQUEST) + + state_parameters_template = [ + {'key': param.key, 'value': param.value} + for param in state.parameters.order_by('id') + ] + target_keys = [item['key'] for item in state_parameters_template] + representative_order = process_nodes[0].order + process_node_ids = [node.id for node in process_nodes] + + filtered_bo = stateflow_services.query_business_objects_by_state_status( + state_ids=[state_id_int], + status=status_value, + process_id=process_id_int, + base_queryset=base_bo, + ) + + queryset = ( + printing_models.PlateOrder.objects + .select_related('customer', 'business_object', 'created_by') + .filter(process=process_id_int, business_object_id__in=filtered_bo.values('id')) + ) + + param_records_prefetch = Prefetch( + 'parameter_records', + queryset=stateflow_models.StateLogParameterRecord.objects.order_by('created_at', 'id'), + to_attr='_prefetched_parameter_records', + ) + target_logs_qs = ( + stateflow_models.StateFlowRecord.objects + .filter(state_id=state_id_int) + .order_by('-completed_at', '-id') + .select_related('completed_by') + .prefetch_related(param_records_prefetch) + ) + queryset = queryset.prefetch_related( + Prefetch( + 'business_object__state_logs', + queryset=target_logs_qs, + to_attr='_prefetched_target_state_logs', + ) + ) + + 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: + options = ', '.join(sorted(self._ORDERING_FIELDS)) + return Response({'detail': f'ordering 不支持: {ordering}(可选: {options})'}, 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') + + 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 = PlateOrderByStateStatusSerializer( + results, + many=True, + context={ + 'request': request, + 'target_state_id': state_id_int, + 'target_parameter_keys': target_keys, + 'requested_status': status_value, + }, + ) + + return Response({ + 'process': { + 'id': process.id, + 'name': process.name, + }, + 'state': { + 'id': state.id if state else None, + 'name': state.name if state else None, + 'order': representative_order, + 'process_node_ids': process_node_ids, + } if state else None, + 'status': status_value, + 'state_parameters': state_parameters_template, + '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, + }) + + # 允许批量更新的字段白名单:只改这里即可增减 PLATE_ORDER_BATCH_UPDATE_ALLOWED_FIELDS = [ "original_id", diff --git a/celerybeat-schedule b/celerybeat-schedule index c52718605b5c5dab19279e9b6980166cd607151b..bf4c17d32a693c9e247da2d774a52e55d873178b 100644 GIT binary patch delta 123 zcmZojXh_(=Cct%tf&UKwasEX-S2h+N;Sp~2np?pl)y9{>&C}(>1%!SG VFe@>|$CsrR6=&w>#mDp90RUZhDZ>B& delta 116 zcmZojXh_(=Ccw3Yf&UKwasEX-TQ(Lh;Sp}ln(N6T)$Nsi77t5 PEVZaOGe0jrp63n#giUt$flhA~MdnaM6QQ9U`%r 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

#F`US$!wM&wr`={m!3eYcb-Tl{W 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 diff --git a/docs/api_v2_plate_orders_by_state_status.md b/docs/api_v2_plate_orders_by_state_status.md new file mode 100644 index 0000000..2e29e55 --- /dev/null +++ b/docs/api_v2_plate_orders_by_state_status.md @@ -0,0 +1,112 @@ +## api_v2:按流程节点状态查询 PlateOrder 列表(process_id + state_id + status) + +> **常见配套接口**: +> - `GET /api/v1/stateflow/processes/{process_id}/nodes/`(或等价的 `business-objects/{bo_id}/process-nodes/`):用于获取指定流程的全部 `ProcessNode`/`State` 列表(含 order、state_id、参数模板等)。 +> - 请求 `by-state-status` 前通常先调用此接口拿到前端可选的节点,再结合节点信息发起状态过滤查询。 + +### 背景 +- 同一个 `state` 可能复用在多个 `process` 中,因此查询必须同时提供 `process_id` 与 `state_id` +- 需要按节点状态(`status`)过滤 `PlateOrder`,并支持分页/排序 +- 同时返回节点参数模板与订单在该节点的最新参数值 + +### 接口信息 +- **Method**:GET +- **Path**:`/api/v2/plate-orders/by-state-status/` +- **认证**:JWT(`IsAuthenticated`) +- **权限**:仅允许印染/工厂侧用户(`IsPrintingFactory`) + +### Query 参数 +| 参数 | 必填 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `process_id` | 是 | int | - | `stateflow.Process.id` | +| `state_id` | 否 | int | - | `stateflow.State.id`。与 `process_id` 至少存在一条 `ProcessNode` 关联;为空时表示“查询整个流程尚未产生任何流转记录的订单” | +| `status` | 否 | string | `completed` | 节点状态过滤,支持:`completed` / `not_started` / `cancelled` / `in_progress` | +| `ordering` | 否 | string | `-created_at` | 排序字段,支持:`id` / `created_at` / `updated_at` / `design_code`(`design_code` 空值按主键字符串兜底) | +| `limit` | 否 | int | `20` | LimitOffsetPagination 的 limit | +| `offset` | 否 | int | `0` | LimitOffsetPagination 的 offset | + +> `status` 说明: +> - `completed`:该节点存在未撤销的 `StateFlowRecord` +> - `not_started`:从未对该节点留下任何 `StateFlowRecord` +> - `cancelled`:最近一次执行已被撤销(存在 `is_cancelled=True` 的记录,且无未撤销记录) +> - `in_progress`:为后续扩展保留(当前流程模型中不会命中) +> - `state_id` 为空时仅支持 `not_started`(忽略或填写其它值会报错) +> - `state_id` 不为空时仅支持 `completed` / `cancelled` / `in_progress` + +### 返回结构(200) +分页结构 + 节点/流程附加信息。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `process` | object | 流程信息 `{id, name}` | +| `state` | object\|null | 节点信息 `{id, name, order, process_node_ids[]}`;当 `state_id` 为空时为 `null` | +| `status` | string | 本次查询的状态值 | +| `state_parameters` | array | 节点参数模板(`[{key, value}]`) | +| `count` / `next` / `previous` | 同分页接口 | - | +| `results` | array | `PlateOrder` 列表(见下) | + +#### `results[*]` 字段 +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | int | PlateOrder 主键 | +| `design_code` | string\|null | 设计编号;为空时返回主键字符串 | +| `customer` / `customer_name` | int / string | 客户信息 | +| `style_name` | string\|null | 款式名称 | +| `urgency_level` | string | 紧急程度 | +| `is_invalid` | bool | 是否作废 | +| `business_object_id` | int\|null | 关联流程实例 | +| `created_by` | int\|null | 创建人 ID | +| `state_status` | string | 与查询参数一致(方便前端直接展示) | +| `state_parameters` | array | 订单在该节点的最新参数值,key 顺序与模板一致,形如 `[{"key": "temperature", "value": "30"}]`,若从未提交过则 value=null;`state_id` 为空时恒为 `[]` | +| `state_log` | object\|null | 该节点最新一次执行日志 `{id, state_id, completed_at, completed_by, completed_by_username, is_cancelled}`;`status=not_started` 时为 null | +| `created_at` / `updated_at` | string | ISO8601 时间戳 | + +### 错误码 +| HTTP 状态 | 场景 | `detail` | +| --- | --- | --- | +| 400 | 缺少/非法参数、`state` 不属于 `process`、不支持的 `ordering/status` | 具体原因文本 | +| 401 | 未认证 | DRF 默认 | +| 403 | 非工厂用户访问 | `您没有访问印染订单的权限` | +| 404 | `process` 或 `state` 不存在 | `process 不存在` / `state 不存在` | + +### 示例 +``` +GET /api/v2/plate-orders/by-state-status/?process_id=10&state_id=25&status=completed&limit=20&offset=0 +``` +返回: +``` +{ + "process": {"id": 10, "name": "开版流程"}, + "state": {"id": 25, "name": "调色", "order": 1, "process_node_ids": [42]}, + "status": "completed", + "state_parameters": [{"key": "temperature", "value": "25"}], + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "id": 1001, + "design_code": "PO-001", + "customer": 5, + "customer_name": "客户A", + "style_name": "款式X", + "urgency_level": "加急", + "is_invalid": false, + "business_object_id": 3001, + "created_by": 12, + "state_status": "completed", + "state_parameters": [{"key": "temperature", "value": "30"}], + "state_log": { + "id": 888, + "state_id": 25, + "completed_at": "2025-12-20T08:00:00+08:00", + "completed_by": 12, + "completed_by_username": "factory_user", + "is_cancelled": false + }, + "created_at": "2025-12-18T09:00:00+08:00", + "updated_at": "2025-12-18T10:00:00+08:00" + } + ] +} +``` diff --git a/stateflow/services.py b/stateflow/services.py index 7e92639..75508dd 100644 --- a/stateflow/services.py +++ b/stateflow/services.py @@ -2,11 +2,12 @@ Stateflow业务逻辑服务层 """ import copy -from typing import List, Optional, Tuple +from typing import Iterable, List, Optional, Tuple from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.db import transaction +from django.db.models import Exists, OuterRef, QuerySet from . import models User = get_user_model() @@ -664,6 +665,59 @@ def get_process_nodes(business_object: 'models.BusinessObject') -> List[dict]: ] +def query_business_objects_by_state_status( + *, + state_ids: Iterable[int] | None = None, + status: str = 'completed', + process_id: int | None = None, + base_queryset: QuerySet | None = None, +) -> QuerySet: + """ + 按节点状态过滤 BusinessObject 查询集 + + Args: + state_ids: 目标节点集合(为空时表示任意节点) + status: 过滤状态,支持 completed/not_started/cancelled/in_progress + process_id: 限定流程 ID + base_queryset: 可复用的基础查询集 + + Returns: + 过滤后的 QuerySet + """ + if status not in {'completed', 'not_started', 'cancelled', 'in_progress'}: + raise ValueError('unsupported status value') + + qs = base_queryset if base_queryset is not None else models.BusinessObject.objects.all() + state_id_list = list(state_ids) if state_ids is not None else None + + if process_id is not None: + qs = qs.filter(process_id=process_id) + + if state_id_list: + qs = qs.filter(process__process_nodes__state_id__in=state_id_list) + + state_logs = models.StateFlowRecord.objects.filter(business_object_id=OuterRef('pk')) + if state_id_list: + state_logs = state_logs.filter(state_id__in=state_id_list) + + qs = qs.annotate( + has_state_log=Exists(state_logs), + has_state_completed=Exists(state_logs.filter(is_cancelled=False)), + has_state_cancelled=Exists(state_logs.filter(is_cancelled=True)), + ) + + if status == 'completed': + qs = qs.filter(has_state_completed=True) + elif status == 'cancelled': + qs = qs.filter(has_state_cancelled=True) + elif status == 'not_started': + qs = qs.filter(has_state_log=False) + elif status == 'in_progress': + qs = qs.filter(has_state_log=True, has_state_completed=False, has_state_cancelled=False) + + return qs.distinct() + + def clone_business_object( source: 'models.BusinessObject', *, diff --git a/stateflow/tests/test_business_object_api.py b/stateflow/tests/test_business_object_api.py index f1c7480..cb162fb 100644 --- a/stateflow/tests/test_business_object_api.py +++ b/stateflow/tests/test_business_object_api.py @@ -6,6 +6,7 @@ from django.test import TestCase from rest_framework.test import APIClient from rest_framework import status from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType from stateflow import models, services User = get_user_model() @@ -45,10 +46,14 @@ class BusinessObjectAPITestCase(TestCase): models.ProcessNode.objects.create(process=self.process, state=self.state2, order=1) models.ProcessNode.objects.create(process=self.process, state=self.state3, order=2) + self.process_content_type = ContentType.objects.get_for_model(models.Process) + # 创建业务对象 self.business_object = models.BusinessObject.objects.create( name='测试业务对象', - process=self.process + process=self.process, + content_type=self.process_content_type, + object_id=self.process.id, ) def test_reset_api(self): @@ -178,7 +183,6 @@ class BusinessObjectAPITestCase(TestCase): }, format='json' ) - self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(response.data['success']) self.assertIn('parameter_record', response.data) @@ -275,35 +279,28 @@ class BusinessObjectAPITestCase(TestCase): # 验证记录列表 self.assertEqual(len(response.data['records']), 3) - - def test_get_log_parameters_api_by_key(self): - """测试获取指定参数的历史记录 API""" - # 推进并提供初始参数 + def test_get_log_parameters_api_key_history(self): + """测试获取指定参数 key 的历史记录""" response = self.client.post( f'/api/v1/stateflow/business-objects/{self.business_object.id}/advance/', {'parameters': {'temperature': '25.5'}}, format='json' ) - state_log_id = response.data['state_log']['id'] - - # 补充参数 + self.client.post( f'/api/v1/stateflow/business-objects/{self.business_object.id}/state-logs/{state_log_id}/add-parameters/', { - 'parameters': {'temperature': '26.0', 'humidity': '65%'}, + 'parameters': {'temperature': '26.0'}, 'remark': '重测' }, format='json' ) - - # 获取 temperature 的历史 + response = self.client.get( f'/api/v1/stateflow/business-objects/{self.business_object.id}/state-logs/{state_log_id}/parameters/?key=temperature' ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['key'], 'temperature') self.assertEqual(len(response.data['history']), 2) self.assertEqual(response.data['history'][0]['value'], '25.5') self.assertEqual(response.data['history'][1]['value'], '26.0') @@ -673,7 +670,9 @@ class BusinessObjectAPITestCase(TestCase): empty_process = models.Process.objects.create(name='空流程') empty_business_object = models.BusinessObject.objects.create( name='空业务对象', - process=empty_process + process=empty_process, + content_type=self.process_content_type, + object_id=empty_process.id, ) # 尝试推进(应该失败) diff --git a/stateflow/tests/test_business_object_crud_and_filters_api.py b/stateflow/tests/test_business_object_crud_and_filters_api.py index 4eff67b..ffbb7e0 100644 --- a/stateflow/tests/test_business_object_crud_and_filters_api.py +++ b/stateflow/tests/test_business_object_crud_and_filters_api.py @@ -35,6 +35,9 @@ class BusinessObjectCRUDAndFilterAPITestCase(TestCase): self.process_b = models.Process.objects.create(name="流程B", description="用于测试过滤器B") models.ProcessNode.objects.create(process=self.process_b, state=self.state1, order=0) + self.process_content_type = ContentType.objects.get_for_model(models.Process) + self.user_content_type = ContentType.objects.get_for_model(User) + def test_business_object_crud(self): """覆盖 create/list/retrieve/patch/delete 的基本 happy path""" # create(注意:create serializer 不包含 id,需要从 DB 获取) @@ -42,6 +45,8 @@ class BusinessObjectCRUDAndFilterAPITestCase(TestCase): "name": "BO-CRUD-1", "process": self.process_a.id, "description": "测试 CRUD", + "content_type_str": "stateflow.process", + "object_id": self.process_a.id, } resp = self.client.post("/api/v1/stateflow/business-objects/", create_payload, format="json") self.assertEqual(resp.status_code, status.HTTP_201_CREATED) @@ -134,7 +139,7 @@ class BusinessObjectCRUDAndFilterAPITestCase(TestCase): format="json", ) self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn("object_id", resp.data) + self.assertIn("detail", resp.data) def test_business_object_filters(self): """覆盖 BusinessObjectFilterSet 的关键过滤条件""" @@ -142,16 +147,29 @@ class BusinessObjectCRUDAndFilterAPITestCase(TestCase): name="BO-InProgress-Alpha", process=self.process_a, description="alpha desc", + content_type=self.user_content_type, + object_id=self.user.id, ) bo_completed = models.BusinessObject.objects.create( name="BO-Completed-Beta", process=self.process_a, description="beta desc", + content_type=self.user_content_type, + object_id=self.user.id, ) bo_other_process = models.BusinessObject.objects.create( name="BO-OtherProcess-Gamma", process=self.process_b, description="gamma desc", + content_type=self.user_content_type, + object_id=self.user.id, + ) + bo_unlinked = models.BusinessObject.objects.create( + name="BO-Unlinked-Legacy", + process=self.process_a, + description="legacy desc", + content_type=None, + object_id=None, ) # 让 bo_completed 完成(该流程无必填参数,直接推进即可) @@ -175,9 +193,9 @@ class BusinessObjectCRUDAndFilterAPITestCase(TestCase): # process_name contains resp = self.client.get("/api/v1/stateflow/business-objects/?process_name=流程A&limit=10&offset=0") self.assertEqual(resp.status_code, status.HTTP_200_OK) - self.assertEqual(resp.data["count"], 2) + self.assertEqual(resp.data["count"], 3) returned_ids = {x["id"] for x in resp.data["results"]} - self.assertSetEqual(returned_ids, {bo_in_progress.id, bo_completed.id}) + self.assertSetEqual(returned_ids, {bo_in_progress.id, bo_completed.id, bo_unlinked.id}) # overall_status=completed resp = self.client.get("/api/v1/stateflow/business-objects/?overall_status=completed&limit=10&offset=0") @@ -189,7 +207,7 @@ class BusinessObjectCRUDAndFilterAPITestCase(TestCase): resp = self.client.get("/api/v1/stateflow/business-objects/?overall_status=in_progress&limit=10&offset=0") self.assertEqual(resp.status_code, status.HTTP_200_OK) returned_ids = {x["id"] for x in resp.data["results"]} - self.assertSetEqual(returned_ids, {bo_in_progress.id, bo_other_process.id}) + self.assertSetEqual(returned_ids, {bo_in_progress.id, bo_other_process.id, bo_unlinked.id}) # content_type_str + has_content_object ct = ContentType.objects.get_for_model(models.Process) @@ -203,12 +221,15 @@ class BusinessObjectCRUDAndFilterAPITestCase(TestCase): resp = self.client.get("/api/v1/stateflow/business-objects/?has_content_object=true&limit=10&offset=0") self.assertEqual(resp.status_code, status.HTTP_200_OK) returned_ids = {x["id"] for x in resp.data["results"]} - self.assertIn(bo_linked.id, returned_ids) + self.assertSetEqual( + returned_ids, + {bo_in_progress.id, bo_completed.id, bo_other_process.id, bo_linked.id}, + ) resp = self.client.get("/api/v1/stateflow/business-objects/?has_content_object=false&limit=10&offset=0") self.assertEqual(resp.status_code, status.HTTP_200_OK) returned_ids = {x["id"] for x in resp.data["results"]} - self.assertNotIn(bo_linked.id, returned_ids) + self.assertSetEqual(returned_ids, {bo_unlinked.id}) resp = self.client.get("/api/v1/stateflow/business-objects/?content_type_str=stateflow.process&limit=10&offset=0") self.assertEqual(resp.status_code, status.HTTP_200_OK) diff --git a/stateflow/tests/test_clone_business_object.py b/stateflow/tests/test_clone_business_object.py index 736ac8f..682e895 100644 --- a/stateflow/tests/test_clone_business_object.py +++ b/stateflow/tests/test_clone_business_object.py @@ -111,7 +111,8 @@ class CloneBusinessObjectServiceTestCase(TestCase): 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 + new_process = models.Process.objects.create(name='流程B', description='克隆目标流程') + new_object_id = new_process.id cloned = services.clone_business_object( self.business_object, new_object_id=new_object_id, @@ -169,9 +170,10 @@ class CloneBusinessObjectServiceTestCase(TestCase): ) def test_clone_is_independent_from_source(self): + target_process = models.Process.objects.create(name='流程B-独立', description='新的绑定对象') cloned = services.clone_business_object( self.business_object, - new_object_id=(self.business_object.object_id or 0) + 1000, + new_object_id=target_process.id, expected_content_type_id=self.business_object.content_type_id, ) @@ -186,21 +188,18 @@ class CloneBusinessObjectServiceTestCase(TestCase): 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): + def test_clone_empty_business_object_requires_binding(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) + with self.assertRaises(ValueError): + services.clone_business_object( + bo, + new_object_id=None, + expected_content_type_id=None, + ) def test_clone_rejects_mismatched_content_type(self): """content_type 不一致应拒绝克隆""" diff --git a/stateflow/tests/test_parameters.py b/stateflow/tests/test_parameters.py index 254a441..9b1ef54 100644 --- a/stateflow/tests/test_parameters.py +++ b/stateflow/tests/test_parameters.py @@ -4,6 +4,7 @@ """ from django.test import TestCase from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType from stateflow import models, services User = get_user_model() @@ -52,10 +53,14 @@ class ParameterManagementTestCase(TestCase): models.ProcessNode.objects.create(process=self.process, state=self.state2, order=1) models.ProcessNode.objects.create(process=self.process, state=self.state3, order=2) + self.process_content_type = ContentType.objects.get_for_model(models.Process) + # 创建业务对象 self.business_object = models.BusinessObject.objects.create( name='测试业务对象', - process=self.process + process=self.process, + content_type=self.process_content_type, + object_id=self.process.id, ) def test_advance_without_required_parameter_fails(self): diff --git a/stateflow/tests/test_process_list_node_count_api.py b/stateflow/tests/test_process_list_node_count_api.py index a21f48e..145c81f 100644 --- a/stateflow/tests/test_process_list_node_count_api.py +++ b/stateflow/tests/test_process_list_node_count_api.py @@ -37,3 +37,31 @@ class ProcessListNodeCountAPITestCase(TestCase): self.assertEqual(item["node_count"], 2) +class ProcessNodesAPITestCase(TestCase): + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user(username="nodeuser", password="testpass") + self.client.force_authenticate(user=self.user) + + 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="节点查询流程") + models.ProcessNode.objects.create(process=self.process, state=self.state1, order=0) + models.ProcessNode.objects.create(process=self.process, state=self.state2, order=1) + + def test_process_nodes_endpoint_returns_ordered_states(self): + url = f"/api/v1/stateflow/processes/{self.process.id}/nodes/" + resp = self.client.get(url) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.data["process_id"], self.process.id) + self.assertEqual(resp.data["count"], 2) + node_ids = [node["id"] for node in resp.data["nodes"]] + self.assertEqual(node_ids, [self.state1.id, self.state2.id]) + self.assertEqual(resp.data["nodes"][0]["name"], self.state1.name) + self.assertEqual(resp.data["nodes"][1]["name"], self.state2.name) + + def test_process_nodes_endpoint_404_when_missing(self): + resp = self.client.get("/api/v1/stateflow/processes/9999/nodes/") + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + + diff --git a/stateflow/tests/test_services.py b/stateflow/tests/test_services.py index 2c0d5b6..59b512d 100644 --- a/stateflow/tests/test_services.py +++ b/stateflow/tests/test_services.py @@ -40,6 +40,23 @@ class StateFlowServicesTestCase(TestCase): content_type=ct, object_id=self.process.id ) + self.business_object_pending = models.BusinessObject.objects.create( + name='待开始订单', + process=self.process, + description='尚未推进的订单', + content_type=ct, + object_id=self.process.id + ) + + self.other_process = models.Process.objects.create(name='其他流程', description='另一个流程用于过滤测试') + models.ProcessNode.objects.create(process=self.other_process, state=self.state1, order=0) + self.other_business_object = models.BusinessObject.objects.create( + name='其他流程订单', + process=self.other_process, + description='不同流程的订单', + content_type=ct, + object_id=self.other_process.id + ) def test_initial_state(self): """测试初始状态 - current_state 是下一个待执行节点(第一个节点)""" @@ -318,3 +335,52 @@ class StateFlowServicesTestCase(TestCase): self.assertIn('state_name', node) self.assertIn('order', node) self.assertIsInstance(node['state'], models.State) + + def test_query_business_objects_by_state_status_filters_by_completion(self): + """验证按节点状态过滤业务对象的服务函数""" + # 初始状态:两个业务对象都未开始 state1 + qs = services.query_business_objects_by_state_status( + state_ids=[self.state1.id], + status='not_started', + process_id=self.process.id, + ) + ids = set(qs.values_list('id', flat=True)) + self.assertIn(self.business_object.id, ids) + self.assertIn(self.business_object_pending.id, ids) + self.assertNotIn(self.other_business_object.id, ids) + + # 推进第一个业务对象,变为已完成 state1 + services.advance_to_next_state(self.business_object, self.user) + + qs = services.query_business_objects_by_state_status( + state_ids=[self.state1.id], + status='not_started', + process_id=self.process.id, + ) + ids = set(qs.values_list('id', flat=True)) + self.assertNotIn(self.business_object.id, ids) + self.assertIn(self.business_object_pending.id, ids) + + qs_completed = services.query_business_objects_by_state_status( + state_ids=[self.state1.id], + status='completed', + ) + completed_ids = set(qs_completed.values_list('id', flat=True)) + self.assertIn(self.business_object.id, completed_ids) + self.assertNotIn(self.business_object_pending.id, completed_ids) + + def test_query_business_objects_by_state_status_cancelled(self): + """撤销记录应该匹配 cancelled 状态""" + services.advance_to_next_state(self.business_object_pending, self.user) + services.reset_business_object_progress(self.business_object_pending) + qs = services.query_business_objects_by_state_status( + state_ids=[self.state1.id], + status='cancelled', + ) + ids = set(qs.values_list('id', flat=True)) + self.assertIn(self.business_object_pending.id, ids) + + def test_query_business_objects_by_state_status_invalid_status(self): + """非法状态值应抛出异常""" + with self.assertRaises(ValueError): + services.query_business_objects_by_state_status(status='unknown') diff --git a/stateflow/tests/test_step_back.py b/stateflow/tests/test_step_back.py index 78ff02b..a40f3e4 100644 --- a/stateflow/tests/test_step_back.py +++ b/stateflow/tests/test_step_back.py @@ -3,6 +3,7 @@ """ from django.test import TestCase from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType from stateflow import models, services User = get_user_model() @@ -29,10 +30,13 @@ class StepBackTestCase(TestCase): models.ProcessNode.objects.create(process=self.process, state=self.state3, order=2) # 创建业务对象 + process_ct = ContentType.objects.get_for_model(models.Process) self.business_object = models.BusinessObject.objects.create( name='测试业务对象', process=self.process, - description='测试回退功能' + description='测试回退功能', + content_type=process_ct, + object_id=self.process.id, ) def test_step_back_from_not_started(self): diff --git a/stateflow/tests/test_step_back_api.py b/stateflow/tests/test_step_back_api.py index 7ce79f0..da6d385 100644 --- a/stateflow/tests/test_step_back_api.py +++ b/stateflow/tests/test_step_back_api.py @@ -5,6 +5,7 @@ from django.test import TestCase from rest_framework.test import APIClient from rest_framework import status from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType from stateflow import models User = get_user_model() @@ -32,9 +33,12 @@ class StepBackAPITestCase(TestCase): models.ProcessNode.objects.create(process=self.process, state=self.state3, order=2) # 创建业务对象 + process_ct = ContentType.objects.get_for_model(models.Process) self.business_object = models.BusinessObject.objects.create( name='测试对象', - process=self.process + process=self.process, + content_type=process_ct, + object_id=self.process.id, ) def test_step_back_api_success(self):