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
| 文件 | 操作 | 职责 |
|---|---|---|
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 |
修改 | 替换产出/原材料面板组件 |
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
Run: cd backend && source venv/bin/activate && python -c "from app.schemas.production_task import TaskOutputItem; print('OK')"
Expected: OK
Files:
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
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()
在 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)
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 中的调用)
Run: cd backend && source venv/bin/activate && python -c "from app.crud.production_task import set_task_outputs; print('OK')"
Expected: OK
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()
Run: cd backend && source venv/bin/activate && python -c "from app.crud.production_task import set_task_inputs; print('OK')"
Expected: OK
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 中尚未添加)。
Run: cd backend && source venv/bin/activate && python -c "from app.crud.production_task import get_production_task_detail; print('OK')"
Expected: OK
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 })
}
Run: cd web-admin && npx vue-tsc --noEmit 2>&1 | head -20
Expected: 无与 production-task.ts 相关的错误(其他 pre-existing 错误可忽略)
Files:
web-admin/src/views/production/production-work-order/TaskOutputProductDialog.vue参考: web-admin/src/views/production/cell-product/index.vue 第 249-559 行的新增/编辑弹窗表单
创建 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>
Run: cd web-admin && npx vue-tsc --noEmit 2>&1 | grep -i "TaskOutputProductDialog" || echo "No errors for this file"
Expected: 无该文件相关错误
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>
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>
Files:
web-admin/src/views/production/production-work-order/useTaskStepEditor.ts这是最复杂的改造任务。主要变更:
在 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 }>,
})
在 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 {
/* 下拉数据加载失败不阻塞 */
}
}
替换原有的 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 }
}
增强 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 || ''
}
在 initEditor 函数末尾(约第 755 行 configSuppressSave.value = false 之前),增加:
if (mode.value === 'execution') {
void loadOutputDropdownData()
}
在 return 对象中增加:
outputDialogVisible, outputDialogMode, editingOutputIndex, editingOutputData,
outputDropdownOptions, loadOutputDropdownData,
editTaskOutputRow, handleOutputDialogSave,
Run: cd web-admin && npx vue-tsc --noEmit 2>&1 | grep -i "useTaskStepEditor" || echo "No errors"
Expected: 无该文件相关错误
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'
找到「任务输入详情」模板(约第 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>
找到「任务输出详情」模板(约第 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>
Run: cd web-admin && npx vue-tsc --noEmit 2>&1 | head -30
Expected: 无新增错误
Run: cd backend && source venv/bin/activate && python -c "from app.main import app; print('Backend OK')"
Expected: Backend OK
Run: cd web-admin && pnpm build 2>&1 | tail -5
Expected: ✓ built in ... 或类似成功信息
Run: cd web-admin && pnpm lint 2>&1 | tail -10
Expected: 无新增 lint 错误
cd backend && ./start-local.shcd web-admin && pnpm devhttp://localhost:3006/#/production/production-task