1
0
forked from erp-dev/erp

feat: new api for plate order (get_plate_order_by_state_status)

This commit is contained in:
2025-12-25 17:18:44 +08:00
parent 2c5174a1ec
commit ff1ea2482d
21 changed files with 936 additions and 58 deletions

2
.env
View File

@@ -2,7 +2,7 @@ SECRET_KEY=testing_$weak_secret_is_allowd
DEBUG=True DEBUG=True
ALLOWED_HOSTS=yuwenerp.yuwen.cloud,localhost, ALLOWED_HOSTS=yuwenerp.yuwen.cloud,localhost,
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
DB_PORT=6432 DB_PORT=5432
DB_NAME=flower DB_NAME=flower
DB_USER=postgres DB_USER=postgres
DB_PASSWORD=postgres DB_PASSWORD=postgres

View File

@@ -22,6 +22,38 @@ class Command(BaseCommand):
default=0.02, default=0.02,
help='每次明道云请求之间的最小间隔用于限流50qps 建议 >= 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): def handle(self, *args, **options):
payload = sync_mdy_plate_orders_to_staging( payload = sync_mdy_plate_orders_to_staging(
@@ -31,5 +63,8 @@ class Command(BaseCommand):
with_related=options['with_related'], with_related=options['with_related'],
max_related_per_type=options['max_related_per_type'], max_related_per_type=options['max_related_per_type'],
request_interval_seconds=options['request_interval_seconds'], 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))) self.stdout.write(self.style.SUCCESS(str(payload)))

View File

