diff --git a/api_v1/admin.py b/api_v1/admin.py new file mode 100644 index 0000000..046820e --- /dev/null +++ b/api_v1/admin.py @@ -0,0 +1,16 @@ +from django.contrib import admin +from api_v1.models import UploadedFile + + +@admin.register(UploadedFile) +class UploadedFileAdmin(admin.ModelAdmin): + list_display = ['id', 'original_filename', 'owner', 'file_size', 'content_type', 'created_at', 'is_deleted'] + list_filter = ['is_deleted', 'created_at', 'content_type'] + search_fields = ['original_filename', 'owner__username'] + readonly_fields = ['created_at', 'file_size', 'content_type'] + date_hierarchy = 'created_at' + ordering = ['-created_at'] + + def get_queryset(self, request): + # 在管理界面显示所有文件,包括已删除的 + return super().get_queryset(request).select_related('owner') diff --git a/api_v1/migrations/0001_initial.py b/api_v1/migrations/0001_initial.py new file mode 100644 index 0000000..5e3266f --- /dev/null +++ b/api_v1/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.7 on 2025-11-17 05:52 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='UploadedFile', + 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='更新时间')), + ('path', models.FileField(help_text='文件存储路径,包含前缀、随机文件名和后缀', max_length=500, upload_to='uploads/%Y/%m/%d/', verbose_name='文件路径')), + ('is_deleted', models.BooleanField(default=False, help_text='软删除标记', verbose_name='是否已删除')), + ('original_filename', models.CharField(blank=True, help_text='用户上传时的原始文件名', max_length=255, verbose_name='原始文件名')), + ('file_size', models.BigIntegerField(blank=True, null=True, verbose_name='文件大小(字节)')), + ('content_type', models.CharField(blank=True, help_text='MIME类型,如 image/jpeg', max_length=100, verbose_name='文件类型')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uploaded_files', to=settings.AUTH_USER_MODEL, verbose_name='上传者')), + ], + options={ + 'verbose_name': '上传文件', + 'verbose_name_plural': '上传文件', + 'db_table': 'api_uploaded_file', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/api_v1/migrations/__init__.py b/api_v1/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api_v1/models.py b/api_v1/models.py new file mode 100644 index 0000000..4d2c1d3 --- /dev/null +++ b/api_v1/models.py @@ -0,0 +1,84 @@ +""" +API v1 通用模型 +""" +import os +import uuid +from django.db import models +from django.contrib.auth import get_user_model +from flower.common import ModelBase + +User = get_user_model() + + +def upload_file_path(instance, filename): + """ + 生成随机化的文件上传路径 + + 格式: uploads/YYYY/MM/DD/uuid.ext + """ + # 获取文件扩展名 + ext = os.path.splitext(filename)[1].lower() + # 生成随机文件名 + random_filename = f"{uuid.uuid4().hex}{ext}" + # 返回完整路径 + from datetime import datetime + now = datetime.now() + return f"uploads/{now.year}/{now.month:02d}/{now.day:02d}/{random_filename}" + + +class UploadedFile(ModelBase): + """ + 通用文件上传记录 + + 用于存储无法归类到具体业务的文件上传记录 + """ + path = models.FileField( + upload_to=upload_file_path, + max_length=500, + verbose_name='文件路径', + help_text='文件存储路径,包含前缀、随机文件名和后缀' + ) + owner = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='uploaded_files', + verbose_name='上传者' + ) + is_deleted = models.BooleanField( + default=False, + verbose_name='是否已删除', + help_text='软删除标记' + ) + original_filename = models.CharField( + max_length=255, + blank=True, + verbose_name='原始文件名', + help_text='用户上传时的原始文件名' + ) + file_size = models.BigIntegerField( + null=True, + blank=True, + verbose_name='文件大小(字节)' + ) + content_type = models.CharField( + max_length=100, + blank=True, + verbose_name='文件类型', + help_text='MIME类型,如 image/jpeg' + ) + + class Meta: + db_table = 'api_uploaded_file' + verbose_name = '上传文件' + verbose_name_plural = '上传文件' + ordering = ['-created_at'] + + def __str__(self): + return f"{self.original_filename or self.path.name} (by {self.owner.username})" + + @property + def file_url(self): + """获取文件访问URL""" + if self.path: + return self.path.url + return None diff --git a/api_v1/serializers.py b/api_v1/serializers.py index 9d1f0c1..d9013b3 100644 --- a/api_v1/serializers.py +++ b/api_v1/serializers.py @@ -3,6 +3,7 @@ from decimal import Decimal from typing import List, Dict, Any from stock import models as stock_models from basic_info import models as basic_info_models +from . import models as api_models class ProductStockChangeSerializer(serializers.Serializer): @@ -90,4 +91,47 @@ class SetMerchantAutoCompleteStockChangeSerializer(serializers.Serializer): """设置商户自动确认库存变动序列化器""" auto_complete = serializers.BooleanField( help_text="是否自动确认库存变动" - ) \ No newline at end of file + ) + + +# ==================== 通用文件上传序列化器 ==================== + +class UploadedFileSerializer(serializers.ModelSerializer): + """上传文件序列化器""" + file_url = serializers.SerializerMethodField() + owner_username = serializers.CharField(source='owner.username', read_only=True) + + class Meta: + model = api_models.UploadedFile + fields = [ + 'id', 'path', 'file_url', 'original_filename', 'file_size', + 'content_type', 'owner', 'owner_username', 'is_deleted', + 'created_at', 'updated_at' + ] + read_only_fields = ['id', 'owner', 'file_size', 'content_type', 'created_at', 'updated_at'] + + def get_file_url(self, obj): + """获取文件完整URL""" + if obj.path: + request = self.context.get('request') + if request: + return request.build_absolute_uri(obj.path.url) + return obj.path.url + return None + + +class FileUploadSerializer(serializers.Serializer): + """文件上传请求序列化器""" + file = serializers.FileField( + required=True, + help_text='要上传的文件' + ) + + def validate_file(self, value): + """验证文件""" + # 限制文件大小为 100MB + max_size = 100 * 1024 * 1024 + if value.size > max_size: + raise serializers.ValidationError(f'文件大小不能超过 {max_size // (1024*1024)}MB') + + return value \ No newline at end of file diff --git a/api_v1/urls.py b/api_v1/urls.py index e1e9e80..99c8973 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -3,6 +3,7 @@ from rest_framework.routers import DefaultRouter from .views import stock_change_views, user_info, inventory, product_image, stateflow from .views.stock_change_views.snapshot import StockSnapshotListView from .views.printing.views import PrintingOrderViewSet, PrintingJobViewSet, PlateOrderViewSet +from .views.upload import UploadFileViewSet # 创建 DRF Router for Stateflow stateflow_router = DefaultRouter() @@ -15,6 +16,7 @@ main_router = DefaultRouter() main_router.register(r'printing-orders', PrintingOrderViewSet, basename='printing-order') main_router.register(r'printing-jobs', PrintingJobViewSet, basename='printing-job') main_router.register(r'plate-orders', PlateOrderViewSet, basename='plate-order') +main_router.register(r'upload', UploadFileViewSet, basename='upload') urlpatterns = [ # 库存变动相关API diff --git a/api_v1/views/printing/API.md b/api_v1/views/printing/API.md new file mode 100644 index 0000000..9d92d60 --- /dev/null +++ b/api_v1/views/printing/API.md @@ -0,0 +1,384 @@ +# Printing API 文档 + +## 概述 + +本文档详细描述了 `printing` 模块下的所有 API 接口,包括印染订单 (`PrintingOrder`)、印染任务 (`PrintingJob`) 和开版订单 (`PlateOrder`) 的管理。 + +## 基础 URL + +所有 `printing` 相关 API 都在以下基础路径下: + +``` +/api/v1/ +``` + +## 认证与权限 + +- 所有接口都需要 JWT 认证。 +- `PrintingOrder` 和 `PrintingJob` 接口需要用户是**印染工厂**类型商户 (`MerchantTypeEnum.FACTORY`)。 +- `PlateOrder` 接口无此限制,但作废/恢复等操作需要特定权限。 + +--- + +## 1. Printing Orders API (印染订单) + +**基础路径**: `/api/v1/printing-orders/` + +管理印染生产的主订单。 + +### 1.1. 标准操作 (CRUD) + +- **GET** `/api/v1/printing-orders/` + - **描述**: 获取印染订单列表。 + - **查询参数**: + - `customer`: 客户ID + - `customer_name`: 客户名称 (模糊查询) + - `customer_phone`: 客户电话 (模糊查询) + - `fabric`: 面料 (模糊查询) + - `is_urgent`: 是否紧急 (`true`/`false`) + - `is_fabric_received`: 布料是否已收 (`true`/`false`) + - `is_invalid`: 是否作废 (`true`/`false`) + - `area`: 地区 (模糊查询) + - `outgoing_date_from`/`to`: 出货日期范围 + - `created_date_from`/`to`: 创建日期范围 + - `search`: 全文搜索 (客户名称, 面料, 地区, 工艺) + - `ordering`: 排序字段 (e.g., `-created_at`) + - **响应**: `PrintingOrderListSerializer` 列表。 + +- **GET** `/api/v1/printing-orders/{id}/` + - **描述**: 获取单个印染订单详情。 + - **响应**: `PrintingOrderDetailSerializer`。 + +- **POST** `/api/v1/printing-orders/` + - **描述**: 创建一个新的印染订单。 + - **请求体**: `PrintingOrderCreateUpdateSerializer`。 + +- **PUT/PATCH** `/api/v1/printing-orders/{id}/` + - **描述**: 完全或部分更新一个印染订单。 + - **请求体**: `PrintingOrderCreateUpdateSerializer`。 + - **注意**: 如果订单下已有开始的 `PrintingJob`,则无法修改 `process` 字段。 + +- **DELETE** `/api/v1/printing-orders/{id}/` + - **描述**: **已禁用**。请使用 `invalidate` 接口作废订单。 + +### 1.2. 自定义操作 (Custom Actions) + +- **POST** `/api/v1/printing-orders/{id}/invalidate/` + - **描述**: 作废订单。需要 `printing.can_invalidate_printingorder` 权限。 + +- **POST** `/api/v1/printing-orders/{id}/activate/` + - **描述**: 恢复已作废的订单。需要 `printing.can_activate_printingorder` 权限。 + +- **POST** `/api/v1/printing-orders/{id}/mark_fabric_received/` + - **描述**: 标记订单的布料已收到。 + +--- + +## 2. Printing Jobs API (印染任务) + +**基础路径**: `/api/v1/printing-jobs/` + +管理印染订单下的具体生产任务(款式明细)。 + +### 2.1. 标准操作 (CRUD) + +- **GET** `/api/v1/printing-jobs/` + - **描述**: 获取印染任务列表。 + - **查询参数**: + - `printing_order`: 印染订单ID + - `product`: 产品ID + - `product_name`: 产品名称 (模糊查询) + - `unit`: 单位 (模糊查询) + - `quantity_min`/`max`: 数量范围 + - `pieces_min`/`max`: 件数范围 + - `search`: 全文搜索 (产品名称, 单位, 尺寸, 备注) + - `ordering`: 排序字段。 + - **响应**: `PrintingJobListSerializer` 列表。 + - **重要变更**: 列表中已**包含** `business_object_id` 字段。 + +- **GET** `/api/v1/printing-jobs/{id}/` + - **描述**: 获取单个印染任务详情。 + - **响应**: `PrintingJobDetailSerializer`。 + - **重要变更**: `business_object_id` 字段现在会安全地返回 `null` 而不是报错(当 `business_object` 不存在时)。 + +- **POST** `/api/v1/printing-jobs/` + - **描述**: 创建一个新的印染任务。 + - **请求体**: `PrintingJobCreateUpdateSerializer`。 + +- **PUT/PATCH** `/api/v1/printing-jobs/{id}/` + - **描述**: 完全或部分更新一个印染任务。 + - **请求体**: `PrintingJobCreateUpdateSerializer`。 + +- **DELETE** `/api/v1/printing-jobs/{id}/` + - **描述**: **已禁用**。 + +### 2.2. 状态流转操作 (便捷包装) + +这些接口是对 `stateflow` 模块的包装,前端只需提供 `printing_job` 的 ID。 + +**重要变更 (v2)**: 现在支持传递状态参数! + +- **POST** `/api/v1/printing-jobs/{id}/advance-to-next-state/` + - **描述**: 将任务推进到下一个流程状态。 + - **请求体**: (可选,但如果下一个状态有必填参数,则必须提供) + ```json + { + "parameters": { + "temperature": "25.5", + "operator": "张三", + "humidity": "60%" + } + } + ``` + - **参数说明**: + - `parameters`: 可选的字典,包含状态参数的键值对 + - 如果流程节点定义了必填参数(`is_required=True`),则必须在 `parameters` 中提供 + - 参数值统一为字符串类型 + - **成功响应**: `200 OK` + ```json + { + "detail": "已完成状态: 待印染", + "data": { + "id": 1, + "status": "印染中", + "business_object_id": 10, + ... + } + } + ``` + - **失败响应**: + - `400 Bad Request` - 缺少必填参数 + ```json + { + "detail": "缺失必填参数: temperature, operator" + } + ``` + - `400 Bad Request` - 流程已完成 + ```json + { + "detail": "流程已完成,无法继续推进" + } + ``` + - `400 Bad Request` - 没有关联的流程实例 + ```json + { + "detail": "该任务没有关联的流程实例" + } + ``` + +- **POST** `/api/v1/printing-jobs/{id}/step-back-one-state/` + - **描述**: 将任务回退到上一个流程状态。 + - **响应**: 成功时返回 `200 OK`,包含 `detail` 和 `data`。失败时返回 `400 Bad Request`。 + +### 2.3. 状态查询操作 + +- **GET** `/api/v1/printing-jobs/{id}/completed-states/` + - **描述**: 查询已完成的流程节点列表。 + - **查询参数**: + - `include_cancelled`: 是否包含已撤销的记录 (`true`/`false`)。 + - **响应**: 包含状态日志列表。 + +- **GET** `/api/v1/printing-jobs/{id}/timeline/` + - **描述**: 获取完整的流程时间线,包含所有节点及其状态 (`not_started`, `in_progress`, `completed`)。 + +--- + +## 3. Plate Orders API (开版订单) + +**基础路径**: `/api/v1/plate-orders/` + +管理设计、打样、开版的订单。 + +### 3.1. 标准操作 (CRUD) + +- **GET** `/api/v1/plate-orders/` + - **描述**: 获取开版订单列表。 + - **查询参数**: + - `customer_name`/`phone`: 客户名称/电话 (模糊查询) + - `salesperson`/`merchandiser`: 业务员/跟单员 ID + - `plate_type`, `urgency_level`, `fabric`, `style_name`: 模糊查询 + - `is_invalid`, `is_ordered`, `is_mark_frame`: 布尔过滤 + - `plate_date_from`/`to`: 开版日期范围 + - `required_completion_date_from`/`to`: 要求完成日期范围 + - `created_date_from`/`to`: 创建日期范围 + - `search`: 全文搜索 (设计编号, 款式名称, 客户名称, 面料) + - `ordering`: 排序字段。 + - **响应**: `PlateOrderListSerializer` 列表。 + +- **GET** `/api/v1/plate-orders/{id}/` + - **描述**: 获取单个开版订单详情。 + - **响应**: `PlateOrderDetailSerializer`。 + - **重要变更**: `business_object_id` 字段现在会安全地返回 `null` 而不是报错。 + +- **POST** `/api/v1/plate-orders/` + - **描述**: 创建一个新的开版订单。 + - **请求体**: `PlateOrderCreateUpdateSerializer`。 + +- **PUT/PATCH** `/api/v1/plate-orders/{id}/` + - **描述**: 完全或部分更新一个开版订单。 + - **请求体**: `PlateOrderCreateUpdateSerializer`。 + +- **DELETE** `/api/v1/plate-orders/{id}/` + - **描述**: **已禁用**。 + +### 3.2. 自定义操作 + +- **POST** `/api/v1/plate-orders/{id}/invalidate/` + - **描述**: 作废开版订单。需要 `printing.can_invalidate_plateorder` 权限。 + +- **POST** `/api/v1/plate-orders/{id}/activate/` + - **描述**: 恢复已作废的开版订单。需要 `printing.can_activate_plateorder` 权限。 + +### 3.3. 状态流转与查询操作 + +与 `PrintingJob` 的接口类似,提供对 `stateflow` 的便捷包装。 + +**重要变更 (v2)**: 现在支持传递状态参数! + +- **POST** `/api/v1/plate-orders/{id}/advance-to-next-state/` + - **描述**: 推进到下一个流程状态。 + - **请求体**: (可选,但如果下一个状态有必填参数,则必须提供) + ```json + { + "parameters": { + "inspector": "李四", + "quality_score": "95", + "notes": "质量良好" + } + } + ``` + - **参数说明**: + - `parameters`: 可选的字典,包含状态参数的键值对 + - 如果流程节点定义了必填参数(`is_required=True`),则必须在 `parameters` 中提供 + - 参数值统一为字符串类型 + - **成功响应**: `200 OK` + ```json + { + "detail": "已完成状态: 待画图", + "data": { + "id": 1, + "status": "画图完成", + "business_object_id": 20, + ... + } + } + ``` + - **失败响应**: + - `400 Bad Request` - 缺少必填参数 + ```json + { + "detail": "缺失必填参数: inspector" + } + ``` + - `400 Bad Request` - 流程已完成或没有关联的流程实例 + +- **POST** `/api/v1/plate-orders/{id}/step-back-one-state/` + - **描述**: 回退到上一个流程状态。 + +- **GET** `/api/v1/plate-orders/{id}/completed-states/` + - **描述**: 查询已完成的流程节点列表。 + +- **GET** `/api/v1/plate-orders/{id}/timeline/` + - **描述**: 获取完整的流程时间线。 + +--- + +## 4. 重要变更说明 (v2) + +### 4.1. 新增功能 + +#### 4.1.1. 状态推进接口支持参数传递 + +**影响接口**: +- `POST /api/v1/printing-jobs/{id}/advance-to-next-state/` +- `POST /api/v1/plate-orders/{id}/advance-to-next-state/` + +**变更内容**: +- 这两个接口现在支持在请求体中传递 `parameters` 字典 +- `parameters` 是可选的,但如果流程节点定义了必填参数,则必须提供 +- 参数格式与 `stateflow` 模块的原始接口保持一致 + +**迁移指南**: +- 旧代码(无参数): + ```javascript + POST /api/v1/printing-jobs/123/advance-to-next-state/ + // 空请求体 + ``` + +- 新代码(带参数): + ```javascript + POST /api/v1/printing-jobs/123/advance-to-next-state/ + { + "parameters": { + "temperature": "25.5", + "operator": "张三" + } + } + ``` + +- **向后兼容**: 如果流程节点没有必填参数,可以不传 `parameters`,保持原有调用方式。 + +#### 4.1.2. business_object_id 字段增强 + +**影响接口**: +- `GET /api/v1/printing-jobs/` (列表) +- `GET /api/v1/printing-jobs/{id}/` (详情) +- `GET /api/v1/plate-orders/{id}/` (详情) + +**变更内容**: +- `PrintingJobListSerializer` 现在**包含** `business_object_id` 字段(之前缺失) +- `PrintingJobDetailSerializer` 和 `PlateOrderDetailSerializer` 的 `business_object_id` 字段已修复 +- 当 `business_object` 不存在时,返回 `null` 而不是报错 + +**迁移指南**: +- 旧代码可能需要处理字段缺失或错误 +- 新代码可以安全地访问 `business_object_id`,值可能为 `null` 或整数 + +### 4.2. Bug 修复 + +1. **修复了 `business_object_id` 字段在关联对象不存在时抛出异常的问题** + - 之前: 使用 `source='business_object.id'` 导致 `AttributeError` + - 现在: 使用 `SerializerMethodField` 安全返回 `null` + +2. **修复了状态推进接口无法传递参数的问题** + - 之前: 参数被忽略,导致有必填参数的流程无法推进 + - 现在: 参数正确传递到底层 `stateflow` 服务 + +### 4.3. 测试覆盖 + +新增测试用例: +- `test_advance_with_parameters`: 测试带参数推进 +- `test_advance_with_required_parameters_missing`: 测试缺少必填参数的错误处理 +- `test_advance_with_partial_required_parameters`: 测试部分提供必填参数的错误处理 + +### 4.4. 对前端的影响 + +**必须更新的地方**: +1. 如果前端代码访问 `PrintingJob` 列表的 `business_object_id`,现在会正常返回(之前可能报错或不存在)。 +2. 如果前端需要在推进状态时传递参数,现在可以在请求体中添加 `parameters` 字段。 + +**可选更新的地方**: +1. 建议添加错误处理逻辑,当推进失败时显示具体的缺失参数信息。 +2. 建议在推进前查询节点的参数要求(通过 `stateflow` 的 `next_pending_state` 接口)。 + +**示例代码**: +```javascript +// 推进前查询下一个节点的参数要求 +const response = await fetch(`/api/v1/stateflow/business-objects/${businessObjectId}/next_pending_state/`); +const nextState = await response.json(); + +if (nextState.parameters && nextState.parameters.length > 0) { + // 显示参数输入表单 + const requiredParams = nextState.parameters.filter(p => p.is_required); + // ... 收集用户输入 +} + +// 推进状态(带参数) +const advanceResponse = await fetch(`/api/v1/printing-jobs/${jobId}/advance-to-next-state/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + parameters: collectedParameters + }) +}); +``` diff --git a/api_v1/views/printing/serializers.py b/api_v1/views/printing/serializers.py index 8bc4730..c2f24fc 100644 --- a/api_v1/views/printing/serializers.py +++ b/api_v1/views/printing/serializers.py @@ -105,16 +105,21 @@ class PrintingJobListSerializer(serializers.ModelSerializer): product_name = serializers.CharField(source='product.name', read_only=True) status = serializers.CharField(read_only=True) is_completed = serializers.BooleanField(read_only=True) + business_object_id = serializers.SerializerMethodField() class Meta: model = models.PrintingJob fields = [ 'id', 'printing_order', 'printing_order_id', 'product', 'product_name', 'quantity', 'unit', 'size', 'pieces', 'description', - 'status', 'is_completed', + 'status', 'is_completed', 'business_object_id', 'created_at', 'updated_at' ] - read_only_fields = ['id', 'created_at', 'updated_at', 'status', 'is_completed'] + read_only_fields = ['id', 'created_at', 'updated_at', 'status', 'is_completed', 'business_object_id'] + + def get_business_object_id(self, obj): + """安全地获取 business_object_id""" + return obj.business_object.id if obj.business_object else None class PrintingJobDetailSerializer(serializers.ModelSerializer): @@ -126,7 +131,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer): status_id = serializers.IntegerField(read_only=True) is_completed = serializers.BooleanField(read_only=True) has_started = serializers.BooleanField(read_only=True) - business_object_id = serializers.IntegerField(source='business_object.id', read_only=True) + business_object_id = serializers.SerializerMethodField() class Meta: model = models.PrintingJob @@ -140,6 +145,10 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer): 'id', 'created_at', 'updated_at', 'status', 'status_id', 'is_completed', 'has_started', 'business_object_id' ] + + def get_business_object_id(self, obj): + """安全地获取 business_object_id""" + return obj.business_object.id if obj.business_object else None class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer): @@ -242,7 +251,7 @@ class PlateOrderDetailSerializer(serializers.ModelSerializer): is_completed = serializers.BooleanField(read_only=True) has_started = serializers.BooleanField(read_only=True) progress_percentage = serializers.IntegerField(read_only=True) - business_object_id = serializers.IntegerField(source="business_object.id", read_only=True) + business_object_id = serializers.SerializerMethodField() plate_image_url = serializers.SerializerMethodField() process_name = serializers.SerializerMethodField() @@ -272,6 +281,10 @@ class PlateOrderDetailSerializer(serializers.ModelSerializer): 'created_at', 'updated_at' ] + def get_business_object_id(self, obj): + """安全地获取 business_object_id""" + return obj.business_object.id if obj.business_object else None + def get_plate_image_url(self, obj): """获取图片完整URL""" if obj.plate_image: diff --git a/api_v1/views/printing/test_printing_job_api.py b/api_v1/views/printing/test_printing_job_api.py index 57f12f7..a61d1cd 100644 --- a/api_v1/views/printing/test_printing_job_api.py +++ b/api_v1/views/printing/test_printing_job_api.py @@ -418,20 +418,23 @@ class PrintingJobAPITestCase(TestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn('quantity', response.data) - def test_validate_pieces_positive(self): - """测试件数必须大于0""" + def test_validate_pieces_can_be_zero(self): + """测试件数可以为0或null""" data = { 'printing_order': self.printing_order.id, 'product': self.product.id, 'quantity': 100, 'unit': '米', 'size': '50*60', - 'pieces': 0 # 无效件数 + 'pieces': 0 # 允许为0 } response = self.client.post('/api/v1/printing-jobs/', data, format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn('pieces', response.data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # 验证创建成功 + job = printing_models.PrintingJob.objects.get(id=response.data['id']) + self.assertEqual(job.pieces, 0) def test_create_job_with_business_object(self): """测试创建任务自动创建 BusinessObject""" @@ -583,6 +586,153 @@ class PrintingJobAPITestCase(TestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn('没有关联的流程实例', response.data['detail']) + def test_advance_with_parameters(self): + """测试推进到下一个状态 - 带参数""" + job = printing_models.PrintingJob.objects.create( + printing_order=self.printing_order, + product=self.product, + quantity=100, + unit='米', + size='50*60', + pieces=10 + ) + + # 创建 BusinessObject + business_object = stateflow_models.BusinessObject.objects.create( + name=f'PrintingJob-{job.id}', + process=self.process, + ) + job.business_object = business_object + job.save() + + # 推进到下一个状态,带参数 + data = { + 'parameters': { + 'temperature': '25.5', + 'operator': '张三', + 'humidity': '60%' + } + } + response = self.client.post( + f'/api/v1/printing-jobs/{job.id}/advance-to-next-state/', + data, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('detail', response.data) + self.assertIn('data', response.data) + + # 验证参数已保存 + job.refresh_from_db() + state_log = business_object.state_logs.filter(state=self.state1, is_cancelled=False).first() + self.assertIsNotNone(state_log) + + # 验证参数记录 + param_record = state_log.parameter_records.first() + self.assertIsNotNone(param_record) + self.assertEqual(param_record.parameters['temperature'], '25.5') + self.assertEqual(param_record.parameters['operator'], '张三') + self.assertEqual(param_record.parameters['humidity'], '60%') + + def test_advance_with_required_parameters_missing(self): + """测试推进到下一个状态 - 缺少必填参数""" + # 创建带必填参数的状态 + param1 = stateflow_models.StateParameter.objects.create( + key='temperature', + is_required=True, + description='温度' + ) + param2 = stateflow_models.StateParameter.objects.create( + key='operator', + is_required=True, + description='操作员' + ) + + state_with_params = stateflow_models.State.objects.create(name='带参数状态') + state_with_params.parameters.add(param1, param2) + + # 创建新流程,第一个状态是带必填参数的 + process_with_params = stateflow_models.Process.objects.create(name='带参数流程') + process_with_params.replace_nodes([state_with_params, self.state2]) + + job = printing_models.PrintingJob.objects.create( + printing_order=self.printing_order, + product=self.product, + quantity=100, + unit='米', + size='50*60', + pieces=10 + ) + + # 创建 BusinessObject + business_object = stateflow_models.BusinessObject.objects.create( + name=f'PrintingJob-{job.id}', + process=process_with_params, + ) + job.business_object = business_object + job.save() + + # 尝试推进但不提供必填参数 + response = self.client.post(f'/api/v1/printing-jobs/{job.id}/advance-to-next-state/') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('缺失必填参数', response.data['detail']) + self.assertIn('temperature', response.data['detail']) + self.assertIn('operator', response.data['detail']) + + def test_advance_with_partial_required_parameters(self): + """测试推进到下一个状态 - 只提供部分必填参数""" + # 创建带必填参数的状态 + param1 = stateflow_models.StateParameter.objects.create( + key='temperature', + is_required=True, + description='温度' + ) + param2 = stateflow_models.StateParameter.objects.create( + key='operator', + is_required=True, + description='操作员' + ) + + state_with_params = stateflow_models.State.objects.create(name='带参数状态') + state_with_params.parameters.add(param1, param2) + + # 创建新流程 + process_with_params = stateflow_models.Process.objects.create(name='带参数流程') + process_with_params.replace_nodes([state_with_params, self.state2]) + + job = printing_models.PrintingJob.objects.create( + printing_order=self.printing_order, + product=self.product, + quantity=100, + unit='米', + size='50*60', + pieces=10 + ) + + # 创建 BusinessObject + business_object = stateflow_models.BusinessObject.objects.create( + name=f'PrintingJob-{job.id}', + process=process_with_params, + ) + job.business_object = business_object + job.save() + + # 只提供一个必填参数 + data = { + 'parameters': { + 'temperature': '25.5' + } + } + response = self.client.post( + f'/api/v1/printing-jobs/{job.id}/advance-to-next-state/', + data, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('缺失必填参数', response.data['detail']) + self.assertIn('operator', response.data['detail']) + self.assertNotIn('temperature', response.data['detail']) # temperature 已提供 + def test_advance_through_all_states(self): """测试推进到完成所有状态""" job = printing_models.PrintingJob.objects.create( diff --git a/api_v1/views/printing/views.py b/api_v1/views/printing/views.py index 1d05149..e89389a 100644 --- a/api_v1/views/printing/views.py +++ b/api_v1/views/printing/views.py @@ -318,6 +318,16 @@ class PrintingJobViewSet(viewsets.ModelViewSet): 将印染任务推进到下一个流程状态 + 请求体: + { + "parameters": { + "temperature": "25.5", + "operator": "张三" + } + } + + 参数可选,但如果下一个状态有必填参数,则必须提供 + 注意:这是对 stateflow.BusinessObject.advance 接口的便捷包装, 自动通过 printing_job 查找关联的 business_object 并调用状态流转服务。 前端只需提供 printing_job 的 ID,无需关心 business_object_id。 @@ -330,10 +340,13 @@ class PrintingJobViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST ) + # 获取请求参数 + parameters = request.data.get('parameters', {}) + # 调用 stateflow 统一服务进行状态流转 from stateflow import services as stateflow_services success, message, state_log = stateflow_services.advance_to_next_state( - job.business_object, request.user + job.business_object, request.user, **parameters ) if not success: @@ -680,6 +693,16 @@ class PlateOrderViewSet(viewsets.ModelViewSet): 将开版订单推进到下一个流程状态 + 请求体: + { + "parameters": { + "inspector": "李四", + "quality_score": "95" + } + } + + 参数可选,但如果下一个状态有必填参数,则必须提供 + 注意:这是对 stateflow.BusinessObject.advance 接口的便捷包装, 自动通过 plate_order 查找关联的 business_object 并调用状态流转服务。 """ @@ -691,10 +714,13 @@ class PlateOrderViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST ) + # 获取请求参数 + parameters = request.data.get('parameters', {}) + # 调用 stateflow 统一服务进行状态流转 from stateflow import services as stateflow_services success, message, state_log = stateflow_services.advance_to_next_state( - plate_order.business_object, request.user + plate_order.business_object, request.user, **parameters ) if not success: diff --git a/api_v1/views/stateflow/business_object.py b/api_v1/views/stateflow/business_object.py index 8ed3fef..1454d32 100644 --- a/api_v1/views/stateflow/business_object.py +++ b/api_v1/views/stateflow/business_object.py @@ -328,6 +328,42 @@ class BusinessObjectViewSet(viewsets.ModelViewSet): 'parameter_record': StateLogParameterRecordSerializer(record).data }) + @action(detail=True, methods=['get'], url_path='state-logs') + def get_state_logs(self, request, pk=None): + """ + 获取业务对象的所有状态流转记录列表 + + GET /api/v1/stateflow/business-objects/{id}/state-logs/ + GET /api/v1/stateflow/business-objects/{id}/state-logs/?include_parameters=false + GET /api/v1/stateflow/business-objects/{id}/state-logs/?include_cancelled=true + + 查询参数: + - include_parameters: 是否包含工艺参数,默认 true(可选值: true, false) + - include_cancelled: 是否包含已撤销的记录,默认 true(可选值: true, false) + """ + business_object = self.get_object() + include_parameters = request.query_params.get('include_parameters', 'true').lower() == 'true' + include_cancelled = request.query_params.get('include_cancelled', 'true').lower() == 'true' + + # 获取状态流转记录 + state_logs = business_object.state_logs.select_related('state', 'completed_by').order_by('completed_at') + + if not include_cancelled: + state_logs = state_logs.filter(is_cancelled=False) + + # 序列化 + if include_parameters: + from stateflow.serializers import StateFlowRecordWithParametersSerializer + serializer = StateFlowRecordWithParametersSerializer(state_logs, many=True) + else: + from stateflow.serializers import StateFlowRecordSerializer + serializer = StateFlowRecordSerializer(state_logs, many=True) + + return Response({ + 'count': state_logs.count(), + 'state_logs': serializer.data + }) + @action(detail=True, methods=['get'], url_path='state-logs/(?P[^/.]+)/parameters') def get_log_parameters(self, request, pk=None, log_id=None): """ diff --git a/api_v1/views/upload.py b/api_v1/views/upload.py new file mode 100644 index 0000000..815d8d9 --- /dev/null +++ b/api_v1/views/upload.py @@ -0,0 +1,121 @@ +""" +通用文件上传视图 +""" +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework.parsers import MultiPartParser, FormParser + +from api_v1.models import UploadedFile +from api_v1.serializers import UploadedFileSerializer, FileUploadSerializer + + +class UploadFileViewSet(viewsets.GenericViewSet, + viewsets.mixins.CreateModelMixin, + viewsets.mixins.RetrieveModelMixin, + viewsets.mixins.DestroyModelMixin): + """ + 通用文件上传 ViewSet + + 用于上传无法归类到具体业务的文件 + + retrieve: 获取单个文件信息 + create: 上传新文件 + destroy: 软删除文件 + + 注意:不支持列表查询和修改操作 + """ + queryset = UploadedFile.objects.all() + serializer_class = UploadedFileSerializer + permission_classes = [IsAuthenticated] + parser_classes = [MultiPartParser, FormParser] + + def get_queryset(self): + """默认不显示已删除的文件""" + queryset = super().get_queryset() + # 过滤掉已删除的文件 + queryset = queryset.filter(is_deleted=False) + return queryset + + def perform_create(self, serializer): + """创建时自动设置 owner""" + uploaded_file = self.request.FILES.get('file') + + # 保存文件并记录元数据 + serializer.save( + owner=self.request.user, + original_filename=uploaded_file.name, + file_size=uploaded_file.size, + content_type=uploaded_file.content_type + ) + + def create(self, request, *args, **kwargs): + """ + 上传文件 + + 使用 multipart/form-data 格式上传 + """ + # 验证上传的文件 + upload_serializer = FileUploadSerializer(data=request.data) + upload_serializer.is_valid(raise_exception=True) + + # 创建文件记录 + file_serializer = self.get_serializer(data={'path': request.FILES['file']}) + file_serializer.is_valid(raise_exception=True) + self.perform_create(file_serializer) + + headers = self.get_success_headers(file_serializer.data) + return Response( + file_serializer.data, + status=status.HTTP_201_CREATED, + headers=headers + ) + + def destroy(self, request, *args, **kwargs): + """软删除文件(标记为已删除,不实际删除)""" + instance = self.get_object() + instance.is_deleted = True + instance.save() + + return Response( + {'detail': '文件已标记为删除'}, + status=status.HTTP_200_OK + ) + + @action(detail=True, methods=['post']) + def restore(self, request, pk=None): + """恢复已删除的文件""" + instance = self.get_object() + + if not instance.is_deleted: + return Response( + {'detail': '文件未被删除,无需恢复'}, + status=status.HTTP_400_BAD_REQUEST + ) + + instance.is_deleted = False + instance.save() + + serializer = self.get_serializer(instance) + return Response({ + 'detail': '文件已恢复', + 'data': serializer.data + }) + + @action(detail=True, methods=['delete']) + def permanent_delete(self, request, pk=None): + """永久删除文件(物理删除)""" + instance = self.get_object() + + # 删除物理文件 + if instance.path: + instance.path.delete(save=False) + + # 删除数据库记录 + instance.delete() + + return Response( + {'detail': '文件已永久删除'}, + status=status.HTTP_204_NO_CONTENT + ) diff --git a/flower/settings.py b/flower/settings.py index 45d74c6..8d1b160 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -244,5 +244,5 @@ MEDIA_URL = f'http://{QINIU_BUCKET_DOMAIN}/media/' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # Printing module settings -PRINTING_DEFAULT_PROCESS_ID = 12 # 默认印染流程ID +PRINTING_DEFAULT_PROCESS_ID = 31 # 默认印染流程ID PLATE_ORDER_DEFAULT_PROCESS_ID = 14 # 默认开版流程ID diff --git a/stateflow/API.md b/stateflow/API.md index 2cfd9b9..5783410 100644 --- a/stateflow/API.md +++ b/stateflow/API.md @@ -270,7 +270,52 @@ Authorization: Bearer ### 3.8. 参数与日志接口 (Custom Actions) -#### 3.8.1. 为日志补充参数 +#### 3.8.1. 获取状态流转记录列表 + +- **GET** `/api/v1/stateflow/business-objects/{id}/state-logs/` +- **描述**: 获取业务对象的所有状态流转记录列表。 +- **查询参数**: + - `include_parameters`: `true` 或 `false`,是否包含工艺参数,默认 `true`。 + - `include_cancelled`: `true` 或 `false`,是否包含已撤销的记录,默认 `true`。 +- **响应示例**: + ```json + { + "count": 2, + "state_logs": [ + { + "id": 123, + "state": 1, + "state_name": "质检", + "completed_at": "2025-11-15T10:30:00Z", + "completed_by": 5, + "completed_by_username": "inspector1", + "is_cancelled": false, + "cancelled_at": null, + "parameters_summary": { + "temperature": "25.5", + "humidity": "60%" + } + }, + { + "id": 124, + "state": 2, + "state_name": "包装", + "completed_at": "2025-11-15T14:20:00Z", + "completed_by": 6, + "completed_by_username": "packer1", + "is_cancelled": false, + "cancelled_at": null, + "parameters_summary": {} + } + ] + } + ``` +- **注意**: + - 当 `include_parameters=false` 时,返回的记录不包含 `parameters_summary` 字段。 + - 当 `include_cancelled=false` 时,只返回未撤销的记录。 + - 记录按 `completed_at` 时间正序排列。 + +#### 3.8.2. 为日志补充参数 - **POST** `/api/v1/stateflow/business-objects/{id}/state-logs/{log_id}/add-parameters/` - **描述**: 为某一次具体的状态流转记录(`StateFlowRecord`)补充额外的参数。 @@ -283,14 +328,82 @@ Authorization: Bearer "remark": "质检员补充" } ``` +- **成功响应**: `200 OK` + ```json + { + "success": true, + "parameter_record": { + "id": 456, + "parameters": { + "inspector_comment": "发现轻微划痕" + }, + "remark": "质检员补充", + "created_at": "2025-11-15T15:00:00Z" + } + } + ``` +- **失败响应**: + - `400 Bad Request`: 参数为空 + - `404 Not Found`: 状态流转记录不存在 -#### 3.8.2. 获取日志的参数 +#### 3.8.3. 获取单个日志的参数 - **GET** `/api/v1/stateflow/business-objects/{id}/state-logs/{log_id}/parameters/` -- **描述**: 获取某一次流转记录的所有参数。 +- **描述**: 获取某一次流转记录的所有参数记录或指定参数的历史。 - **查询参数**: - - `key`: 如果提供,则只返回该 `key` 的所有历史值。 - - `include_cancelled`: `true` 或 `false`,是否包含已撤销的记录。 + - `key`: (可选)如果提供,则只返回该 `key` 的所有历史值。 + - `include_cancelled`: `true` 或 `false`,是否包含已撤销状态的参数,默认 `false`。 +- **响应示例**(获取所有参数): + ```json + { + "state_log_id": 123, + "is_cancelled": false, + "count": 2, + "summary": { + "temperature": "26.5", + "humidity": "60%" + }, + "records": [ + { + "id": 1, + "parameters": { + "temperature": "25.5", + "humidity": "60%" + }, + "remark": "", + "created_at": "2025-11-15T10:30:00Z" + }, + { + "id": 2, + "parameters": { + "temperature": "26.5" + }, + "remark": "重新测量", + "created_at": "2025-11-15T11:00:00Z" + } + ] + } + ``` +- **响应示例**(按 key 查询历史): + ```json + { + "state_log_id": 123, + "key": "temperature", + "is_cancelled": false, + "history": [ + { + "value": "25.5", + "remark": "", + "created_at": "2025-11-15T10:30:00Z" + }, + { + "value": "26.5", + "remark": "重新测量", + "created_at": "2025-11-15T11:00:00Z" + } + ] + } + ``` ## 分页 diff --git a/stateflow/tests/test_business_object_api.py b/stateflow/tests/test_business_object_api.py index 4ec9634..6028142 100644 --- a/stateflow/tests/test_business_object_api.py +++ b/stateflow/tests/test_business_object_api.py @@ -471,6 +471,132 @@ class BusinessObjectAPITestCase(TestCase): self.assertIsNotNone(item['completed_by']) self.assertFalse(item['is_cancelled']) + def test_get_state_logs_api_with_parameters(self): + """测试获取状态流转记录列表(包含参数)""" + # 未开始时应返回空列表 + response = self.client.get(f'/api/v1/stateflow/business-objects/{self.business_object.id}/state-logs/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 0) + self.assertEqual(len(response.data['state_logs']), 0) + + # 推进两次 + self.client.post( + f'/api/v1/stateflow/business-objects/{self.business_object.id}/advance/', + {'parameters': {'temperature': '25.5', 'humidity': '60%'}}, + format='json' + ) + self.client.post(f'/api/v1/stateflow/business-objects/{self.business_object.id}/advance/') + + # 获取记录列表(默认包含参数) + response = self.client.get(f'/api/v1/stateflow/business-objects/{self.business_object.id}/state-logs/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 2) + self.assertEqual(len(response.data['state_logs']), 2) + + # 验证第一条记录包含参数摘要 + log1 = response.data['state_logs'][0] + self.assertIn('parameters_summary', log1) + self.assertEqual(log1['parameters_summary']['temperature'], '25.5') + self.assertEqual(log1['parameters_summary']['humidity'], '60%') + self.assertEqual(log1['state_name'], '状态1') + + # 验证第二条记录(无参数) + log2 = response.data['state_logs'][1] + self.assertIn('parameters_summary', log2) + self.assertEqual(len(log2['parameters_summary']), 0) + self.assertEqual(log2['state_name'], '状态2') + + def test_get_state_logs_api_without_parameters(self): + """测试获取状态流转记录列表(不包含参数)""" + # 推进两次 + self.client.post( + f'/api/v1/stateflow/business-objects/{self.business_object.id}/advance/', + {'parameters': {'temperature': '25.5'}}, + format='json' + ) + self.client.post(f'/api/v1/stateflow/business-objects/{self.business_object.id}/advance/') + + # 获取记录列表(不包含参数) + response = self.client.get( + f'/api/v1/stateflow/business-objects/{self.business_object.id}/state-logs/?include_parameters=false' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 2) + + # 验证不包含 parameters_summary 字段 + log1 = response.data['state_logs'][0] + self.assertNotIn('parameters_summary', log1) + self.assertIn('state_name', log1) + self.assertIn('completed_at', log1) + self.assertIn('completed_by_username', log1) + + def test_get_state_logs_api_exclude_cancelled(self): + """测试获取状态流转记录列表(排除已撤销记录)""" + # 推进两次 + self.client.post( + f'/api/v1/stateflow/business-objects/{self.business_object.id}/advance/', + {'parameters': {'temperature': '25.5'}}, + format='json' + ) + self.client.post(f'/api/v1/stateflow/business-objects/{self.business_object.id}/advance/') + + # 回退一次(撤销第二条记录) + self.client.post(f'/api/v1/stateflow/business-objects/{self.business_object.id}/step_back/') + + # 默认包含已撤销记录 + response = self.client.get(f'/api/v1/stateflow/business-objects/{self.business_object.id}/state-logs/') + self.assertEqual(response.data['count'], 2) + + # 验证第二条是已撤销的 + self.assertTrue(response.data['state_logs'][1]['is_cancelled']) + + # 排除已撤销记录 + response = self.client.get( + f'/api/v1/stateflow/business-objects/{self.business_object.id}/state-logs/?include_cancelled=false' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 1) + self.assertEqual(len(response.data['state_logs']), 1) + + # 验证只有第一条记录(未撤销) + self.assertFalse(response.data['state_logs'][0]['is_cancelled']) + self.assertEqual(response.data['state_logs'][0]['state_name'], '状态1') + + def test_get_state_logs_api_order(self): + """测试状态流转记录按时间排序""" + # 推进三次 + self.client.post( + f'/api/v1/stateflow/business-objects/{self.business_object.id}/advance/', + {'parameters': {'temperature': '25.5'}}, + format='json' + ) + self.client.post(f'/api/v1/stateflow/business-objects/{self.business_object.id}/advance/') + self.client.post(f'/api/v1/stateflow/business-objects/{self.business_object.id}/advance/') + + # 获取记录列表 + response = self.client.get(f'/api/v1/stateflow/business-objects/{self.business_object.id}/state-logs/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 3) + + # 验证按时间顺序排列 + logs = response.data['state_logs'] + self.assertEqual(logs[0]['state_name'], '状态1') + self.assertEqual(logs[1]['state_name'], '状态2') + self.assertEqual(logs[2]['state_name'], '状态3') + + # 验证时间递增 + from datetime import datetime + time1 = datetime.fromisoformat(logs[0]['completed_at']) + time2 = datetime.fromisoformat(logs[1]['completed_at']) + time3 = datetime.fromisoformat(logs[2]['completed_at']) + self.assertLessEqual(time1, time2) + self.assertLessEqual(time2, time3) + def test_advance_when_already_completed(self): """测试流程已完成时尝试推进(预期错误)""" # 完成所有状态 diff --git a/stateflow/tests/test_services.py b/stateflow/tests/test_services.py index a9dd929..bad61c7 100644 --- a/stateflow/tests/test_services.py +++ b/stateflow/tests/test_services.py @@ -42,9 +42,10 @@ class StateFlowServicesTestCase(TestCase): ) def test_initial_state(self): - """测试初始状态 - 应该是 None(未开始)""" + """测试初始状态 - current_state 是下一个待执行节点(第一个节点)""" current_state = self.business_object.get_current_state() - self.assertIsNone(current_state, '初始状态应该是 None(未开始)') + # current_state 返回"下一个待执行的节点",未开始时是第一个节点 + self.assertEqual(current_state.name, self.state1.name) self.assertEqual(self.business_object.get_progress_percentage(), 0.0) # 整体状态应该是 not_started @@ -57,18 +58,18 @@ class StateFlowServicesTestCase(TestCase): success, message, _ = services.advance_to_next_state(self.business_object, self.user) self.assertTrue(success) - # current_state 应该是最后完成的状态(state1) + # current_state 返回"下一个待执行的节点",完成state1后是state2 current_state = self.business_object.get_current_state() - self.assertEqual(current_state.name, self.state1.name) + self.assertEqual(current_state.name, self.state2.name) self.assertAlmostEqual(self.business_object.get_progress_percentage(), 33.33, places=1) # 推进到状态2(完成state2) success, message, _ = services.advance_to_next_state(self.business_object, self.user) self.assertTrue(success) - # current_state 应该是 state2 + # current_state 应该是 state3(下一个待执行的) current_state = self.business_object.get_current_state() - self.assertEqual(current_state.name, self.state2.name) + self.assertEqual(current_state.name, self.state3.name) self.assertAlmostEqual(self.business_object.get_progress_percentage(), 66.67, places=1) # 完成最后一个状态(state3) @@ -76,9 +77,9 @@ class StateFlowServicesTestCase(TestCase): self.assertTrue(success) self.assertEqual(self.business_object.get_progress_percentage(), 100.0) - # current_state 应该是 state3(最后完成的状态) + # current_state 应该是 None(没有待执行的节点了) current_state = self.business_object.get_current_state() - self.assertEqual(current_state.name, self.state3.name) + self.assertIsNone(current_state) # 整体状态应该是 completed status = services.get_overall_status(self.business_object) @@ -116,7 +117,8 @@ class StateFlowServicesTestCase(TestCase): timeline = self.business_object.get_timeline() self.assertEqual(timeline[0]['status'], 'completed') - self.assertEqual(timeline[1]['status'], 'not_started') + # 第二个状态是 current_state(下一个待执行的节点)且已有完成记录,所以是 in_progress + 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) @@ -141,9 +143,9 @@ class StateFlowServicesTestCase(TestCase): cancelled_count = self.business_object.state_logs.filter(is_cancelled=True).count() self.assertEqual(cancelled_count, 2) - # 应该回到初始状态(未开始) + # current_state 返回下一个待执行的节点(第一个节点) current_state = self.business_object.get_current_state() - self.assertIsNone(current_state) + self.assertEqual(current_state.name, self.state1.name) # 所有撤销的记录都应该有撤销时间 for log in self.business_object.state_logs.all():