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

生产任务 I/O 与细胞产品库联动 — 实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 生产任务的产出和原材料面板与细胞产品库、库位系统联动,实现产出即入库、消耗即释放。

Architecture: 后端事务内 diff 计算新增/删除/修改,每个操作联动细胞产品 CRUD 和库位占用。前端产出面板改为子弹窗+列表模式,原材料面板增强展示。

Tech Stack: FastAPI + SQLAlchemy (sync Session), Vue 3 + Element Plus + TypeScript

Design Spec: docs/superpowers/specs/2026-05-22-task-io-cell-product-integration-design.md


File Structure

后端

文件 操作 职责
backend/app/schemas/production_task.py 修改 TaskOutputItem 扩展完整细胞产品字段
backend/app/crud/production_task.py 修改 set_task_outputs 增加 diff + 细胞产品 CRUD + 库位联动;set_task_inputs 增加细胞产品状态联动
backend/app/crud/cell_product.py 修改 提取 _create_cell_product_obj 和 _update_cell_product_fields 供任务模块复用
backend/app/api/production.py 修改 路由适配新 schema(如果参数名有变化)
backend/app/crud/production_task.py 修改 _task_output_to_dict 增加细胞产品完整信息

前端

文件 操作 职责
web-admin/src/api/production-task.ts 修改 fetchSetTaskOutputs 参数类型适配
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 修改 替换产出/原材料面板组件

Task 1: 后端 Schema — 扩展 TaskOutputItem

Files:

  • Modify: backend/app/schemas/production_task.py (第 92-101 行)

  • [ ] Step 1: 扩展 TaskOutputItem 添加完整细胞产品字段

在 backend/app/schemas/production_task.py 中,将 TaskOutputItem 从简单字段扩展为包含完整细胞产品属性:

