From 9898d71a7e3e7df9831118d1abaa733aa09fbf62 Mon Sep 17 00:00:00 2001 From: colaftc Date: Tue, 11 Nov 2025 18:05:10 +0800 Subject: [PATCH] feat: new modul (stateflow) --- api_v1/urls.py | 12 +- api_v1/views/__init__.py | 3 + api_v1/views/stateflow/__init__.py | 5 + api_v1/views/stateflow/business_object.py | 169 ++++ api_v1/views/stateflow/process.py | 59 ++ api_v1/views/stateflow/state.py | 53 ++ api_v1/views/stateflow/swagger.yml | 819 ++++++++++++++++++ stateflow/API.md | 390 +++++++++ stateflow/CHANGELOG.md | 201 +++++ stateflow/README.md | 284 ++++++ stateflow/admin.py | 159 +++- stateflow/apps.py | 1 + stateflow/migrations/0001_initial.py | 45 + .../0002_remove_state_data_stateparameter.py | 33 + ...ss_options_alter_state_options_and_more.py | 35 + .../migrations/0004_remove_state_previous.py | 17 + .../0005_alter_state_description_and_more.py | 23 + ...rocess_state_nodes_processnode_and_more.py | 40 + .../0007_alter_process_current_state.py | 19 + ...0008_remove_process_current_state_order.py | 34 + ...emove_order_current_state_orderstatelog.py | 39 + ...010_remove_orderstatelog_notes_and_more.py | 44 + .../0011_rename_order_to_business_object.py | 76 ++ ...er_businessobject_content_type_and_more.py | 40 + stateflow/models.py | 158 +++- stateflow/serializers.py | 350 ++++++++ stateflow/services.py | 275 ++++++ stateflow/test_api.py | 187 ++++ stateflow/tests.py | 157 +++- stateflow/views.py | 3 - 30 files changed, 3723 insertions(+), 7 deletions(-) create mode 100644 api_v1/views/stateflow/__init__.py create mode 100644 api_v1/views/stateflow/business_object.py create mode 100644 api_v1/views/stateflow/process.py create mode 100644 api_v1/views/stateflow/state.py create mode 100644 api_v1/views/stateflow/swagger.yml create mode 100644 stateflow/API.md create mode 100644 stateflow/CHANGELOG.md create mode 100644 stateflow/README.md create mode 100644 stateflow/migrations/0001_initial.py create mode 100644 stateflow/migrations/0002_remove_state_data_stateparameter.py create mode 100644 stateflow/migrations/0003_alter_process_options_alter_state_options_and_more.py create mode 100644 stateflow/migrations/0004_remove_state_previous.py create mode 100644 stateflow/migrations/0005_alter_state_description_and_more.py create mode 100644 stateflow/migrations/0006_remove_process_state_nodes_processnode_and_more.py create mode 100644 stateflow/migrations/0007_alter_process_current_state.py create mode 100644 stateflow/migrations/0008_remove_process_current_state_order.py create mode 100644 stateflow/migrations/0009_remove_order_current_state_orderstatelog.py create mode 100644 stateflow/migrations/0010_remove_orderstatelog_notes_and_more.py create mode 100644 stateflow/migrations/0011_rename_order_to_business_object.py create mode 100644 stateflow/migrations/0012_alter_businessobject_content_type_and_more.py create mode 100644 stateflow/serializers.py create mode 100644 stateflow/services.py create mode 100644 stateflow/test_api.py delete mode 100644 stateflow/views.py diff --git a/api_v1/urls.py b/api_v1/urls.py index de7e51d..8bb1a03 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -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//image/', product_image.ProductImageUploadView.as_view(), name='product_image_upload'), + + # Stateflow API (使用 Router) + path('stateflow/', include(router.urls)), ] diff --git a/api_v1/views/__init__.py b/api_v1/views/__init__.py index dcf22b6..84e8878 100644 --- a/api_v1/views/__init__.py +++ b/api_v1/views/__init__.py @@ -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', ] diff --git a/api_v1/views/stateflow/__init__.py b/api_v1/views/stateflow/__init__.py new file mode 100644 index 0000000..685e5a5 --- /dev/null +++ b/api_v1/views/stateflow/__init__.py @@ -0,0 +1,5 @@ +from .state import StateViewSet +from .process import ProcessViewSet +from .business_object import BusinessObjectViewSet + +__all__ = ['StateViewSet', 'ProcessViewSet', 'BusinessObjectViewSet'] diff --git a/api_v1/views/stateflow/business_object.py b/api_v1/views/stateflow/business_object.py new file mode 100644 index 0000000..f3e9cc5 --- /dev/null +++ b/api_v1/views/stateflow/business_object.py @@ -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) diff --git a/api_v1/views/stateflow/process.py b/api_v1/views/stateflow/process.py new file mode 100644 index 0000000..ae8d7d4 --- /dev/null +++ b/api_v1/views/stateflow/process.py @@ -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 diff --git a/api_v1/views/stateflow/state.py b/api_v1/views/stateflow/state.py new file mode 100644 index 0000000..bef679c --- /dev/null +++ b/api_v1/views/stateflow/state.py @@ -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 diff --git a/api_v1/views/stateflow/swagger.yml b/api_v1/views/stateflow/swagger.yml new file mode 100644 index 0000000..dbe9636 --- /dev/null +++ b/api_v1/views/stateflow/swagger.yml @@ -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] \ No newline at end of file diff --git a/stateflow/API.md b/stateflow/API.md new file mode 100644 index 0000000..c46e76d --- /dev/null +++ b/stateflow/API.md @@ -0,0 +1,390 @@ +# Stateflow API 文档 + +## 概述 + +Stateflow API 提供了状态节点和流程的 CRUD 操作接口。 + +## 基础 URL + +``` +/api/v1/ +``` + +## 认证 + +所有 API 都需要 JWT 认证。在请求头中添加: + +``` +Authorization: Bearer +``` + +## 分页 + +使用 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 " +``` + +响应: +```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 " \ + -H "Content-Type: application/json" \ + -d '{ + "name": "订单流程", + "description": "标准订单处理流程", + "nodes": [ + {"state_id": 1, "order": 0}, + {"state_id": 2, "order": 1} + ] + }' +``` diff --git a/stateflow/CHANGELOG.md b/stateflow/CHANGELOG.md new file mode 100644 index 0000000..4bcc537 --- /dev/null +++ b/stateflow/CHANGELOG.md @@ -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"章节。 diff --git a/stateflow/README.md b/stateflow/README.md new file mode 100644 index 0000000..e694afc --- /dev/null +++ b/stateflow/README.md @@ -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`(未开始),而不是第一个节点 diff --git a/stateflow/admin.py b/stateflow/admin.py index 8c38f3f..1c7c840 100644 --- a/stateflow/admin.py +++ b/stateflow/admin.py @@ -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 = "
".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') diff --git a/stateflow/apps.py b/stateflow/apps.py index 0ec3af1..559c3df 100644 --- a/stateflow/apps.py +++ b/stateflow/apps.py @@ -4,3 +4,4 @@ from django.apps import AppConfig class StateflowConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'stateflow' + verbose_name = '印染流程' diff --git a/stateflow/migrations/0001_initial.py b/stateflow/migrations/0001_initial.py new file mode 100644 index 0000000..7d21e08 --- /dev/null +++ b/stateflow/migrations/0001_initial.py @@ -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, + }, + ), + ] diff --git a/stateflow/migrations/0002_remove_state_data_stateparameter.py b/stateflow/migrations/0002_remove_state_data_stateparameter.py new file mode 100644 index 0000000..01d8a59 --- /dev/null +++ b/stateflow/migrations/0002_remove_state_data_stateparameter.py @@ -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, + }, + ), + ] diff --git a/stateflow/migrations/0003_alter_process_options_alter_state_options_and_more.py b/stateflow/migrations/0003_alter_process_options_alter_state_options_and_more.py new file mode 100644 index 0000000..4a9eead --- /dev/null +++ b/stateflow/migrations/0003_alter_process_options_alter_state_options_and_more.py @@ -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='上一级状态'), + ), + ] diff --git a/stateflow/migrations/0004_remove_state_previous.py b/stateflow/migrations/0004_remove_state_previous.py new file mode 100644 index 0000000..301f449 --- /dev/null +++ b/stateflow/migrations/0004_remove_state_previous.py @@ -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', + ), + ] diff --git a/stateflow/migrations/0005_alter_state_description_and_more.py b/stateflow/migrations/0005_alter_state_description_and_more.py new file mode 100644 index 0000000..9389f12 --- /dev/null +++ b/stateflow/migrations/0005_alter_state_description_and_more.py @@ -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='参数描述'), + ), + ] diff --git a/stateflow/migrations/0006_remove_process_state_nodes_processnode_and_more.py b/stateflow/migrations/0006_remove_process_state_nodes_processnode_and_more.py new file mode 100644 index 0000000..bbf6c91 --- /dev/null +++ b/stateflow/migrations/0006_remove_process_state_nodes_processnode_and_more.py @@ -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='节点列表'), + ), + ] diff --git a/stateflow/migrations/0007_alter_process_current_state.py b/stateflow/migrations/0007_alter_process_current_state.py new file mode 100644 index 0000000..6ffdad4 --- /dev/null +++ b/stateflow/migrations/0007_alter_process_current_state.py @@ -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='当前状态'), + ), + ] diff --git a/stateflow/migrations/0008_remove_process_current_state_order.py b/stateflow/migrations/0008_remove_process_current_state_order.py new file mode 100644 index 0000000..de6fc5f --- /dev/null +++ b/stateflow/migrations/0008_remove_process_current_state_order.py @@ -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': '订单', + }, + ), + ] diff --git a/stateflow/migrations/0009_remove_order_current_state_orderstatelog.py b/stateflow/migrations/0009_remove_order_current_state_orderstatelog.py new file mode 100644 index 0000000..436d7e3 --- /dev/null +++ b/stateflow/migrations/0009_remove_order_current_state_orderstatelog.py @@ -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')}, + }, + ), + ] diff --git a/stateflow/migrations/0010_remove_orderstatelog_notes_and_more.py b/stateflow/migrations/0010_remove_orderstatelog_notes_and_more.py new file mode 100644 index 0000000..6b13532 --- /dev/null +++ b/stateflow/migrations/0010_remove_orderstatelog_notes_and_more.py @@ -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', + ), + ] diff --git a/stateflow/migrations/0011_rename_order_to_business_object.py b/stateflow/migrations/0011_rename_order_to_business_object.py new file mode 100644 index 0000000..067c125 --- /dev/null +++ b/stateflow/migrations/0011_rename_order_to_business_object.py @@ -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='关联流程'), + ), + ] diff --git a/stateflow/migrations/0012_alter_businessobject_content_type_and_more.py b/stateflow/migrations/0012_alter_businessobject_content_type_and_more.py new file mode 100644 index 0000000..a038039 --- /dev/null +++ b/stateflow/migrations/0012_alter_businessobject_content_type_and_more.py @@ -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='业务对象'), + ), + ] diff --git a/stateflow/models.py b/stateflow/models.py index 71a8362..b2e3738 100644 --- a/stateflow/models.py +++ b/stateflow/models.py @@ -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'] diff --git a/stateflow/serializers.py b/stateflow/serializers.py new file mode 100644 index 0000000..509d808 --- /dev/null +++ b/stateflow/serializers.py @@ -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 diff --git a/stateflow/services.py b/stateflow/services.py new file mode 100644 index 0000000..eb6e796 --- /dev/null +++ b/stateflow/services.py @@ -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 diff --git a/stateflow/test_api.py b/stateflow/test_api.py new file mode 100644 index 0000000..cd2914e --- /dev/null +++ b/stateflow/test_api.py @@ -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) diff --git a/stateflow/tests.py b/stateflow/tests.py index 7ce503c..04b0107 100644 --- a/stateflow/tests.py +++ b/stateflow/tests.py @@ -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']) diff --git a/stateflow/views.py b/stateflow/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/stateflow/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here.