2026-05-22-task-io-cell-product-integration-design.md 14 KB

生产任务 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 扩展为包含完整细胞产品字段:

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)

伪代码:

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)

伪代码:

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模式)下所有信息正确展示