@@ -2,6 +2,7 @@ import asyncio
import logging import logging
from datetime import datetime from datetime import datetime
import time import time
from typing import Literal
from django.utils import timezone from django.utils import timezone
@@ -93,6 +94,9 @@ def sync_mdy_plate_orders_to_staging(
with_related: bool = True, with_related: bool = True,
max_related_per_type: int = 5, max_related_per_type: int = 5,
request_interval_seconds: float = 0.02, request_interval_seconds: float = 0.02,
use_checkpoint: bool = True,
update_checkpoint: bool = True,
sort_direction: Literal["asc", "desc"] = "asc",
) -> dict: ) -> dict:
"""同步明道云开版数据表到暂存表(含可选跨表关联数据)。 """同步明道云开版数据表到暂存表(含可选跨表关联数据)。
@@ -104,16 +108,29 @@ def sync_mdy_plate_orders_to_staging(
说明: 说明:
- max_pages 表示“单次任务最多处理多少页”(不是最大页码) - max_pages 表示“单次任务最多处理多少页”(不是最大页码)
- max_records 表示“单次任务最多处理多少条记录”0/None 表示不限制) - max_records 表示“单次任务最多处理多少条记录”0/None 表示不限制)
- use_checkpoint 控制本次是否读取 DataSync 游标
- update_checkpoint 控制本次是否写入 DataSync 记录
- sort_direction 为 `asc`(默认)或 `desc`,决定按时间升/降序抓取
""" """
max_records = max_records or 0 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( last_sync = None
if use_checkpoint:
last_sync = (
api_models.DataSync.objects.filter(
table_name=api_models.DataSync.TableName.PLATE_ORDER table_name=api_models.DataSync.TableName.PLATE_ORDER
).order_by("-created_at").first() )
.order_by("-created_at")
.first()
)
last_ctime = last_sync.last_ctime if last_sync else None last_ctime = last_sync.last_ctime if last_sync else None
last_rowid = last_sync.last_rowid if last_sync else "" 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 synced_rows = 0
page_index = max(1, start_page_index) page_index = max(1, start_page_index)
@@ -133,7 +150,7 @@ def sync_mdy_plate_orders_to_staging(
page=page_index, page=page_index,
page_size=page_size, page_size=page_size,
sort_id="ctime", sort_id="ctime",
is_asc=True, is_asc=is_asc,
) )
) )
if request_interval_seconds: if request_interval_seconds:
@@ -156,7 +173,7 @@ def sync_mdy_plate_orders_to_staging(
record_ctime = _parse_mdy_datetime(row.get("ctime")) 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: if record_ctime < last_ctime:
continue continue
if record_ctime == last_ctime and last_rowid and rowid == last_rowid: if record_ctime == last_ctime and last_rowid and rowid == last_rowid:
@@ -192,7 +209,14 @@ def sync_mdy_plate_orders_to_staging(
record_ctime = _parse_mdy_datetime(row.get("ctime")) record_ctime = _parse_mdy_datetime(row.get("ctime"))
if record_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
else:
should_update = (
record_ctime > latest_ctime if is_asc else record_ctime < latest_ctime
)
if should_update:
latest_ctime = record_ctime latest_ctime = record_ctime
latest_rowid = rowid latest_rowid = rowid
elif record_ctime == latest_ctime: elif record_ctime == latest_ctime:
@@ -209,6 +233,7 @@ def sync_mdy_plate_orders_to_staging(
record_last_ctime = latest_ctime or last_ctime record_last_ctime = latest_ctime or last_ctime
record_last_rowid = latest_rowid or last_rowid record_last_rowid = latest_rowid or last_rowid
if update_checkpoint:
api_models.DataSync.objects.create( api_models.DataSync.objects.create(
table_name=api_models.DataSync.TableName.PLATE_ORDER, table_name=api_models.DataSync.TableName.PLATE_ORDER,
page_index=page_index, page_index=page_index,
@@ -217,7 +242,10 @@ def sync_mdy_plate_orders_to_staging(
total_count=total_count, total_count=total_count,
last_ctime=record_last_ctime, last_ctime=record_last_ctime,
last_rowid=record_last_rowid, last_rowid=record_last_rowid,
note=f"asc scan; with_related={with_related}", note=(
f"{sort_direction} scan; with_related={with_related}; "
f"use_checkpoint={use_checkpoint}"
),
) )
payload = { payload = {
@@ -227,6 +255,9 @@ def sync_mdy_plate_orders_to_staging(
"total_count": total_count, "total_count": total_count,
"last_ctime": record_last_ctime.isoformat() if record_last_ctime else None, "last_ctime": record_last_ctime.isoformat() if record_last_ctime else None,
"with_related": with_related, "with_related": with_related,
"sort_direction": sort_direction,
"use_checkpoint": use_checkpoint,
"update_checkpoint": update_checkpoint,
} }
logger.info("明道云开版暂存同步完成: %s", payload) logger.info("明道云开版暂存同步完成: %s", payload)
return payload return payload

View File

@@ -2,6 +2,8 @@
Process API ViewSet Process API ViewSet
""" """
from rest_framework import viewsets, filters 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 rest_framework.pagination import LimitOffsetPagination
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django.db.models import Count from django.db.models import Count
@@ -10,6 +12,7 @@ from stateflow.serializers import (
ProcessListSerializer, ProcessListSerializer,
ProcessDetailSerializer, ProcessDetailSerializer,
ProcessCreateUpdateSerializer, ProcessCreateUpdateSerializer,
StateListSerializer,
) )
@@ -57,3 +60,16 @@ class ProcessViewSet(viewsets.ModelViewSet):
queryset = queryset.prefetch_related('process_nodes__state') queryset = queryset.prefetch_related('process_nodes__state')
return queryset 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,
})

View File

@@ -960,6 +960,239 @@ class PlateOrderByProcessV2APITest(TestCase):
self.assertEqual(resp.status_code, 400) 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): class PlateOrderBatchUpdateV2APITest(TestCase):
def setUp(self): def setUp(self):
self.client = APIClient() self.client = APIClient()

View File

@@ -7,6 +7,7 @@ from api_v2.views import (
PrintingJobBatchAdvanceSubmitView, PrintingJobBatchAdvanceSubmitView,
PlateOrderByProcessNodeView, PlateOrderByProcessNodeView,
PlateOrderByProcessView, PlateOrderByProcessView,
PlateOrderByStateStatusView,
PlateOrderBatchUpdateView, PlateOrderBatchUpdateView,
BusinessObjectCloneView, BusinessObjectCloneView,
) )
@@ -18,6 +19,7 @@ urlpatterns = [
path('printing-jobs/batch-advance/', PrintingJobBatchAdvanceSubmitView.as_view(), name='api_v2_printing_job_batch_advance_submit'), 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-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-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('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'), path('stateflow/business-objects/clone/', BusinessObjectCloneView.as_view(), name='api_v2_stateflow_business_object_clone'),
] ]

View File

