forked from erp-dev/erp
feat: upload api
This commit is contained in:
16
api_v1/admin.py
Normal file
16
api_v1/admin.py
Normal file
@@ -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')
|
||||||
37
api_v1/migrations/0001_initial.py
Normal file
37
api_v1/migrations/0001_initial.py
Normal file
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
api_v1/migrations/__init__.py
Normal file
0
api_v1/migrations/__init__.py
Normal file
84
api_v1/models.py
Normal file
84
api_v1/models.py
Normal file
@@ -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
|
||||||
@@ -3,6 +3,7 @@ from decimal import Decimal
|
|||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
from stock import models as stock_models
|
from stock import models as stock_models
|
||||||
from basic_info import models as basic_info_models
|
from basic_info import models as basic_info_models
|
||||||
|
from . import models as api_models
|
||||||
|
|
||||||
|
|
||||||
class ProductStockChangeSerializer(serializers.Serializer):
|
class ProductStockChangeSerializer(serializers.Serializer):
|
||||||
@@ -91,3 +92,46 @@ class SetMerchantAutoCompleteStockChangeSerializer(serializers.Serializer):
|
|||||||
auto_complete = serializers.BooleanField(
|
auto_complete = serializers.BooleanField(
|
||||||
help_text="是否自动确认库存变动"
|
help_text="是否自动确认库存变动"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 通用文件上传序列化器 ====================
|
||||||
|
|
||||||
|
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
|
||||||
@@ -3,6 +3,7 @@ from rest_framework.routers import DefaultRouter
|
|||||||
from .views import stock_change_views, user_info, inventory, product_image, stateflow
|
from .views import stock_change_views, user_info, inventory, product_image, stateflow
|
||||||
from .views.stock_change_views.snapshot import StockSnapshotListView
|
from .views.stock_change_views.snapshot import StockSnapshotListView
|
||||||
from .views.printing.views import PrintingOrderViewSet, PrintingJobViewSet, PlateOrderViewSet
|
from .views.printing.views import PrintingOrderViewSet, PrintingJobViewSet, PlateOrderViewSet
|
||||||
|
from .views.upload import UploadFileViewSet
|
||||||
|
|
||||||
# 创建 DRF Router for Stateflow
|
# 创建 DRF Router for Stateflow
|
||||||
stateflow_router = DefaultRouter()
|
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-orders', PrintingOrderViewSet, basename='printing-order')
|
||||||
main_router.register(r'printing-jobs', PrintingJobViewSet, basename='printing-job')
|
main_router.register(r'printing-jobs', PrintingJobViewSet, basename='printing-job')
|
||||||
main_router.register(r'plate-orders', PlateOrderViewSet, basename='plate-order')
|
main_router.register(r'plate-orders', PlateOrderViewSet, basename='plate-order')
|
||||||
|
main_router.register(r'upload', UploadFileViewSet, basename='upload')
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# 库存变动相关API
|
# 库存变动相关API
|
||||||
|
|||||||
384
api_v1/views/printing/API.md
Normal file
384
api_v1/views/printing/API.md
Normal file
@@ -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
|
||||||
|
})
|
||||||
|
});
|
||||||
|
```
|
||||||
@@ -105,16 +105,21 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
|
|||||||
product_name = serializers.CharField(source='product.name', read_only=True)
|
product_name = serializers.CharField(source='product.name', read_only=True)
|
||||||
status = serializers.CharField(read_only=True)
|
status = serializers.CharField(read_only=True)
|
||||||
is_completed = serializers.BooleanField(read_only=True)
|
is_completed = serializers.BooleanField(read_only=True)
|
||||||
|
business_object_id = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PrintingJob
|
model = models.PrintingJob
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'printing_order', 'printing_order_id', 'product', 'product_name',
|
'id', 'printing_order', 'printing_order_id', 'product', 'product_name',
|
||||||
'quantity', 'unit', 'size', 'pieces', 'description',
|
'quantity', 'unit', 'size', 'pieces', 'description',
|
||||||
'status', 'is_completed',
|
'status', 'is_completed', 'business_object_id',
|
||||||
'created_at', 'updated_at'
|
'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):
|
class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
||||||
@@ -126,7 +131,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
|||||||
status_id = serializers.IntegerField(read_only=True)
|
status_id = serializers.IntegerField(read_only=True)
|
||||||
is_completed = serializers.BooleanField(read_only=True)
|
is_completed = serializers.BooleanField(read_only=True)
|
||||||
has_started = 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:
|
class Meta:
|
||||||
model = models.PrintingJob
|
model = models.PrintingJob
|
||||||
@@ -141,6 +146,10 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
|||||||
'status', 'status_id', 'is_completed', 'has_started', 'business_object_id'
|
'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):
|
class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer):
|
||||||
"""印染款式明细创建/更新序列化器"""
|
"""印染款式明细创建/更新序列化器"""
|
||||||
@@ -242,7 +251,7 @@ class PlateOrderDetailSerializer(serializers.ModelSerializer):
|
|||||||
is_completed = serializers.BooleanField(read_only=True)
|
is_completed = serializers.BooleanField(read_only=True)
|
||||||
has_started = serializers.BooleanField(read_only=True)
|
has_started = serializers.BooleanField(read_only=True)
|
||||||
progress_percentage = serializers.IntegerField(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()
|
plate_image_url = serializers.SerializerMethodField()
|
||||||
process_name = serializers.SerializerMethodField()
|
process_name = serializers.SerializerMethodField()
|
||||||
|
|
||||||
@@ -272,6 +281,10 @@ class PlateOrderDetailSerializer(serializers.ModelSerializer):
|
|||||||
'created_at', 'updated_at'
|
'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):
|
def get_plate_image_url(self, obj):
|
||||||
"""获取图片完整URL"""
|
"""获取图片完整URL"""
|
||||||
if obj.plate_image:
|
if obj.plate_image:
|
||||||
|
|||||||
@@ -418,20 +418,23 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn('quantity', response.data)
|
self.assertIn('quantity', response.data)
|
||||||
|
|
||||||
def test_validate_pieces_positive(self):
|
def test_validate_pieces_can_be_zero(self):
|
||||||
"""测试件数必须大于0"""
|
"""测试件数可以为0或null"""
|
||||||
data = {
|
data = {
|
||||||
'printing_order': self.printing_order.id,
|
'printing_order': self.printing_order.id,
|
||||||
'product': self.product.id,
|
'product': self.product.id,
|
||||||
'quantity': 100,
|
'quantity': 100,
|
||||||
'unit': '米',
|
'unit': '米',
|
||||||
'size': '50*60',
|
'size': '50*60',
|
||||||
'pieces': 0 # 无效件数
|
'pieces': 0 # 允许为0
|
||||||
}
|
}
|
||||||
|
|
||||||
response = self.client.post('/api/v1/printing-jobs/', data, format='json')
|
response = self.client.post('/api/v1/printing-jobs/', data, format='json')
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
self.assertIn('pieces', response.data)
|
|
||||||
|
# 验证创建成功
|
||||||
|
job = printing_models.PrintingJob.objects.get(id=response.data['id'])
|
||||||
|
self.assertEqual(job.pieces, 0)
|
||||||
|
|
||||||
def test_create_job_with_business_object(self):
|
def test_create_job_with_business_object(self):
|
||||||
"""测试创建任务自动创建 BusinessObject"""
|
"""测试创建任务自动创建 BusinessObject"""
|
||||||
@@ -583,6 +586,153 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn('没有关联的流程实例', response.data['detail'])
|
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):
|
def test_advance_through_all_states(self):
|
||||||
"""测试推进到完成所有状态"""
|
"""测试推进到完成所有状态"""
|
||||||
job = printing_models.PrintingJob.objects.create(
|
job = printing_models.PrintingJob.objects.create(
|
||||||
|
|||||||
@@ -318,6 +318,16 @@ class PrintingJobViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
将印染任务推进到下一个流程状态
|
将印染任务推进到下一个流程状态
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"temperature": "25.5",
|
||||||
|
"operator": "张三"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
参数可选,但如果下一个状态有必填参数,则必须提供
|
||||||
|
|
||||||
注意:这是对 stateflow.BusinessObject.advance 接口的便捷包装,
|
注意:这是对 stateflow.BusinessObject.advance 接口的便捷包装,
|
||||||
自动通过 printing_job 查找关联的 business_object 并调用状态流转服务。
|
自动通过 printing_job 查找关联的 business_object 并调用状态流转服务。
|
||||||
前端只需提供 printing_job 的 ID,无需关心 business_object_id。
|
前端只需提供 printing_job 的 ID,无需关心 business_object_id。
|
||||||
@@ -330,10 +340,13 @@ class PrintingJobViewSet(viewsets.ModelViewSet):
|
|||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 获取请求参数
|
||||||
|
parameters = request.data.get('parameters', {})
|
||||||
|
|
||||||
# 调用 stateflow 统一服务进行状态流转
|
# 调用 stateflow 统一服务进行状态流转
|
||||||
from stateflow import services as stateflow_services
|
from stateflow import services as stateflow_services
|
||||||
success, message, state_log = stateflow_services.advance_to_next_state(
|
success, message, state_log = stateflow_services.advance_to_next_state(
|
||||||
job.business_object, request.user
|
job.business_object, request.user, **parameters
|
||||||
)
|
)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
@@ -680,6 +693,16 @@ class PlateOrderViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
将开版订单推进到下一个流程状态
|
将开版订单推进到下一个流程状态
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"inspector": "李四",
|
||||||
|
"quality_score": "95"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
参数可选,但如果下一个状态有必填参数,则必须提供
|
||||||
|
|
||||||
注意:这是对 stateflow.BusinessObject.advance 接口的便捷包装,
|
注意:这是对 stateflow.BusinessObject.advance 接口的便捷包装,
|
||||||
自动通过 plate_order 查找关联的 business_object 并调用状态流转服务。
|
自动通过 plate_order 查找关联的 business_object 并调用状态流转服务。
|
||||||
"""
|
"""
|
||||||
@@ -691,10 +714,13 @@ class PlateOrderViewSet(viewsets.ModelViewSet):
|
|||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 获取请求参数
|
||||||
|
parameters = request.data.get('parameters', {})
|
||||||
|
|
||||||
# 调用 stateflow 统一服务进行状态流转
|
# 调用 stateflow 统一服务进行状态流转
|
||||||
from stateflow import services as stateflow_services
|
from stateflow import services as stateflow_services
|
||||||
success, message, state_log = stateflow_services.advance_to_next_state(
|
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:
|
if not success:
|
||||||
|
|||||||
@@ -328,6 +328,42 @@ class BusinessObjectViewSet(viewsets.ModelViewSet):
|
|||||||
'parameter_record': StateLogParameterRecordSerializer(record).data
|
'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<log_id>[^/.]+)/parameters')
|
@action(detail=True, methods=['get'], url_path='state-logs/(?P<log_id>[^/.]+)/parameters')
|
||||||
def get_log_parameters(self, request, pk=None, log_id=None):
|
def get_log_parameters(self, request, pk=None, log_id=None):
|
||||||
"""
|
"""
|
||||||
|
|||||||
121
api_v1/views/upload.py
Normal file
121
api_v1/views/upload.py
Normal file
@@ -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
|
||||||
|
)
|
||||||
@@ -244,5 +244,5 @@ MEDIA_URL = f'http://{QINIU_BUCKET_DOMAIN}/media/'
|
|||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
# Printing module settings
|
# Printing module settings
|
||||||
PRINTING_DEFAULT_PROCESS_ID = 12 # 默认印染流程ID
|
PRINTING_DEFAULT_PROCESS_ID = 31 # 默认印染流程ID
|
||||||
PLATE_ORDER_DEFAULT_PROCESS_ID = 14 # 默认开版流程ID
|
PLATE_ORDER_DEFAULT_PROCESS_ID = 14 # 默认开版流程ID
|
||||||
|
|||||||
123
stateflow/API.md
123
stateflow/API.md
@@ -270,7 +270,52 @@ Authorization: Bearer <access_token>
|
|||||||
|
|
||||||
### 3.8. 参数与日志接口 (Custom Actions)
|
### 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/`
|
- **POST** `/api/v1/stateflow/business-objects/{id}/state-logs/{log_id}/add-parameters/`
|
||||||
- **描述**: 为某一次具体的状态流转记录(`StateFlowRecord`)补充额外的参数。
|
- **描述**: 为某一次具体的状态流转记录(`StateFlowRecord`)补充额外的参数。
|
||||||
@@ -283,14 +328,82 @@ Authorization: Bearer <access_token>
|
|||||||
"remark": "质检员补充"
|
"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/`
|
- **GET** `/api/v1/stateflow/business-objects/{id}/state-logs/{log_id}/parameters/`
|
||||||
- **描述**: 获取某一次流转记录的所有参数。
|
- **描述**: 获取某一次流转记录的所有参数记录或指定参数的历史。
|
||||||
- **查询参数**:
|
- **查询参数**:
|
||||||
- `key`: 如果提供,则只返回该 `key` 的所有历史值。
|
- `key`: (可选)如果提供,则只返回该 `key` 的所有历史值。
|
||||||
- `include_cancelled`: `true` 或 `false`,是否包含已撤销的记录。
|
- `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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## 分页
|
## 分页
|
||||||
|
|||||||
@@ -471,6 +471,132 @@ class BusinessObjectAPITestCase(TestCase):
|
|||||||
self.assertIsNotNone(item['completed_by'])
|
self.assertIsNotNone(item['completed_by'])
|
||||||
self.assertFalse(item['is_cancelled'])
|
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):
|
def test_advance_when_already_completed(self):
|
||||||
"""测试流程已完成时尝试推进(预期错误)"""
|
"""测试流程已完成时尝试推进(预期错误)"""
|
||||||
# 完成所有状态
|
# 完成所有状态
|
||||||
|
|||||||
@@ -42,9 +42,10 @@ class StateFlowServicesTestCase(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_initial_state(self):
|
def test_initial_state(self):
|
||||||
"""测试初始状态 - 应该是 None(未开始)"""
|
"""测试初始状态 - current_state 是下一个待执行节点(第一个节点)"""
|
||||||
current_state = self.business_object.get_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)
|
self.assertEqual(self.business_object.get_progress_percentage(), 0.0)
|
||||||
|
|
||||||
# 整体状态应该是 not_started
|
# 整体状态应该是 not_started
|
||||||
@@ -57,18 +58,18 @@ class StateFlowServicesTestCase(TestCase):
|
|||||||
success, message, _ = services.advance_to_next_state(self.business_object, self.user)
|
success, message, _ = services.advance_to_next_state(self.business_object, self.user)
|
||||||
self.assertTrue(success)
|
self.assertTrue(success)
|
||||||
|
|
||||||
# current_state 应该是最后完成的状态(state1)
|
# current_state 返回"下一个待执行的节点",完成state1后是state2
|
||||||
current_state = self.business_object.get_current_state()
|
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)
|
self.assertAlmostEqual(self.business_object.get_progress_percentage(), 33.33, places=1)
|
||||||
|
|
||||||
# 推进到状态2(完成state2)
|
# 推进到状态2(完成state2)
|
||||||
success, message, _ = services.advance_to_next_state(self.business_object, self.user)
|
success, message, _ = services.advance_to_next_state(self.business_object, self.user)
|
||||||
self.assertTrue(success)
|
self.assertTrue(success)
|
||||||
|
|
||||||
# current_state 应该是 state2
|
# current_state 应该是 state3(下一个待执行的)
|
||||||
current_state = self.business_object.get_current_state()
|
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)
|
self.assertAlmostEqual(self.business_object.get_progress_percentage(), 66.67, places=1)
|
||||||
|
|
||||||
# 完成最后一个状态(state3)
|
# 完成最后一个状态(state3)
|
||||||
@@ -76,9 +77,9 @@ class StateFlowServicesTestCase(TestCase):
|
|||||||
self.assertTrue(success)
|
self.assertTrue(success)
|
||||||
self.assertEqual(self.business_object.get_progress_percentage(), 100.0)
|
self.assertEqual(self.business_object.get_progress_percentage(), 100.0)
|
||||||
|
|
||||||
# current_state 应该是 state3(最后完成的状态)
|
# current_state 应该是 None(没有待执行的节点了)
|
||||||
current_state = self.business_object.get_current_state()
|
current_state = self.business_object.get_current_state()
|
||||||
self.assertEqual(current_state.name, self.state3.name)
|
self.assertIsNone(current_state)
|
||||||
|
|
||||||
# 整体状态应该是 completed
|
# 整体状态应该是 completed
|
||||||
status = services.get_overall_status(self.business_object)
|
status = services.get_overall_status(self.business_object)
|
||||||
@@ -116,7 +117,8 @@ class StateFlowServicesTestCase(TestCase):
|
|||||||
|
|
||||||
timeline = self.business_object.get_timeline()
|
timeline = self.business_object.get_timeline()
|
||||||
self.assertEqual(timeline[0]['status'], 'completed')
|
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.assertEqual(timeline[2]['status'], 'not_started')
|
||||||
self.assertIsNotNone(timeline[0]['completed_by'])
|
self.assertIsNotNone(timeline[0]['completed_by'])
|
||||||
self.assertEqual(timeline[0]['completed_by'].id, self.user.id)
|
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()
|
cancelled_count = self.business_object.state_logs.filter(is_cancelled=True).count()
|
||||||
self.assertEqual(cancelled_count, 2)
|
self.assertEqual(cancelled_count, 2)
|
||||||
|
|
||||||
# 应该回到初始状态(未开始)
|
# current_state 返回下一个待执行的节点(第一个节点)
|
||||||
current_state = self.business_object.get_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():
|
for log in self.business_object.state_logs.all():
|
||||||
|
|||||||
Reference in New Issue
Block a user