This commit is contained in:
zlwu committed 2026-05-22 16:25:04 +08:00
1 parent 53d3b21f57
commit 1f5de9030e
54 files changed
+10954 -4322

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,348 @@
# 生产任务 I/O 与细胞产品库联动设计
> 日期:2026-05-22
> 状态:已确认,待实施
> 范围:生产任务执行弹窗 — 原材料/产出与细胞产品库 + 库位联动
---
## 1. 背景
### 当前问题
1. **产出脱节**:任务产出面板只有简单字段(名称、条码、数量、单位、备注),与细胞产品库完全脱节,产出的细胞产品不会自动入库。
2. **原材料信息不足**:选择细胞产品作为原材料时,只显示编码和名称,无法查看产品类型、代次、储物格等关键信息。
3. **库位不同步**:原材料消耗和产出创建不会联动库位状态(占用/释放)。
4. **先选后删除**:删除已选的原材料后恢复,当前无联动处理。
### 已有基础
- `BizProductionTaskOutput` 表已有 `cell_product_id`、`storage_slot_id` 字段(未使用)
- `BizCellProduct` 表已有 `production_task_id` 字段(未使用)
- `BizCellProduct` CRUD 已有完整的库位占用自动创建/释放逻辑
- `_refresh_slot_cache()` 已确保库位缓存一致性
---
## 2. 设计决策
| 维度 | 决策 |
|------|------|
| 产出联动模式 | **产出即入库** — 保存产出时自动在 `biz_cell_product` 创建记录 + 写入库位占用 |
| 删除恢复策略 | **删除即释放** — 删除产出/原材料后立即释放库位,重新添加需重新选位 |
| 产出表单范围 | **完整表单** — 与细胞产品库新增弹窗字段一致 |
| 原材料面板 | **同步增强** — 选择细胞产品时展示完整产品信息 |
| 产出交互方式 | **子弹窗 + 列表** — 添加/编辑弹出表单弹窗,列表展示核心字段 |
| 实现架构 | **后端一体化** — 一个事务内完成 diff + 产品 CRUD + 库位联动 |
---
## 3. 数据模型
### 3.1 无 DDL 变更
所有需要的字段已存在,只需激活:
- `biz_production_task_output.cell_product_id` — 开始写入实际产品 ID
- `biz_production_task_output.storage_slot_id` — 开始写入实际库位 ID
- `biz_cell_product.production_task_id` — 产出创建时写入任务 ID
### 3.2 数据流向
```
产出保存 (set_task_outputs)
├─ 新增 → create BizCellProduct (status=frozen, production_task_id=taskId)
│ + create BizStorageSlotOccupancy (item_source=production)
│ + _refresh_slot_cache()
│ + create BizProductionTaskOutput (关联 cell_product_id)
│
├─ 删除 → update BizCellProduct (status=voided)
│ + remove BizStorageSlotOccupancy
│ + _refresh_slot_cache()
│ + delete BizProductionTaskOutput
│
└─ 修改 → update BizCellProduct 字段
+ 如库位变更:迁移 occupancy + _refresh_slot_cache()
+ update BizProductionTaskOutput
原材料保存 (set_task_inputs)
├─ 新增 cell_product → update BizCellProduct (status=used)
│ + remove BizStorageSlotOccupancy
│ + _refresh_slot_cache()
│
└─ 删除 cell_product → update BizCellProduct (status=frozen)
+ create BizStorageSlotOccupancy (恢复原位)
+ _refresh_slot_cache()
```
---
## 4. 后端 API 改造
### 4.1 Schema 变更
**`backend/app/schemas/production_task.py`**
`TaskOutputItem` 扩展为包含完整细胞产品字段:
```python
class TaskOutputItem(BaseModel):
# 关联字段
cell_product_id: str | None = None # 编辑已有产出时传入
# 细胞产品完整字段
product_code: str
product_name: str
product_type_id: str | None = None
spec_id: str | None = None
passage: str | None = None # P1-P8
batch_no: str | None = None
storage_slot_id: str | None = None
expected_cell_count: float | None = None
viability: float | None = None # 0-100
freeze_date: str | None = None # YYYY-MM-DD
quantity: int = 1
unit: str | None = None
expiry_date: str | None = None # YYYY-MM-DD
product_images: str | None = None # 逗号分隔 URL
product_status: str = "frozen"
remark: str | None = None
```
`TaskInputItem` 不变更结构,但后端增加联动逻辑。
### 4.2 CRUD 改造
**`backend/app/crud/production_task.py`**
#### `set_task_outputs(db, task_id, items)`
伪代码:
```python
async def set_task_outputs(db: AsyncSession, task_id: str, items: list[TaskOutputItem]):
# 1. 加载旧的 outputs
old_outputs = await _get_task_outputs(db, task_id)
old_map = {o.cell_product_id: o for o in old_outputs if o.cell_product_id}
# 2. 构建 new map
new_map = {}
for item in items:
if item.cell_product_id:
new_map[item.cell_product_id] = item
else:
new_map[f"__new_{id(item)}"] = item
# 3. 处理删除
for cp_id, old in old_map.items():
if cp_id not in new_map:
# 作废细胞产品
await cell_product_svc.update_status(db, cp_id, "voided")
# 释放库位
await occupancy_svc.remove_by_item(db, "cell_product", cp_id)
# 删除关联
await db.delete(old)
# 4. 处理新增和修改
result_outputs = []
for key, item in new_map.items():
if key.startswith("__new_"):
# 新增:创建细胞产品 + 占位
product = await cell_product_svc.create(db, {
"product_code": item.product_code,
"product_name": item.product_name,
...全部字段,
"product_status": "frozen",
"production_task_id": task_id,
})
if item.storage_slot_id:
await occupancy_svc.create(db, {
"slot_id": item.storage_slot_id,
"item_type": "cell_product",
"item_id": product.id,
"item_source": "production",
})
# 创建关联
output = BizProductionTaskOutput(
task_id=task_id,
cell_product_id=product.id,
storage_slot_id=item.storage_slot_id,
output_name=item.product_name,
output_barcode=item.product_code,
quantity=item.quantity,
unit=item.unit,
remark=item.remark,
)
result_outputs.append(output)
else:
# 修改:更新细胞产品 + 处理库位变更
await cell_product_svc.update(db, key, {...更新字段})
old_output = old_map[key]
if old_output.storage_slot_id != item.storage_slot_id:
# 库位变更:释放旧位 + 占新位
if old_output.storage_slot_id:
await occupancy_svc.remove_by_item(db, "cell_product", key)
if item.storage_slot_id:
await occupancy_svc.create(db, {...新占位})
# 更新关联表
_update_output_fields(old_output, item)
result_outputs.append(old_output)
await db.flush()
return result_outputs
```
#### `set_task_inputs(db, task_id, items)`
伪代码:
```python
async def set_task_inputs(db: AsyncSession, task_id: str, items: list[TaskInputItem]):
old_inputs = await _get_task_inputs(db, task_id)
old_cp_ids = {i.input_id for i in old_inputs if i.input_type == "cell_product" and i.input_id}
new_cp_ids = {item.input_id for item in items if item.input_type == "cell_product" and item.input_id}
# 被移除的细胞产品 → 恢复 frozen + 恢复库位
for pid in old_cp_ids - new_cp_ids:
await cell_product_svc.update_status(db, pid, "frozen")
# 查找该产品原来的 slot,重新占位
product = await cell_product_svc.get(db, pid)
if product and product.storage_slot_id:
await occupancy_svc.create(db, {
"slot_id": product.storage_slot_id,
"item_type": "cell_product",
"item_id": pid,
"item_source": "production",
})
# 新增的细胞产品 → used + 释放库位
for pid in new_cp_ids - old_cp_ids:
await cell_product_svc.update_status(db, pid, "used")
await occupancy_svc.remove_by_item(db, "cell_product", pid)
# 替换 inputs
await _replace_inputs(db, task_id, items)
```
### 4.3 路由适配
**`backend/app/api/production.py`**
`set_task_outputs` 和 `set_task_inputs` 路由的请求体 schema 更新,其余不变。
---
## 5. 前端改造
### 5.1 新增组件
#### `TaskOutputProductDialog.vue`
- 位置:`web-admin/src/views/production/production-work-order/TaskOutputProductDialog.vue`
- Props:`visible`, `mode` (add/edit), `productData`, `dropdownOptions`
- 功能:
- 完整细胞产品表单(与 `cell-product/index.vue` 新增弹窗一致)
- 4列栅格布局
- 自动生成产品编码(调用 `fetchNextCellProductCode`)
- 下拉选项由父组件传入(产品类型、规格、储物格、单位)
- 产品图片卡片化上传
- Emit:`save(formData)` / `cancel`
#### `TaskOutputList.vue`
- 位置:`web-admin/src/views/production/production-work-order/TaskOutputList.vue`
- Props:`outputs`, `readonly`
- 展示列:序号、产品编码、产品名称、产品类型(tag)、代次、储物格位置、数量/单位、状态(tag)、操作
- Emit:`edit(row)` / `delete(index)` / `add()`
#### `TaskInputEnhancedTable.vue`
- 位置:`web-admin/src/views/production/production-work-order/TaskInputEnhancedTable.vue`
- Props:`inputs`, `cellProductOptions`, `readonly`
- 功能:
- 类型切换(样本/细胞产品)
- 样本行:编码、名称、备注
- 细胞产品行:产品编码、产品名称、产品类型、代次、储物格位置、状态(tag)、备注
- 选择细胞产品时自动填充所有展示列
- Emit:`update(inputs)` / `add()` / `remove(index)`
### 5.2 现有组件修改
#### `useTaskStepEditor.ts`
**产出相关改造**:
- `addTaskOutputRow()` → 不再添加空行,改为设置 `outputDialogVisible = true, outputDialogMode = 'add'`
- `editTaskOutputRow(row)` → 设置 `outputDialogVisible = true, outputDialogMode = 'edit', editingOutputData = row`
- `removeTaskOutputRow(i)` → 确认后从列表移除(实际库位释放在 `saveTaskOutputs` 时后端处理)
- `saveTaskOutputs()` → 提交增强后的完整数据到后端
- 新增状态:`outputDialogVisible`, `outputDialogMode`, `editingOutputData`
- 新增方法:`handleOutputDialogSave(formData)` — 将表单数据合并到 `taskOutputs` 列表
**原材料相关改造**:
- `onCellProductInputSelect(row, val)` → 从 `cellProductMasterById` 填充完整信息(产品类型、代次、储物格等)
- 增加 `loadInputDropdownData()` — 加载细胞产品列表(含完整字段)
#### `TaskExecutionDialog.vue`
- 产出面板:替换为 `<TaskOutputList>` + `<TaskOutputProductDialog>`
- 原材料面板:替换为 `<TaskInputEnhancedTable>`
- 下拉数据加载:复用细胞产品库的 `loadDropdownData()` 逻辑
#### `api/production-task.ts`
- `fetchSetTaskOutputs` 参数适配新 schema(增加完整细胞产品字段)
---
## 6. 改造范围清单
### 后端
| 文件 | 操作 |
|------|------|
| `backend/app/schemas/production_task.py` | 修改 — `TaskOutputItem` 扩展完整细胞产品字段 |
| `backend/app/crud/production_task.py` | 修改 — `set_task_outputs` 增加 diff + 产品 CRUD + 库位联动 |
| `backend/app/crud/production_task.py` | 修改 — `set_task_inputs` 增加 diff + 产品状态变更 + 库位联动 |
| `backend/app/api/production.py` | 修改 — 路由适配新 schema |
| `backend/app/crud/cell_product.py` | 可能修改 — 提取 `create_single` / `update_fields` 供复用 |
### 前端
| 文件 | 操作 |
|------|------|
| `web-admin/src/views/production/production-work-order/TaskOutputProductDialog.vue` | **新建** |
| `web-admin/src/views/production/production-work-order/TaskOutputList.vue` | **新建** |
| `web-admin/src/views/production/production-work-order/TaskInputEnhancedTable.vue` | **新建** |
| `web-admin/src/views/production/production-work-order/useTaskStepEditor.ts` | 修改 — 产出/原材料逻辑重构 |
| `web-admin/src/views/production/production-work-order/TaskExecutionDialog.vue` | 修改 — 替换产出/原材料面板 |
| `web-admin/src/api/production-task.ts` | 修改 — API 参数适配 |
### 不涉及
- 数据库 DDL(无新表、无新列)
- 工艺路线/工序步骤相关代码
- 细胞产品库独立页面
- 库位管理独立页面
---
## 7. 风险和约束
1. **事务大小**:如果一次产出数量很多(如50份),事务内会创建大量记录。建议限制单次产出 ≤ 20 条。
2. **库位冲突**:用户选择已被占用的库位时,后端应返回明确错误,前端应在下拉中标记已占位。
3. **并发安全**:同一库位同时被两个任务选中 → 后端需在 occupancy 表加唯一约束或乐观锁。
4. **原材料恢复**:删除原材料恢复细胞产品时,如果原库位已被其他产品占用 → 需要提示用户选择新库位或报错。
---
## 8. 验收标准
1. 产出面板能通过子弹窗新增完整的细胞产品(所有字段可填写)
2. 保存产出后,细胞产品库页面能看到新创建的产品
3. 产出产品在库位管理页面显示为已占用
4. 删除产出后,细胞产品状态变为已作废,库位释放
5. 原材料选择细胞产品后,展示完整信息(类型、代次、库位等)
6. 保存原材料后,被消耗的细胞产品状态变为已使用,库位释放
7. 删除原材料中的细胞产品后,产品状态恢复为已冻存,库位恢复占用
8. 查看详情(readonly模式)下所有信息正确展示