forked from erp-dev/erp
fix: business.object_id maybe null, missing parameters when plate-Order cloned
This commit is contained in:
107
docs/api_v2_plate_orders_by_process.md
Normal file
107
docs/api_v2_plate_orders_by_process.md
Normal file
@@ -0,0 +1,107 @@
|
||||
## api_v2:按流程查询 PlateOrder 列表(返回所有节点参数 `process_params`)
|
||||
|
||||
### 目标
|
||||
- 按 `process_id` + `created_at` 时间范围查询 `PlateOrder` 列表
|
||||
- 每条 `PlateOrder` 额外返回 `process_params`:**该流程的全部节点**,以及每个节点的 **是否已执行** 与 **参数 key/value(订单维度)**
|
||||
|
||||
### 接口信息
|
||||
- **Method**:GET
|
||||
- **Path**:`/api/v2/plate-orders/by-process/`
|
||||
- **认证**:JWT(`IsAuthenticated`)
|
||||
- **权限**:仅允许印染/工厂侧用户(`IsPrintingFactory`)
|
||||
|
||||
### Query 参数
|
||||
| 参数 | 必填 | 类型 | 默认值 | 说明 |
|
||||
|---|---:|---|---|---|
|
||||
| `process_id` | 是 | int | - | `stateflow.Process.id` |
|
||||
| `date_from` | 是 | string(date) | - | 起始日期 `YYYY-MM-DD`(按 `PlateOrder.created_at` 过滤,闭区间) |
|
||||
| `date_to` | 是 | string(date) | - | 结束日期 `YYYY-MM-DD`(闭区间,包含当日 23:59:59.999999) |
|
||||
| `plate_order` | 否 | string | - | 单一查询参数:同时支持主键与设计编号(见“查询规则”) |
|
||||
| `ordering` | 否 | string | `-created_at` | 排序字段(见“排序规则”) |
|
||||
| `limit` | 否 | int | `20` | 分页大小(LimitOffsetPagination) |
|
||||
| `offset` | 否 | int | `0` | 分页偏移(LimitOffsetPagination) |
|
||||
|
||||
### 查询规则(plate_order)
|
||||
- 当 `plate_order` 为**纯数字**:
|
||||
- 匹配 `PlateOrder.id == int(plate_order)` **或**
|
||||
- 匹配 `PlateOrder.design_code icontains plate_order`
|
||||
- 当 `plate_order` 为**非纯数字**:
|
||||
- 仅匹配 `PlateOrder.design_code icontains plate_order`
|
||||
|
||||
### 排序规则(ordering)
|
||||
- 默认:`-created_at`
|
||||
- 支持字段:`id` / `created_at` / `updated_at` / `design_code`
|
||||
- `design_code` 排序规则:
|
||||
- 当 `design_code` 为空时,使用 `id` 的字符串作为兜底值参与排序
|
||||
- 传入不支持的 `ordering`:返回 **400**
|
||||
|
||||
### 返回值(200)
|
||||
响应为分页结构。
|
||||
|
||||
#### 顶层字段
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `process` | object | 流程信息(见下) |
|
||||
| `count` | int | 总数 |
|
||||
| `next` | string\|null | 下一页链接 |
|
||||
| `previous` | string\|null | 上一页链接 |
|
||||
| `results` | array[object] | `PlateOrder` 列表(见下) |
|
||||
|
||||
#### `process` 字段
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | `Process.id` |
|
||||
| `name` | string | `Process.name` |
|
||||
| `node_count` | int | 节点数量 |
|
||||
|
||||
#### `results` 元素字段(PlateOrder 列表项)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | PlateOrder 主键 |
|
||||
| `design_code` | string\|null | 设计编号;若为空则返回 `id` 的字符串兜底值 |
|
||||
| `customer` | int | 客户 ID |
|
||||
| `customer_name` | string | 客户名称 |
|
||||
| `style_name` | string\|null | 款号名称 |
|
||||
| `urgency_level` | string | 紧急程度 |
|
||||
| `is_invalid` | bool | 是否作废 |
|
||||
| `business_object_id` | int\|null | 关联流程实例 ID |
|
||||
| `process_params` | array[object] | **流程全部节点**的参数结构(见下,严格 schema) |
|
||||
| `created_at` | string | 创建时间(ISO 8601) |
|
||||
| `updated_at` | string | 更新时间(ISO 8601) |
|
||||
|
||||
#### `process_params` 元素字段(严格 schema)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `process_node_id` | int | `ProcessNode.id` |
|
||||
| `state_id` | int | `State.id` |
|
||||
| `node_name` | string | 节点名称(`State.name`) |
|
||||
| `order` | int | 节点顺序号 |
|
||||
| `is_executed` | bool | **是否已执行**:存在未撤销的 `StateFlowRecord` 则为 true;仅有撤销记录视为 false |
|
||||
| `params` | array[object] | 该节点参数 key/value(订单维度,见下) |
|
||||
|
||||
#### `params` 元素字段(订单维度:严格 schema)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `key` | string | 参数键(来自该节点 `State.parameters`) |
|
||||
| `value` | JSONValue\|null | 订单在该节点的最新已提交参数值;未执行/未提交则为 null |
|
||||
|
||||
**JSONValue 定义**:
|
||||
- `string` \| `number` \| `boolean` \| `object` \| `array` \| `null`
|
||||
|
||||
**不变性约束(前端可依赖)**
|
||||
- `process_params` **一定存在**,类型恒为 `array`
|
||||
- `process_params` **一定包含该流程的全部节点**,按 `order` 升序
|
||||
- 对每个节点:
|
||||
- `params` **一定存在**,类型恒为 `array`
|
||||
- `params` 的 **key 集合与顺序**与该节点 `State.parameters` **完全一致**
|
||||
- 若该节点没有任何参数:`params=[]`
|
||||
|
||||
### 常见错误码
|
||||
| HTTP 状态码 | 场景 | 返回 `detail` |
|
||||
|---:|---|---|
|
||||
| 400 | 缺少/非法参数(如 `process_id` 非数字、`date_from/date_to` 缺失或格式错误、`ordering` 不支持) | 错误原因文本 |
|
||||
| 401 | 未认证 | DRF 默认 |
|
||||
| 403 | 无权限(非工厂用户) | `您没有访问印染订单的权限` |
|
||||
| 404 | `process_id` 不存在 | `process 不存在` |
|
||||
|
||||
|
||||
104
docs/bug-fix/business_object_object_id_null.md
Normal file
104
docs/bug-fix/business_object_object_id_null.md
Normal file
@@ -0,0 +1,104 @@
|
||||
## Bug 调研报告:`BusinessObject.content_type/object_id` 为空导致状态流转记录不可追溯
|
||||
|
||||
### 背景与结论摘要
|
||||
- **问题**:`stateflow.BusinessObject` 的 `content_type_id` / `object_id` 允许为空,导致部分流程实例无法追溯到真实业务对象;相关 `StateFlowRecord` / 参数记录即使存在,也无法再关联回订单/任务等业务数据。
|
||||
- **结论**:`object_id=NULL` 不是“默认 process 能自动补齐”的字段;它只能在创建/绑定 `BusinessObject` 时显式写入。历史/旁路创建路径只写了 `process/name`,且 services 层此前允许在未绑定的 `BusinessObject` 上继续写日志,最终放大成数据不可追溯问题。
|
||||
- **处理目标**:在 **API + services 层永久性杜绝新增未绑定 BusinessObject**,并提供历史数据回填/隔离方案。
|
||||
|
||||
### 现象与影响
|
||||
- **现象**:
|
||||
- `BusinessObject.content_type_id IS NULL` 或 `BusinessObject.object_id IS NULL`
|
||||
- `StateFlowRecord.business_object_id` 通常不为空,但其指向的 `BusinessObject` 可能未绑定业务对象 → **日志“有记录但无归属”**
|
||||
- **影响**:
|
||||
- 无法通过 `BusinessObject.content_object` 找到关联订单/任务(GenericForeignKey 失效)
|
||||
- 依赖“从流程实例回溯业务对象”的接口/审计/参数回显/报表逻辑可能崩溃或产生错误结果
|
||||
- 一旦继续在未绑定对象上推进流程,会持续产生日志与参数记录,扩大脏数据
|
||||
|
||||
### 数据证据(测试库抽样,2025-12-17)
|
||||
> 注:以下数字来自本次调研时的测试库,仅用于说明问题形态与证据链;生产环境请用同样的统计口径核对。
|
||||
|
||||
- **表级统计(BusinessObject)**:
|
||||
- `BusinessObject.total = 119`
|
||||
- `content_type_id IS NULL = 70`
|
||||
- `object_id IS NULL = 65`
|
||||
- `content_type_id IS NULL AND object_id IS NULL = 65`(典型“完全未绑定”)
|
||||
- `content_type_id IS NULL AND object_id IS NOT NULL = 5`(“半绑定”异常,通常来自不完整回填/旧逻辑)
|
||||
- **时间分布(出现明显分界)**:
|
||||
- 2025-11-18 ~ 2025-12-09:新增的 `BusinessObject` 几乎全部为未绑定
|
||||
- 2025-12-10 之后:新增 `BusinessObject` 基本为已绑定
|
||||
- 这与“历史旁路/旧版本逻辑 → 后续修复上线”的典型形态一致
|
||||
- **关键反证(排除‘当前 printing 正确路径’)**:
|
||||
- 当前库中 `PrintingJob.min_id = 16`,`id <= 15` 的 `PrintingJob` 已不存在
|
||||
- 但仍存在多条 `BusinessObject.name = PrintingJob-1..15` 且 `content_type_id/object_id = NULL`
|
||||
- 说明这些 `BusinessObject` 是“创建时就未绑定”,且后续对应业务对象被清理/重建(GFK 不会级联清理 BusinessObject)后遗留为孤儿
|
||||
|
||||
### 根因分析(为什么会产生 NULL)
|
||||
- **Schema 层允许为空(放行)**:
|
||||
- `stateflow/migrations/0012_alter_businessobject_content_type_and_more.py` 将 `content_type/object_id` 改为 `null=True, blank=True`
|
||||
- `stateflow/models.py::BusinessObject` 仍保持字段可空(历史兼容需要)
|
||||
- **创建入口存在“只创建流程实例、不绑定业务对象”的旁路(直接成因)**:
|
||||
- 旁路创建方式通常类似:`BusinessObject.objects.create(name=..., process=...)`(不传 `content_type/object_id`)
|
||||
- 该模式在仓库测试代码中可见(反映团队历史习惯/旧实现可能性),且与库中 `PrintingJob-*` 未绑定样本吻合
|
||||
- **services 层此前缺少硬防线(放大器)**:
|
||||
- 若允许在未绑定的 `BusinessObject` 上执行 `advance_to_next_state`,则会不断生成 `StateFlowRecord` / 参数记录,但永远无法追溯业务对象
|
||||
|
||||
### 为什么“默认 process”不能保证 `object_id` 非空
|
||||
- **`process_id` 只决定流程模板**,并不能推出“关联的业务对象是谁”
|
||||
- 业务对象 ID 必须来自具体实例(如 `PlateOrder.id` / `PrintingJob.id`),而这个 ID 只有在业务对象入库后才确定
|
||||
- 因此任何“先建 BusinessObject、后忘记补绑定”的路径都会产生 `object_id=NULL`,且 DB 允许该脏数据落库
|
||||
|
||||
### 处理方法(已落地)
|
||||
#### 1) API 层:禁止创建/更新未绑定 BusinessObject(永久杜绝新增)
|
||||
- **位置**:`stateflow/serializers.py::BusinessObjectCreateUpdateSerializer`
|
||||
- **规则**:
|
||||
- `content_type` 与 `object_id` **均为必填且不可为空**
|
||||
- `object_id` 必须能在 `content_type` 指向的模型中查到真实对象(避免“悬空绑定”)
|
||||
|
||||
#### 2) Services 层:禁止在未绑定 BusinessObject 上推进/克隆(永久杜绝新增日志污染)
|
||||
- **推进**:`stateflow/services.py::advance_to_next_state`
|
||||
- 若 `content_type_id/object_id` 为空:直接返回失败(禁止推进)
|
||||
- 若 `content_object` 为 `None`(悬空绑定):直接返回失败(禁止推进)
|
||||
- **克隆**:`stateflow/services.py::clone_business_object`
|
||||
- 若源对象未绑定:抛错禁止克隆
|
||||
- 克隆目标 `new_object_id` 必填,且目标对象必须存在(避免生成新的悬空绑定)
|
||||
|
||||
#### 3) 历史数据修复:提供 printing 维度回填命令(修复“仍存在且可确定归属”的记录)
|
||||
- **命令**:`stateflow/management/commands/repair_business_object_bindings.py`
|
||||
- **用途**:对 `PrintingJob.business_object` / `PlateOrder.business_object` 一对一绑定关系做一致性回填(`content_type/object_id/name`)
|
||||
- **用法**:
|
||||
- 仅统计不落库:`uv run manage.py repair_business_object_bindings --dry-run`
|
||||
- 分类型:`--only printingjob|plateorder|all`
|
||||
- 分批:`--limit N`
|
||||
- **重要限制**:
|
||||
- 若 `BusinessObject` 已成为孤儿(业务对象已被删除/不存在),则无法可靠回填,只能**隔离/清理**(见下节建议)
|
||||
|
||||
### 建议补齐的“永久性治理”(防止新旁路再次引入)
|
||||
- **收敛创建入口(强烈建议)**:
|
||||
- 统一通过“创建并绑定”的工厂函数/服务创建 BusinessObject,禁止散落的 `BusinessObject.objects.create(...)`
|
||||
- 对 printing 等业务模型,创建时必须使用“先保存业务对象 → 再创建并绑定 BusinessObject”的顺序
|
||||
- **全仓扫描与 CI 约束**:
|
||||
- 对 `BusinessObject.objects.create(` 做静态扫描,若未同时出现 `content_type` 与 `object_id` 则在 CI 失败(或至少告警)
|
||||
- **线上监控/告警**:
|
||||
- 增加周期性检查:若发现“近 X 分钟/小时新增的 BusinessObject 存在 NULL 绑定”,立刻告警(Sentry/日志/钉钉均可)
|
||||
- 目的:让问题从“长期潜伏”变为“即时可见”
|
||||
- **(可选)DB 级兜底**:
|
||||
- 若允许:用触发器/约束保证“新写入必须非空”,同时保留历史空值(更强的最后一道防线)
|
||||
|
||||
### 历史孤儿数据建议处置
|
||||
- **识别**:
|
||||
- `content_type_id/object_id` 均为空的 BusinessObject
|
||||
- 或者 `content_object is None` 的悬空绑定
|
||||
- **处置选项**(需业务确认):
|
||||
- **清理**:删除孤儿 BusinessObject 及其 state_logs(若这些日志对业务无意义且不会被引用)
|
||||
- **隔离**:保留数据但在查询/报表中排除,并记录“不可追溯原因”(便于审计)
|
||||
- **人工映射**:若能通过其它字段(如外部单号、备注)恢复归属,可进行一次性人工回填
|
||||
|
||||
### 验证与回归
|
||||
- **新增防线验证点**:
|
||||
- 创建/更新 BusinessObject API:缺失绑定应直接拒绝
|
||||
- 推进/克隆:未绑定/悬空绑定应被禁止(不再产生新的 StateFlowRecord)
|
||||
- **建议回归覆盖**:
|
||||
- printing 创建 PlateOrder/PrintingJob 后,关联的 BusinessObject 必须是已绑定状态
|
||||
- 批量推进/单条推进在遇到历史脏数据时应给出明确错误或被自愈修复(取决于调用方策略)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user