# 临时质检 · 外部质检对象编号 Implementation Plan > **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:** 在 `biz_qc_work_order` 增加可空字段 `external_target_code`;手动创建/更新临时工单时用纯函数归一化互斥目标(有产品则清空外部编号,有外部编号则清空产品 ID);列表/详情透出字段;前端弹窗用来源切换(方案 A);卡片标题与详情按来源展示。生产派发链路不改。 **Tech Stack:** FastAPI / SQLAlchemy / Pydantic v2 / Vue 3 / Element Plus;SQL MySQL 5.7。 **Spec:** `docs/superpowers/specs/2026-08-20-temporary-qc-external-target-code-design.md` --- ## File map | File | Responsibility | |------|----------------| | Create: `docs/sql/2026-08-20-qc-work-order-external-target-code.sql` | 增量 ALTER | | Modify: `backend/app/models/models.py` | `BizQcWorkOrder.external_target_code` | | Modify: `backend/app/schemas/qc_work_order.py` | Create/Update Body 增加 `externalTargetCode` | | Modify: `backend/app/crud/qc_work_order.py` | 归一化校验、create/update、`_wo_to_dict`、keyword | | Create: `backend/tests/test_temp_qc_target_resolve.py` | 互斥归一化纯函数单测 | | Modify: `backend/app/api/qc.py` | create/update 传入 `external_target_code` | | Modify: `web-admin/src/api/qc-work-order.ts` | 类型与 create/update 参数 | | Modify: `web-admin/src/views/quality-inspection/qc-work-order/index.vue` | 方案 A 弹窗 | | Modify: `web-admin/src/utils/qc-work-order-card-meta.ts` | 卡片标题 + temporary meta | | Modify: `web-admin/src/views/quality-inspection/qc-work-order/composables/inbound-view-sections.ts` | 详情分区 | | Modify: `web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderTaskSubList.vue` | 任务侧「质检对象」兜底 | | Modify: `web-admin/src/utils/qc-work-order-tabs.ts` | temporary keyword placeholder | --- ### Task 1: SQL 增量字段 **Files:** - Create: `docs/sql/2026-08-20-qc-work-order-external-target-code.sql` - [ ] **Step 1: Write SQL** ```sql -- 临时质检:外部质检对象编号(手写,可空) -- MySQL 5.7 ALTER TABLE `biz_qc_work_order` ADD COLUMN `external_target_code` varchar(64) DEFAULT NULL COMMENT '外部质检对象编号(手写)' AFTER `cell_product_id`; ``` - [ ] **Step 2: Commit** ```bash git add docs/sql/2026-08-20-qc-work-order-external-target-code.sql git commit -m "docs(sql): add external_target_code on biz_qc_work_order" ``` --- ### Task 2: 后端模型 + 互斥归一化纯函数(TDD) **Files:** - Modify: `backend/app/models/models.py`(`BizQcWorkOrder`,约 `cell_product_id` 后) - Modify: `backend/app/crud/qc_work_order.py` - Create: `backend/tests/test_temp_qc_target_resolve.py` - [ ] **Step 1: Write failing tests** ```python """临时质检对象:系统产品 / 外部编号互斥归一化。""" import unittest from app.crud.qc_work_order import resolve_temp_qc_target class TestResolveTempQcTarget(unittest.TestCase): def test_cell_product_only(self): cp, ext = resolve_temp_qc_target( cell_product_id=" pid-1 ", external_target_code=" " ) self.assertEqual(cp, "pid-1") self.assertIsNone(ext) def test_external_only(self): cp, ext = resolve_temp_qc_target( cell_product_id="", external_target_code=" EXT-1 " ) self.assertIsNone(cp) self.assertEqual(ext, "EXT-1") def test_both_raises(self): with self.assertRaises(ValueError) as ctx: resolve_temp_qc_target( cell_product_id="pid", external_target_code="EXT" ) self.assertIn("不能同时", str(ctx.exception)) def test_neither_raises(self): with self.assertRaises(ValueError) as ctx: resolve_temp_qc_target(cell_product_id="", external_target_code="") self.assertIn("质检对象", str(ctx.exception)) def test_external_too_long(self): with self.assertRaises(ValueError) as ctx: resolve_temp_qc_target( cell_product_id="", external_target_code="x" * 65 ) self.assertIn("64", str(ctx.exception)) if __name__ == "__main__": unittest.main() ``` - [ ] **Step 2: Run tests — expect FAIL (import / missing function)** ```bash cd backend && source venv/bin/activate && python -m pytest tests/test_temp_qc_target_resolve.py -v ``` Expected: FAIL(`resolve_temp_qc_target` 未定义) - [ ] **Step 3: Add model column** In `BizQcWorkOrder`, immediately after `cell_product_id`: ```python cell_product_id = Column(String(64), nullable=True, comment="质检对象-细胞产品ID") external_target_code = Column(String(64), nullable=True, comment="外部质检对象编号(手写)") ``` - [ ] **Step 4: Implement `resolve_temp_qc_target` near `_validate_cell_product_id`** ```python def resolve_temp_qc_target( *, cell_product_id: Optional[str] = None, external_target_code: Optional[str] = None, ) -> Tuple[Optional[str], Optional[str]]: """临时质检对象互斥归一化。返回 (cell_product_id|None, external_target_code|None)。""" cp = (cell_product_id or "").strip() or None ext = (external_target_code or "").strip() or None if cp and ext: raise ValueError("质检对象不能同时选择系统产品并填写外部编号") if not cp and not ext: raise ValueError("请选择质检对象或填写外部编号") if ext and len(ext) > 64: raise ValueError("外部编号最长 64 个字符") if ext: return None, ext return cp, None ``` Ensure `Tuple` / `Optional` imports exist at top of `qc_work_order.py`. - [ ] **Step 5: Run tests — expect PASS** ```bash cd backend && source venv/bin/activate && python -m pytest tests/test_temp_qc_target_resolve.py -v ``` Expected: PASS - [ ] **Step 6: Commit** ```bash git add backend/app/models/models.py backend/app/crud/qc_work_order.py backend/tests/test_temp_qc_target_resolve.py git commit -m "feat(backend): resolve temporary QC target cell product vs external code" ``` --- ### Task 3: Schema + create/update + dict + keyword + API **Files:** - Modify: `backend/app/schemas/qc_work_order.py` - Modify: `backend/app/crud/qc_work_order.py`(`create_temp_qc_work_order`、`update_qc_work_order`、`_wo_to_dict`、列表 keyword) - Modify: `backend/app/api/qc.py` - [ ] **Step 1: Extend Pydantic bodies** In `QcWorkOrderCreateBody` add: ```python external_target_code: Optional[str] = Field( None, alias="externalTargetCode", description="外部质检对象编号(手写)" ) ``` In `QcWorkOrderUpdateBody` add the same field. - [ ] **Step 2: Update `create_temp_qc_work_order`** Change signature to accept `external_target_code: str = ""` and replace the old forced `_validate_cell_product_id` block with: ```python cp_id, ext_code = resolve_temp_qc_target( cell_product_id=cell_product_id, external_target_code=external_target_code, ) if cp_id: cp_id = _validate_cell_product_id(db, cp_id, tenant_no) wo = BizQcWorkOrder( ... cell_product_id=cp_id, external_target_code=ext_code, ... ) ``` Keep other fields unchanged. - [ ] **Step 3: Update `update_qc_work_order`** Add param `external_target_code: Optional[str] = None`. For `wo_type == "temporary"`, when **either** `cell_product_id is not None` **or** `external_target_code is not None`(编辑弹窗总会同时提交两者时用显式传参): 约定:编辑临时工单时 API **始终同时传入** `cellProductId` 与 `externalTargetCode`(一侧为空串)。实现: ```python if wo_type == "temporary": if cell_product_id is not None or external_target_code is not None: cp_id, ext_code = resolve_temp_qc_target( cell_product_id=cell_product_id if cell_product_id is not None else (wo.cell_product_id or ""), external_target_code=external_target_code if external_target_code is not None else (getattr(wo, "external_target_code", None) or ""), ) # 前端编辑会同时提交两侧;为避免「只改备注」误走上面分支, # API 层:仅当 body 显式带了 cellProductId 或 externalTargetCode 字段时才更新对象。 if cp_id: cp_id = _validate_cell_product_id(db, cp_id, tenant_no) wo.cell_product_id = cp_id wo.external_target_code = ext_code ``` 更稳妥的约定(推荐在本 Task 落实): - `update` 增加显式参数;API 用 `body.model_fields_set` / `body.model_dump(exclude_unset=True)` 判断是否更新质检对象。 - 若 `cellProductId` 或 `externalTargetCode` **任一在 unset 之外**:用 body 值(缺省当 `""`)调用 `resolve_temp_qc_target`,再写回。 - 若两者都 unset:不改质检对象(备注/状态路径不变)。 Example in API: ```python data = body.model_dump(by_alias=False, exclude_unset=True) touch_target = "cell_product_id" in data or "external_target_code" in data wo = wo_svc.update_qc_work_order( ..., cell_product_id=data.get("cell_product_id") if touch_target else None, external_target_code=data.get("external_target_code") if touch_target else None, # 当 touch_target 时,传入两侧(缺省 ""),crud 内若两者参数均 not None 则 resolve ... ) ``` Simplest crud rule that matches frontend(编辑弹窗总是带两侧): ```python if wo_type == "temporary" and cell_product_id is not None and external_target_code is not None: cp_id, ext_code = resolve_temp_qc_target( cell_product_id=cell_product_id, external_target_code=external_target_code, ) if cp_id: cp_id = _validate_cell_product_id(db, cp_id, tenant_no) wo.cell_product_id = cp_id wo.external_target_code = ext_code ``` 备注-only 更新(`WorkOrderRemarkDialog`)不传这两个字段 → 两者保持 `None` → 不改对象。前端临时编辑弹窗必须 **同时传** `cellProductId` 与 `externalTargetCode`(一侧 `""`)。 - [ ] **Step 4: `_wo_to_dict` 增加字段** Next to `cellProductId`: ```python "cellProductId": getattr(wo, "cell_product_id", None) or "", "externalTargetCode": getattr(wo, "external_target_code", None) or "", ``` - [ ] **Step 5: Keyword — general list** In `list_*` keyword block(约 `keyword_conditions = [...]`),追加: ```python BizQcWorkOrder.external_target_code.like(like), ``` Also in `list_temp_qc_work_orders` if still used: ```python q0 = q0.filter( or_( BizQcWorkOrder.work_order_no.like(like), BizQcWorkOrder.external_target_code.like(like), ) ) ``` (需 `or_` 已导入。) - [ ] **Step 6: Wire API create/update** `create_qc_work_order` temporary branch: ```python wo = wo_svc.create_temp_qc_work_order( db, tenant_no=tenant_no, user_id=user.get("user_id"), create_dept_id=user.get("dept_id", ""), cell_product_id=body.cell_product_id or "", external_target_code=body.external_target_code or "", remark=body.remark or "", ) ``` `update_qc_work_order` API: pass `external_target_code=body.external_target_code`(与 `cell_product_id` 同样,可为 None)。 - [ ] **Step 7: Smoke import** ```bash cd backend && source venv/bin/activate && python -c "from app.crud.qc_work_order import resolve_temp_qc_target, create_temp_qc_work_order; from app.schemas.qc_work_order import QcWorkOrderCreateBody; print('OK')" ``` Expected: `OK` - [ ] **Step 8: Commit** ```bash git add backend/app/schemas/qc_work_order.py backend/app/crud/qc_work_order.py backend/app/api/qc.py git commit -m "feat(backend): persist external target code on temporary QC work orders" ``` --- ### Task 4: 前端 API 类型 **Files:** - Modify: `web-admin/src/api/qc-work-order.ts` - [ ] **Step 1: Extend `QcWorkOrder`** After `cellProductId?: string` add: ```typescript externalTargetCode?: string ``` - [ ] **Step 2: Extend create/update payloads** ```typescript export function fetchCreateQcWorkOrder(data: { workOrderType?: string protocolNo?: string sampleCode?: string receptionSource?: string qcBizType?: string cellProductId?: string externalTargetCode?: string remark?: string }) { ... } export function fetchUpdateQcWorkOrder( id: string, data: { cellProductId?: string externalTargetCode?: string workOrderStatus?: string qcConclusion?: string remark?: string } ) { ... } ``` - [ ] **Step 3: Commit** ```bash git add web-admin/src/api/qc-work-order.ts git commit -m "feat(web-admin): type externalTargetCode on QC work order API" ``` --- ### Task 5: 新增/编辑弹窗(方案 A) **Files:** - Modify: `web-admin/src/views/quality-inspection/qc-work-order/index.vue` - [ ] **Step 1: Template — temporary form block** Replace the temporary branch of the add dialog form so it includes来源切换. Structure: ```vue
系统细胞产品 外部科研编号
``` (若项目 Element Plus 版本 `el-radio-button` 用 `label` 而非 `value`,与仓库其它页保持一致。) - [ ] **Step 2: Script — form state & rules** ```typescript const addForm = reactive({ sampleCode: '', receptionSource: 'cell_storage', qcBizType: 'inbound', targetSource: 'cell_product' as 'cell_product' | 'external', cellProductId: '', externalTargetCode: '', remark: '' }) const addRules = computed(() => { if (addMode.value !== 'temporary') { return { /* existing inbound rules */ } } if (addForm.targetSource === 'external') { return { externalTargetCode: [ { required: true, message: '请填写外部编号', trigger: 'blur' }, { validator: (_: unknown, v: string, cb: (e?: Error) => void) => { if ((v || '').trim().length > 64) cb(new Error('外部编号最长 64 个字符')) else cb() }, trigger: 'blur' } ] } } return { cellProductId: [ { required: true, message: '请选择质检对象(细胞产品)', trigger: 'change' } ] } }) ``` Bind `:rules="addRules"` on `el-form`(若原为静态 `addRules`,改为 computed)。 ```typescript function onTargetSourceChange() { addForm.cellProductId = '' addForm.externalTargetCode = '' cellProductOptions.value = [] nextTick(() => addFormRef.value?.clearValidate()) } ``` - [ ] **Step 3: Open handlers** `handleAddTemporary`: set `targetSource = 'cell_product'`, clear both target fields, then `searchCellProductOptions('')`. `handleEdit`: ```typescript const hasExternal = !!(row.externalTargetCode || '').trim() addForm.targetSource = hasExternal ? 'external' : 'cell_product' addForm.cellProductId = row.cellProductId || '' addForm.externalTargetCode = row.externalTargetCode || '' addForm.remark = row.remark || '' cellProductOptions.value = [] if (addForm.targetSource === 'cell_product') { ensureCellProductOption(row) searchCellProductOptions(row.cellProductCode || row.cellProductName || '') } ``` - [ ] **Step 4: `submitAdd` temporary branches** ```typescript if (editingId.value) { const payload = addMode.value === 'temporary' ? { cellProductId: addForm.targetSource === 'cell_product' ? addForm.cellProductId : '', externalTargetCode: addForm.targetSource === 'external' ? addForm.externalTargetCode.trim() : '', remark: addForm.remark } : { remark: addForm.remark /* + status fields if any */ } await fetchUpdateQcWorkOrder(editingId.value, payload) ... } else if (addMode.value === 'temporary') { await fetchCreateQcWorkOrder({ workOrderType: 'temporary', cellProductId: addForm.targetSource === 'cell_product' ? addForm.cellProductId : '', externalTargetCode: addForm.targetSource === 'external' ? addForm.externalTargetCode.trim() : '', remark: addForm.remark }) ``` 注意:备注编辑弹窗 `submitRemarkEdit` **不要**带 `cellProductId`/`externalTargetCode`。 - [ ] **Step 5: Manual UI check**(dev server) - 临时 Tab → 新增 → 切换外部 → 填编号 → 创建成功 - 列表能看到编号(Task 6 完成后) - 编辑切回来源系统 → 选产品 → 保存 - [ ] **Step 6: Commit** ```bash git add web-admin/src/views/quality-inspection/qc-work-order/index.vue git commit -m "feat(web-admin): temporary QC dialog source toggle for external target code" ``` --- ### Task 6: 列表卡片 + 详情 + 任务子列表 + placeholder **Files:** - Modify: `web-admin/src/utils/qc-work-order-card-meta.ts` - Modify: `web-admin/src/views/quality-inspection/qc-work-order/composables/inbound-view-sections.ts` - Modify: `web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderTaskSubList.vue` - Modify: `web-admin/src/utils/qc-work-order-tabs.ts` - [ ] **Step 1: Card title** ```typescript export function resolveTemporaryWorkOrderCardTitle(row: QcWorkOrder): string { const external = String(row.externalTargetCode || '').trim() if (external) return external return formatTempCellProductField(row, row.cellProductCode) } ``` - [ ] **Step 2: `temporaryFields` — 质检对象** 在 `temporaryFields` 数组靠前插入(产品名称之前): ```typescript { label: '质检对象', getValue: (row) => String(row.externalTargetCode || '').trim() || '—', visible: (row) => !!(row.externalTargetCode || '').trim() }, ``` 保留产品名称/类型字段(外部单会显示 `—`,可接受)。 - [ ] **Step 3: `buildTemporaryViewSections`** After 工单来源 fields,分支: ```typescript const external = String(detail.externalTargetCode || '').trim() if (external) { basicFields.push( { label: '质检对象', value: external }, { label: '对象来源', value: '外部', tagType: 'warning' } ) } else { basicFields.push( { label: '产品编码', value: detail.cellProductCode || '—' }, { label: '产品名称', value: detail.cellProductName || '—' }, { label: '产品类型', value: detail.cellProductTypeName || '—' }, { label: '代次', value: detail.cellProductPassage || '—' }, { label: '规格', value: resolveCellProductSpecDisplay(detail) || '—' } ) } ``` (其余状态/备注字段保持原顺序接在后面。) - [ ] **Step 4: Task sublist 质检对象 section** In `QcWorkOrderTaskSubList.vue` where `cellProductSection` is built for temp QC: ```typescript const external = String(d.externalTargetCode || '').trim() const cellProductSection: ViewSection[] = isTempQc ? [ { title: '质检对象', fields: external ? [ { label: '外部编号', value: external }, { label: '对象来源', value: '外部', tagType: 'warning' as const } ] : [ { label: '产品编码', value: d.cellProductCode || '—' }, { label: '产品名称', value: d.cellProductName || '—' }, { label: '产品类型', value: d.cellProductTypeName || '—' }, { label: '代次', value: d.cellProductPassage || '—' }, { label: '规格', value: resolveCellProductSpecDisplay(d) || '—' } ] } ] : [] ``` (确认 `ViewField` 支持 `tagType`;若该处 fields 不支持 tag,则对象来源用纯文案。) - [ ] **Step 5: Keyword placeholder** ```typescript if (tab.key === 'temporary') return '工单号 / 外部编号' ``` - [ ] **Step 6: Commit** ```bash git add web-admin/src/utils/qc-work-order-card-meta.ts \ web-admin/src/views/quality-inspection/qc-work-order/composables/inbound-view-sections.ts \ web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderTaskSubList.vue \ web-admin/src/utils/qc-work-order-tabs.ts git commit -m "feat(web-admin): show external target code on temporary QC list and detail" ``` --- ### Task 7: 图谱更新 + 验收 - [ ] **Step 1: 执行库表变更**(本地/测试库) ```bash # 使用你们现有方式执行 docs/sql/2026-08-20-qc-work-order-external-target-code.sql ``` - [ ] **Step 2: 手工验收清单** 1. 系统产品路径:新建临时工单 → 列表标题为产品编码 → 详情产品字段正常 2. 外部编号路径:新建 → 列表标题为外部编号 → 详情显示质检对象 +「外部」 3. 同一外部编号建两单均成功 4. 编辑:外部 ↔ 系统切换后保存,对侧字段清空 5. 备注编辑不丢质检对象 6. keyword 搜外部编号能命中 7. 生产派发临时质检(若有现成数据)行为不变 - [ ] **Step 3: Update graph** ```bash ./scripts/update-graph.sh all ``` - [ ] **Step 4: Commit graph if changed** ```bash git add backend/graphify-out web-admin/graphify-out git commit -m "chore: update graphify after temporary QC external target code" ``` --- ## Spec coverage self-check | Spec 要求 | Task | |-----------|------| | `external_target_code` 字段 | 1, 2 | | 互斥校验 / 不唯一 / ≤64 | 2, 3, 5 | | Create/Update API | 3, 4, 5 | | 方案 A 弹窗 | 5 | | 列表标题 / meta / 详情 / 任务侧 | 6 | | keyword | 3, 6 | | 生产派发不动 | (无 Task 改派发代码) | | 不做伪细胞产品 / 不借 sample_code | (未引入) | ## Placeholder scan 无 TBD/TODO;函数名统一 `resolve_temp_qc_target` / `externalTargetCode` / `external_target_code`。