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: 在生产工单页面新增出库生产工单 tab,输注计划创建时自动创建出库工单,删除时自动作废,详情展示关联业务信息。
Architecture: 在现有生产工单 index.vue 中扩展第 4 个 tab(outbound),复用通用列表/详情 API。后端通过 work_order_type="outbound" 过滤,通过 infusion_schedule_id 反查关联链。自动创建/作废通过在 pilot_application.py 的 CRUD 函数中挂载钩子实现。
Tech Stack: Python 3.9 / FastAPI / SQLAlchemy / MySQL / Vue 3 / Element Plus / TypeScript
Design Spec: docs/superpowers/specs/2026-05-26-outbound-production-work-order-design.md
| 操作 | 文件 | 职责 |
|---|---|---|
| 新增 | docs/sql/2026-05-26-outbound-work-order.sql |
ALTER TABLE DDL |
| 修改 | backend/app/models/models.py (BizProductionWorkOrder 类) |
新增 biz_type 字段 |
| 修改 | backend/app/crud/production_work_order.py |
状态映射、_wo_to_dict、create_outbound 函数签名、列表增强、详情增强 |
| 修改 | backend/app/crud/pilot_application.py |
create_schedule 自动创建钩子、soft_delete_schedule 作废钩子 |
| 修改 | web-admin/src/views/production/production-work-order/index.vue |
新 tab、列配置、操作按钮、详情弹窗、状态映射 |
Files:
Create: docs/sql/2026-05-26-outbound-work-order.sql
[ ] Step 1: 创建 SQL 迁移脚本
-- 出库生产工单:新增 biz_type 字段 + 更新 comment
ALTER TABLE biz_production_work_order
ADD COLUMN biz_type VARCHAR(20) NULL COMMENT '业务类型: pilot_service=中试服务, clinical_application=临床应用' AFTER infusion_schedule_id;
ALTER TABLE biz_production_work_order
MODIFY COLUMN work_order_type VARCHAR(20) NOT NULL DEFAULT 'inbound' COMMENT '工单类型: inbound=入库, temporary=临时, outbound=出库';
ALTER TABLE biz_production_work_order
MODIFY COLUMN work_order_status VARCHAR(16) NOT NULL DEFAULT 'dpz' COMMENT '工单状态 dpz待配置 jxz进行中 ywc已完成 yzf已作废';
Run: source venv/bin/activate && python -c "from app.core.database import engine; from sqlalchemy import text; conn = engine.connect(); [conn.execute(text(s)) for s in open('docs/sql/2026-05-26-outbound-work-order.sql').read().split(';') if s.strip() and not s.strip().startswith('--')]; conn.commit(); print('OK')"
或手动在 MySQL 客户端中执行。Expected: OK
git add docs/sql/2026-05-26-outbound-work-order.sql
git commit -m "feat(production): add biz_type column and yzf status for outbound work orders"
Files:
Modify: backend/app/models/models.py (BizProductionWorkOrder 类, 约 line 2430)
[ ] Step 1: 在 BizProductionWorkOrder 类中新增 biz_type 字段
在 infusion_schedule_id 字段之后新增:
biz_type = Column(String(20), nullable=True, comment="业务类型: pilot_service=中试服务, clinical_application=临床应用")
同时更新 work_order_type 字段的 comment:
work_order_type = Column(String(20), nullable=False, default="inbound", comment="工单类型: inbound=入库, temporary=临时, outbound=出库")
更新 work_order_status 字段的 comment:
work_order_status = Column(String(16), nullable=False, default="dpz", comment="工单状态 dpz待配置 jxz进行中 ywc已完成 yzf已作废")
Run: cd backend && source venv/bin/activate && python -c "from app.models.models import BizProductionWorkOrder; print('OK')"
Expected: OK
git add backend/app/models/models.py
git commit -m "feat(production): add biz_type field to BizProductionWorkOrder model"
Files:
Modify: backend/app/crud/production_work_order.py
[ ] Step 1: 扩展状态映射和来源标签
文件顶部 _WORK_ORDER_STATUS_LABELS (约 line 32-36) 修改为:
_WORK_ORDER_STATUS_LABELS = {
"dpz": "待配置",
"jxz": "进行中",
"ywc": "已完成",
"yzf": "已作废",
}
在 _wo_to_dict() 函数 (约 line 87-111) 中,"infusionScheduleId" 之后新增:
"bizType": getattr(wo, "biz_type", None) or None,
"bizTypeLabel": {"pilot_service": "中试服务", "clinical_application": "临床应用"}.get(getattr(wo, "biz_type", None) or "", ""),
函数签名 (约 line 638-646) 修改为:
def create_outbound_production_work_order(
db: Session,
*,
schedule_id: str,
tenant_no: str,
user_id: Optional[str] = None,
create_dept_id: str = "",
remark: str = "",
biz_type: str = "pilot_service",
) -> BizProductionWorkOrder:
在创建 BizProductionWorkOrder 对象时 (约 line 658-674) 新增字段:
wo = BizProductionWorkOrder(
id=generate_uuid(),
work_order_no=_generate_outbound_work_order_no(db, tenant_no),
work_order_type="outbound",
reception_id=None,
protocol_sample_id=None,
collection_protocol_id=None,
protocol_no=None,
sample_code=None,
reception_source="outbound",
infusion_schedule_id=schedule_id,
biz_type=biz_type,
work_order_status="dpz",
tenant_no=tenant_no,
remark=remark,
create_by=user_id,
create_dept_id=create_dept_id,
)
在 _enrich_wo_items() 函数之后新增辅助函数:
_BIZ_TYPE_LABELS = {"pilot_service": "中试服务", "clinical_application": "临床应用"}
def _enrich_outbound_items(db: Session, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""为 outbound 类型工单附加申请单号、细胞类型、规格、输注时间等关联数据。"""
from app.models.models import (
BizPilotApplication,
BizPilotInfusionPlan,
BizPilotInfusionSchedule,
)
outbound_ids = [
it["infusionScheduleId"]
for it in items
if it.get("workOrderType") == "outbound" and it.get("infusionScheduleId")
]
if not outbound_ids:
return items
schedules = (
db.query(BizPilotInfusionSchedule)
.filter(
BizPilotInfusionSchedule.id.in_(outbound_ids),
BizPilotInfusionSchedule.del_flag == 0,
)
.all()
)
sched_map = {s.id: s for s in schedules}
plan_ids = list({s.infusion_plan_id for s in schedules if s.infusion_plan_id})
plan_map = {}
if plan_ids:
plans = db.query(BizPilotInfusionPlan).filter(BizPilotInfusionPlan.id.in_(plan_ids)).all()
plan_map = {p.id: p for p in plans}
app_ids = list({p.pilot_application_id for p in plan_map.values() if p.pilot_application_id})
app_map = {}
if app_ids:
apps = db.query(BizPilotApplication).filter(BizPilotApplication.id.in_(app_ids)).all()
app_map = {a.id: a for a in apps}
for it in items:
if it.get("workOrderType") != "outbound" or not it.get("infusionScheduleId"):
continue
sched = sched_map.get(it["infusionScheduleId"])
if not sched:
continue
plan = plan_map.get(sched.infusion_plan_id) if sched.infusion_plan_id else None
app = app_map.get(plan.pilot_application_id) if plan and plan.pilot_application_id else None
it["applicationNo"] = app.application_no if app else ""
it["cellType"] = sched.cell_type or ""
it["specText"] = sched.spec_text or ""
it["infusionTime"] = _fmt_dt(sched.infusion_time) if sched.infusion_time else ""
it["infusionLocation"] = sched.infusion_location or ""
return items
然后在 list_production_work_orders() (约 line 343) 的 return 之前调用:
将:
items = _enrich_wo_items(db, [_wo_to_dict(x) for x in rows])
return {"items": items, "total": int(total)}
改为:
items = _enrich_wo_items(db, [_wo_to_dict(x) for x in rows])
items = _enrich_outbound_items(db, items)
return {"items": items, "total": int(total)}
在 _wo_to_response_dict() 函数 (约 line 160-164) 中,对 outbound 类型附加关联信息:
将:
def _wo_to_response_dict(db: Session, wo: BizProductionWorkOrder) -> Dict[str, Any]:
"""工单响应(含接收记录关联的样本类型、接收人、接收时间及 reception 详情)。"""
out = _enrich_wo_items(db, [_wo_to_dict(wo)])[0]
_attach_reception_detail(db, wo, out, wo.tenant_no or "")
return out
改为:
def _wo_to_response_dict(db: Session, wo: BizProductionWorkOrder) -> Dict[str, Any]:
"""工单响应(含接收记录关联的样本类型、接收人、接收时间及 reception 详情)。"""
out = _enrich_wo_items(db, [_wo_to_dict(wo)])[0]
if wo.work_order_type == "outbound" and wo.infusion_schedule_id:
_attach_outbound_detail(db, wo, out)
else:
_attach_reception_detail(db, wo, out, wo.tenant_no or "")
return out
新增 _attach_outbound_detail 函数:
def _attach_outbound_detail(
db: Session, wo: BizProductionWorkOrder, out: Dict[str, Any]
) -> None:
"""outbound 工单详情附加申请单 + 输注计划信息。"""
from app.models.models import (
BizPilotApplication,
BizPilotInfusionPlan,
BizPilotInfusionSchedule,
)
_APP_STATUS_LABELS = {"dks": "待开始", "jxz": "进行中", "ywc": "已完成", "yzf": "已作废"}
sched = (
db.query(BizPilotInfusionSchedule)
.filter(BizPilotInfusionSchedule.id == wo.infusion_schedule_id, BizPilotInfusionSchedule.del_flag == 0)
.first()
)
plan = None
app = None
if sched and sched.infusion_plan_id:
plan = db.query(BizPilotInfusionPlan).filter(BizPilotInfusionPlan.id == sched.infusion_plan_id).first()
if plan and plan.pilot_application_id:
app = db.query(BizPilotApplication).filter(BizPilotApplication.id == plan.pilot_application_id).first()
out["reception"] = {}
out["pilotApplication"] = {
"applicationNo": app.application_no if app else "",
"applicationName": app.application_name if app else "",
"applicationStatus": app.application_status if app else "",
"applicationStatusLabel": _APP_STATUS_LABELS.get(app.application_status, "") if app else "",
} if app else {}
out["infusionSchedule"] = {
"cellType": sched.cell_type if sched else "",
"specText": sched.spec_text if sched else "",
"infusionTime": _fmt_dt(sched.infusion_time) if sched and sched.infusion_time else "",
"infusionLocation": sched.infusion_location if sched else "",
"infusionMethod": sched.infusion_method if sched else "",
"scheduleStatus": sched.schedule_status if sched else "",
} if sched else {}
Run: cd backend && source venv/bin/activate && python -c "from app.crud.production_work_order import list_production_work_orders, _wo_to_response_dict, create_outbound_production_work_order; print('OK')"
Expected: OK
git add backend/app/crud/production_work_order.py
git commit -m "feat(production): extend CRUD for outbound work orders — biz_type, yzf status, list/detail enrichment"
Files:
Modify: backend/app/crud/pilot_application.py (create_schedule 函数, 约 line 926)
[ ] Step 1: 在 create_schedule 中增加自动创建出库工单
在 create_schedule() 函数 (约 line 966) 的 if commit: 之前插入:
# 自动创建出库生产工单
from app.crud import production_work_order as pwo_svc
pwo_svc.create_outbound_production_work_order(
db,
schedule_id=schedule_id,
tenant_no=tenant_no,
user_id=user_id,
create_dept_id=dept_id,
biz_type="pilot_service",
)
注意位置:应在 _create_schedule_products 之后、if commit: 之前,确保 schedule_id 和 products 已写入但还未 commit(在同一事务内)。
Run: cd backend && source venv/bin/activate && python -c "from app.crud.pilot_application import create_schedule; print('OK')"
Expected: OK
git add backend/app/crud/pilot_application.py
git commit -m "feat(pilot): auto-create outbound production work order on schedule creation"
Files:
Modify: backend/app/crud/pilot_application.py (soft_delete_schedule 函数, 约 line 1064)
[ ] Step 1: 在 soft_delete_schedule 中增加作废逻辑
在 soft_delete_schedule() 函数中,row.del_flag = 1 (约 line 1077) 之前插入:
# 关联的出库生产工单标记为已作废
from app.models.models import BizProductionWorkOrder
outbound_wo = (
db.query(BizProductionWorkOrder)
.filter(
BizProductionWorkOrder.infusion_schedule_id == schedule_id,
BizProductionWorkOrder.tenant_no == tenant_no,
BizProductionWorkOrder.del_flag == 0,
BizProductionWorkOrder.work_order_type == "outbound",
)
.first()
)
if outbound_wo:
outbound_wo.work_order_status = "yzf"
outbound_wo.update_time = datetime.now()
Run: cd backend && source venv/bin/activate && python -c "from app.crud.pilot_application import soft_delete_schedule; print('OK')"
Expected: OK
git add backend/app/crud/pilot_application.py
git commit -m "feat(pilot): auto-void outbound production work order on schedule deletion"
Files:
Modify: web-admin/src/views/production/production-work-order/index.vue
[ ] Step 1: 扩展 WorkOrderTypeTab 类型
约 line 432,修改为:
type WorkOrderTypeTab = 'inbound' | 'temporary' | 'outbound' | 'all'
约 line 15-19,在 全部生产工单 之前插入 outbound tab:
<el-tabs v-model="activeTypeTab" class="work-order-type-tabs" @tab-change="handleTypeTabChange">
<el-tab-pane label="入库生产工单" name="inbound" />
<el-tab-pane label="临时生产工单" name="temporary" />
<el-tab-pane label="出库生产工单" name="outbound" />
<el-tab-pane label="全部生产工单" name="all" />
</el-tabs>
约 line 27,修改 placeholder 为 computed:
将:
:placeholder="activeTypeTab === 'temporary' ? '工单号' : '工单号/协议号/样本编码'"
改为:
:placeholder="searchPlaceholder"
在 script setup 区域新增 computed:
const searchPlaceholder = computed(() => {
if (activeTypeTab.value === 'temporary') return '工单号'
if (activeTypeTab.value === 'outbound') return '工单号/申请单号'
return '工单号/协议号/样本编码'
})
约 line 69,修改:
将:
<div v-if="activeTypeTab !== 'all'" class="list-toolbar">
改为:
<div v-if="activeTypeTab !== 'all' && activeTypeTab !== 'outbound'" class="list-toolbar">
约 line 94-100,修改类型 tag:
将:
<el-table-column v-if="activeTypeTab === 'all'" prop="workOrderType" label="工单类型" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.workOrderType === 'temporary' ? 'warning' : 'success'" size="small">
{{ row.workOrderType === 'temporary' ? '临时' : '入库' }}
</el-tag>
</template>
</el-table-column>
改为:
<el-table-column v-if="activeTypeTab === 'all'" prop="workOrderType" label="工单类型" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="woTypeTag(row.workOrderType).type" size="small">
{{ woTypeTag(row.workOrderType).label }}
</el-tag>
</template>
</el-table-column>
在 script 中 sourceTag 函数之后新增:
const woTypeTag = (type: string) => {
const map: Record<string, { label: string; type: string }> = {
inbound: { label: '入库', type: 'success' },
temporary: { label: '临时', type: 'warning' },
outbound: { label: '出库', type: 'primary' },
}
return map[type] || { label: type, type: 'info' }
}
在 全部工单类型 列之后、协议号 列之前(约 line 100-101 之间),插入 outbound 专属列:
<!-- 出库工单专属列 -->
<el-table-column v-if="activeTypeTab === 'outbound'" prop="bizTypeLabel" label="业务类型" min-width="110" align="center">
<template #default="{ row }">
<el-tag :type="row.bizType === 'pilot_service' ? 'primary' : 'success'" size="small">
{{ row.bizTypeLabel || '—' }}
</el-tag>
</template>
</el-table-column>
<el-table-column v-if="activeTypeTab === 'outbound'" prop="applicationNo" label="申请单号" min-width="150" align="center">
<template #default="{ row }">{{ row.applicationNo || '—' }}</template>
</el-table-column>
<el-table-column v-if="activeTypeTab === 'outbound'" prop="cellType" label="细胞类型" min-width="110" align="center">
<template #default="{ row }">{{ row.cellType || '—' }}</template>
</el-table-column>
<el-table-column v-if="activeTypeTab === 'outbound'" prop="specText" label="规格" min-width="110" align="center">
<template #default="{ row }">{{ row.specText || '—' }}</template>
</el-table-column>
<el-table-column v-if="activeTypeTab === 'outbound'" prop="infusionTime" label="输注时间" min-width="170" align="center">
<template #default="{ row }">{{ row.infusionTime || '—' }}</template>
</el-table-column>
约 line 434,修改:
将:
const showReceptionCols = computed(() => activeTypeTab.value !== 'temporary')
改为:
const showReceptionCols = computed(() => activeTypeTab.value !== 'temporary' && activeTypeTab.value !== 'outbound')
约 line 145-178 的操作列 <el-table-column> 添加 v-if:
将:
<el-table-column
label="操作"
min-width="600"
fixed="right"
align="center"
header-align="center"
>
改为:
<el-table-column
v-if="activeTypeTab !== 'all'"
label="操作"
min-width="600"
fixed="right"
align="center"
header-align="center"
>
约 line 510-517,修改 woStatusTag:
将:
const woStatusTag = (status: string) => {
const map: Record<string, { label: string; type: string }> = {
dpz: { label: '待配置', type: 'info' },
jxz: { label: '进行中', type: 'warning' },
ywc: { label: '已完成', type: 'success' }
}
return map[status] || { label: status, type: 'info' }
}
改为:
const woStatusTag = (status: string) => {
const map: Record<string, { label: string; type: string }> = {
dpz: { label: '待配置', type: 'info' },
jxz: { label: '进行中', type: 'warning' },
ywc: { label: '已完成', type: 'success' },
yzf: { label: '已作废', type: 'danger' }
}
return map[status] || { label: status, type: 'info' }
}
约 line 38-41,增加选项:
将:
<el-option label="待配置" value="dpz" />
<el-option label="进行中" value="jxz" />
<el-option label="已完成" value="ywc" />
改为:
<el-option label="待配置" value="dpz" />
<el-option label="进行中" value="jxz" />
<el-option label="已完成" value="ywc" />
<el-option label="已作废" value="yzf" />
git add web-admin/src/views/production/production-work-order/index.vue
git commit -m "feat(production): add outbound work order tab with columns, filters, and status mapping"
Files:
Modify: web-admin/src/views/production/production-work-order/index.vue (viewSections computed, 约 line 640)
[ ] Step 1: 更新 viewSections computed,增加 outbound 分支
约 line 640-733,替换整个 viewSections computed。
核心逻辑:
isOutbound = d.workOrderType === 'outbound' const viewSections = computed<ViewSection[]>(() => {
if (!detail.value) return []
const d = detail.value
const isTemp = d.workOrderType === 'temporary'
const isOutbound = d.workOrderType === 'outbound'
const r = d.reception || {}
// ── 基本信息 fields ──
const baseFields: ViewSection['fields'] = [
{ label: '工单号', value: d.workOrderNo },
{
label: '工单类型',
value: isTemp ? '临时' : isOutbound ? '出库' : '入库',
tagType: isTemp ? 'warning' : isOutbound ? 'primary' : 'success'
},
]
// outbound 显示业务类型
if (isOutbound) {
baseFields.push({
label: '业务类型',
value: d.bizTypeLabel || (d.bizType === 'pilot_service' ? '中试服务' : d.bizType === 'clinical_application' ? '临床应用' : '—'),
tagType: 'primary' as const
})
}
// inbound 显示协议/样本字段
if (!isTemp && !isOutbound) {
baseFields.push(
{ label: '协议号', value: d.protocolNo },
{
label: '协议状态',
value: protocolStatusLabelBySource(String(d.protocolStatus || ''), d.receptionSource || 'cell_storage'),
tagType: protocolStatusTagTypeBySource(String(d.protocolStatus || ''), d.receptionSource || 'cell_storage')
},
{ label: '样本编码', value: d.sampleCode },
{
label: '来源类型',
value:
r.sourceTypeLabel ||
d.receptionSourceLabel ||
receptionSourceLabel(d.receptionSource || ''),
tagType: 'primary' as const
},
{ label: '样本类型', value: r.sampleTypeName || d.sampleTypeName || '—' }
)
}
baseFields.push(
{
label: '工单状态',
value: woStatusTag(d.workOrderStatus).label,
tagType: woStatusTag(d.workOrderStatus).type as any
},
{ label: '备注', value: d.remark || '—', span: true },
{ label: '创建时间', value: d.createTime || '—' }
)
const sections: ViewSection[] = [
{ title: '基本信息', fields: baseFields }
]
// outbound: 申请单 + 输注计划
if (isOutbound) {
const pa = d.pilotApplication || {}
const sched = d.infusionSchedule || {}
const appStatusLabel = (s: string) => {
const m: Record<string, string> = { dks: '待开始', jxz: '进行中', ywc: '已完成', yzf: '已作废' }
return m[s] || s || '—'
}
const appStatusTagType = (s: string) => {
const m: Record<string, string> = { dks: 'info', jxz: 'warning', ywc: 'success', yzf: 'danger' }
return m[s] || 'info'
}
sections.push({
title: '中试申请信息',
fields: [
{ label: '申请单号', value: pa.applicationNo || '—' },
{ label: '申请名称', value: pa.applicationName || '—' },
{
label: '申请状态',
value: appStatusLabel(pa.applicationStatus),
tagType: appStatusTagType(pa.applicationStatus) as any
},
]
})
sections.push({
title: '输注计划信息',
fields: [
{ label: '细胞类型', value: sched.cellType || '—' },
{ label: '规格', value: sched.specText || '—' },
{ label: '输注时间', value: sched.infusionTime || '—' },
{ label: '输注地点', value: sched.infusionLocation || '—' },
{ label: '输注方式', value: sched.infusionMethod || '—' },
]
})
return sections
}
// inbound: 运输 + 样本接收
const transportFields: ViewSection['fields'] = [
{ label: '运输方式', value: transportTypeLabel(String(r.transportType || '')) }
]
if (r.transportType === 'kdys') {
transportFields.push(
{ label: '快递公司', value: r.expressCompanyName || '—' },
{ label: '快递单号', value: r.waybillNo || '—' }
)
} else if (r.transportType === 'ryxd') {
transportFields.push(
{ label: '携带人', value: r.carrierName || '—' },
{ label: '携带人电话', value: r.carrierPhone || '—' }
)
}
if (r.etaTime) transportFields.push({ label: '预计到达', value: r.etaTime })
const transportSection = transportFields.length > 1 || r.transportType ? [{ title: '运输信息', fields: transportFields }] : []
return [
{ title: '基本信息', fields: baseFields },
...transportSection,
...(!isTemp ? [{
title: '样本接收',
slot: 'reception-extra',
fields: [
{ label: '接收时间', value: r.receptionTime || d.receptionTime || '—' },
{ label: '接收经手人', value: r.handlerName || d.handlerName || '—' },
{ label: '接收温度(℃)', value: r.receiveTemperature ? `${r.receiveTemperature}℃` : '—' },
{ label: '体积(ml)', value: formatMetric(r.volumeMl) },
{ label: '净重(g)', value: formatMetric(r.netWeightG) },
{
label: '结论',
value: r.acceptConclusionLabel || acceptConclusionLabel(String(r.acceptConclusion || '')),
tagType: r.acceptConclusion === 'js' ? 'success' : r.acceptConclusion === 'jj' ? 'danger' : 'info'
},
{ label: '检查项', value: formatCheckResults(r.checkResults), span: true },
{ label: '备注', value: r.remark || '—', span: true }
]
}] : [])
]
})
Run: cd web-admin && pnpm build 2>&1 | tail -5
Expected: Build 成功(可能有无关的 type warning,但不应有 error)
git add web-admin/src/views/production/production-work-order/index.vue
git commit -m "feat(production): add outbound work order detail dialog with pilot application and infusion schedule sections"
Files: 无文件修改
Run: cd backend && source venv/bin/activate && python -c "from app.main import app; print('App OK')"
Expected: App OK
Run: cd web-admin && pnpm build 2>&1 | tail -5
Expected: Build 成功
git add -A
git commit -m "fix(production): final adjustments for outbound work order feature"