class TaskOutputItem(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    # 关联字段
    cell_product_id: Optional[str] = Field(None, alias="cellProductId")
    # 细胞产品完整字段
    product_code: Optional[str] = Field(None, alias="productCode")
    product_name: Optional[str] = Field(None, alias="productName")
    product_type_id: Optional[str] = Field(None, alias="productTypeId")
    spec_id: Optional[str] = Field(None, alias="specId")
    passage: Optional[str] = None
    batch_no: Optional[str] = Field(None, alias="batchNo")
    storage_slot_id: Optional[str] = Field(None, alias="storageSlotId")
    expected_cell_count: Optional[float] = Field(None, alias="expectedCellCount")
    viability: Optional[float] = None
    freeze_date: Optional[str] = Field(None, alias="freezeDate")
    quantity: Optional[float] = None
    unit: Optional[str] = None
    expiry_date: Optional[str] = Field(None, alias="expiryDate")
    product_images: Optional[str] = Field(None, alias="productImages")
    product_status: Optional[str] = Field(None, alias="productStatus")
    source_type: Optional[str] = Field(None, alias="sourceType")
    # 兼容旧字段
    output_name: Optional[str] = Field(None, alias="outputName")
    output_barcode: Optional[str] = Field(None, alias="outputBarcode")
    remark: Optional[str] = None
    attachments: Optional[str] = None
  • Step 2: 确认文件无语法错误

Run: cd backend && source venv/bin/activate && python -c "from app.schemas.production_task import TaskOutputItem; print('OK')" Expected: OK


Task 2: 后端 CRUD — 改造 set_task_outputs

Files:

  • Modify: backend/app/crud/production_task.py (第 163-176 行 _task_output_to_dict,第 870-891 行 set_task_outputs)
  • Modify: backend/app/crud/cell_product.py (提取内部辅助函数)

  • [ ] Step 1: 增强 _task_output_to_dict 返回细胞产品信息

在 backend/app/crud/production_task.py 中修改 _task_output_to_dict 函数,增加细胞产品的完整信息:

def _task_output_to_dict(o: BizProductionTaskOutput, *, product_dict: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
    result = {
        "id": o.id,
        "taskId": o.task_id,
        "cellProductId": o.cell_product_id or "",
        "storageSlotId": o.storage_slot_id or "",
        "outputName": o.output_name or "",
        "outputBarcode": o.output_barcode or "",
        "quantity": float(o.quantity) if o.quantity is not None else None,
        "unit": o.unit or "",
        "remark": o.remark or "",
        "attachments": o.attachments or "",
    }
    if product_dict:
        result["productCode"] = product_dict.get("productCode", "")
        result["productName"] = product_dict.get("productName", "")
        result["productTypeId"] = product_dict.get("productTypeId", "")
        result["productTypeName"] = product_dict.get("productTypeName", "")
        result["specId"] = product_dict.get("specId", "")
        result["specName"] = product_dict.get("specName", "")
        result["passage"] = product_dict.get("passage", "")
        result["batchNo"] = product_dict.get("batchNo", "")
        result["storageSlotName"] = product_dict.get("storageSlotName", "")
        result["expectedCellCount"] = product_dict.get("expectedCellCount")
        result["viability"] = product_dict.get("viability")
        result["freezeDate"] = product_dict.get("freezeDate", "")
        result["expiryDate"] = product_dict.get("expiryDate", "")
        result["productStatus"] = product_dict.get("productStatus", "")
        result["productImages"] = product_dict.get("productImages", "")
    return result
  • Step 2: 改造 set_task_outputs 增加 diff + 细胞产品联动

在 backend/app/crud/production_task.py 中,完全重写 set_task_outputs 函数。在文件顶部增加 import:

from app.models.models import (
    BizCellProduct,
    BizStorageSlotOccupancy,
)
from app.crud import storage_occupancy as occupancy_svc
from app.crud import cell_product as cell_product_svc

替换 set_task_outputs 函数为:

def set_task_outputs(
    db: Session, task_id: str, tenant_no: str,
    user_id: Optional[str], items: List[Dict[str, Any]],
) -> None:
    now = datetime.now()

    # --- 1. 加载旧 outputs(未软删的) ---
    old_rows = db.query(BizProductionTaskOutput).filter(
        and_(
            BizProductionTaskOutput.task_id == task_id,
            BizProductionTaskOutput.del_flag == 0,
        )
    ).all()
    old_cp_map: Dict[str, BizProductionTaskOutput] = {}
    for o in old_rows:
        if o.cell_product_id:
            old_cp_map[o.cell_product_id] = o

    # --- 2. 构建新 outputs 的 cp_id 集合 ---
    new_cp_ids: set = set()
    for it in items:
        cp_id = (it.get("cell_product_id") or it.get("cellProductId") or "").strip()
        if cp_id:
            new_cp_ids.add(cp_id)

    # --- 3. 处理删除(旧有新无) ---
    for cp_id, old_row in old_cp_map.items():
        if cp_id not in new_cp_ids:
            # 作废细胞产品
            product = db.query(BizCellProduct).filter(
                BizCellProduct.id == cp_id, BizCellProduct.del_flag == 0
            ).first()
            if product:
                product.product_status = "voided"
                product.update_by = user_id
                product.update_time = now
                db.add(product)
                # 释放库位
                if product.storage_slot_id:
                    occupancy_svc.remove_occupancy_by_item(db, "cell_product", cp_id, user_id=user_id)
            # 软删关联行
            old_row.del_flag = 1
            old_row.update_by = user_id
            old_row.update_time = now
            db.add(old_row)

    # --- 4. 处理新增和修改 ---
    for it in items:
        cp_id = (it.get("cell_product_id") or it.get("cellProductId") or "").strip()

        if cp_id and cp_id in old_cp_map:
            # === 修改已有 ===
            product = db.query(BizCellProduct).filter(
                BizCellProduct.id == cp_id, BizCellProduct.del_flag == 0
            ).first()
            if product:
                old_slot_id = product.storage_slot_id
                # 更新产品字段
                _update_product_from_output(product, it, user_id, now)
                new_slot_id = product.storage_slot_id
                # 库位变更处理
                if old_slot_id != new_slot_id:
                    if old_slot_id:
                        occupancy_svc.remove_occupancy_by_item(db, "cell_product", cp_id, user_id=user_id)
                    if new_slot_id:
                        _create_occupancy_for_product(db, product, tenant_no, user_id)
            # 更新关联行
            old_row = old_cp_map[cp_id]
            old_row.output_name = product.product_name if product else (it.get("output_name") or it.get("outputName") or "")
            old_row.output_barcode = product.product_code if product else (it.get("output_barcode") or it.get("outputBarcode") or "")
            old_row.storage_slot_id = product.storage_slot_id if product else None
            old_row.quantity = it.get("quantity")
            old_row.unit = (it.get("unit") or "").strip() or None
            old_row.remark = (it.get("remark") or "").strip() or None
            old_row.update_by = user_id
            old_row.update_time = now
            db.add(old_row)
        else:
            # === 新增 ===
            product = BizCellProduct(
                id=generate_uuid(),
                product_code=(it.get("product_code") or it.get("productCode") or "").strip(),
                product_name=(it.get("product_name") or it.get("productName") or "").strip(),
                product_type_id=(it.get("product_type_id") or it.get("productTypeId") or "").strip() or None,
                spec_id=(it.get("spec_id") or it.get("specId") or "").strip() or None,
                passage=(it.get("passage") or "").strip() or None,
                batch_no=(it.get("batch_no") or it.get("batchNo") or "").strip() or None,
                storage_slot_id=(it.get("storage_slot_id") or it.get("storageSlotId") or "").strip() or None,
                expected_cell_count=it.get("expected_cell_count") if it.get("expected_cell_count") is not None else (it.get("expectedCellCount") if it.get("expectedCellCount") is not None else None),
                viability=it.get("viability") if it.get("viability") is not None else None,
                freeze_date=_parse_optional_date(it.get("freeze_date") or it.get("freezeDate")),
                product_status=it.get("product_status") or it.get("productStatus") or "frozen",
                source_type="production",
                production_task_id=task_id,
                quantity=it.get("quantity"),
                unit=(it.get("unit") or "").strip() or None,
                expiry_date=_parse_optional_date(it.get("expiry_date") or it.get("expiryDate")),
                product_images=(it.get("product_images") or it.get("productImages") or "").strip() or None,
                tenant_no=tenant_no,
                order_num=0,
                remark=(it.get("remark") or "").strip() or None,
                create_by=user_id,
                create_dept_id="",
                create_time=now,
                update_by=user_id,
                update_time=now,
                del_flag=0,
            )
            db.add(product)
            db.flush()
            # 创建库位占用
            if product.storage_slot_id:
                _create_occupancy_for_product(db, product, tenant_no, user_id)
            # 创建关联行
            output_row = BizProductionTaskOutput(
                id=generate_uuid(), task_id=task_id, tenant_no=tenant_no,
                cell_product_id=product.id,
                storage_slot_id=product.storage_slot_id,
                output_name=product.product_name or "",
                output_barcode=product.product_code or "",
                quantity=product.quantity,
                unit=product.unit,
                remark=product.remark,
                attachments=(it.get("attachments") or "").strip() or None,
                order_num=0, create_by=user_id, create_dept_id="",
                create_time=now, update_by=user_id, update_time=now, del_flag=0,
            )
            db.add(output_row)

    db.flush()
    db.commit()
  • Step 3: 添加辅助函数

在 set_task_outputs 函数之前,添加两个辅助函数:

def _parse_optional_date(val):
    """解析可选日期字符串"""
    if not val:
        return None
    from dateutil.parser import parse as _parse_dt
    try:
        return _parse_dt(str(val))
    except Exception:
        return None


def _update_product_from_output(product: BizCellProduct, it: Dict[str, Any], user_id, now):
    """从 output item 更新细胞产品字段"""
    for field_map in [
        ("product_name", "product_name", "productName"),
        ("product_type_id", "product_type_id", "productTypeId"),
        ("spec_id", "spec_id", "specId"),
        ("batch_no", "batch_no", "batchNo"),
        ("storage_slot_id", "storage_slot_id", "storageSlotId"),
        ("unit", "unit", "unit"),
        ("product_images", "product_images", "productImages"),
        ("remark", "remark", "remark"),
    ]:
        val = it.get(field_map[1]) or it.get(field_map[2])
        if val is not None:
            setattr(product, field_map[0], (val or "").strip() or None)
    for num_field in [("expected_cell_count", "expected_cell_count", "expectedCellCount"), ("viability", "viability", "viability")]:
        val = it.get(num_field[1])
        if val is None:
            val = it.get(num_field[2])
        if val is not None:
            setattr(product, num_field[0], val)
    for date_field in [("freeze_date", "freeze_date", "freezeDate"), ("expiry_date", "expiry_date", "expiryDate")]:
        val = it.get(date_field[1]) or it.get(date_field[2])
        if val is not None:
            setattr(product, date_field[0], _parse_optional_date(val))
    passage = it.get("passage")
    if passage is not None:
        product.passage = (passage or "").strip() or None
    quantity = it.get("quantity")
    if quantity is not None:
        product.quantity = quantity
    product.update_by = user_id
    product.update_time = now


def _create_occupancy_for_product(db: Session, product: BizCellProduct, tenant_no: str, user_id: Optional[str]):
    """为细胞产品创建库位占用记录"""
    if not product.storage_slot_id:
        return
    occ = BizStorageSlotOccupancy(
        id=generate_uuid(),
        slot_id=product.storage_slot_id,
        item_type="cell_product",
        item_id=product.id,
        item_code=product.product_code or "",
        item_name=product.product_name or "",
        item_source="production",
        stored_at=datetime.now(),
        occupancy_status="stored",
        tenant_no=tenant_no,
        order_num=0,
        create_by=user_id,
        create_dept_id="",
        create_time=datetime.now(),
        update_by=user_id,
        update_time=datetime.now(),
        del_flag=0,
    )
    db.add(occ)
    db.flush()
    occupancy_svc._refresh_slot_cache(db, product.storage_slot_id)
  • Step 4: 在 storage_occupancy.py 中确认 remove_occupancy_by_item 存在

Run: cd backend && source venv/bin/activate && python -c "from app.crud.storage_occupancy import remove_occupancy_by_item; print('OK')" Expected: OK(如果函数名不同,查阅 backend/app/crud/storage_occupancy.py 找到正确的按 item_type+item_id 删除的函数名并修正 Step 3 中的调用)

  • Step 5: 验证后端模块导入无误

Run: cd backend && source venv/bin/activate && python -c "from app.crud.production_task import set_task_outputs; print('OK')" Expected: OK


Task 3: 后端 CRUD — 改造 set_task_inputs

Files:

  • Modify: backend/app/crud/production_task.py (第 850-867 行 set_task_inputs)

  • [ ] Step 1: 重写 set_task_inputs 增加细胞产品状态联动

替换 set_task_inputs 函数:

def set_task_inputs(
    db: Session, task_id: str, tenant_no: str,
    user_id: Optional[str], items: List[Dict[str, Any]],
) -> None:
    now = datetime.now()

    # --- 1. 加载旧 inputs ---
    old_rows = db.query(BizProductionTaskInput).filter(
        and_(
            BizProductionTaskInput.task_id == task_id,
            BizProductionTaskInput.del_flag == 0,
        )
    ).all()
    old_cp_ids: set = set()
    for o in old_rows:
        if o.input_type == "cell_product" and o.input_id:
            old_cp_ids.add(o.input_id.strip())

    # --- 2. 构建新的细胞产品 ID 集合 ---
    new_cp_ids: set = set()
    for it in items:
        input_type = (it.get("input_type") or it.get("inputType") or "").strip()
        input_id = (it.get("input_id") or it.get("inputId") or "").strip()
        if input_type == "cell_product" and input_id:
            new_cp_ids.add(input_id)

    # --- 3. 被移除的细胞产品 → 恢复 frozen + 恢复库位 ---
    for pid in old_cp_ids - new_cp_ids:
        product = db.query(BizCellProduct).filter(
            BizCellProduct.id == pid, BizCellProduct.del_flag == 0
        ).first()
        if product and product.product_status == "used":
            product.product_status = "frozen"
            product.update_by = user_id
            product.update_time = now
            db.add(product)
            # 恢复库位占用
            if product.storage_slot_id:
                _create_occupancy_for_product(db, product, tenant_no, user_id)

    # --- 4. 新增的细胞产品 → 标记 used + 释放库位 ---
    for pid in new_cp_ids - old_cp_ids:
        product = db.query(BizCellProduct).filter(
            BizCellProduct.id == pid, BizCellProduct.del_flag == 0
        ).first()
        if product and product.product_status != "used":
            product.product_status = "used"
            product.update_by = user_id
            product.update_time = now
            db.add(product)
            # 释放库位
            if product.storage_slot_id:
                occupancy_svc.remove_occupancy_by_item(db, "cell_product", pid, user_id=user_id)

    # --- 5. 软删旧行 + 创建新行 ---
    for o in old_rows:
        o.del_flag = 1
        o.update_by = user_id
        o.update_time = now
        db.add(o)

    for it in items:
        db.add(BizProductionTaskInput(
            id=generate_uuid(), task_id=task_id, tenant_no=tenant_no,
            input_type=(it.get("input_type") or it.get("inputType") or "").strip() or None,
            input_id=(it.get("input_id") or it.get("inputId") or "").strip() or None,
            input_code=(it.get("input_code") or it.get("inputCode") or "").strip() or None,
            input_name=(it.get("input_name") or it.get("inputName") or "").strip() or None,
            remark=(it.get("remark") or "").strip() or None,
            order_num=0, create_by=user_id, create_dept_id="",
            create_time=now, update_by=user_id, update_time=now, del_flag=0,
        ))

    db.flush()
    db.commit()
  • Step 2: 验证

Run: cd backend && source venv/bin/activate && python -c "from app.crud.production_task import set_task_inputs; print('OK')" Expected: OK


Task 4: 后端 — 增强 get_production_task_detail 产出信息

Files:

  • Modify: backend/app/crud/production_task.py (get_production_task_detail 中 outputs 部分约第 499-505 行)

  • [ ] Step 1: 在 get_production_task_detail 中为 outputs 填充细胞产品信息

找到 get_production_task_detail 函数中处理 outputs 的部分(约第 499-505 行),将:

    result["outputs"] = [_task_output_to_dict(o) for o in outputs]

替换为:

    # 产出列表:填充细胞产品详情
    output_dicts = []
    cp_ids = [o.cell_product_id for o in outputs if o.cell_product_id]
    cp_map: Dict[str, Dict] = {}
    if cp_ids:
        cp_rows = db.query(BizCellProduct).filter(
            BizCellProduct.id.in_(cp_ids), BizCellProduct.del_flag == 0
        ).all()
        cp_dicts = cell_product_svc._enrich_product_dicts(
            db, tenant_no, [cell_product_svc._row_to_dict(p) for p in cp_rows]
        )
        for d in cp_dicts:
            cp_map[d["id"]] = d
    for o in outputs:
        pdict = cp_map.get(o.cell_product_id) if o.cell_product_id else None
        output_dicts.append(_task_output_to_dict(o, product_dict=pdict))
    result["outputs"] = output_dicts

注意:需要在文件顶部添加 from app.crud import cell_product as cell_product_svc(如果 Task 2 中尚未添加)。

  • Step 2: 验证

Run: cd backend && source venv/bin/activate && python -c "from app.crud.production_task import get_production_task_detail; print('OK')" Expected: OK


Task 5: 前端 API 层 — 更新 fetchSetTaskOutputs 参数

Files:

  • Modify: web-admin/src/api/production-task.ts (第 72-74 行)

  • [ ] Step 1: 更新 fetchSetTaskOutputs 参数类型

将 web-admin/src/api/production-task.ts 中的 fetchSetTaskOutputs 添加 TypeScript 类型注释:

export interface TaskOutputItem {
  cellProductId?: string
  productCode?: string
  productName?: string
  productTypeId?: string
  specId?: string
  passage?: string
  batchNo?: string
  storageSlotId?: string
  expectedCellCount?: number
  viability?: number
  freezeDate?: string
  quantity?: number
  unit?: string
  expiryDate?: string
  productImages?: string
  productStatus?: string
  sourceType?: string
  outputName?: string
  outputBarcode?: string
  remark?: string
  attachments?: string
}

export function fetchSetTaskOutputs(taskId: string, data: TaskOutputItem[]) {
  return request.post({ url: `/web/admin/production-tasks/${taskId}/outputs`, params: data })
}
  • Step 2: 验证 TS 无报错

Run: cd web-admin && npx vue-tsc --noEmit 2>&1 | head -20 Expected: 无与 production-task.ts 相关的错误(其他 pre-existing 错误可忽略)


Task 6: 前端 — 新建 TaskOutputProductDialog.vue

Files:

  • Create: web-admin/src/views/production/production-work-order/TaskOutputProductDialog.vue

参考: web-admin/src/views/production/cell-product/index.vue 第 249-559 行的新增/编辑弹窗表单

  • Step 1: 创建组件文件

创建 web-admin/src/views/production/production-work-order/TaskOutputProductDialog.vue,内容为:

<!-- 产出细胞产品新增/编辑子弹窗 -->
<template>
  <el-dialog
    v-model="dialogVisible"
    :title="isEdit ? '编辑产出产品' : '新增产出产品'"
    width="1080px"
    class="dialog-form-layout"
    align-center
    destroy-on-close
    :close-on-click-modal="false"
    @close="handleClose"
  >
    <el-form
      ref="formRef"
      :model="formData"
      :rules="formRules"
      label-position="top"
      scroll-to-error
      :scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
    >
      <div class="form-grid-4">
        <el-form-item label="产品编码" prop="productCode">
          <el-input v-model="formData.productCode" placeholder="请输入产品编码" :readonly="isEdit" />
        </el-form-item>
        <el-form-item label="产品名称" prop="productName">
          <el-input v-model="formData.productName" placeholder="请输入产品名称" />
        </el-form-item>
        <el-form-item label="产品类型" prop="productTypeId">
          <el-select v-model="formData.productTypeId" placeholder="请选择产品类型" clearable style="width: 100%">
            <el-option v-for="item in dropdownOptions.productTypes" :key="item.id" :label="item.productTypeName" :value="item.id" />
          </el-select>
        </el-form-item>
        <el-form-item label="规格" prop="specId">
          <el-select v-model="formData.specId" placeholder="请选择规格" clearable style="width: 100%">
            <el-option v-for="item in dropdownOptions.specs" :key="item.id" :label="item.specName" :value="item.id" />
          </el-select>
        </el-form-item>
        <el-form-item label="代次" prop="passage">
          <el-select v-model="formData.passage" placeholder="请选择代次" clearable style="width: 100%">
            <el-option v-for="p in passageOptions" :key="p" :label="p" :value="p" />
          </el-select>
        </el-form-item>
        <el-form-item label="批次号" prop="batchNo">
          <el-input v-model="formData.batchNo" placeholder="请输入批次号" />
        </el-form-item>
        <el-form-item label="储物格位置" prop="storageSlotId">
          <el-select v-model="formData.storageSlotId" placeholder="请选择储物格" clearable filterable style="width: 100%">
            <el-option v-for="item in dropdownOptions.storageSlots" :key="item.id" :label="item.name" :value="item.id" />
          </el-select>
        </el-form-item>
        <el-form-item label="预计细胞数量(亿)" prop="expectedCellCount">
          <el-input-number v-model="formData.expectedCellCount" :min="0" :precision="4" controls-position="right" style="width: 100%" />
        </el-form-item>
        <el-form-item label="活性(%)" prop="viability">
          <el-input-number v-model="formData.viability" :min="0" :max="100" :precision="1" controls-position="right" style="width: 100%" />
        </el-form-item>
        <el-form-item label="冻存日期" prop="freezeDate">
          <el-date-picker v-model="formData.freezeDate" type="date" placeholder="请选择冻存日期" value-format="YYYY-MM-DD" style="width: 100%" />
        </el-form-item>
        <el-form-item label="数量" prop="quantity">
          <el-input-number v-model="formData.quantity" :min="1" :precision="0" controls-position="right" style="width: 100%" />
        </el-form-item>
        <el-form-item label="单位">
          <el-select v-model="formData.unit" placeholder="请选择单位" clearable filterable remote reserve-keyword style="width: 100%" :loading="unitLoading" :remote-method="searchUnits" @visible-change="(vis: boolean) => vis && searchUnits('')">
            <el-option v-for="item in unitOptions" :key="item.value" :label="item.label" :value="item.value" />
          </el-select>
        </el-form-item>
        <el-form-item label="有效期" prop="expiryDate">
          <el-date-picker v-model="formData.expiryDate" type="date" placeholder="请选择有效期" value-format="YYYY-MM-DD" style="width: 100%" />
        </el-form-item>
        <el-form-item label="备注" class="form-col-span-2">
          <el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="可选" />
        </el-form-item>
      </div>
    </el-form>
    <template #footer>
      <el-button class="btn-ghost" @click="handleClose">取消</el-button>
      <el-button class="btn-main" type="primary" @click="handleSubmit">
        <el-icon><Check /></el-icon>确定
      </el-button>
    </template>
  </el-dialog>
</template>

<script setup lang="ts">
  import { Check } from '@element-plus/icons-vue'
  import { fetchNextCellProductCode } from '@/api/cell-product'
  import { fetchItemUnitList } from '@/api/admin'

  const props = defineProps<{
    visible: boolean
    isEdit: boolean
    productData?: Record<string, any>
    dropdownOptions: {
      productTypes: Array<{ id: string; productTypeName: string }>
      specs: Array<{ id: string; specName: string }>
      storageSlots: Array<{ id: string; name: string }>
    }
  }>()

  const emit = defineEmits<{
    (e: 'update:visible', val: boolean): void
    (e: 'save', data: Record<string, any>): void
  }>()

  const passageOptions = ['P1', 'P2', 'P3', 'P4', 'P5', 'P6', 'P7', 'P8']

  const dialogVisible = computed({
    get: () => props.visible,
    set: (val) => emit('update:visible', val),
  })

  const formRef = ref()
  const formData = reactive({
    productCode: '',
    productName: '',
    productTypeId: '',
    specId: '',
    passage: '',
    batchNo: '',
    storageSlotId: '',
    expectedCellCount: undefined as number | undefined,
    viability: undefined as number | undefined,
    freezeDate: '',
    quantity: 1,
    unit: '',
    expiryDate: '',
    remark: '',
  })

  const formRules = {
    productCode: [{ required: true, message: '请输入产品编码', trigger: 'blur' }],
    productName: [{ required: true, message: '请输入产品名称', trigger: 'blur' }],
    batchNo: [{ required: true, message: '请输入批次号', trigger: 'blur' }],
  }

  const unitOptions = ref<{ label: string; value: string }[]>([])
  const unitLoading = ref(false)

  async function searchUnits(query: string) {
    unitLoading.value = true
    try {
      const res = await fetchItemUnitList({ page: 1, pageSize: 100, keyword: query?.trim() || undefined })
      unitOptions.value = (res.list || []).map((u: any) => {
        const name = (u.unitName || '').trim()
        const code = (u.unitCode || '').trim()
        return { label: code ? `${name}(${code})` : name, value: name }
      }).filter((o: any) => o.value)
    } catch {
      unitOptions.value = []
    } finally {
      unitLoading.value = false
    }
  }

  function resetForm() {
    formData.productCode = ''
    formData.productName = ''
    formData.productTypeId = ''
    formData.specId = ''
    formData.passage = ''
    formData.batchNo = ''
    formData.storageSlotId = ''
    formData.expectedCellCount = undefined
    formData.viability = undefined
    formData.freezeDate = ''
    formData.quantity = 1
    formData.unit = ''
    formData.expiryDate = ''
    formData.remark = ''
  }

  watch(() => props.visible, async (val) => {
    if (val) {
      resetForm()
      formRef.value?.clearValidate?.()
      if (props.isEdit && props.productData) {
        Object.assign(formData, {
          productCode: props.productData.productCode || '',
          productName: props.productData.productName || '',
          productTypeId: props.productData.productTypeId || '',
          specId: props.productData.specId || '',
          passage: props.productData.passage || '',
          batchNo: props.productData.batchNo || '',
          storageSlotId: props.productData.storageSlotId || '',
          expectedCellCount: props.productData.expectedCellCount ?? undefined,
          viability: props.productData.viability ?? undefined,
          freezeDate: props.productData.freezeDate || '',
          quantity: props.productData.quantity ?? 1,
          unit: props.productData.unit || '',
          expiryDate: props.productData.expiryDate || '',
          remark: props.productData.remark || '',
        })
      } else {
        try {
          const res = await fetchNextCellProductCode()
          if (res?.code) formData.productCode = res.code
        } catch { /* 自动生成失败时用户可手动输入 */ }
      }
      void searchUnits('')
    }
  })

  function handleClose() {
    dialogVisible.value = false
  }

  async function handleSubmit() {
    await formRef.value?.validate?.()
    emit('save', { ...formData })
    dialogVisible.value = false
  }
</script>
  • Step 2: 验证无语法错误

Run: cd web-admin && npx vue-tsc --noEmit 2>&1 | grep -i "TaskOutputProductDialog" || echo "No errors for this file" Expected: 无该文件相关错误


Task 7: 前端 — 新建 TaskOutputList.vue

Files:

  • Create: web-admin/src/views/production/production-work-order/TaskOutputList.vue

  • [ ] Step 1: 创建产出列表组件

创建 web-admin/src/views/production/production-work-order/TaskOutputList.vue:

<!-- 产出列表 -->
<template>
  <el-table :data="outputs" stripe class="inner-table">
    <el-table-column type="index" label="序号" width="56" align="center" :index="(i: number) => i + 1" />
    <el-table-column prop="productCode" label="产品编码" min-width="160" align="center" />
    <el-table-column prop="productName" label="产品名称" min-width="140" align="center" />
    <el-table-column label="产品类型" min-width="110" align="center">
      <template #default="{ row }">
        <span>{{ row.productTypeName || '—' }}</span>
      </template>
    </el-table-column>
    <el-table-column prop="passage" label="代次" width="80" align="center">
      <template #default="{ row }">{{ row.passage || '—' }}</template>
    </el-table-column>
    <el-table-column prop="storageSlotName" label="储物格位置" min-width="140" align="center">
      <template #default="{ row }">{{ row.storageSlotName || '—' }}</template>
    </el-table-column>
    <el-table-column label="数量/单位" min-width="100" align="center">
      <template #default="{ row }">{{ row.quantity ?? '—' }} {{ row.unit || '' }}</template>
    </el-table-column>
    <el-table-column label="状态" width="100" align="center">
      <template #default="{ row }">
        <el-tag v-if="row.productStatus" :type="productStatusTag(row.productStatus).type" size="small">
          {{ productStatusTag(row.productStatus).label }}
        </el-tag>
        <span v-else>—</span>
      </template>
    </el-table-column>
    <el-table-column v-if="!readonly" label="操作" width="140" align="center">
      <template #default="{ row, $index }">
        <el-button link type="primary" size="small" @click="emit('edit', row)">编辑</el-button>
        <el-button link type="danger" size="small" @click="emit('delete', $index)">删除</el-button>
      </template>
    </el-table-column>
  </el-table>
  <el-empty v-if="!outputs.length" description="暂无产出项,点击上方「添加产出」按钮" :image-size="48" />
</template>

<script setup lang="ts">
  defineProps<{
    outputs: Record<string, any>[]
    readonly?: boolean
  }>()

  const emit = defineEmits<{
    (e: 'edit', row: Record<string, any>): void
    (e: 'delete', index: number): void
    (e: 'add'): void
  }>()

  function productStatusTag(status: string) {
    const map: Record<string, { label: string; type: string }> = {
      frozen: { label: '已冻存', type: 'success' },
      sold: { label: '已售卖', type: 'warning' },
      used: { label: '已使用', type: 'info' },
      voided: { label: '已作废', type: 'danger' },
    }
    return map[status] || { label: status, type: 'info' }
  }
</script>

Task 8: 前端 — 新建 TaskInputEnhancedTable.vue

Files:

  • Create: web-admin/src/views/production/production-work-order/TaskInputEnhancedTable.vue

  • [ ] Step 1: 创建增强版原材料表格组件

创建 web-admin/src/views/production/production-work-order/TaskInputEnhancedTable.vue:

<!-- 增强版原材料表格 -->
<template>
  <el-table :data="inputs" stripe class="inner-table">
    <el-table-column type="index" label="序号" width="56" align="center" :index="(i: number) => i + 1" />
    <el-table-column label="输入类型" min-width="120" align="center">
      <template #default="{ row }">
        <span v-if="readonly">{{ row.inputType === 'sample' ? '样本' : row.inputType === 'cell_product' ? '细胞产品' : row.inputType }}</span>
        <el-select v-else v-model="row.inputType" placeholder="选择类型" class="inner-cell-select" @change="() => { row.inputId = ''; row.inputCode = ''; row.inputName = '' }">
          <el-option label="样本" value="sample" />
          <el-option label="细胞产品" value="cell_product" />
        </el-select>
      </template>
    </el-table-column>
    <el-table-column label="编码" min-width="150" align="center">
      <template #default="{ row }">
        <template v-if="row.inputType === 'cell_product'">
          <el-select
            v-if="!readonly"
            v-model="row.inputId"
            placeholder="选择细胞产品"
            filterable
            class="inner-cell-select"
            :loading="cellProductLoading"
            @change="(val: string) => onCellProductSelect(row, val)"
          >
            <el-option v-for="opt in cellProductOptions" :key="opt.id" :label="opt.label" :value="opt.id" />
          </el-select>
          <span v-else>{{ row.inputCode || '—' }}</span>
        </template>
        <template v-else>
          <span>{{ row.inputCode || '—' }}</span>
        </template>
      </template>
    </el-table-column>
    <el-table-column label="名称" min-width="130" align="center">
      <template #default="{ row }">
        <span>{{ row.inputName || '—' }}</span>
      </template>
    </el-table-column>
    <!-- 细胞产品增强列 -->
    <el-table-column v-if="hasCellProductRows" label="产品类型" min-width="110" align="center">
      <template #default="{ row }">
        <span v-if="row.inputType === 'cell_product'">{{ row.productTypeName || '—' }}</span>
        <span v-else>—</span>
      </template>
    </el-table-column>
    <el-table-column v-if="hasCellProductRows" label="代次" width="80" align="center">
      <template #default="{ row }">
        <span v-if="row.inputType === 'cell_product'">{{ row.passage || '—' }}</span>
        <span v-else>—</span>
      </template>
    </el-table-column>
    <el-table-column v-if="hasCellProductRows" label="储物格位置" min-width="140" align="center">
      <template #default="{ row }">
        <span v-if="row.inputType === 'cell_product'">{{ row.storageSlotName || '—' }}</span>
        <span v-else>—</span>
      </template>
    </el-table-column>
    <el-table-column v-if="hasCellProductRows" label="状态" width="100" align="center">
      <template #default="{ row }">
        <el-tag v-if="row.inputType === 'cell_product' && row.productStatus" :type="productStatusTag(row.productStatus).type" size="small">
          {{ productStatusTag(row.productStatus).label }}
        </el-tag>
        <span v-else>—</span>
      </template>
    </el-table-column>
    <el-table-column label="备注" min-width="120" align="center">
      <template #default="{ row }">
        <span v-if="readonly">{{ row.remark || '—' }}</span>
        <el-input v-else v-model="row.remark" placeholder="备注" class="inner-cell-input" />
      </template>
    </el-table-column>
    <el-table-column v-if="!readonly" label="操作" width="80" align="center">
      <template #default="{ $index }">
        <el-button link type="danger" size="small" @click="emit('remove', $index)">删除</el-button>
      </template>
    </el-table-column>
  </el-table>
  <el-empty v-if="!inputs.length" description="暂无输入项" :image-size="48" />
</template>

<script setup lang="ts">
  const props = defineProps<{
    inputs: Record<string, any>[]
    cellProductOptions: Array<{ id: string; label: string; master?: Record<string, any> }>
    cellProductLoading?: boolean
    readonly?: boolean
  }>()

  const emit = defineEmits<{
    (e: 'update', inputs: Record<string, any>[]): void
    (e: 'remove', index: number): void
    (e: 'select-cell-product', row: Record<string, any>, id: string): void
  }>()

  const hasCellProductRows = computed(() => props.inputs.some((r) => r.inputType === 'cell_product'))

  function onCellProductSelect(row: Record<string, any>, val: string) {
    emit('select-cell-product', row, val)
  }

  function productStatusTag(status: string) {
    const map: Record<string, { label: string; type: string }> = {
      frozen: { label: '已冻存', type: 'success' },
      sold: { label: '已售卖', type: 'warning' },
      used: { label: '已使用', type: 'info' },
      voided: { label: '已作废', type: 'danger' },
    }
    return map[status] || { label: status, type: 'info' }
  }
</script>

Task 9: 前端 — 重构 useTaskStepEditor.ts

Files:

  • Modify: web-admin/src/views/production/production-work-order/useTaskStepEditor.ts

这是最复杂的改造任务。主要变更:

  1. 产出操作改为子弹窗模式
  2. 原材料选择细胞产品时填充完整信息
  3. 保存时提交增强后的数据
  • Step 1: 增加产出子弹窗状态

在 useTaskStepEditor composable 中(约第 72-74 行 selectedVirtualStep 之后),增加产出子弹窗状态:

  // ── 产出子弹窗状态 ────────────────────────────────────────
  const outputDialogVisible = ref(false)
  const outputDialogMode = ref<'add' | 'edit'>('add')
  const editingOutputIndex = ref(-1)
  const editingOutputData = ref<Record<string, any> | null>(null)

  // ── 产出下拉选项 ─────────────────────────────────────────
  const outputDropdownOptions = reactive({
    productTypes: [] as Array<{ id: string; productTypeName: string }>,
    specs: [] as Array<{ id: string; specName: string }>,
    storageSlots: [] as Array<{ id: string; name: string }>,
  })
  • Step 2: 增加下拉选项加载函数

在 composable 中增加:

  async function loadOutputDropdownData() {
    try {
      const [typeRes, specRes] = await Promise.all([
        (await import('@/api/admin')).fetchCellProductTypeList({ page: 1, pageSize: 100 }),
        (await import('@/api/admin')).fetchCellProductSpecList({ page: 1, pageSize: 100 }),
      ])
      outputDropdownOptions.productTypes = typeRes?.list || typeRes || []
      outputDropdownOptions.specs = specRes?.list || specRes || []
      // 储物格加载(复用细胞产品库的逻辑)
      const facilityRes = await (await import('@/api/admin')).fetchStorageFacilityList()
      const facilities = facilityRes?.list || facilityRes || []
      const { buildTreePathLabelFromFlat } = await import('@/utils/tree-path')
      const { fetchStorageSlotList } = await import('@/api/admin')
      const leafNodes = facilities.filter((n: any) => n.isLeaf === 1)
      const slotOptions: Array<{ id: string; name: string }> = []
      for (const node of leafNodes) {
        const slotRes: any = await fetchStorageSlotList({ facilityNodeId: node.id })
        const slots = slotRes?.list || slotRes || []
        const nodePath = buildTreePathLabelFromFlat(node.id, facilities, { nameKey: 'nodeName' })
        for (const slot of slots) {
          const code = slot.slotCode || ''
          const name = nodePath && code ? `${nodePath} / ${code}` : code || nodePath
          slotOptions.push({ id: slot.id, name })
        }
      }
      outputDropdownOptions.storageSlots = slotOptions
    } catch {
      /* 下拉数据加载失败不阻塞 */
    }
  }
  • Step 3: 修改产出操作函数

替换原有的 addTaskOutputRow、removeTaskOutputRow、saveTaskOutputs:

  function addTaskOutputRow() {
    outputDialogMode.value = 'add'
    editingOutputIndex.value = -1
    editingOutputData.value = null
    outputDialogVisible.value = true
  }

  function editTaskOutputRow(row: Record<string, any>, index: number) {
    outputDialogMode.value = 'edit'
    editingOutputIndex.value = index
    editingOutputData.value = { ...row }
    outputDialogVisible.value = true
  }

  function handleOutputDialogSave(formData: Record<string, any>) {
    if (outputDialogMode.value === 'add') {
      taskOutputs.value.push({
        cellProductId: '',
        ...formData,
        outputName: formData.productName,
        outputBarcode: formData.productCode,
        productStatus: 'frozen',
        sourceType: 'production',
      })
    } else if (outputDialogMode.value === 'edit' && editingOutputIndex.value >= 0) {
      const existing = taskOutputs.value[editingOutputIndex.value]
      taskOutputs.value[editingOutputIndex.value] = {
        ...existing,
        ...formData,
        outputName: formData.productName || existing.outputName,
        outputBarcode: formData.productCode || existing.outputBarcode,
      }
    }
  }

  function removeTaskOutputRow(i: number) {
    taskOutputs.value.splice(i, 1)
  }

  async function saveTaskOutputs() {
    if (!taskDetail.value?.id) return
    if (!taskOutputs.value.length) {
      ElMessage.warning('请至少添加一项产出(细胞产品)')
      return
    }
    const invalid = taskOutputs.value.find(
      (row) => !(row.productCode || row.outputBarcode || '').trim() || !(row.productName || row.outputName || '').trim()
    )
    if (invalid) {
      ElMessage.warning('请补全每行产出的产品编码与名称')
      return
    }
    outputSaving.value = true
    try {
      await fetchSetTaskOutputs(taskDetail.value.id, taskOutputs.value)
      ElMessage.success('产出已保存')
      await loadTaskDetail(taskDetail.value.id)
    } catch { /* global error handling */ } finally { outputSaving.value = false }
  }
  • Step 4: 修改原材料选择联动

增强 onCellProductInputSelect 函数,填充完整产品信息:

  function onCellProductInputSelect(row: Record<string, any>, productId: string) {
    if (!productId) { row.inputId = ''; return }
    const master = cellProductMasterById.value[productId]
    if (!master) return
    row.inputId = master.id
    row.inputCode = master.product_code || master.productCode || ''
    row.inputName = master.product_name || master.productName || ''
    // 增强信息填充
    row.productTypeName = master.product_type_name || master.productTypeName || ''
    row.passage = master.passage || ''
    row.storageSlotName = master.storage_slot_name || master.storageSlotName || ''
    row.productStatus = master.product_status || master.productStatus || ''
  }
  • Step 5: 在 initEditor 中加载下拉选项

在 initEditor 函数末尾(约第 755 行 configSuppressSave.value = false 之前),增加:

    if (mode.value === 'execution') {
      void loadOutputDropdownData()
    }
  • Step 6: 在 return 对象中导出新状态和方法

在 return 对象中增加:

    outputDialogVisible, outputDialogMode, editingOutputIndex, editingOutputData,
    outputDropdownOptions, loadOutputDropdownData,
    editTaskOutputRow, handleOutputDialogSave,
  • Step 7: 验证

Run: cd web-admin && npx vue-tsc --noEmit 2>&1 | grep -i "useTaskStepEditor" || echo "No errors" Expected: 无该文件相关错误


Task 10: 前端 — 改造 TaskExecutionDialog.vue

Files:

  • Modify: web-admin/src/views/production/production-work-order/TaskExecutionDialog.vue

  • [ ] Step 1: 添加新组件 import

在 <script setup> 中增加:

  import TaskOutputList from './TaskOutputList.vue'
  import TaskOutputProductDialog from './TaskOutputProductDialog.vue'
  import TaskInputEnhancedTable from './TaskInputEnhancedTable.vue'
  • Step 2: 替换原材料面板(input template)

找到「任务输入详情」模板(约第 162-222 行 <template v-if="editor.selectedVirtualStep.value === 'input'">),将内部的 <el-table> 替换为:

        <template v-if="editor.selectedVirtualStep.value === 'input'">
          <div class="execution-detail-header">
            <h3>📥 原材料(样本/细胞产品)<span class="virtual-step-required">必填</span></h3>
            <div v-if="!readonly" class="execution-detail-actions">
              <el-button class="btn-main" type="primary" size="small" @click="editor.addTaskInputRow"><el-icon><Plus /></el-icon>添加输入</el-button>
              <el-button class="btn-main" type="primary" size="small" :loading="editor.inputSaving.value" @click="editor.saveTaskInputs"><el-icon><Check /></el-icon>保存</el-button>
            </div>
          </div>
          <div class="execution-detail-body">
            <TaskInputEnhancedTable
              :inputs="editor.taskInputs.value"
              :cell-product-options="editor.cellProductOptions.value"
              :cell-product-loading="editor.cellProductOptionsLoading.value"
              :readonly="readonly"
              @remove="editor.removeTaskInputRow"
              @select-cell-product="editor.onCellProductInputSelect"
            />
          </div>
        </template>
  • Step 3: 替换产出面板(output template)

找到「任务输出详情」模板(约第 226-275 行 <template v-else-if="editor.selectedVirtualStep.value === 'output'">),替换为:

        <template v-else-if="editor.selectedVirtualStep.value === 'output'">
          <div class="execution-detail-header">
            <h3>📤 产出(细胞产品)<span class="virtual-step-required">必填</span></h3>
            <div v-if="!readonly" class="execution-detail-actions">
              <el-button class="btn-main" type="primary" size="small" @click="editor.addTaskOutputRow"><el-icon><Plus /></el-icon>添加产出</el-button>
              <el-button class="btn-main" type="primary" size="small" :loading="editor.outputSaving.value" @click="editor.saveTaskOutputs"><el-icon><Check /></el-icon>保存</el-button>
            </div>
          </div>
          <div class="execution-detail-body">
            <TaskOutputList
              :outputs="editor.taskOutputs.value"
              :readonly="readonly"
              @edit="(row: Record<string, any>) => editor.editTaskOutputRow(row, editor.taskOutputs.value.indexOf(row))"
              @delete="editor.removeTaskOutputRow"
            />
          </div>

          <!-- 产出细胞产品子弹窗 -->
          <TaskOutputProductDialog
            v-model:visible="editor.outputDialogVisible.value"
            :is-edit="editor.outputDialogMode.value === 'edit'"
            :product-data="editor.editingOutputData.value || undefined"
            :dropdown-options="editor.outputDropdownOptions"
            @save="editor.handleOutputDialogSave"
          />
        </template>
  • Step 4: 验证

Run: cd web-admin && npx vue-tsc --noEmit 2>&1 | head -30 Expected: 无新增错误


Task 11: 整体验证

  • Step 1: 后端启动验证

Run: cd backend && source venv/bin/activate && python -c "from app.main import app; print('Backend OK')" Expected: Backend OK

  • Step 2: 前端 build 验证

Run: cd web-admin && pnpm build 2>&1 | tail -5 Expected: ✓ built in ... 或类似成功信息

  • Step 3: 前端 lint 验证

Run: cd web-admin && pnpm lint 2>&1 | tail -10 Expected: 无新增 lint 错误

  • Step 4: 手动功能验证(开发环境)
  1. 启动后端 cd backend && ./start-local.sh
  2. 启动前端 cd web-admin && pnpm dev
  3. 打开 http://localhost:3006/#/production/production-task
  4. 选择一个「进行中」的任务,点击「进入执行」
  5. 点击「产出(细胞产品)」虚拟步骤
  6. 点击「添加产出」→ 验证子弹窗弹出,表单字段完整
  7. 填写产品信息,选择储物格 → 点击确定 → 验证列表显示
  8. 点击「保存」→ 验证后端创建细胞产品 + 库位占用
  9. 前往「细胞产品库」页面 → 验证新产品出现
  10. 前往「库位管理」页面 → 验证储物格显示已占用
  11. 回到任务执行弹窗 → 删除该产出 → 保存 → 验证细胞产品状态变为已作废 + 库位释放
  12. 点击「原材料」→ 添加细胞产品类型 → 选择一个已有产品 → 保存 → 验证产品状态变为已使用 + 库位释放
  13. 删除该原材料 → 保存 → 验证产品恢复为已冻存 + 库位恢复