diff --git a/docs/superpowers/plans/2026-08-20-temporary-qc-external-target-code.md b/docs/superpowers/plans/2026-08-20-temporary-qc-external-target-code.md new file mode 100644 index 00000000..6629ac83 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-temporary-qc-external-target-code.md @@ -0,0 +1,698 @@ +# 临时质检 · 外部质检对象编号 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`。