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 c527186..bf4c17d 100644 Binary files a/celerybeat-schedule and b/celerybeat-schedule differ diff --git a/celerybeat-schedule-shm b/celerybeat-schedule-shm deleted file mode 100644 index 0937832..0000000 Binary files a/celerybeat-schedule-shm and /dev/null differ diff --git a/celerybeat-schedule-wal b/celerybeat-schedule-wal deleted file mode 100644 index 0c43ec2..0000000 Binary files a/celerybeat-schedule-wal and /dev/null differ 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):