forked from erp-dev/erp
feat: new modul (stateflow)
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
from django.urls import path, include
|
||||
from .views import stock_change_views, user_info, inventory, product_image
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import stock_change_views, user_info, inventory, product_image, stateflow
|
||||
|
||||
# 创建 DRF Router
|
||||
router = DefaultRouter()
|
||||
router.register(r'states', stateflow.StateViewSet, basename='state')
|
||||
router.register(r'processes', stateflow.ProcessViewSet, basename='process')
|
||||
router.register(r'business-objects', stateflow.BusinessObjectViewSet, basename='business-object')
|
||||
|
||||
urlpatterns = [
|
||||
# 库存变动相关API
|
||||
@@ -20,4 +27,7 @@ urlpatterns = [
|
||||
|
||||
# 产品图片上传 API
|
||||
path('products/<int:product_id>/image/', product_image.ProductImageUploadView.as_view(), name='product_image_upload'),
|
||||
|
||||
# Stateflow API (使用 Router)
|
||||
path('stateflow/', include(router.urls)),
|
||||
]
|
||||
|
||||
@@ -9,6 +9,8 @@ from .stock_change_views import (
|
||||
set_merchant_auto_complete_stock_change,
|
||||
)
|
||||
|
||||
# Stateflow views
|
||||
from . import stateflow
|
||||
|
||||
# 旧版本保留在 stock_change.py 文件中,测试通过后可删除
|
||||
|
||||
@@ -17,4 +19,5 @@ __all__ = [
|
||||
'list_stock_changes',
|
||||
'get_stock_change',
|
||||
'set_merchant_auto_complete_stock_change',
|
||||
'stateflow',
|
||||
]
|
||||
|
||||
5
api_v1/views/stateflow/__init__.py
Normal file
5
api_v1/views/stateflow/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .state import StateViewSet
|
||||
from .process import ProcessViewSet
|
||||
from .business_object import BusinessObjectViewSet
|
||||
|
||||
__all__ = ['StateViewSet', 'ProcessViewSet', 'BusinessObjectViewSet']
|
||||
169
api_v1/views/stateflow/business_object.py
Normal file
169
api_v1/views/stateflow/business_object.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
BusinessObject API ViewSet
|
||||
"""
|
||||
from rest_framework import viewsets, filters, status
|
||||
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_filters import rest_framework as django_filters
|
||||
from stateflow import models, services
|
||||
from stateflow.serializers import (
|
||||
BusinessObjectListSerializer,
|
||||
BusinessObjectDetailSerializer,
|
||||
BusinessObjectCreateUpdateSerializer,
|
||||
StateListSerializer,
|
||||
)
|
||||
|
||||
|
||||
class BusinessObjectFilterSet(django_filters.FilterSet):
|
||||
"""业务对象过滤器"""
|
||||
name = django_filters.CharFilter(lookup_expr='icontains')
|
||||
process_name = django_filters.CharFilter(field_name='process__name', lookup_expr='icontains')
|
||||
overall_status = django_filters.ChoiceFilter(
|
||||
choices=[
|
||||
('not_started', '未开始'),
|
||||
('in_progress', '进行中'),
|
||||
('completed', '已完成')
|
||||
],
|
||||
method='filter_overall_status'
|
||||
)
|
||||
content_type_str = django_filters.CharFilter(method='filter_content_type')
|
||||
has_content_object = django_filters.BooleanFilter(method='filter_has_content_object')
|
||||
|
||||
def filter_overall_status(self, queryset, name, value):
|
||||
"""过滤整体状态"""
|
||||
business_object_ids = []
|
||||
for obj in queryset:
|
||||
if services.get_overall_status(obj) == value:
|
||||
business_object_ids.append(obj.id)
|
||||
return queryset.filter(id__in=business_object_ids)
|
||||
|
||||
def filter_content_type(self, queryset, name, value):
|
||||
"""通过 app_label.model 字符串过滤"""
|
||||
try:
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
app_label, model = value.split('.')
|
||||
content_type = ContentType.objects.get(app_label=app_label, model=model)
|
||||
return queryset.filter(content_type=content_type)
|
||||
except:
|
||||
return queryset.none()
|
||||
|
||||
def filter_has_content_object(self, queryset, name, value):
|
||||
"""过滤是否有关联对象"""
|
||||
if value:
|
||||
return queryset.exclude(content_type__isnull=True)
|
||||
else:
|
||||
return queryset.filter(content_type__isnull=True)
|
||||
|
||||
class Meta:
|
||||
model = models.BusinessObject
|
||||
fields = ['name', 'process', 'process_name', 'overall_status',
|
||||
'content_type_str', 'has_content_object']
|
||||
|
||||
|
||||
class BusinessObjectViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
业务对象 CRUD 接口
|
||||
|
||||
业务对象是流程的实例,可以选择性地关联到实际的业务模型(如订单、工单等)
|
||||
|
||||
list: 获取业务对象列表
|
||||
retrieve: 获取业务对象详情(包含时间线和日志)
|
||||
create: 创建业务对象
|
||||
update: 更新业务对象
|
||||
partial_update: 部分更新业务对象
|
||||
destroy: 删除业务对象
|
||||
|
||||
查询参数:
|
||||
- name: 按名称模糊查询
|
||||
- process: 按流程ID过滤
|
||||
- process_name: 按流程名称模糊查询
|
||||
- overall_status: 按整体状态过滤 (not_started/in_progress/completed)
|
||||
- content_type_str: 按关联对象类型过滤,格式: app_label.model
|
||||
- has_content_object: 是否有关联对象 (true/false)
|
||||
- search: 全文搜索(名称和描述)
|
||||
- ordering: 排序字段
|
||||
"""
|
||||
queryset = models.BusinessObject.objects.all()
|
||||
pagination_class = LimitOffsetPagination
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||
filterset_class = BusinessObjectFilterSet
|
||||
search_fields = ['name', 'description']
|
||||
ordering_fields = ['id', 'name', 'created_at', 'updated_at']
|
||||
ordering = ['-created_at']
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""根据动作选择序列化器"""
|
||||
if self.action == 'list':
|
||||
return BusinessObjectListSerializer
|
||||
elif self.action in ['create', 'update', 'partial_update']:
|
||||
return BusinessObjectCreateUpdateSerializer
|
||||
else: # retrieve
|
||||
return BusinessObjectDetailSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""优化查询"""
|
||||
queryset = super().get_queryset()
|
||||
if self.action == 'list':
|
||||
queryset = queryset.select_related('process', 'content_type')
|
||||
elif self.action == 'retrieve':
|
||||
queryset = queryset.select_related('process', 'content_type').prefetch_related(
|
||||
'state_logs__state',
|
||||
'state_logs__completed_by',
|
||||
'process__process_nodes__state'
|
||||
)
|
||||
return queryset
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def advance(self, request, pk=None):
|
||||
"""推进到下一个状态"""
|
||||
business_object = self.get_object()
|
||||
user = request.user
|
||||
|
||||
success, message = services.advance_to_next_state(business_object, user)
|
||||
|
||||
if success:
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': message,
|
||||
'business_object': BusinessObjectDetailSerializer(business_object).data
|
||||
})
|
||||
else:
|
||||
return Response({
|
||||
'success': False,
|
||||
'message': message
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def reset(self, request, pk=None):
|
||||
"""重置进度"""
|
||||
business_object = self.get_object()
|
||||
services.reset_order_progress(business_object)
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': '进度已重置',
|
||||
'business_object': BusinessObjectDetailSerializer(business_object).data
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def timeline(self, request, pk=None):
|
||||
"""获取状态时间线"""
|
||||
business_object = self.get_object()
|
||||
timeline = services.get_business_object_state_timeline(business_object)
|
||||
|
||||
timeline_data = [
|
||||
{
|
||||
'state': StateListSerializer(item['state']).data,
|
||||
'status': item['status'],
|
||||
'order': item['order'],
|
||||
'completed_at': item['completed_at'],
|
||||
'completed_by': item['completed_by'].username if item['completed_by'] else None,
|
||||
'cancelled_at': item['cancelled_at'],
|
||||
'is_cancelled': item['is_cancelled'],
|
||||
}
|
||||
for item in timeline
|
||||
]
|
||||
|
||||
return Response(timeline_data)
|
||||
59
api_v1/views/stateflow/process.py
Normal file
59
api_v1/views/stateflow/process.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Process API ViewSet
|
||||
"""
|
||||
from rest_framework import viewsets, filters
|
||||
from rest_framework.pagination import LimitOffsetPagination
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django.db.models import Count
|
||||
from stateflow import models
|
||||
from stateflow.serializers import (
|
||||
ProcessListSerializer,
|
||||
ProcessDetailSerializer,
|
||||
ProcessCreateUpdateSerializer,
|
||||
)
|
||||
|
||||
|
||||
class ProcessViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
流程 CRUD 接口
|
||||
|
||||
list: 获取流程列表
|
||||
retrieve: 获取流程详情(包含节点)
|
||||
create: 创建流程
|
||||
update: 更新流程
|
||||
partial_update: 部分更新流程
|
||||
destroy: 删除流程
|
||||
|
||||
查询参数:
|
||||
- name: 按名称模糊查询
|
||||
- search: 全文搜索(名称和描述)
|
||||
- ordering: 排序字段,支持 id, name, node_count, created_at, updated_at
|
||||
"""
|
||||
pagination_class = LimitOffsetPagination
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||
filterset_fields = ['name']
|
||||
search_fields = ['name', 'description']
|
||||
ordering_fields = ['id', 'name', 'node_count', 'created_at', 'updated_at']
|
||||
ordering = ['-created_at']
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""根据动作选择序列化器"""
|
||||
if self.action == 'list':
|
||||
return ProcessListSerializer
|
||||
elif self.action in ['create', 'update', 'partial_update']:
|
||||
return ProcessCreateUpdateSerializer
|
||||
else: # retrieve
|
||||
return ProcessDetailSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""优化查询"""
|
||||
queryset = models.Process.objects.all()
|
||||
|
||||
if self.action == 'list':
|
||||
# list 时添加节点计数注解
|
||||
queryset = queryset.annotate(node_count=Count('process_nodes'))
|
||||
elif self.action == 'retrieve':
|
||||
# retrieve 时预加载节点和状态
|
||||
queryset = queryset.prefetch_related('process_nodes__state')
|
||||
|
||||
return queryset
|
||||
53
api_v1/views/stateflow/state.py
Normal file
53
api_v1/views/stateflow/state.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
State API ViewSet
|
||||
"""
|
||||
from rest_framework import viewsets, filters
|
||||
from rest_framework.pagination import LimitOffsetPagination
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from stateflow import models
|
||||
from stateflow.serializers import (
|
||||
StateListSerializer,
|
||||
StateDetailSerializer,
|
||||
StateCreateUpdateSerializer,
|
||||
)
|
||||
|
||||
|
||||
class StateViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
状态节点 CRUD 接口
|
||||
|
||||
list: 获取状态列表
|
||||
retrieve: 获取状态详情(包含参数)
|
||||
create: 创建状态
|
||||
update: 更新状态
|
||||
partial_update: 部分更新状态
|
||||
destroy: 删除状态
|
||||
|
||||
查询参数:
|
||||
- name: 按名称模糊查询
|
||||
- search: 全文搜索(名称和描述)
|
||||
- ordering: 排序字段,支持 id, name, created_at, updated_at
|
||||
"""
|
||||
queryset = models.State.objects.all()
|
||||
pagination_class = LimitOffsetPagination
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||
filterset_fields = ['name']
|
||||
search_fields = ['name', 'description']
|
||||
ordering_fields = ['id', 'name', 'created_at', 'updated_at']
|
||||
ordering = ['-created_at']
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""根据动作选择序列化器"""
|
||||
if self.action == 'list':
|
||||
return StateListSerializer
|
||||
elif self.action in ['create', 'update', 'partial_update']:
|
||||
return StateCreateUpdateSerializer
|
||||
else: # retrieve
|
||||
return StateDetailSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""优化查询,retrieve 时预加载参数"""
|
||||
queryset = super().get_queryset()
|
||||
if self.action == 'retrieve':
|
||||
queryset = queryset.prefetch_related('parameters')
|
||||
return queryset
|
||||
819
api_v1/views/stateflow/swagger.yml
Normal file
819
api_v1/views/stateflow/swagger.yml
Normal file
@@ -0,0 +1,819 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Flower Stateflow API
|
||||
version: 1.0.0
|
||||
description: >
|
||||
REST API for managing Stateflow states, processes, and business objects.
|
||||
servers:
|
||||
- url: http://localhost:8000/api/v1
|
||||
security:
|
||||
- BearerAuth: []
|
||||
tags:
|
||||
- name: States
|
||||
- name: Processes
|
||||
- name: BusinessObjects
|
||||
|
||||
paths:
|
||||
/stateflow/states/:
|
||||
get:
|
||||
tags: [States]
|
||||
summary: List states
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Limit'
|
||||
- $ref: '#/components/parameters/Offset'
|
||||
- name: name
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: Filter by exact name
|
||||
- name: search
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: Full-text search across name and description
|
||||
- name: ordering
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [id, -id, name, -name, created_at, -created_at, updated_at, -updated_at]
|
||||
description: Order results by a field (prefix with "-" for descending)
|
||||
responses:
|
||||
'200':
|
||||
description: Paginated state list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PaginatedStateList'
|
||||
post:
|
||||
tags: [States]
|
||||
summary: Create state
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/StateCreateRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: Created state
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/StateDetail'
|
||||
/stateflow/states/{id}/:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/StateId'
|
||||
get:
|
||||
tags: [States]
|
||||
summary: Retrieve state
|
||||
responses:
|
||||
'200':
|
||||
description: State detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/StateDetail'
|
||||
put:
|
||||
tags: [States]
|
||||
summary: Update state
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/StateCreateRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Updated state
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/StateDetail'
|
||||
patch:
|
||||
tags: [States]
|
||||
summary: Partially update state
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/StatePartialRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Updated state
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/StateDetail'
|
||||
delete:
|
||||
tags: [States]
|
||||
summary: Delete state
|
||||
responses:
|
||||
'204':
|
||||
description: Deleted
|
||||
|
||||
/stateflow/processes/:
|
||||
get:
|
||||
tags: [Processes]
|
||||
summary: List processes
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Limit'
|
||||
- $ref: '#/components/parameters/Offset'
|
||||
- name: name
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: Filter by exact name
|
||||
- name: search
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: Full-text search across name and description
|
||||
- name: ordering
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [id, -id, name, -name, node_count, -node_count, created_at, -created_at, updated_at, -updated_at]
|
||||
description: Order results by a field (prefix with "-" for descending)
|
||||
responses:
|
||||
'200':
|
||||
description: Paginated process list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PaginatedProcessList'
|
||||
post:
|
||||
tags: [Processes]
|
||||
summary: Create process
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProcessCreateRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: Created process
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProcessDetail'
|
||||
/stateflow/processes/{id}/:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/ProcessId'
|
||||
get:
|
||||
tags: [Processes]
|
||||
summary: Retrieve process
|
||||
responses:
|
||||
'200':
|
||||
description: Process detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProcessDetail'
|
||||
put:
|
||||
tags: [Processes]
|
||||
summary: Update process
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProcessCreateRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Updated process
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProcessDetail'
|
||||
patch:
|
||||
tags: [Processes]
|
||||
summary: Partially update process
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProcessPartialRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Updated process
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProcessDetail'
|
||||
delete:
|
||||
tags: [Processes]
|
||||
summary: Delete process
|
||||
responses:
|
||||
'204':
|
||||
description: Deleted
|
||||
|
||||
/stateflow/business-objects/:
|
||||
get:
|
||||
tags: [BusinessObjects]
|
||||
summary: List business objects
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Limit'
|
||||
- $ref: '#/components/parameters/Offset'
|
||||
- name: name
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: Filter by name (case-insensitive, contains)
|
||||
- name: process
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
description: Filter by process ID
|
||||
- name: process_name
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: Filter by process name (case-insensitive, contains)
|
||||
- name: overall_status
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [not_started, in_progress, completed]
|
||||
description: Filter by overall status
|
||||
- name: content_type_str
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: Filter by related object type (app_label.model)
|
||||
- name: has_content_object
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
description: true for only records linked to actual objects; false for unlinked
|
||||
- name: search
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: Full-text search across name and description
|
||||
- name: ordering
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [id, -id, name, -name, created_at, -created_at, updated_at, -updated_at]
|
||||
description: Order results by a field (prefix with "-" for descending)
|
||||
responses:
|
||||
'200':
|
||||
description: Paginated business object list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PaginatedBusinessObjectList'
|
||||
post:
|
||||
tags: [BusinessObjects]
|
||||
summary: Create business object
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BusinessObjectCreateRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: Created business object
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BusinessObjectDetail'
|
||||
/stateflow/business-objects/{id}/:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/BusinessObjectId'
|
||||
get:
|
||||
tags: [BusinessObjects]
|
||||
summary: Retrieve business object
|
||||
responses:
|
||||
'200':
|
||||
description: Business object detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BusinessObjectDetail'
|
||||
put:
|
||||
tags: [BusinessObjects]
|
||||
summary: Update business object
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BusinessObjectCreateRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Updated business object
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BusinessObjectDetail'
|
||||
patch:
|
||||
tags: [BusinessObjects]
|
||||
summary: Partially update business object
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BusinessObjectPartialRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Updated business object
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BusinessObjectDetail'
|
||||
delete:
|
||||
tags: [BusinessObjects]
|
||||
summary: Delete business object
|
||||
responses:
|
||||
'204':
|
||||
description: Deleted
|
||||
/stateflow/business-objects/{id}/advance/:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/BusinessObjectId'
|
||||
post:
|
||||
tags: [BusinessObjects]
|
||||
summary: Advance business object to next state
|
||||
responses:
|
||||
'200':
|
||||
description: Advanced successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BusinessObjectAdvanceResponse'
|
||||
'400':
|
||||
description: Cannot advance
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BusinessObjectAdvanceResponse'
|
||||
/stateflow/business-objects/{id}/reset/:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/BusinessObjectId'
|
||||
post:
|
||||
tags: [BusinessObjects]
|
||||
summary: Reset progress (mark records as cancelled)
|
||||
responses:
|
||||
'200':
|
||||
description: Reset successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BusinessObjectResetResponse'
|
||||
/stateflow/business-objects/{id}/timeline/:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/BusinessObjectId'
|
||||
get:
|
||||
tags: [BusinessObjects]
|
||||
summary: Get state timeline
|
||||
responses:
|
||||
'200':
|
||||
description: Timeline data
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/StateTimelineItem'
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
|
||||
parameters:
|
||||
Limit:
|
||||
name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Number of results to return per page (default configured on server)
|
||||
Offset:
|
||||
name: offset
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Offset into the result set for pagination
|
||||
StateId:
|
||||
name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
description: State ID
|
||||
ProcessId:
|
||||
name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
description: Process ID
|
||||
BusinessObjectId:
|
||||
name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
description: Business object ID
|
||||
|
||||
schemas:
|
||||
DateTime:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
StateParameter:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
key:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
required: [id, key, value]
|
||||
|
||||
StateListItem:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
created_at:
|
||||
$ref: '#/components/schemas/DateTime'
|
||||
updated_at:
|
||||
$ref: '#/components/schemas/DateTime'
|
||||
required: [id, name, created_at, updated_at]
|
||||
|
||||
StateDetail:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/StateListItem'
|
||||
- type: object
|
||||
properties:
|
||||
parameters:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/StateParameter'
|
||||
|
||||
StateCreateRequest:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
parameters:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
required: [key, value]
|
||||
required: [name]
|
||||
|
||||
StatePartialRequest:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
parameters:
|
||||
type: array
|
||||
nullable: true
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
required: [key, value]
|
||||
|
||||
ProcessNode:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
state_id:
|
||||
type: integer
|
||||
state_name:
|
||||
type: string
|
||||
order:
|
||||
type: integer
|
||||
required: [id, state_id, state_name, order]
|
||||
|
||||
ProcessListItem:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
node_count:
|
||||
type: integer
|
||||
created_at:
|
||||
$ref: '#/components/schemas/DateTime'
|
||||
updated_at:
|
||||
$ref: '#/components/schemas/DateTime'
|
||||
required: [id, name, node_count, created_at, updated_at]
|
||||
|
||||
ProcessDetail:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ProcessListItem'
|
||||
- type: object
|
||||
properties:
|
||||
nodes:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ProcessNode'
|
||||
|
||||
ProcessCreateRequest:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
nodes:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
state_id:
|
||||
type: integer
|
||||
order:
|
||||
type: integer
|
||||
required: [state_id, order]
|
||||
required: [name]
|
||||
|
||||
ProcessPartialRequest:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
nodes:
|
||||
type: array
|
||||
nullable: true
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
state_id:
|
||||
type: integer
|
||||
order:
|
||||
type: integer
|
||||
required: [state_id, order]
|
||||
|
||||
StateFlowRecord:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
state:
|
||||
type: integer
|
||||
state_name:
|
||||
type: string
|
||||
completed_at:
|
||||
$ref: '#/components/schemas/DateTime'
|
||||
nullable: true
|
||||
completed_by:
|
||||
type: integer
|
||||
nullable: true
|
||||
completed_by_username:
|
||||
type: string
|
||||
nullable: true
|
||||
is_cancelled:
|
||||
type: boolean
|
||||
cancelled_at:
|
||||
$ref: '#/components/schemas/DateTime'
|
||||
nullable: true
|
||||
required: [id, state, state_name, is_cancelled]
|
||||
|
||||
StateTimelineItem:
|
||||
type: object
|
||||
properties:
|
||||
state:
|
||||
$ref: '#/components/schemas/StateListItem'
|
||||
status:
|
||||
type: string
|
||||
enum: [not_started, in_progress, completed, cancelled]
|
||||
order:
|
||||
type: integer
|
||||
completed_at:
|
||||
$ref: '#/components/schemas/DateTime'
|
||||
nullable: true
|
||||
completed_by:
|
||||
type: string
|
||||
nullable: true
|
||||
cancelled_at:
|
||||
$ref: '#/components/schemas/DateTime'
|
||||
nullable: true
|
||||
is_cancelled:
|
||||
type: boolean
|
||||
required: [state, status, order, is_cancelled]
|
||||
|
||||
BusinessObjectListItem:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
process:
|
||||
type: integer
|
||||
process_name:
|
||||
type: string
|
||||
current_state_name:
|
||||
type: string
|
||||
nullable: true
|
||||
overall_status:
|
||||
type: string
|
||||
enum: [not_started, in_progress, completed]
|
||||
progress_percentage:
|
||||
type: number
|
||||
format: float
|
||||
content_type:
|
||||
type: integer
|
||||
nullable: true
|
||||
object_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
content_type_name:
|
||||
type: string
|
||||
nullable: true
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
created_at:
|
||||
$ref: '#/components/schemas/DateTime'
|
||||
updated_at:
|
||||
$ref: '#/components/schemas/DateTime'
|
||||
required: [id, name, process, process_name, overall_status, progress_percentage, created_at, updated_at]
|
||||
|
||||
BusinessObjectDetail:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BusinessObjectListItem'
|
||||
- type: object
|
||||
properties:
|
||||
process_detail:
|
||||
$ref: '#/components/schemas/ProcessDetail'
|
||||
current_state:
|
||||
$ref: '#/components/schemas/StateDetail'
|
||||
nullable: true
|
||||
timeline:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/StateTimelineItem'
|
||||
state_logs:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/StateFlowRecord'
|
||||
|
||||
BusinessObjectCreateRequest:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
process:
|
||||
type: integer
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
content_type:
|
||||
type: integer
|
||||
nullable: true
|
||||
object_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
content_type_str:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Alternative to content_type; format app_label.model
|
||||
required: [name, process]
|
||||
|
||||
BusinessObjectPartialRequest:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
process:
|
||||
type: integer
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
content_type:
|
||||
type: integer
|
||||
nullable: true
|
||||
object_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
content_type_str:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
BusinessObjectAdvanceResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
message:
|
||||
type: string
|
||||
business_object:
|
||||
$ref: '#/components/schemas/BusinessObjectDetail'
|
||||
nullable: true
|
||||
required: [success, message]
|
||||
|
||||
BusinessObjectResetResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
message:
|
||||
type: string
|
||||
business_object:
|
||||
$ref: '#/components/schemas/BusinessObjectDetail'
|
||||
required: [success, message, business_object]
|
||||
|
||||
PaginatedStateList:
|
||||
type: object
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
next:
|
||||
type: string
|
||||
nullable: true
|
||||
previous:
|
||||
type: string
|
||||
nullable: true
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/StateListItem'
|
||||
required: [count, results]
|
||||
|
||||
PaginatedProcessList:
|
||||
type: object
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
next:
|
||||
type: string
|
||||
nullable: true
|
||||
previous:
|
||||
type: string
|
||||
nullable: true
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ProcessListItem'
|
||||
required: [count, results]
|
||||
|
||||
PaginatedBusinessObjectList:
|
||||
type: object
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
next:
|
||||
type: string
|
||||
nullable: true
|
||||
previous:
|
||||
type: string
|
||||
nullable: true
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BusinessObjectListItem'
|
||||
required: [count, results]
|
||||
390
stateflow/API.md
Normal file
390
stateflow/API.md
Normal file
@@ -0,0 +1,390 @@
|
||||
# Stateflow API 文档
|
||||
|
||||
## 概述
|
||||
|
||||
Stateflow API 提供了状态节点和流程的 CRUD 操作接口。
|
||||
|
||||
## 基础 URL
|
||||
|
||||
```
|
||||
/api/v1/
|
||||
```
|
||||
|
||||
## 认证
|
||||
|
||||
所有 API 都需要 JWT 认证。在请求头中添加:
|
||||
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
## 分页
|
||||
|
||||
使用 LimitOffset 分页:
|
||||
|
||||
- `limit`: 返回结果数量(默认无限制)
|
||||
- `offset`: 偏移量(默认0)
|
||||
|
||||
示例:
|
||||
```
|
||||
GET /api/v1/states/?limit=10&offset=0
|
||||
```
|
||||
|
||||
响应格式:
|
||||
```json
|
||||
{
|
||||
"count": 100,
|
||||
"next": "http://example.com/api/v1/states/?limit=10&offset=10",
|
||||
"previous": null,
|
||||
"results": [...]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State API (状态节点)
|
||||
|
||||
### 1. 列表查询
|
||||
|
||||
**GET** `/api/v1/states/`
|
||||
|
||||
查询参数:
|
||||
- `limit`: 分页限制
|
||||
- `offset`: 分页偏移
|
||||
- `name`: 按名称精确查询
|
||||
- `search`: 全文搜索(名称和描述)
|
||||
- `ordering`: 排序字段(id, name, created_at, updated_at)
|
||||
|
||||
示例:
|
||||
```bash
|
||||
curl -X GET "http://localhost:8000/api/v1/states/?search=审核&limit=10&offset=0" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "待审核",
|
||||
"description": "等待审核",
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 详情查询
|
||||
|
||||
**GET** `/api/v1/states/{id}/`
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "待审核",
|
||||
"description": "等待审核",
|
||||
"parameters": [
|
||||
{
|
||||
"id": 1,
|
||||
"key": "timeout",
|
||||
"value": "24h",
|
||||
"description": "超时时间"
|
||||
}
|
||||
],
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 创建状态
|
||||
|
||||
**POST** `/api/v1/states/`
|
||||
|
||||
请求体:
|
||||
```json
|
||||
{
|
||||
"name": "待审核",
|
||||
"description": "等待审核",
|
||||
"parameters": [
|
||||
{
|
||||
"key": "timeout",
|
||||
"value": "24h",
|
||||
"description": "超时时间"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
响应:`201 Created`
|
||||
```json
|
||||
{
|
||||
"name": "待审核",
|
||||
"description": "等待审核",
|
||||
"parameters": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 更新状态
|
||||
|
||||
**PUT** `/api/v1/states/{id}/`
|
||||
|
||||
请求体:
|
||||
```json
|
||||
{
|
||||
"name": "已审核",
|
||||
"description": "审核通过",
|
||||
"parameters": [
|
||||
{
|
||||
"key": "approver",
|
||||
"value": "admin",
|
||||
"description": "审核人"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
注意:`parameters` 数组会完全替换原有参数。
|
||||
|
||||
**PATCH** `/api/v1/states/{id}/`
|
||||
|
||||
部分更新,可以只更新某些字段:
|
||||
```json
|
||||
{
|
||||
"description": "新的描述"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 删除状态
|
||||
|
||||
**DELETE** `/api/v1/states/{id}/`
|
||||
|
||||
响应:`204 No Content`
|
||||
|
||||
---
|
||||
|
||||
## Process API (流程)
|
||||
|
||||
### 1. 列表查询
|
||||
|
||||
**GET** `/api/v1/processes/`
|
||||
|
||||
查询参数:
|
||||
- `limit`: 分页限制
|
||||
- `offset`: 分页偏移
|
||||
- `name`: 按名称精确查询
|
||||
- `search`: 全文搜索(名称和描述)
|
||||
- `ordering`: 排序字段(id, name, node_count, created_at, updated_at)
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"count": 2,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "订单流程",
|
||||
"description": "标准订单处理流程",
|
||||
"node_count": 3,
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 详情查询
|
||||
|
||||
**GET** `/api/v1/processes/{id}/`
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "订单流程",
|
||||
"description": "标准订单处理流程",
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"state_id": 1,
|
||||
"state_name": "待审核",
|
||||
"order": 0
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"state_id": 2,
|
||||
"state_name": "已审核",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"state_id": 3,
|
||||
"state_name": "已发货",
|
||||
"order": 2
|
||||
}
|
||||
],
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 创建流程
|
||||
|
||||
**POST** `/api/v1/processes/`
|
||||
|
||||
请求体:
|
||||
```json
|
||||
{
|
||||
"name": "订单流程",
|
||||
"description": "标准订单处理流程",
|
||||
"nodes": [
|
||||
{
|
||||
"state_id": 1,
|
||||
"order": 0
|
||||
},
|
||||
{
|
||||
"state_id": 2,
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"state_id": 3,
|
||||
"order": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
响应:`201 Created`
|
||||
|
||||
### 4. 更新流程
|
||||
|
||||
**PUT** `/api/v1/processes/{id}/`
|
||||
|
||||
请求体:
|
||||
```json
|
||||
{
|
||||
"name": "更新后的流程",
|
||||
"description": "新的描述",
|
||||
"nodes": [
|
||||
{
|
||||
"state_id": 2,
|
||||
"order": 0
|
||||
},
|
||||
{
|
||||
"state_id": 3,
|
||||
"order": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
注意:`nodes` 数组会完全替换原有节点。
|
||||
|
||||
**PATCH** `/api/v1/processes/{id}/`
|
||||
|
||||
部分更新。
|
||||
|
||||
### 5. 删除流程
|
||||
|
||||
**DELETE** `/api/v1/processes/{id}/`
|
||||
|
||||
响应:`204 No Content`
|
||||
|
||||
---
|
||||
|
||||
## 错误响应
|
||||
|
||||
所有 API 遵循统一的错误响应格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "错误描述"
|
||||
}
|
||||
```
|
||||
|
||||
或字段级错误:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": ["该字段不能为空"],
|
||||
"nodes": [
|
||||
{
|
||||
"state_id": ["状态ID 999 不存在"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
常见状态码:
|
||||
- `200 OK`: 成功
|
||||
- `201 Created`: 创建成功
|
||||
- `204 No Content`: 删除成功
|
||||
- `400 Bad Request`: 请求参数错误
|
||||
- `401 Unauthorized`: 未认证
|
||||
- `403 Forbidden`: 无权限
|
||||
- `404 Not Found`: 资源不存在
|
||||
- `500 Internal Server Error`: 服务器错误
|
||||
|
||||
## 示例代码
|
||||
|
||||
### Python (requests)
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
# 获取 token
|
||||
response = requests.post('http://localhost:8000/api/token/', {
|
||||
'username': 'admin',
|
||||
'password': 'password'
|
||||
})
|
||||
token = response.json()['access']
|
||||
|
||||
# 创建状态
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
response = requests.post(
|
||||
'http://localhost:8000/api/v1/states/',
|
||||
json={
|
||||
'name': '待审核',
|
||||
'description': '等待审核'
|
||||
},
|
||||
headers=headers
|
||||
)
|
||||
state = response.json()
|
||||
```
|
||||
|
||||
### JavaScript (fetch)
|
||||
|
||||
```javascript
|
||||
// 获取状态列表
|
||||
const response = await fetch('/api/v1/states/?limit=10&offset=0', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
console.log(data.results);
|
||||
```
|
||||
|
||||
### curl
|
||||
|
||||
```bash
|
||||
# 创建流程
|
||||
curl -X POST "http://localhost:8000/api/v1/processes/" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "订单流程",
|
||||
"description": "标准订单处理流程",
|
||||
"nodes": [
|
||||
{"state_id": 1, "order": 0},
|
||||
{"state_id": 2, "order": 1}
|
||||
]
|
||||
}'
|
||||
```
|
||||
201
stateflow/CHANGELOG.md
Normal file
201
stateflow/CHANGELOG.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Stateflow 修正记录
|
||||
|
||||
## 2025-11-11: 重要架构调整
|
||||
|
||||
### 修正1: 日志不可删除,仅能标记为已撤销
|
||||
|
||||
**问题**: 之前重置进度时会删除所有完成记录,导致操作历史丢失。
|
||||
|
||||
**解决方案**:
|
||||
- 添加 `OrderStateLog.is_cancelled` 字段(是否已撤销)
|
||||
- 添加 `OrderStateLog.cancelled_at` 字段(撤销时间)
|
||||
- 修改 `reset_order_progress()` 函数,不再删除记录,而是:
|
||||
```python
|
||||
order.state_logs.filter(is_cancelled=False).update(
|
||||
is_cancelled=True,
|
||||
cancelled_at=timezone.now()
|
||||
)
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- ✅ 保留完整的操作历史
|
||||
- ✅ 可以审计谁在什么时候撤销了进度
|
||||
- ✅ 支持未来可能的"恢复"功能
|
||||
|
||||
### 修正2: 初始状态改为 None(未开始)
|
||||
|
||||
**问题**: 之前订单创建后,初始状态就是第一个节点,无法区分"未开始"和"进行中"。
|
||||
|
||||
**解决方案**:
|
||||
- 修改 `get_order_current_state()` 函数:
|
||||
- 没有任何有效完成记录时返回 `None`(而不是第一个节点)
|
||||
- 所有状态都完成后也返回 `None`
|
||||
- 新增 `get_overall_status()` 函数:
|
||||
- 用于区分 `'not_started'`(未开始)和 `'completed'`(已完成)
|
||||
- 修改 `advance_to_next_state()` 函数:
|
||||
- 当当前状态为 `None` 且没有完成记录时,自动推进到第一个节点
|
||||
|
||||
**状态转换流程**:
|
||||
```
|
||||
创建订单 → None (not_started)
|
||||
↓ 第一次推进
|
||||
状态1 完成 → 状态2 (in_progress)
|
||||
↓ 第二次推进
|
||||
状态2 完成 → 状态3 (in_progress)
|
||||
↓ 第三次推进
|
||||
状态3 完成 → None (completed)
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- ✅ 可以区分"未开始处理"和"正在处理"
|
||||
- ✅ 可以区分"正在处理"和"已完成"
|
||||
- ✅ API 可以根据状态显示不同的操作按钮
|
||||
|
||||
### 修正3: 撤销记录保存撤销时间
|
||||
|
||||
**问题**: 重置进度时,无法知道具体在什么时候撤销的。
|
||||
|
||||
**解决方案**:
|
||||
- 在 `OrderStateLog` 模型中添加 `cancelled_at` 字段
|
||||
- 重置进度时,自动记录撤销时间:
|
||||
```python
|
||||
order.state_logs.filter(is_cancelled=False).update(
|
||||
is_cancelled=True,
|
||||
cancelled_at=timezone.now()
|
||||
)
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- ✅ 完整的审计追踪
|
||||
- ✅ 可以分析订单被重置的频率和原因
|
||||
- ✅ 便于问题排查和数据分析
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
```bash
|
||||
# 生成迁移文件
|
||||
python manage.py makemigrations stateflow
|
||||
|
||||
# 应用迁移
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
迁移文件: `0010_remove_orderstatelog_notes_and_more.py`
|
||||
|
||||
变更内容:
|
||||
- 添加 `cancelled_at` 字段(DateTimeField, nullable)
|
||||
- 添加 `is_cancelled` 字段(BooleanField, default=False)
|
||||
|
||||
## 测试验证
|
||||
|
||||
所有测试通过 ✅ (6 tests in 0.598s)
|
||||
|
||||
新增测试:
|
||||
- `test_reset_progress`: 验证重置进度不删除记录
|
||||
- `test_timeline_with_cancelled`: 验证时间线包含撤销记录
|
||||
- `test_initial_state`: 验证初始状态为 None
|
||||
|
||||
## API 变更
|
||||
|
||||
### 返回值变更
|
||||
|
||||
**`advance_to_next_state(order, user)`**:
|
||||
- 之前: `bool`
|
||||
- 现在: `Tuple[bool, str]` (是否成功, 消息)
|
||||
|
||||
**`can_advance_to_next_state(order)`**:
|
||||
- 之前: `Tuple[bool, str]`
|
||||
- 现在: `Tuple[bool, str]` (是否可以推进, 原因)
|
||||
|
||||
### 新增函数
|
||||
|
||||
**`get_overall_status(order) -> str`**:
|
||||
- 返回: `'not_started'` | `'in_progress'` | `'completed'`
|
||||
- 用途: 区分未开始和已完成(两者的 current_state 都是 None)
|
||||
|
||||
### 行为变更
|
||||
|
||||
**`reset_order_progress(order)`**:
|
||||
- 之前: 删除所有完成记录
|
||||
- 现在: 标记所有记录为已撤销,记录撤销时间
|
||||
|
||||
**`get_order_current_state(order)`**:
|
||||
- 之前: 无记录时返回第一个节点
|
||||
- 现在: 无记录时返回 `None`
|
||||
|
||||
## Admin 界面更新
|
||||
|
||||
### OrderStateLog 内联
|
||||
|
||||
新增字段显示:
|
||||
- `is_cancelled`: 是否已撤销
|
||||
- `cancelled_at`: 撤销时间
|
||||
|
||||
### OrderAdmin
|
||||
|
||||
显示逻辑更新:
|
||||
- `current_state_display`: 显示"未开始"、"已完成"或具体状态名
|
||||
- 重置进度操作提示: "已标记为撤销"而非"已删除"
|
||||
|
||||
## 升级指南
|
||||
|
||||
如果你已经在使用旧版本的 stateflow:
|
||||
|
||||
1. **备份数据库**(重要!)
|
||||
|
||||
2. **应用迁移**:
|
||||
```bash
|
||||
python manage.py migrate stateflow
|
||||
```
|
||||
|
||||
3. **更新代码**:
|
||||
- 将 `advance_to_next_state()` 的返回值解包为 `(success, message)`
|
||||
- 使用 `get_overall_status()` 替代直接判断 `current_state is None`
|
||||
|
||||
4. **测试验证**:
|
||||
```bash
|
||||
python manage.py test stateflow
|
||||
```
|
||||
|
||||
## 向后兼容性
|
||||
|
||||
⚠️ **破坏性变更**:
|
||||
- `get_order_current_state()` 的返回值变化
|
||||
- `advance_to_next_state()` 的返回值从 `bool` 变为 `Tuple[bool, str]`
|
||||
|
||||
✅ **兼容性保持**:
|
||||
- 数据库表结构只增不减
|
||||
- 现有的 OrderStateLog 记录自动设置 `is_cancelled=False`
|
||||
- API 端点可以平滑升级
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **永远不要删除 OrderStateLog**
|
||||
- 使用 Admin 界面时,设置 `can_delete=False`
|
||||
- 在 API 中禁用 DELETE 端点
|
||||
|
||||
2. **记录所有操作人**
|
||||
- `advance_to_next_state()` 必须传入 `completed_by`
|
||||
- 可以在中间件中自动获取当前用户
|
||||
|
||||
3. **定期清理撤销记录**
|
||||
- 建议保留至少 1 年的撤销记录
|
||||
- 可以归档到历史表而不是直接删除
|
||||
|
||||
4. **监控重置频率**
|
||||
- 高频率的重置可能表明流程设计有问题
|
||||
- 可以添加告警机制
|
||||
|
||||
## 疑问解答
|
||||
|
||||
### Q: ProcessNode 和 OrderStateLog 有什么区别?
|
||||
|
||||
**A**:
|
||||
- **ProcessNode**: 流程模板(定义流程有哪些步骤)
|
||||
- **OrderStateLog**: 订单实例的执行记录(记录实际完成了什么)
|
||||
|
||||
类比:
|
||||
- ProcessNode = 菜谱(定义做菜步骤)
|
||||
- OrderStateLog = 做菜记录(记录实际操作)
|
||||
|
||||
详细说明请参考 README.md 的"ProcessNode vs OrderStateLog"章节。
|
||||
284
stateflow/README.md
Normal file
284
stateflow/README.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# Stateflow 状态流转系统
|
||||
|
||||
## 架构概述
|
||||
|
||||
Stateflow 是一个基于日志的订单状态流转管理系统,采用服务层模式实现业务逻辑与数据访问的分离。
|
||||
|
||||
### 核心概念
|
||||
|
||||
- **State (状态)**: 流程中的一个节点,可以附带参数
|
||||
- **Process (流程模板)**: 由多个有序状态组成的流程定义
|
||||
- **Order (订单实例)**: 流程的具体执行实例
|
||||
- **OrderStateLog (完成日志)**: 记录订单完成每个状态的时间点和操作人
|
||||
|
||||
## 数据模型
|
||||
|
||||
### State
|
||||
```python
|
||||
State(name, parameters=[StateParameter(...)])
|
||||
```
|
||||
- 状态节点定义
|
||||
- 支持附加参数(键值对)
|
||||
|
||||
### Process
|
||||
```python
|
||||
Process(name, state_nodes=ManyToMany[ProcessNode])
|
||||
```
|
||||
- 流程模板,定义状态流转顺序
|
||||
- 通过 `ProcessNode` 中间表关联 `State`,支持排序
|
||||
|
||||
### ProcessNode
|
||||
```python
|
||||
ProcessNode(process, state, order)
|
||||
```
|
||||
- Process 和 State 的多对多关联表
|
||||
- `order` 字段定义状态在流程中的执行顺序
|
||||
|
||||
### Order
|
||||
```python
|
||||
Order(process, metadata={})
|
||||
```
|
||||
- 流程的具体实例
|
||||
- **不存储** `current_state` 字段
|
||||
- 当前状态通过查询 `OrderStateLog` 推导
|
||||
|
||||
### OrderStateLog
|
||||
```python
|
||||
OrderStateLog(order, state, completed_at, completed_by, is_cancelled, cancelled_at)
|
||||
```
|
||||
- 记录订单完成某个状态的日志
|
||||
- `completed_at`: 完成时间
|
||||
- `completed_by`: 操作人
|
||||
- `is_cancelled`: 是否已撤销
|
||||
- `cancelled_at`: 撤销时间
|
||||
- 唯一约束: `(order, state)` - 每个状态只能有一条记录
|
||||
- **不允许删除**,重置进度时只标记为已撤销
|
||||
|
||||
## 核心逻辑
|
||||
|
||||
### 状态推导规则
|
||||
|
||||
当前状态的推导遵循以下规则:
|
||||
|
||||
1. **未开始**: 没有任何有效完成日志(或所有记录都被撤销) → 返回 `None`
|
||||
2. **进行中**: 有部分完成日志 → 返回下一个未完成的状态
|
||||
3. **已完成**: 所有状态都已完成 → 返回 `None`
|
||||
|
||||
**重要**: 初始状态是 `None`(未开始),而不是第一个节点。
|
||||
|
||||
示例:
|
||||
```python
|
||||
from stateflow.services import get_order_current_state, get_overall_status
|
||||
|
||||
# 获取当前状态(None = 未开始或已完成)
|
||||
current_state = get_order_current_state(order)
|
||||
|
||||
# 获取整体状态类型
|
||||
status = get_overall_status(order) # 'not_started', 'in_progress', 'completed'
|
||||
```
|
||||
|
||||
### 状态推进
|
||||
|
||||
```python
|
||||
from stateflow.services import advance_to_next_state
|
||||
|
||||
# 推进到下一个状态(需要传入操作人)
|
||||
success = advance_to_next_state(order, completed_by=request.user)
|
||||
```
|
||||
|
||||
推进规则:
|
||||
- 如果是初始状态(`None`),推进到第一个节点
|
||||
- 自动查找当前状态
|
||||
- 验证是否可以推进(是否已完成)
|
||||
- 创建 `OrderStateLog` 记录完成时间和操作人
|
||||
- 返回 `(True, message)` 表示成功,`(False, reason)` 表示失败
|
||||
|
||||
### 完整时间线
|
||||
|
||||
```python
|
||||
from stateflow.services import get_order_state_timeline
|
||||
|
||||
# 获取完整的状态时间线
|
||||
timeline = get_order_state_timeline(order)
|
||||
# [
|
||||
# {'state': State1, 'completed_at': datetime, 'completed_by': User, 'is_completed': True},
|
||||
# {'state': State2, 'completed_at': None, 'completed_by': None, 'is_completed': False},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
## 服务层 API
|
||||
|
||||
所有业务逻辑封装在 `stateflow/services.py` 中,可在 Admin、API、任务队列等场景复用。
|
||||
|
||||
### 主要函数
|
||||
|
||||
#### `get_order_current_state(order) -> State | None`
|
||||
获取订单当前应处理的状态。
|
||||
|
||||
#### `get_overall_status(order) -> str`
|
||||
返回订单整体状态:
|
||||
- `'not_started'`: 未开始
|
||||
- `'in_progress'`: 进行中
|
||||
- `'completed'`: 已完成
|
||||
|
||||
#### `get_order_state_status(order, state) -> str`
|
||||
返回订单中某个状态的状态:
|
||||
- `'not_started'`: 未开始
|
||||
- `'in_progress'`: 进行中
|
||||
- `'completed'`: 已完成
|
||||
|
||||
#### `advance_to_next_state(order, completed_by) -> Tuple[bool, str]`
|
||||
推进到下一个状态,创建完成日志。返回 (是否成功, 消息)。
|
||||
|
||||
#### `get_order_state_timeline(order) -> List[dict]`
|
||||
获取完整时间线,包含所有状态的完成情况(包括已撤销的记录)。
|
||||
|
||||
#### `get_progress_percentage(order) -> float`
|
||||
计算完成进度百分比(0.0 - 100.0)。
|
||||
|
||||
#### `reset_order_progress(order)`
|
||||
重置订单进度。注意:**不删除日志记录**,而是标记为已撤销并记录撤销时间,保留完整的操作历史。
|
||||
|
||||
#### `can_advance_to_next_state(order) -> Tuple[bool, str]`
|
||||
检查是否可以继续推进。返回 (是否可以推进, 原因)。
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 1. 创建流程模板
|
||||
|
||||
```python
|
||||
from stateflow.models import State, Process, ProcessNode
|
||||
|
||||
# 创建状态
|
||||
pending = State.objects.create(name="待审核")
|
||||
approved = State.objects.create(name="已审核")
|
||||
shipped = State.objects.create(name="已发货")
|
||||
|
||||
# 创建流程
|
||||
process = Process.objects.create(name="订单流程")
|
||||
|
||||
# 关联状态(设置顺序)
|
||||
ProcessNode.objects.create(process=process, state=pending, order=1)
|
||||
ProcessNode.objects.create(process=process, state=approved, order=2)
|
||||
ProcessNode.objects.create(process=process, state=shipped, order=3)
|
||||
```
|
||||
|
||||
### 2. 创建订单并推进
|
||||
|
||||
```python
|
||||
from stateflow.models import Order
|
||||
from stateflow.services import get_order_current_state, advance_to_next_state, get_overall_status
|
||||
|
||||
# 创建订单实例
|
||||
order = Order.objects.create(process=process, metadata={'order_no': 'SO-001'})
|
||||
|
||||
# 查看当前状态(初始状态是 None)
|
||||
current = get_order_current_state(order) # None
|
||||
status = get_overall_status(order) # 'not_started'
|
||||
|
||||
# 推进到第一个状态
|
||||
success, msg = advance_to_next_state(order, completed_by=request.user)
|
||||
|
||||
current = get_order_current_state(order) # approved
|
||||
|
||||
# 推进到下一个状态
|
||||
success, msg = advance_to_next_state(order, completed_by=request.user)
|
||||
|
||||
current = get_order_current_state(order) # shipped
|
||||
```
|
||||
|
||||
### 3. 在 API 中使用
|
||||
|
||||
```python
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from stateflow.services import advance_to_next_state, get_order_state_timeline
|
||||
|
||||
class OrderViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def advance(self, request, pk=None):
|
||||
order = self.get_object()
|
||||
success, message = advance_to_next_state(order, completed_by=request.user)
|
||||
|
||||
if success:
|
||||
return Response({'status': 'advanced', 'message': message})
|
||||
return Response({'status': 'failed', 'message': message}, status=400)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def timeline(self, request, pk=None):
|
||||
order = self.get_object()
|
||||
timeline = get_order_state_timeline(order)
|
||||
return Response(timeline)
|
||||
```
|
||||
|
||||
## Admin 配置
|
||||
|
||||
Admin 界面提供以下功能:
|
||||
|
||||
1. **State 管理**: 创建和编辑状态,支持内联编辑参数
|
||||
2. **Process 管理**: 定义流程,通过内联表添加有序状态节点
|
||||
3. **Order 管理**:
|
||||
- 查看完成日志(只读内联)
|
||||
- 批量操作:推进到下一状态、重置进度
|
||||
- 显示当前状态、完成百分比
|
||||
4. **OrderStateLog 管理**: 只读日志查看,用于审计
|
||||
|
||||
## 迁移历史
|
||||
|
||||
- `0001_initial.py`: 初始模型(State, Process with CharField state_nodes)
|
||||
- `0002-0007`: 其他应用迁移
|
||||
- `0008_process_state_nodes_remove_process_current_state...`: 将 state_nodes 改为 ManyToMany,创建 ProcessNode 和 Order
|
||||
- `0009_remove_order_current_state_orderstatelog`: 移除 Order.current_state,创建 OrderStateLog
|
||||
|
||||
## 测试
|
||||
|
||||
运行测试:
|
||||
```bash
|
||||
python manage.py test stateflow
|
||||
```
|
||||
|
||||
测试覆盖:
|
||||
- ✅ 初始状态推导
|
||||
- ✅ 状态推进逻辑
|
||||
- ✅ 状态类型判断
|
||||
- ✅ 完整时间线生成
|
||||
- ✅ 进度重置
|
||||
|
||||
## 设计优势
|
||||
|
||||
1. **审计完整**: 每次状态变更都有时间和操作人记录
|
||||
2. **数据一致**: 状态从日志推导,无冗余存储
|
||||
3. **逻辑复用**: 服务层可在 Admin/API/Task 中共享
|
||||
4. **灵活扩展**: 可轻松添加状态前置条件、权限检查等
|
||||
5. **可追溯**: 通过 OrderStateLog 实现完整的操作历史
|
||||
6. **历史保留**: 重置进度时不删除记录,保留完整的操作痕迹
|
||||
7. **撤销追踪**: 记录撤销时间,方便审计和问题追溯
|
||||
|
||||
## ProcessNode vs OrderStateLog
|
||||
|
||||
很多人会混淆这两个表,这里做详细说明:
|
||||
|
||||
### ProcessNode (流程模板)
|
||||
- **作用**: 定义流程包含哪些状态,以及执行顺序
|
||||
- **关系**: Process ↔ State 的多对多关联
|
||||
- **层级**: 模板层(一对多)
|
||||
- **类比**: "菜谱",定义做菜的步骤
|
||||
- **示例**: 订单流程 = [待审核(1) → 已审核(2) → 已发货(3)]
|
||||
|
||||
### OrderStateLog (订单实例)
|
||||
- **作用**: 记录某个订单完成了哪些状态,以及完成时间、操作人
|
||||
- **关系**: Order → State 的完成记录
|
||||
- **层级**: 实例层(每个订单独立)
|
||||
- **类比**: "做菜记录",记录实际做了什么、何时做的
|
||||
- **示例**: 订单#001 在 2025-01-10 由张三完成了"待审核"状态
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **唯一约束**: 每个 `(order, state)` 只能有一条记录
|
||||
2. **顺序性**: 必须按 ProcessNode.order 顺序完成,不能跳过
|
||||
3. **操作人必填**: `advance_to_next_state()` 必须传入 `completed_by` 参数
|
||||
4. **不可删除**: OrderStateLog 不允许删除,只能标记为已撤销
|
||||
5. **保留历史**: 重置进度时保留所有历史记录,并记录撤销时间
|
||||
6. **初始状态**: 新订单的初始状态是 `None`(未开始),而不是第一个节点
|
||||
@@ -1,3 +1,160 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin import action
|
||||
from django.utils.safestring import mark_safe
|
||||
from . import models
|
||||
|
||||
# Register your models here.
|
||||
|
||||
class StateParameterInline(admin.StackedInline):
|
||||
model = models.StateParameter
|
||||
extra = 1
|
||||
|
||||
|
||||
class ProcessNodeInline(admin.StackedInline):
|
||||
model = models.ProcessNode
|
||||
extra = 1
|
||||
autocomplete_fields = ('state',)
|
||||
ordering = ('order',)
|
||||
|
||||
|
||||
@admin.register(models.State)
|
||||
class StateAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'parameter_overview',
|
||||
)
|
||||
search_fields = ('name', 'description')
|
||||
list_filter = ('created_at', 'updated_at', 'processes__name')
|
||||
inlines = [StateParameterInline]
|
||||
|
||||
@admin.display(description='参数概览')
|
||||
def parameter_overview(self, obj: models.State):
|
||||
params = obj.parameters.all()
|
||||
if params:
|
||||
result = "<br>".join([f"{param.key}={param.value}" for param in params])
|
||||
else:
|
||||
result = "-"
|
||||
return mark_safe(result)
|
||||
|
||||
|
||||
@admin.register(models.Process)
|
||||
class ProcessAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
'id',
|
||||
'name',
|
||||
'node_count',
|
||||
'description',
|
||||
'updated_at',
|
||||
)
|
||||
search_fields = ('name', 'description')
|
||||
list_filter = ('created_at', 'updated_at')
|
||||
inlines = [ProcessNodeInline]
|
||||
|
||||
@admin.display(description='节点数量')
|
||||
def node_count(self, obj: models.Process):
|
||||
return obj.process_nodes.count()
|
||||
|
||||
|
||||
class StateFlowRecordInline(admin.StackedInline):
|
||||
model = models.StateFlowRecord
|
||||
extra = 0
|
||||
fields = ('state', 'completed_at', 'completed_by', 'is_cancelled', 'cancelled_at')
|
||||
readonly_fields = ('completed_at', 'completed_by', 'cancelled_at')
|
||||
can_delete = False
|
||||
ordering = ('completed_at',)
|
||||
|
||||
|
||||
@admin.register(models.BusinessObject)
|
||||
class BusinessObjectAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
'id',
|
||||
'name',
|
||||
'process',
|
||||
'content_object_display',
|
||||
'current_state_display',
|
||||
'progress',
|
||||
'params',
|
||||
'updated_at',
|
||||
)
|
||||
search_fields = ('name', 'description')
|
||||
list_filter = ('process', 'created_at', 'updated_at')
|
||||
actions = ['advance_to_next_state_action', 'reset_progress_action']
|
||||
autocomplete_fields = ('process',)
|
||||
inlines = [StateFlowRecordInline]
|
||||
fieldsets = [
|
||||
('基本信息', {
|
||||
'fields': ['name', 'process', 'description']
|
||||
}),
|
||||
('关联对象(可选)', {
|
||||
'fields': ['content_type', 'object_id'],
|
||||
'description': '可选择关联到实际的业务对象,如订单、工单等'
|
||||
}),
|
||||
]
|
||||
|
||||
@admin.display(description='关联对象')
|
||||
def content_object_display(self, obj: models.BusinessObject):
|
||||
"""显示关联对象"""
|
||||
if obj.content_object:
|
||||
return f"{obj.content_type} #{obj.object_id}"
|
||||
return "-"
|
||||
|
||||
@action(description='前进到下一个状态')
|
||||
def advance_to_next_state_action(self, request, queryset):
|
||||
from . import services
|
||||
success_count = 0
|
||||
messages = []
|
||||
|
||||
for obj in queryset:
|
||||
success, message = services.advance_to_next_state(obj, request.user)
|
||||
if success:
|
||||
success_count += 1
|
||||
messages.append(f"{obj.name}: {message}")
|
||||
|
||||
self.message_user(request, f'成功推进 {success_count}/{queryset.count()} 个业务对象。详情: {"; ".join(messages[:5])}')
|
||||
|
||||
@action(description='重置进度')
|
||||
def reset_progress_action(self, request, queryset):
|
||||
from . import services
|
||||
for obj in queryset:
|
||||
services.reset_business_object_progress(obj)
|
||||
self.message_user(request, f'已重置 {queryset.count()} 个业务对象的进度。')
|
||||
|
||||
@admin.display(description='当前状态', ordering='process')
|
||||
def current_state_display(self, obj: models.BusinessObject):
|
||||
from . import services
|
||||
current_state = obj.get_current_state()
|
||||
overall_status = services.get_overall_status(obj)
|
||||
|
||||
if overall_status == 'not_started':
|
||||
return '未开始'
|
||||
elif overall_status == 'completed':
|
||||
return '已完成'
|
||||
elif current_state:
|
||||
return current_state.name
|
||||
return '-'
|
||||
|
||||
@admin.display(description='进度')
|
||||
def progress(self, obj: models.BusinessObject):
|
||||
return f"{obj.get_progress_percentage():.1f}%"
|
||||
|
||||
@admin.display(description='本步骤参数')
|
||||
def params(self, obj: models.BusinessObject):
|
||||
current_state = obj.get_current_state()
|
||||
if current_state:
|
||||
params = current_state.parameters.all()
|
||||
if params:
|
||||
return ", ".join([f"{param.key}={param.value}" for param in params])
|
||||
return "-"
|
||||
|
||||
|
||||
@admin.register(models.StateFlowRecord)
|
||||
class StateFlowRecordAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'business_object', 'state', 'completed_at', 'completed_by', 'is_cancelled', 'cancelled_at')
|
||||
search_fields = ('business_object__name', 'state__name')
|
||||
list_filter = ('state', 'is_cancelled', 'completed_at', 'completed_by')
|
||||
readonly_fields = ('business_object', 'state', 'completed_at', 'completed_by', 'cancelled_at')
|
||||
fields = ('business_object', 'state', 'completed_at', 'completed_by', 'is_cancelled', 'cancelled_at')
|
||||
autocomplete_fields = ('business_object', 'state', 'completed_by')
|
||||
|
||||
@@ -4,3 +4,4 @@ from django.apps import AppConfig
|
||||
class StateflowConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'stateflow'
|
||||
verbose_name = '印染流程'
|
||||
|
||||
45
stateflow/migrations/0001_initial.py
Normal file
45
stateflow/migrations/0001_initial.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-11 03:12
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='State',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('name', models.CharField(max_length=100, verbose_name='状态名称')),
|
||||
('data', models.JSONField(blank=True, null=True, verbose_name='参数表')),
|
||||
('description', models.TextField(blank=True, verbose_name='状态描述')),
|
||||
('next', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stateflow.state', verbose_name='下一个状态')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Process',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('name', models.CharField(max_length=100, verbose_name='流程名称')),
|
||||
('state_nodes', models.CharField(max_length=200, verbose_name='节点列表')),
|
||||
('description', models.TextField(blank=True, verbose_name='流程描述')),
|
||||
('current_state', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='current_for_processes', to='stateflow.state', verbose_name='当前状态')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-11 03:51
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stateflow', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='state',
|
||||
name='data',
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StateParameter',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('key', models.CharField(max_length=100, verbose_name='参数键')),
|
||||
('value', models.CharField(max_length=200, verbose_name='参数值')),
|
||||
('description', models.TextField(blank=True, verbose_name='参数描述')),
|
||||
('state', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='parameters', to='stateflow.state', verbose_name='关联参数')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-11 03:56
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stateflow', '0002_remove_state_data_stateparameter'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='process',
|
||||
options={'verbose_name': '印染流程', 'verbose_name_plural': '印染流程'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='state',
|
||||
options={'verbose_name': '流程节点', 'verbose_name_plural': '流程节点'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='stateparameter',
|
||||
options={'verbose_name': '工艺参数', 'verbose_name_plural': '工艺参数'},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='state',
|
||||
name='next',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='state',
|
||||
name='previous',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stateflow.state', verbose_name='上一级状态'),
|
||||
),
|
||||
]
|
||||
17
stateflow/migrations/0004_remove_state_previous.py
Normal file
17
stateflow/migrations/0004_remove_state_previous.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-11 03:57
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stateflow', '0003_alter_process_options_alter_state_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='state',
|
||||
name='previous',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-11 04:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stateflow', '0004_remove_state_previous'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='state',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=200, verbose_name='状态描述'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='stateparameter',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=200, verbose_name='参数描述'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-11 04:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stateflow', '0005_alter_state_description_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='process',
|
||||
name='state_nodes',
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProcessNode',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('order', models.PositiveIntegerField(default=0, verbose_name='顺序')),
|
||||
('process', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='process_nodes', to='stateflow.process', verbose_name='流程')),
|
||||
('state', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='state_process_nodes', to='stateflow.state', verbose_name='节点')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '流程节点关联',
|
||||
'verbose_name_plural': '流程节点关联',
|
||||
'ordering': ['order', 'id'],
|
||||
'unique_together': {('process', 'order')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='process',
|
||||
name='state_nodes',
|
||||
field=models.ManyToManyField(related_name='processes', through='stateflow.ProcessNode', to='stateflow.state', verbose_name='节点列表'),
|
||||
),
|
||||
]
|
||||
19
stateflow/migrations/0007_alter_process_current_state.py
Normal file
19
stateflow/migrations/0007_alter_process_current_state.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-11 04:16
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stateflow', '0006_remove_process_state_nodes_processnode_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='process',
|
||||
name='current_state',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='current_for_processes', to='stateflow.state', verbose_name='当前状态'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-11 04:27
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stateflow', '0007_alter_process_current_state'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='process',
|
||||
name='current_state',
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Order',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('name', models.CharField(max_length=100, verbose_name='订单名称')),
|
||||
('description', models.TextField(blank=True, verbose_name='订单描述')),
|
||||
('current_state', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='current_for_orders', to='stateflow.state', verbose_name='当前状态')),
|
||||
('process', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='stateflow.process', verbose_name='关联流程')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '订单',
|
||||
'verbose_name_plural': '订单',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-11 04:37
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stateflow', '0008_remove_process_current_state_order'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='order',
|
||||
name='current_state',
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrderStateLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('completed_at', models.DateTimeField(auto_now_add=True, verbose_name='完成时间')),
|
||||
('notes', models.TextField(blank=True, verbose_name='备注')),
|
||||
('completed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='completed_order_states', to=settings.AUTH_USER_MODEL, verbose_name='完成人')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='state_logs', to='stateflow.order', verbose_name='订单')),
|
||||
('state', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='order_logs', to='stateflow.state', verbose_name='状态节点')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '订单状态日志',
|
||||
'verbose_name_plural': '订单状态日志',
|
||||
'ordering': ['completed_at'],
|
||||
'unique_together': {('order', 'state')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-11 05:24
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stateflow', '0009_remove_order_current_state_orderstatelog'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='orderstatelog',
|
||||
name='notes',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderstatelog',
|
||||
name='cancelled_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='撤销时间'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderstatelog',
|
||||
name='is_cancelled',
|
||||
field=models.BooleanField(default=False, verbose_name='是否已撤销'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderstatelog',
|
||||
name='completed_by',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='操作人'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderstatelog',
|
||||
name='state',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='stateflow.state', verbose_name='状态'),
|
||||
),
|
||||
migrations.AlterModelTable(
|
||||
name='orderstatelog',
|
||||
table='order_state_log',
|
||||
),
|
||||
]
|
||||
76
stateflow/migrations/0011_rename_order_to_business_object.py
Normal file
76
stateflow/migrations/0011_rename_order_to_business_object.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# Generated manually
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('stateflow', '0010_remove_orderstatelog_notes_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# 先重命名表(保留数据)
|
||||
migrations.RenameModel(
|
||||
old_name='Order',
|
||||
new_name='BusinessObject',
|
||||
),
|
||||
migrations.RenameModel(
|
||||
old_name='OrderStateLog',
|
||||
new_name='StateFlowRecord',
|
||||
),
|
||||
|
||||
# 更新 BusinessObject 的字段
|
||||
migrations.AlterModelOptions(
|
||||
name='businessobject',
|
||||
options={'verbose_name': '业务对象', 'verbose_name_plural': '业务对象'},
|
||||
),
|
||||
migrations.AlterModelTable(
|
||||
name='businessobject',
|
||||
table='business_object',
|
||||
),
|
||||
|
||||
# 添加 GenericForeignKey 字段
|
||||
migrations.AddField(
|
||||
model_name='businessobject',
|
||||
name='content_type',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='关联对象类型', default=1),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='businessobject',
|
||||
name='object_id',
|
||||
field=models.PositiveIntegerField(verbose_name='关联对象ID', default=0),
|
||||
preserve_default=False,
|
||||
),
|
||||
|
||||
# 更新 StateFlowRecord
|
||||
migrations.RenameField(
|
||||
model_name='stateflowrecord',
|
||||
old_name='order',
|
||||
new_name='business_object',
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='stateflowrecord',
|
||||
options={'ordering': ['completed_at'], 'verbose_name': '状态流转记录', 'verbose_name_plural': '状态流转记录'},
|
||||
),
|
||||
migrations.AlterModelTable(
|
||||
name='stateflowrecord',
|
||||
table='state_flow_record',
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='stateflowrecord',
|
||||
unique_together={('business_object', 'state')},
|
||||
),
|
||||
|
||||
# 更新 Process 的 related_name
|
||||
migrations.AlterField(
|
||||
model_name='businessobject',
|
||||
name='process',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='business_objects', to='stateflow.process', verbose_name='关联流程'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-11 08:23
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('stateflow', '0011_rename_order_to_business_object'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='businessobject',
|
||||
name='content_type',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='关联对象类型'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='businessobject',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, verbose_name='描述'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='businessobject',
|
||||
name='name',
|
||||
field=models.CharField(max_length=100, verbose_name='业务对象名称'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='businessobject',
|
||||
name='object_id',
|
||||
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='关联对象ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='stateflowrecord',
|
||||
name='business_object',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='state_logs', to='stateflow.businessobject', verbose_name='业务对象'),
|
||||
),
|
||||
]
|
||||
@@ -1,3 +1,159 @@
|
||||
from django.db import models
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from flower.common import ModelBase
|
||||
from typing import List
|
||||
|
||||
# Create your models here.
|
||||
|
||||
class State(ModelBase):
|
||||
"""State model representing a state in a stateflow diagram."""
|
||||
name = models.CharField(max_length=100, verbose_name='状态名称')
|
||||
# previous = models.ForeignKey('self', null=True, blank=True, on_delete=models.SET_NULL, verbose_name='上一级状态')
|
||||
description = models.CharField(max_length=200, blank=True, verbose_name='状态描述')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def add_additional_params(self, params: dict):
|
||||
"""Add additional parameters to the state's data field."""
|
||||
if not self.data:
|
||||
self.data = {}
|
||||
self.data.update(params)
|
||||
self.save()
|
||||
|
||||
def get_additional_params(self) -> dict:
|
||||
"""Retrieve additional parameters from the state's data field."""
|
||||
return self.data if self.data else {}
|
||||
|
||||
class Meta:
|
||||
verbose_name = '流程节点'
|
||||
verbose_name_plural = '流程节点'
|
||||
|
||||
|
||||
class StateParameter(ModelBase):
|
||||
"""StateParameter model representing parameters associated with a state."""
|
||||
state = models.ForeignKey(State, on_delete=models.PROTECT, related_name='parameters', verbose_name='关联参数')
|
||||
key = models.CharField(max_length=100, verbose_name='参数键')
|
||||
value = models.CharField(max_length=200, verbose_name='参数值')
|
||||
description = models.CharField(max_length=200, blank=True, verbose_name='参数描述')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.state.name} - {self.key}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = '工艺参数'
|
||||
verbose_name_plural = '工艺参数'
|
||||
|
||||
|
||||
class Process(ModelBase):
|
||||
"""Process model representing a stateflow process template."""
|
||||
name = models.CharField(max_length=100, verbose_name='流程名称')
|
||||
state_nodes = models.ManyToManyField(
|
||||
State,
|
||||
through='ProcessNode',
|
||||
related_name='processes',
|
||||
verbose_name='节点列表',
|
||||
)
|
||||
description = models.TextField(blank=True, verbose_name='流程描述')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def replace_nodes(self, nodes: List[State]):
|
||||
"""Replace all nodes with new ordered list."""
|
||||
# 清空现有节点后按照传入顺序重建
|
||||
self.process_nodes.all().delete()
|
||||
for order, node in enumerate(nodes):
|
||||
ProcessNode.objects.create(process=self, state=node, order=order)
|
||||
|
||||
def get_nodes(self) -> List[State]:
|
||||
"""Retrieve the list of state nodes as State instances."""
|
||||
process_nodes = self.process_nodes.select_related('state').order_by('order', 'id')
|
||||
return [item.state for item in process_nodes]
|
||||
|
||||
class Meta:
|
||||
verbose_name = '印染流程'
|
||||
verbose_name_plural = '印染流程'
|
||||
|
||||
|
||||
class ProcessNode(ModelBase):
|
||||
"""Intermediate model describing ordered relation between Process and State."""
|
||||
process = models.ForeignKey(Process, on_delete=models.CASCADE, related_name='process_nodes', verbose_name='流程')
|
||||
state = models.ForeignKey(State, on_delete=models.PROTECT, related_name='state_process_nodes', verbose_name='节点')
|
||||
order = models.PositiveIntegerField(default=0, verbose_name='顺序')
|
||||
|
||||
class Meta:
|
||||
verbose_name = '流程节点关联'
|
||||
verbose_name_plural = '流程节点关联'
|
||||
ordering = ['order', 'id']
|
||||
unique_together = ('process', 'order')
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.process.name} -> {self.state.name} ({self.order})"
|
||||
|
||||
|
||||
class BusinessObject(ModelBase):
|
||||
"""业务对象 - 代表流程的一个实例,通过日志跟踪进度"""
|
||||
name = models.CharField(max_length=100, verbose_name='业务对象名称')
|
||||
process = models.ForeignKey(Process, on_delete=models.PROTECT, related_name='business_objects', verbose_name='关联流程')
|
||||
description = models.TextField(blank=True, verbose_name='描述')
|
||||
|
||||
# GenericForeignKey - 关联到任意模型(可选)
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True, verbose_name='关联对象类型')
|
||||
object_id = models.PositiveIntegerField(null=True, blank=True, verbose_name='关联对象ID')
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.process.name})"
|
||||
|
||||
def get_current_state(self) -> 'State':
|
||||
"""获取当前状态(通过services推导)"""
|
||||
from . import services
|
||||
return services.get_business_object_current_state(self)
|
||||
|
||||
def get_completed_node_ids(self) -> List[int]:
|
||||
"""获取已完成的节点ID列表"""
|
||||
from . import services
|
||||
return services.get_completed_node_ids(self)
|
||||
|
||||
def get_current_parameters(self) -> List['StateParameter']:
|
||||
"""获取当前状态的参数"""
|
||||
from . import services
|
||||
return services.get_current_state_parameters(self)
|
||||
|
||||
def get_progress_percentage(self) -> float:
|
||||
"""计算进度百分比"""
|
||||
from . import services
|
||||
return services.get_progress_percentage(self)
|
||||
|
||||
def get_state_status(self, state: 'State') -> str:
|
||||
"""获取指定状态的状态"""
|
||||
from . import services
|
||||
return services.get_business_object_state_status(self, state)
|
||||
|
||||
def get_timeline(self) -> List[dict]:
|
||||
"""获取状态时间线"""
|
||||
from . import services
|
||||
return services.get_business_object_state_timeline(self)
|
||||
|
||||
class Meta:
|
||||
db_table = 'business_object'
|
||||
verbose_name = '业务对象'
|
||||
verbose_name_plural = '业务对象'
|
||||
|
||||
|
||||
class StateFlowRecord(ModelBase):
|
||||
"""状态流转记录"""
|
||||
business_object = models.ForeignKey(BusinessObject, on_delete=models.CASCADE, related_name='state_logs', verbose_name='业务对象')
|
||||
state = models.ForeignKey(State, on_delete=models.CASCADE, verbose_name='状态')
|
||||
completed_at = models.DateTimeField(auto_now_add=True, verbose_name='完成时间')
|
||||
completed_by = models.ForeignKey('auth.User', on_delete=models.SET_NULL, null=True, verbose_name='操作人')
|
||||
cancelled_at = models.DateTimeField(null=True, blank=True, verbose_name='撤销时间')
|
||||
is_cancelled = models.BooleanField(default=False, verbose_name='是否已撤销')
|
||||
|
||||
class Meta:
|
||||
db_table = 'state_flow_record'
|
||||
verbose_name = '状态流转记录'
|
||||
verbose_name_plural = '状态流转记录'
|
||||
unique_together = [['business_object', 'state']]
|
||||
ordering = ['completed_at']
|
||||
|
||||
350
stateflow/serializers.py
Normal file
350
stateflow/serializers.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""
|
||||
Stateflow 序列化器
|
||||
"""
|
||||
from rest_framework import serializers
|
||||
from django.db import transaction
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from stateflow import models
|
||||
|
||||
|
||||
class StateParameterSerializer(serializers.ModelSerializer):
|
||||
"""状态参数序列化器"""
|
||||
|
||||
class Meta:
|
||||
model = models.StateParameter
|
||||
fields = ['id', 'key', 'value', 'description']
|
||||
read_only_fields = ['id']
|
||||
|
||||
|
||||
class StateListSerializer(serializers.ModelSerializer):
|
||||
"""状态列表序列化器(不包含参数)"""
|
||||
|
||||
class Meta:
|
||||
model = models.State
|
||||
fields = ['id', 'name', 'description', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class StateDetailSerializer(serializers.ModelSerializer):
|
||||
"""状态详情序列化器(包含参数)"""
|
||||
parameters = StateParameterSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.State
|
||||
fields = ['id', 'name', 'description', 'parameters', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class StateCreateUpdateSerializer(serializers.ModelSerializer):
|
||||
"""状态创建/更新序列化器"""
|
||||
parameters = StateParameterSerializer(many=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = models.State
|
||||
fields = ['name', 'description', 'parameters']
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
parameters_data = validated_data.pop('parameters', [])
|
||||
state = models.State.objects.create(**validated_data)
|
||||
|
||||
# 创建参数
|
||||
for param_data in parameters_data:
|
||||
models.StateParameter.objects.create(state=state, **param_data)
|
||||
|
||||
return state
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
parameters_data = validated_data.pop('parameters', None)
|
||||
|
||||
# 更新基本字段
|
||||
instance.name = validated_data.get('name', instance.name)
|
||||
instance.description = validated_data.get('description', instance.description)
|
||||
instance.save()
|
||||
|
||||
# 如果提供了参数数据,更新参数
|
||||
if parameters_data is not None:
|
||||
# 删除旧参数
|
||||
instance.parameters.all().delete()
|
||||
# 创建新参数
|
||||
for param_data in parameters_data:
|
||||
models.StateParameter.objects.create(state=instance, **param_data)
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
# Process 序列化器
|
||||
|
||||
class ProcessNodeSerializer(serializers.ModelSerializer):
|
||||
"""流程节点序列化器"""
|
||||
state_id = serializers.IntegerField(source='state.id', read_only=True)
|
||||
state_name = serializers.CharField(source='state.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.ProcessNode
|
||||
fields = ['id', 'state_id', 'state_name', 'order']
|
||||
read_only_fields = ['id']
|
||||
|
||||
|
||||
class ProcessNodeCreateSerializer(serializers.Serializer):
|
||||
"""流程节点创建序列化器"""
|
||||
state_id = serializers.IntegerField(help_text="状态ID")
|
||||
order = serializers.IntegerField(help_text="排序号", min_value=0)
|
||||
|
||||
def validate_state_id(self, value):
|
||||
"""验证状态是否存在"""
|
||||
try:
|
||||
models.State.objects.get(id=value)
|
||||
except models.State.DoesNotExist:
|
||||
raise serializers.ValidationError(f"状态ID {value} 不存在")
|
||||
return value
|
||||
|
||||
|
||||
class ProcessListSerializer(serializers.ModelSerializer):
|
||||
"""流程列表序列化器(不包含节点)"""
|
||||
node_count = serializers.IntegerField(read_only=True, help_text="节点数量")
|
||||
|
||||
class Meta:
|
||||
model = models.Process
|
||||
fields = ['id', 'name', 'description', 'node_count', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class ProcessDetailSerializer(serializers.ModelSerializer):
|
||||
"""流程详情序列化器(包含节点)"""
|
||||
nodes = ProcessNodeSerializer(source='process_nodes', many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Process
|
||||
fields = ['id', 'name', 'description', 'nodes', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class ProcessCreateUpdateSerializer(serializers.ModelSerializer):
|
||||
"""流程创建/更新序列化器"""
|
||||
nodes = ProcessNodeCreateSerializer(many=True, required=False, help_text="流程节点列表")
|
||||
|
||||
class Meta:
|
||||
model = models.Process
|
||||
fields = ['name', 'description', 'nodes']
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
nodes_data = validated_data.pop('nodes', [])
|
||||
process = models.Process.objects.create(**validated_data)
|
||||
|
||||
# 创建节点
|
||||
for node_data in nodes_data:
|
||||
models.ProcessNode.objects.create(
|
||||
process=process,
|
||||
state_id=node_data['state_id'],
|
||||
order=node_data['order']
|
||||
)
|
||||
|
||||
return process
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
nodes_data = validated_data.pop('nodes', None)
|
||||
|
||||
# 更新基本字段
|
||||
instance.name = validated_data.get('name', instance.name)
|
||||
instance.description = validated_data.get('description', instance.description)
|
||||
instance.save()
|
||||
|
||||
# 如果提供了节点数据,更新节点
|
||||
if nodes_data is not None:
|
||||
# 删除旧节点
|
||||
instance.process_nodes.all().delete()
|
||||
# 创建新节点
|
||||
for node_data in nodes_data:
|
||||
models.ProcessNode.objects.create(
|
||||
process=instance,
|
||||
state_id=node_data['state_id'],
|
||||
order=node_data['order']
|
||||
)
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
# BusinessObject 序列化器
|
||||
|
||||
class StateFlowRecordSerializer(serializers.ModelSerializer):
|
||||
"""状态流转记录序列化器(只读)"""
|
||||
state_name = serializers.CharField(source='state.name', read_only=True)
|
||||
completed_by_username = serializers.CharField(source='completed_by.username', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.StateFlowRecord
|
||||
fields = [
|
||||
'id', 'state', 'state_name', 'completed_at',
|
||||
'completed_by', 'completed_by_username',
|
||||
'is_cancelled', 'cancelled_at'
|
||||
]
|
||||
read_only_fields = ['id', 'completed_at', 'is_cancelled', 'cancelled_at']
|
||||
|
||||
|
||||
class BusinessObjectListSerializer(serializers.ModelSerializer):
|
||||
"""业务对象列表序列化器"""
|
||||
process_name = serializers.CharField(source='process.name', read_only=True)
|
||||
current_state_name = serializers.SerializerMethodField()
|
||||
overall_status = serializers.SerializerMethodField()
|
||||
progress_percentage = serializers.SerializerMethodField()
|
||||
|
||||
# 关联对象信息
|
||||
content_type_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.BusinessObject
|
||||
fields = [
|
||||
'id', 'name', 'process', 'process_name',
|
||||
'current_state_name', 'overall_status', 'progress_percentage',
|
||||
'content_type', 'object_id', 'content_type_name',
|
||||
'description', 'created_at', 'updated_at'
|
||||
]
|
||||
|
||||
def get_current_state_name(self, obj):
|
||||
from . import services
|
||||
state = services.get_business_object_current_state(obj)
|
||||
return state.name if state else None
|
||||
|
||||
def get_overall_status(self, obj):
|
||||
from . import services
|
||||
return services.get_overall_status(obj)
|
||||
|
||||
def get_progress_percentage(self, obj):
|
||||
from . import services
|
||||
return round(services.get_progress_percentage(obj), 2)
|
||||
|
||||
def get_content_type_name(self, obj):
|
||||
if obj.content_type:
|
||||
return f"{obj.content_type.app_label}.{obj.content_type.model}"
|
||||
return None
|
||||
|
||||
|
||||
class BusinessObjectDetailSerializer(serializers.ModelSerializer):
|
||||
"""业务对象详情序列化器"""
|
||||
process_detail = ProcessDetailSerializer(source='process', read_only=True)
|
||||
current_state = serializers.SerializerMethodField()
|
||||
overall_status = serializers.SerializerMethodField()
|
||||
progress_percentage = serializers.SerializerMethodField()
|
||||
timeline = serializers.SerializerMethodField()
|
||||
state_logs = StateFlowRecordSerializer(many=True, read_only=True)
|
||||
|
||||
# 关联对象信息
|
||||
content_type_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.BusinessObject
|
||||
fields = [
|
||||
'id', 'name', 'process', 'process_detail',
|
||||
'current_state', 'overall_status', 'progress_percentage',
|
||||
'timeline', 'state_logs',
|
||||
'content_type', 'object_id', 'content_type_name',
|
||||
'description', 'created_at', 'updated_at'
|
||||
]
|
||||
|
||||
def get_current_state(self, obj):
|
||||
from . import services
|
||||
state = services.get_business_object_current_state(obj)
|
||||
if state:
|
||||
return StateListSerializer(state).data
|
||||
return None
|
||||
|
||||
def get_overall_status(self, obj):
|
||||
from . import services
|
||||
return services.get_overall_status(obj)
|
||||
|
||||
def get_progress_percentage(self, obj):
|
||||
from . import services
|
||||
return round(services.get_progress_percentage(obj), 2)
|
||||
|
||||
def get_timeline(self, obj):
|
||||
from . import services
|
||||
timeline = services.get_business_object_state_timeline(obj)
|
||||
return [
|
||||
{
|
||||
'state': StateListSerializer(item['state']).data,
|
||||
'status': item['status'],
|
||||
'order': item['order'],
|
||||
'completed_at': item['completed_at'],
|
||||
'completed_by': item['completed_by'].username if item['completed_by'] else None,
|
||||
'cancelled_at': item['cancelled_at'],
|
||||
'is_cancelled': item['is_cancelled'],
|
||||
}
|
||||
for item in timeline
|
||||
]
|
||||
|
||||
def get_content_type_name(self, obj):
|
||||
if obj.content_type:
|
||||
return f"{obj.content_type.app_label}.{obj.content_type.model}"
|
||||
return None
|
||||
|
||||
|
||||
class BusinessObjectCreateUpdateSerializer(serializers.ModelSerializer):
|
||||
"""业务对象创建/更新序列化器"""
|
||||
|
||||
# 支持通过 app_label.model 字符串指定 content_type
|
||||
content_type_str = serializers.CharField(
|
||||
write_only=True,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="格式: app_label.model, 例如: basic_info.order"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.BusinessObject
|
||||
fields = [
|
||||
'name', 'process', 'description',
|
||||
'content_type', 'object_id', 'content_type_str'
|
||||
]
|
||||
extra_kwargs = {
|
||||
'content_type': {'required': False, 'allow_null': True},
|
||||
'object_id': {'required': False, 'allow_null': True},
|
||||
}
|
||||
|
||||
def validate(self, attrs):
|
||||
"""验证 content_type 和 object_id 的一致性"""
|
||||
content_type = attrs.get('content_type')
|
||||
content_type_str = attrs.get('content_type_str')
|
||||
object_id = attrs.get('object_id')
|
||||
|
||||
# 如果提供了 content_type_str,解析并设置 content_type
|
||||
if content_type_str:
|
||||
try:
|
||||
app_label, model = content_type_str.split('.')
|
||||
content_type = ContentType.objects.get(app_label=app_label, model=model)
|
||||
attrs['content_type'] = content_type
|
||||
except (ValueError, ContentType.DoesNotExist):
|
||||
raise serializers.ValidationError({
|
||||
'content_type_str': f'无效的 content_type 格式: {content_type_str}'
|
||||
})
|
||||
|
||||
# 验证:如果提供了 content_type,必须提供 object_id
|
||||
if content_type and not object_id:
|
||||
raise serializers.ValidationError({
|
||||
'object_id': '提供了关联对象类型,必须同时提供对象ID'
|
||||
})
|
||||
|
||||
# 验证:如果提供了 object_id,必须提供 content_type
|
||||
if object_id and not content_type:
|
||||
raise serializers.ValidationError({
|
||||
'content_type': '提供了对象ID,必须同时提供关联对象类型'
|
||||
})
|
||||
|
||||
# 移除临时字段
|
||||
attrs.pop('content_type_str', None)
|
||||
|
||||
return attrs
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
return models.BusinessObject.objects.create(**validated_data)
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
return instance
|
||||
275
stateflow/services.py
Normal file
275
stateflow/services.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
Stateflow业务逻辑服务层
|
||||
"""
|
||||
from typing import List, Optional, Tuple
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from . import models
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def get_business_object_current_state(business_object: 'models.BusinessObject') -> Optional['models.State']:
|
||||
"""
|
||||
获取订单的当前状态
|
||||
|
||||
规则:
|
||||
1. 如果没有任何完成记录(或所有记录都被撤销),返回 None(未开始处理)
|
||||
2. 如果有未完成的节点,返回第一个未完成的节点(进行中)
|
||||
3. 如果所有节点都已完成,返回 None(流程已完成)
|
||||
"""
|
||||
# 获取流程的所有节点(按顺序)
|
||||
process_nodes = business_object.process.process_nodes.select_related('state').order_by('order', 'id')
|
||||
if not process_nodes.exists():
|
||||
return None
|
||||
|
||||
# 获取已完成且未被撤销的状态ID集合
|
||||
completed_state_ids = set(
|
||||
business_object.state_logs.filter(is_cancelled=False).values_list('state_id', flat=True)
|
||||
)
|
||||
|
||||
# 如果没有任何有效完成记录,返回 None(未开始)
|
||||
if not completed_state_ids:
|
||||
return None
|
||||
|
||||
# 找到第一个未完成的节点
|
||||
for node in process_nodes:
|
||||
if node.state_id not in completed_state_ids:
|
||||
return node.state
|
||||
|
||||
# 所有节点都已完成,返回 None(已完成)
|
||||
return None
|
||||
|
||||
|
||||
def get_business_object_state_status(business_object: 'models.BusinessObject', state: 'models.State') -> str:
|
||||
"""
|
||||
获取订单中某个状态的状态
|
||||
|
||||
返回值:
|
||||
- 'not_started': 未开始
|
||||
- 'in_progress': 进行中
|
||||
- 'completed': 已完成
|
||||
"""
|
||||
# 检查是否已完成且未被撤销
|
||||
is_completed = business_object.state_logs.filter(state=state, is_cancelled=False).exists()
|
||||
if is_completed:
|
||||
return 'completed'
|
||||
|
||||
current_state = get_business_object_current_state(business_object)
|
||||
|
||||
# 检查是否为当前状态
|
||||
if current_state and current_state.id == state.id:
|
||||
return 'in_progress'
|
||||
|
||||
return 'not_started'
|
||||
|
||||
|
||||
def get_completed_node_ids(business_object: 'models.BusinessObject') -> List[int]:
|
||||
"""获取订单已完成且未撤销的节点ID列表(按完成顺序)"""
|
||||
return list(
|
||||
business_object.state_logs.filter(is_cancelled=False)
|
||||
.order_by('completed_at')
|
||||
.values_list('state_id', flat=True)
|
||||
)
|
||||
|
||||
|
||||
def get_progress_percentage(business_object: 'models.BusinessObject') -> float:
|
||||
"""计算订单进度百分比"""
|
||||
total_nodes = business_object.process.process_nodes.count()
|
||||
if total_nodes == 0:
|
||||
return 0.0
|
||||
|
||||
completed_count = business_object.state_logs.filter(is_cancelled=False).count()
|
||||
return (completed_count / total_nodes) * 100
|
||||
|
||||
|
||||
def advance_to_next_state(business_object: 'models.BusinessObject', user: 'User') -> Tuple[bool, str]:
|
||||
"""
|
||||
将订单推进到下一个状态
|
||||
|
||||
返回: (是否成功, 消息)
|
||||
"""
|
||||
with transaction.atomic():
|
||||
# 获取当前状态
|
||||
current_state = get_business_object_current_state(business_object)
|
||||
|
||||
# 如果当前状态为 None,说明是初始状态(未开始)
|
||||
if current_state is None:
|
||||
# 获取第一个节点
|
||||
first_node = business_object.process.process_nodes.order_by('order', 'id').first()
|
||||
if not first_node:
|
||||
return False, "流程没有任何节点"
|
||||
|
||||
# 标记第一个状态为已完成
|
||||
models.StateFlowRecord.objects.create(
|
||||
business_object=business_object,
|
||||
state=first_node.state,
|
||||
completed_by=user
|
||||
)
|
||||
return True, f"已完成状态: {first_node.state.name}"
|
||||
|
||||
# 检查当前状态是否已完成(且未撤销)
|
||||
if business_object.state_logs.filter(state=current_state, is_cancelled=False).exists():
|
||||
return False, f"当前状态 '{current_state.name}' 已完成"
|
||||
|
||||
# 标记当前状态为已完成
|
||||
models.StateFlowRecord.objects.create(
|
||||
business_object=business_object,
|
||||
state=current_state,
|
||||
completed_by=user
|
||||
)
|
||||
|
||||
# 检查是否所有状态都已完成
|
||||
next_state = get_business_object_current_state(business_object)
|
||||
if next_state is None:
|
||||
# 所有状态都已完成
|
||||
return True, f"流程已完成,最后状态: {current_state.name}"
|
||||
|
||||
return True, f"已完成状态: {current_state.name}"
|
||||
|
||||
|
||||
def reset_order_progress(business_object: 'models.BusinessObject') -> None:
|
||||
"""
|
||||
重置订单进度
|
||||
|
||||
注意:不删除历史记录,而是标记所有记录为已撤销,并记录撤销时间
|
||||
这样可以保留完整的操作历史
|
||||
"""
|
||||
from django.utils import timezone
|
||||
|
||||
business_object.state_logs.filter(is_cancelled=False).update(
|
||||
is_cancelled=True,
|
||||
cancelled_at=timezone.now()
|
||||
)
|
||||
|
||||
|
||||
def get_current_state_parameters(business_object: 'models.BusinessObject') -> List['models.StateParameter']:
|
||||
"""获取订单当前状态的参数列表"""
|
||||
current_state = get_business_object_current_state(business_object)
|
||||
if current_state:
|
||||
return list(current_state.parameters.all())
|
||||
return []
|
||||
|
||||
|
||||
def get_overall_status(business_object: 'models.BusinessObject') -> str:
|
||||
"""
|
||||
获取订单的整体状态
|
||||
|
||||
返回值:
|
||||
- 'not_started': 未开始(没有任何有效的完成记录)
|
||||
- 'in_progress': 进行中(有部分状态已完成)
|
||||
- 'completed': 已完成(所有状态都已完成)
|
||||
"""
|
||||
current_state = get_business_object_current_state(business_object)
|
||||
|
||||
# 检查是否有任何有效的完成记录
|
||||
has_completed = business_object.state_logs.filter(is_cancelled=False).exists()
|
||||
|
||||
if not has_completed:
|
||||
return 'not_started'
|
||||
|
||||
if current_state is None:
|
||||
# 有完成记录,但当前状态为 None,说明所有状态都已完成
|
||||
return 'completed'
|
||||
|
||||
return 'in_progress'
|
||||
|
||||
|
||||
def can_advance_to_next_state(business_object: 'models.BusinessObject') -> Tuple[bool, str]:
|
||||
"""
|
||||
检查订单是否可以推进到下一个状态
|
||||
|
||||
返回: (是否可以推进, 原因)
|
||||
"""
|
||||
# 检查流程是否有节点
|
||||
first_node = business_object.process.process_nodes.order_by('order', 'id').first()
|
||||
if not first_node:
|
||||
return False, "流程没有任何节点"
|
||||
|
||||
current_state = get_business_object_current_state(business_object)
|
||||
|
||||
# 如果当前状态为 None
|
||||
if current_state is None:
|
||||
# 检查是否所有状态都已完成
|
||||
total_nodes = business_object.process.process_nodes.count()
|
||||
completed_count = business_object.state_logs.filter(is_cancelled=False).count()
|
||||
|
||||
if completed_count == 0:
|
||||
# 未开始,可以推进到第一个状态
|
||||
return True, f"可以开始处理,将推进到: {first_node.state.name}"
|
||||
else:
|
||||
# 所有状态都已完成
|
||||
return False, "流程已完成,无法继续推进"
|
||||
|
||||
# 检查当前状态是否已完成
|
||||
if business_object.state_logs.filter(state=current_state, is_cancelled=False).exists():
|
||||
return False, f"当前状态 '{current_state.name}' 已完成,无法重复完成"
|
||||
|
||||
return True, f"可以推进到: {current_state.name}"
|
||||
|
||||
|
||||
def get_business_object_state_timeline(business_object: 'models.BusinessObject') -> List[dict]:
|
||||
"""
|
||||
获取订单状态时间线(包括未开始、进行中和已完成的状态)
|
||||
|
||||
返回格式:
|
||||
[
|
||||
{
|
||||
'state': State对象,
|
||||
'status': 'not_started' | 'in_progress' | 'completed' | 'cancelled',
|
||||
'business_object': 顺序号,
|
||||
'completed_at': 完成时间(如果已完成),
|
||||
'completed_by': 完成人(如果已完成),
|
||||
'cancelled_at': 撤销时间(如果已撤销),
|
||||
'is_cancelled': 是否已撤销,
|
||||
},
|
||||
...
|
||||
]
|
||||
"""
|
||||
timeline = []
|
||||
process_nodes = business_object.process.process_nodes.select_related('state').order_by('order', 'id')
|
||||
current_state = get_business_object_current_state(business_object)
|
||||
|
||||
# 构建状态日志映射(包括已撤销的记录)
|
||||
state_logs_map = {
|
||||
log.state_id: log
|
||||
for log in business_object.state_logs.select_related('completed_by')
|
||||
}
|
||||
|
||||
for node in process_nodes:
|
||||
state = node.state
|
||||
log = state_logs_map.get(state.id)
|
||||
|
||||
if log:
|
||||
if log.is_cancelled:
|
||||
status = 'cancelled'
|
||||
else:
|
||||
status = 'completed'
|
||||
completed_at = log.completed_at
|
||||
completed_by = log.completed_by
|
||||
cancelled_at = log.cancelled_at
|
||||
is_cancelled = log.is_cancelled
|
||||
elif current_state and state.id == current_state.id:
|
||||
status = 'in_progress'
|
||||
completed_at = None
|
||||
completed_by = None
|
||||
cancelled_at = None
|
||||
is_cancelled = False
|
||||
else:
|
||||
status = 'not_started'
|
||||
completed_at = None
|
||||
completed_by = None
|
||||
cancelled_at = None
|
||||
is_cancelled = False
|
||||
|
||||
timeline.append({
|
||||
'state': state,
|
||||
'status': status,
|
||||
'order': node.order,
|
||||
'completed_at': completed_at,
|
||||
'completed_by': completed_by,
|
||||
'cancelled_at': cancelled_at,
|
||||
'is_cancelled': is_cancelled,
|
||||
})
|
||||
|
||||
return timeline
|
||||
187
stateflow/test_api.py
Normal file
187
stateflow/test_api.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Stateflow API 测试
|
||||
"""
|
||||
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 stateflow import models
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class StateAPITestCase(TestCase):
|
||||
"""测试 State API"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create_user(username='testuser', password='testpass')
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def test_create_state(self):
|
||||
"""测试创建状态"""
|
||||
data = {
|
||||
'name': '测试状态',
|
||||
'description': '这是一个测试状态',
|
||||
'parameters': [
|
||||
{'key': 'param1', 'value': 'value1', 'description': '参数1'},
|
||||
{'key': 'param2', 'value': 'value2', 'description': '参数2'},
|
||||
]
|
||||
}
|
||||
|
||||
response = self.client.post('/api/v1/stateflow/states/', data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['name'], '测试状态')
|
||||
|
||||
# 验证参数已创建
|
||||
state = models.State.objects.get(name='测试状态')
|
||||
self.assertEqual(state.parameters.count(), 2)
|
||||
|
||||
def test_list_states(self):
|
||||
"""测试获取状态列表"""
|
||||
models.State.objects.create(name='状态1', description='描述1')
|
||||
models.State.objects.create(name='状态2', description='描述2')
|
||||
|
||||
response = self.client.get('/api/v1/stateflow/states/?limit=10&offset=0')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['count'], 2)
|
||||
self.assertEqual(len(response.data['results']), 2)
|
||||
|
||||
def test_retrieve_state(self):
|
||||
"""测试获取状态详情"""
|
||||
state = models.State.objects.create(name='测试状态', description='描述')
|
||||
models.StateParameter.objects.create(state=state, key='key1', value='value1')
|
||||
|
||||
response = self.client.get(f'/api/v1/stateflow/states/{state.id}/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['name'], '测试状态')
|
||||
self.assertEqual(len(response.data['parameters']), 1)
|
||||
|
||||
def test_update_state(self):
|
||||
"""测试更新状态"""
|
||||
state = models.State.objects.create(name='旧名称', description='旧描述')
|
||||
|
||||
data = {
|
||||
'name': '新名称',
|
||||
'description': '新描述',
|
||||
'parameters': [
|
||||
{'key': 'new_param', 'value': 'new_value', 'description': '新参数'},
|
||||
]
|
||||
}
|
||||
|
||||
response = self.client.put(f'/api/v1/stateflow/states/{state.id}/', data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
state.refresh_from_db()
|
||||
self.assertEqual(state.name, '新名称')
|
||||
self.assertEqual(state.parameters.count(), 1)
|
||||
|
||||
def test_delete_state(self):
|
||||
"""测试删除状态"""
|
||||
state = models.State.objects.create(name='待删除状态')
|
||||
|
||||
response = self.client.delete(f'/api/v1/stateflow/states/{state.id}/')
|
||||
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||
self.assertFalse(models.State.objects.filter(id=state.id).exists())
|
||||
|
||||
def test_search_states(self):
|
||||
"""测试搜索状态"""
|
||||
models.State.objects.create(name='审核状态', description='需要审核')
|
||||
models.State.objects.create(name='发货状态', description='已经发货')
|
||||
|
||||
response = self.client.get('/api/v1/stateflow/states/?search=审核&limit=10&offset=0')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
|
||||
|
||||
class ProcessAPITestCase(TestCase):
|
||||
"""测试 Process API"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create_user(username='testuser', password='testpass')
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
# 创建测试状态
|
||||
self.state1 = models.State.objects.create(name='状态1')
|
||||
self.state2 = models.State.objects.create(name='状态2')
|
||||
self.state3 = models.State.objects.create(name='状态3')
|
||||
|
||||
def test_create_process(self):
|
||||
"""测试创建流程"""
|
||||
data = {
|
||||
'name': '测试流程',
|
||||
'description': '这是一个测试流程',
|
||||
'nodes': [
|
||||
{'state_id': self.state1.id, 'order': 0},
|
||||
{'state_id': self.state2.id, 'order': 1},
|
||||
{'state_id': self.state3.id, 'order': 2},
|
||||
]
|
||||
}
|
||||
|
||||
response = self.client.post('/api/v1/stateflow/processes/', data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['name'], '测试流程')
|
||||
|
||||
# 验证节点已创建
|
||||
process = models.Process.objects.get(name='测试流程')
|
||||
self.assertEqual(process.process_nodes.count(), 3)
|
||||
|
||||
def test_list_processes(self):
|
||||
"""测试获取流程列表"""
|
||||
models.Process.objects.create(name='流程1', description='描述1')
|
||||
models.Process.objects.create(name='流程2', description='描述2')
|
||||
|
||||
response = self.client.get('/api/v1/stateflow/processes/?limit=10&offset=0')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['count'], 2)
|
||||
self.assertEqual(len(response.data['results']), 2)
|
||||
|
||||
def test_retrieve_process(self):
|
||||
"""测试获取流程详情"""
|
||||
process = models.Process.objects.create(name='测试流程', description='描述')
|
||||
models.ProcessNode.objects.create(process=process, state=self.state1, order=0)
|
||||
models.ProcessNode.objects.create(process=process, state=self.state2, order=1)
|
||||
|
||||
response = self.client.get(f'/api/v1/stateflow/processes/{process.id}/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['name'], '测试流程')
|
||||
self.assertEqual(len(response.data['nodes']), 2)
|
||||
|
||||
def test_update_process(self):
|
||||
"""测试更新流程"""
|
||||
process = models.Process.objects.create(name='旧名称', description='旧描述')
|
||||
models.ProcessNode.objects.create(process=process, state=self.state1, order=0)
|
||||
|
||||
data = {
|
||||
'name': '新名称',
|
||||
'description': '新描述',
|
||||
'nodes': [
|
||||
{'state_id': self.state2.id, 'order': 0},
|
||||
{'state_id': self.state3.id, 'order': 1},
|
||||
]
|
||||
}
|
||||
|
||||
response = self.client.put(f'/api/v1/stateflow/processes/{process.id}/', data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
process.refresh_from_db()
|
||||
self.assertEqual(process.name, '新名称')
|
||||
self.assertEqual(process.process_nodes.count(), 2)
|
||||
|
||||
def test_delete_process(self):
|
||||
"""测试删除流程"""
|
||||
process = models.Process.objects.create(name='待删除流程')
|
||||
|
||||
response = self.client.delete(f'/api/v1/stateflow/processes/{process.id}/')
|
||||
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||
self.assertFalse(models.Process.objects.filter(id=process.id).exists())
|
||||
|
||||
def test_search_processes(self):
|
||||
"""测试搜索流程"""
|
||||
models.Process.objects.create(name='订单流程', description='处理订单')
|
||||
models.Process.objects.create(name='退货流程', description='处理退货')
|
||||
|
||||
response = self.client.get('/api/v1/stateflow/processes/?search=订单&limit=10&offset=0')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
@@ -1,3 +1,158 @@
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from . import models, services
|
||||
|
||||
# Create your tests here.
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class OrderStateFlowTestCase(TestCase):
|
||||
"""测试订单状态流转逻辑"""
|
||||
|
||||
def setUp(self):
|
||||
"""设置测试数据"""
|
||||
# 创建测试用户
|
||||
self.user = User.objects.create_user(username='testuser', password='testpass')
|
||||
|
||||
# 创建状态节点
|
||||
self.state1 = models.State.objects.create(name='状态1', description='第一个状态')
|
||||
self.state2 = models.State.objects.create(name='状态2', description='第二个状态')
|
||||
self.state3 = models.State.objects.create(name='状态3', description='第三个状态')
|
||||
|
||||
# 创建流程
|
||||
self.process = models.Process.objects.create(name='测试流程', description='用于测试的流程')
|
||||
|
||||
# 添加流程节点
|
||||
models.ProcessNode.objects.create(process=self.process, state=self.state1, order=0)
|
||||
models.ProcessNode.objects.create(process=self.process, state=self.state2, order=1)
|
||||
models.ProcessNode.objects.create(process=self.process, state=self.state3, order=2)
|
||||
|
||||
# 创建订单(业务对象)
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
# 使用 Process 作为临时的关联对象(实际使用时应该关联真实的业务对象)
|
||||
ct = ContentType.objects.get_for_model(models.Process)
|
||||
self.business_object = models.BusinessObject.objects.create(
|
||||
name='测试订单',
|
||||
process=self.process,
|
||||
description='测试订单描述',
|
||||
content_type=ct,
|
||||
object_id=self.process.id
|
||||
)
|
||||
|
||||
def test_initial_state(self):
|
||||
"""测试初始状态 - 应该是 None(未开始)"""
|
||||
current_state = self.business_object.get_current_state()
|
||||
self.assertIsNone(current_state, '初始状态应该是 None(未开始)')
|
||||
self.assertEqual(self.business_object.get_progress_percentage(), 0.0)
|
||||
|
||||
# 整体状态应该是 not_started
|
||||
status = services.get_overall_status(self.business_object)
|
||||
self.assertEqual(status, 'not_started')
|
||||
|
||||
def test_advance_to_next_state(self):
|
||||
"""测试推进到下一个状态"""
|
||||
# 第一次推进应该完成第一个状态
|
||||
success, message = services.advance_to_next_state(self.business_object, self.user)
|
||||
self.assertTrue(success)
|
||||
|
||||
current_state = self.business_object.get_current_state()
|
||||
self.assertEqual(current_state.id, self.state2.id)
|
||||
self.assertAlmostEqual(self.business_object.get_progress_percentage(), 33.33, places=1)
|
||||
|
||||
# 推进到状态3
|
||||
success, message = services.advance_to_next_state(self.business_object, self.user)
|
||||
self.assertTrue(success)
|
||||
|
||||
current_state = self.business_object.get_current_state()
|
||||
self.assertEqual(current_state.id, self.state3.id)
|
||||
self.assertAlmostEqual(self.business_object.get_progress_percentage(), 66.67, places=1)
|
||||
|
||||
# 完成最后一个状态
|
||||
success, message = services.advance_to_next_state(self.business_object, self.user)
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(self.business_object.get_progress_percentage(), 100.0)
|
||||
|
||||
# 应该返回 None(所有状态都已完成)
|
||||
current_state = self.business_object.get_current_state()
|
||||
self.assertIsNone(current_state)
|
||||
|
||||
# 整体状态应该是 completed
|
||||
status = services.get_overall_status(self.business_object)
|
||||
self.assertEqual(status, 'completed')
|
||||
|
||||
def test_state_status(self):
|
||||
"""测试状态的状态"""
|
||||
# 初始状态,所有状态都应该是 not_started
|
||||
self.assertEqual(services.get_business_object_state_status(self.business_object, self.state1), 'not_started')
|
||||
self.assertEqual(services.get_business_object_state_status(self.business_object, self.state2), 'not_started')
|
||||
|
||||
# 完成第一个状态
|
||||
services.advance_to_next_state(self.business_object, self.user)
|
||||
self.assertEqual(services.get_business_object_state_status(self.business_object, self.state1), 'completed')
|
||||
self.assertEqual(services.get_business_object_state_status(self.business_object, self.state2), 'in_progress')
|
||||
self.assertEqual(services.get_business_object_state_status(self.business_object, self.state3), 'not_started')
|
||||
|
||||
def test_timeline(self):
|
||||
"""测试时间线"""
|
||||
timeline = self.business_object.get_timeline()
|
||||
self.assertEqual(len(timeline), 3)
|
||||
|
||||
# 所有状态都应该是 not_started
|
||||
for item in timeline:
|
||||
self.assertEqual(item['status'], 'not_started')
|
||||
|
||||
# 完成第一个状态
|
||||
services.advance_to_next_state(self.business_object, self.user)
|
||||
|
||||
timeline = self.business_object.get_timeline()
|
||||
self.assertEqual(timeline[0]['status'], 'completed')
|
||||
self.assertEqual(timeline[1]['status'], 'in_progress')
|
||||
self.assertEqual(timeline[2]['status'], 'not_started')
|
||||
self.assertIsNotNone(timeline[0]['completed_by'])
|
||||
self.assertEqual(timeline[0]['completed_by'].id, self.user.id)
|
||||
self.assertFalse(timeline[0]['is_cancelled'])
|
||||
|
||||
def test_reset_progress(self):
|
||||
"""测试重置进度 - 不删除记录,而是标记为已撤销"""
|
||||
# 完成两个状态
|
||||
services.advance_to_next_state(self.business_object, self.user)
|
||||
services.advance_to_next_state(self.business_object, self.user)
|
||||
|
||||
# 确认有2条日志记录
|
||||
self.assertEqual(self.business_object.state_logs.count(), 2)
|
||||
|
||||
# 重置进度
|
||||
services.reset_order_progress(self.business_object)
|
||||
|
||||
# 日志记录应该还在(不删除)
|
||||
self.assertEqual(self.business_object.state_logs.count(), 2)
|
||||
|
||||
# 但所有记录都应该标记为已撤销
|
||||
cancelled_count = self.business_object.state_logs.filter(is_cancelled=True).count()
|
||||
self.assertEqual(cancelled_count, 2)
|
||||
|
||||
# 应该回到初始状态(未开始)
|
||||
current_state = self.business_object.get_current_state()
|
||||
self.assertIsNone(current_state)
|
||||
|
||||
# 所有撤销的记录都应该有撤销时间
|
||||
for log in self.business_object.state_logs.all():
|
||||
self.assertTrue(log.is_cancelled)
|
||||
self.assertIsNotNone(log.cancelled_at)
|
||||
|
||||
def test_timeline_with_cancelled(self):
|
||||
"""测试包含撤销记录的时间线"""
|
||||
# 推进并完成所有状态
|
||||
services.advance_to_next_state(self.business_object, self.user)
|
||||
services.advance_to_next_state(self.business_object, self.user)
|
||||
services.advance_to_next_state(self.business_object, self.user)
|
||||
|
||||
# 重置进度
|
||||
services.reset_order_progress(self.business_object)
|
||||
|
||||
timeline = self.business_object.get_timeline()
|
||||
|
||||
# 所有状态都应该显示为 cancelled
|
||||
for item in timeline:
|
||||
self.assertEqual(item['status'], 'cancelled')
|
||||
self.assertTrue(item['is_cancelled'])
|
||||
self.assertIsNotNone(item['cancelled_at'])
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
Reference in New Issue
Block a user