@@ -10,6 +10,7 @@ from .printing import (
PrintingJobBatchAdvanceSubmitView, PrintingJobBatchAdvanceSubmitView,
PlateOrderByProcessNodeView, PlateOrderByProcessNodeView,
PlateOrderByProcessView, PlateOrderByProcessView,
PlateOrderByStateStatusView,
PlateOrderBatchUpdateView, PlateOrderBatchUpdateView,
) )
from .stateflow import BusinessObjectCloneView from .stateflow import BusinessObjectCloneView
@@ -22,6 +23,7 @@ __all__ = [
'PrintingJobBatchAdvanceSubmitView', 'PrintingJobBatchAdvanceSubmitView',
'PlateOrderByProcessNodeView', 'PlateOrderByProcessNodeView',
'PlateOrderByProcessView', 'PlateOrderByProcessView',
'PlateOrderByStateStatusView',
'PlateOrderBatchUpdateView', 'PlateOrderBatchUpdateView',
'BusinessObjectCloneView', 'BusinessObjectCloneView',
] ]

View File

@@ -3,7 +3,7 @@ import datetime
from django.utils import timezone from django.utils import timezone
from django.db import transaction from django.db import transaction
from django.db import models as django_models 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 django.db.models.functions import Cast, Coalesce
from rest_framework import serializers, status, permissions from rest_framework import serializers, status, permissions
from rest_framework.pagination import LimitOffsetPagination 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 printing import models as printing_models
from api_man.serializers import ProductSerializer from api_man.serializers import ProductSerializer
from stateflow import models as stateflow_models from stateflow import models as stateflow_models
from stateflow import services as stateflow_services
class IsPrintingFactory(permissions.BasePermission): class IsPrintingFactory(permissions.BasePermission):
@@ -721,6 +722,102 @@ class PlateOrderByProcessSerializer(serializers.ModelSerializer):
return result 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): class PlateOrderByProcessView(APIView):
""" """
按 process_id 查询 PlateOrder 列表,并在每条 PlateOrder 中返回 process 的所有节点参数结构。 按 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 = [ PLATE_ORDER_BATCH_UPDATE_ALLOWED_FIELDS = [
"original_id", "original_id",

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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"
}
]
}
```

View File

@@ -2,11 +2,12 @@
Stateflow业务逻辑服务层 Stateflow业务逻辑服务层
""" """
import copy 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.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction from django.db import transaction
from django.db.models import Exists, OuterRef, QuerySet
from . import models from . import models
User = get_user_model() 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( def clone_business_object(
source: 'models.BusinessObject', source: 'models.BusinessObject',
*, *,

View File

@@ -6,6 +6,7 @@ from django.test import TestCase
from rest_framework.test import APIClient from rest_framework.test import APIClient
from rest_framework import status from rest_framework import status
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from stateflow import models, services from stateflow import models, services
User = get_user_model() 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.state2, order=1)
models.ProcessNode.objects.create(process=self.process, state=self.state3, order=2) 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( self.business_object = models.BusinessObject.objects.create(
name='测试业务对象', name='测试业务对象',
process=self.process process=self.process,
content_type=self.process_content_type,
object_id=self.process.id,
) )
def test_reset_api(self): def test_reset_api(self):
@@ -178,7 +183,6 @@ class BusinessObjectAPITestCase(TestCase):
}, },
format='json' format='json'
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data['success']) self.assertTrue(response.data['success'])
self.assertIn('parameter_record', response.data) self.assertIn('parameter_record', response.data)
@@ -275,35 +279,28 @@ class BusinessObjectAPITestCase(TestCase):
# 验证记录列表 # 验证记录列表
self.assertEqual(len(response.data['records']), 3) self.assertEqual(len(response.data['records']), 3)
def test_get_log_parameters_api_key_history(self):
def test_get_log_parameters_api_by_key(self): """测试获取指定参数 key 的历史记录"""
"""测试获取指定参数的历史记录 API"""
# 推进并提供初始参数
response = self.client.post( response = self.client.post(
f'/api/v1/stateflow/business-objects/{self.business_object.id}/advance/', f'/api/v1/stateflow/business-objects/{self.business_object.id}/advance/',
{'parameters': {'temperature': '25.5'}}, {'parameters': {'temperature': '25.5'}},
format='json' format='json'
) )
state_log_id = response.data['state_log']['id'] state_log_id = response.data['state_log']['id']
# 补充参数
self.client.post( self.client.post(
f'/api/v1/stateflow/business-objects/{self.business_object.id}/state-logs/{state_log_id}/add-parameters/', 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': '重测' 'remark': '重测'
}, },
format='json' format='json'
) )
# 获取 temperature 的历史
response = self.client.get( response = self.client.get(
f'/api/v1/stateflow/business-objects/{self.business_object.id}/state-logs/{state_log_id}/parameters/?key=temperature' 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.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['key'], 'temperature')
self.assertEqual(len(response.data['history']), 2) self.assertEqual(len(response.data['history']), 2)
self.assertEqual(response.data['history'][0]['value'], '25.5') self.assertEqual(response.data['history'][0]['value'], '25.5')
self.assertEqual(response.data['history'][1]['value'], '26.0') self.assertEqual(response.data['history'][1]['value'], '26.0')
@@ -673,7 +670,9 @@ class BusinessObjectAPITestCase(TestCase):
empty_process = models.Process.objects.create(name='空流程') empty_process = models.Process.objects.create(name='空流程')
empty_business_object = models.BusinessObject.objects.create( empty_business_object = models.BusinessObject.objects.create(
name='空业务对象', name='空业务对象',
process=empty_process process=empty_process,
content_type=self.process_content_type,
object_id=empty_process.id,
) )
# 尝试推进(应该失败) # 尝试推进(应该失败)

View File

@@ -35,6 +35,9 @@ class BusinessObjectCRUDAndFilterAPITestCase(TestCase):
self.process_b = models.Process.objects.create(name="流程B", description="用于测试过滤器B") self.process_b = models.Process.objects.create(name="流程B", description="用于测试过滤器B")
models.ProcessNode.objects.create(process=self.process_b, state=self.state1, order=0) 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): def test_business_object_crud(self):
"""覆盖 create/list/retrieve/patch/delete 的基本 happy path""" """覆盖 create/list/retrieve/patch/delete 的基本 happy path"""
# create注意create serializer 不包含 id需要从 DB 获取) # create注意create serializer 不包含 id需要从 DB 获取)
@@ -42,6 +45,8 @@ class BusinessObjectCRUDAndFilterAPITestCase(TestCase):
"name": "BO-CRUD-1", "name": "BO-CRUD-1",
"process": self.process_a.id, "process": self.process_a.id,
"description": "测试 CRUD", "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") resp = self.client.post("/api/v1/stateflow/business-objects/", create_payload, format="json")
self.assertEqual(resp.status_code, status.HTTP_201_CREATED) self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
@@ -134,7 +139,7 @@ class BusinessObjectCRUDAndFilterAPITestCase(TestCase):
format="json", format="json",
) )
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) 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): def test_business_object_filters(self):
"""覆盖 BusinessObjectFilterSet 的关键过滤条件""" """覆盖 BusinessObjectFilterSet 的关键过滤条件"""
@@ -142,16 +147,29 @@ class BusinessObjectCRUDAndFilterAPITestCase(TestCase):
name="BO-InProgress-Alpha", name="BO-InProgress-Alpha",
process=self.process_a, process=self.process_a,
description="alpha desc", description="alpha desc",
content_type=self.user_content_type,
object_id=self.user.id,
) )
bo_completed = models.BusinessObject.objects.create( bo_completed = models.BusinessObject.objects.create(
name="BO-Completed-Beta", name="BO-Completed-Beta",
process=self.process_a, process=self.process_a,
description="beta desc", description="beta desc",
content_type=self.user_content_type,
object_id=self.user.id,
) )
bo_other_process = models.BusinessObject.objects.create( bo_other_process = models.BusinessObject.objects.create(
name="BO-OtherProcess-Gamma", name="BO-OtherProcess-Gamma",
process=self.process_b, process=self.process_b,
description="gamma desc", 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 完成(该流程无必填参数,直接推进即可) # 让 bo_completed 完成(该流程无必填参数,直接推进即可)
@@ -175,9 +193,9 @@ class BusinessObjectCRUDAndFilterAPITestCase(TestCase):
# process_name contains # process_name contains
resp = self.client.get("/api/v1/stateflow/business-objects/?process_name=流程A&limit=10&offset=0") 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.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"]} 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 # overall_status=completed
resp = self.client.get("/api/v1/stateflow/business-objects/?overall_status=completed&limit=10&offset=0") 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") 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) self.assertEqual(resp.status_code, status.HTTP_200_OK)
returned_ids = {x["id"] for x in resp.data["results"]} 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 # content_type_str + has_content_object
ct = ContentType.objects.get_for_model(models.Process) 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") 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) self.assertEqual(resp.status_code, status.HTTP_200_OK)
returned_ids = {x["id"] for x in resp.data["results"]} 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") 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) self.assertEqual(resp.status_code, status.HTTP_200_OK)
returned_ids = {x["id"] for x in resp.data["results"]} 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") 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) self.assertEqual(resp.status_code, status.HTTP_200_OK)

View File

@@ -111,7 +111,8 @@ class CloneBusinessObjectServiceTestCase(TestCase):
self.business_object.refresh_from_db() self.business_object.refresh_from_db()
def test_clone_business_object_copies_all_records_and_parameters(self): 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( cloned = services.clone_business_object(
self.business_object, self.business_object,
new_object_id=new_object_id, new_object_id=new_object_id,
@@ -169,9 +170,10 @@ class CloneBusinessObjectServiceTestCase(TestCase):
) )
def test_clone_is_independent_from_source(self): def test_clone_is_independent_from_source(self):
target_process = models.Process.objects.create(name='流程B-独立', description='新的绑定对象')
cloned = services.clone_business_object( cloned = services.clone_business_object(
self.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, 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() src_first_param = src_first_log.parameter_records.order_by('created_at', 'id').first()
self.assertEqual(src_first_param.parameters['temperature'], '25.5') 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( bo = models.BusinessObject.objects.create(
name='BO-empty', name='BO-empty',
process=self.process, process=self.process,
description='empty', description='empty',
) )
cloned = services.clone_business_object( with self.assertRaises(ValueError):
services.clone_business_object(
bo, bo,
new_object_id=None, new_object_id=None,
expected_content_type_id=None, expected_content_type_id=None,
) )
self.assertNotEqual(cloned.id, bo.id)
self.assertIsNone(cloned.content_type_id)
self.assertIsNone(cloned.object_id)
self.assertEqual(cloned.state_logs.count(), 0)
def test_clone_rejects_mismatched_content_type(self): def test_clone_rejects_mismatched_content_type(self):
"""content_type 不一致应拒绝克隆""" """content_type 不一致应拒绝克隆"""

View File

@@ -4,6 +4,7 @@
""" """
from django.test import TestCase from django.test import TestCase
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from stateflow import models, services from stateflow import models, services
User = get_user_model() 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.state2, order=1)
models.ProcessNode.objects.create(process=self.process, state=self.state3, order=2) 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( self.business_object = models.BusinessObject.objects.create(
name='测试业务对象', 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): def test_advance_without_required_parameter_fails(self):

View File

@@ -37,3 +37,31 @@ class ProcessListNodeCountAPITestCase(TestCase):
self.assertEqual(item["node_count"], 2) 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)

View File

@@ -40,6 +40,23 @@ class StateFlowServicesTestCase(TestCase):
content_type=ct, content_type=ct,
object_id=self.process.id 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): def test_initial_state(self):
"""测试初始状态 - current_state 是下一个待执行节点(第一个节点)""" """测试初始状态 - current_state 是下一个待执行节点(第一个节点)"""
@@ -318,3 +335,52 @@ class StateFlowServicesTestCase(TestCase):
self.assertIn('state_name', node) self.assertIn('state_name', node)
self.assertIn('order', node) self.assertIn('order', node)
self.assertIsInstance(node['state'], models.State) 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')

View File

@@ -3,6 +3,7 @@
""" """
from django.test import TestCase from django.test import TestCase
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from stateflow import models, services from stateflow import models, services
User = get_user_model() User = get_user_model()
@@ -29,10 +30,13 @@ class StepBackTestCase(TestCase):
models.ProcessNode.objects.create(process=self.process, state=self.state3, order=2) 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( self.business_object = models.BusinessObject.objects.create(
name='测试业务对象', name='测试业务对象',
process=self.process, process=self.process,
description='测试回退功能' description='测试回退功能',
content_type=process_ct,
object_id=self.process.id,
) )
def test_step_back_from_not_started(self): def test_step_back_from_not_started(self):

View File

@@ -5,6 +5,7 @@ from django.test import TestCase
from rest_framework.test import APIClient from rest_framework.test import APIClient
from rest_framework import status from rest_framework import status
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from stateflow import models from stateflow import models
User = get_user_model() User = get_user_model()
@@ -32,9 +33,12 @@ class StepBackAPITestCase(TestCase):
models.ProcessNode.objects.create(process=self.process, state=self.state3, order=2) 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( self.business_object = models.BusinessObject.objects.create(
name='测试对象', name='测试对象',
process=self.process process=self.process,
content_type=process_ct,
object_id=self.process.id,
) )
def test_step_back_api_success(self): def test_step_back_api_success(self):