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
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

View File

@@ -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)))

View File

@@ -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

View File

@@ -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,
})

View File

@@ -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()

View File

@@ -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'),
]

View File

@@ -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',
]

View File

@@ -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",

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业务逻辑服务层
"""
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',
*,

View File

@@ -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,
)
# 尝试推进(应该失败)

View File

@@ -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)

View File

@@ -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 不一致应拒绝克隆"""

View File

@@ -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):

View File

@@ -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)

View File

@@ -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')

View File

@@ -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):

View File

@@ -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):