forked from erp-dev/erp
feat: new api for plate order (get_plate_order_by_state_status)
This commit is contained in:
2
.env
2
.env
@@ -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
|
||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
|||||||
233
api_v2/tests.py
233
api_v2/tests.py
@@ -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()
|
||||||
|
|||||||
@@ -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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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.
112
docs/api_v2_plate_orders_by_state_status.md
Normal file
112
docs/api_v2_plate_orders_by_state_status.md
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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',
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 尝试推进(应该失败)
|
# 尝试推进(应该失败)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 不一致应拒绝克隆"""
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user