提交
This commit is contained in:
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模式)下所有信息正确展示
|
||||
Reference in new issue
Block a user