Files
zkxw/docs/superpowers/plans/2026-08-20-temporary-qc-external-target-code.md
T
zlwuandCursor 19b0346f2d docs: add temporary QC external target code implementation plan
Break the approved design into SQL, backend validation, API, and web-admin UI tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 10:30:33 +08:00

699 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 临时质检 · 外部质检对象编号 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
<div v-if="addMode === 'temporary'" class="form-grid-2">
<el-form-item v-if="editingId" label="工单号" class="form-col-span-2">
<el-input :model-value="editingWorkOrderNo" disabled />
</el-form-item>
<el-form-item label="来源类型" prop="targetSource" class="form-col-span-2">
<el-radio-group v-model="addForm.targetSource" @change="onTargetSourceChange">
<el-radio-button value="cell_product">系统细胞产品</el-radio-button>
<el-radio-button value="external">外部科研编号</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="addForm.targetSource === 'cell_product'"
label="质检对象"
prop="cellProductId"
class="form-col-span-2"
>
<!-- existing el-select remote unchanged -->
</el-form-item>
<el-form-item
v-else
label="质检对象"
prop="externalTargetCode"
class="form-col-span-2"
>
<el-input
v-model="addForm.externalTargetCode"
maxlength="64"
show-word-limit
placeholder="请输入外部编号"
clearable
/>
</el-form-item>
<!-- remark unchanged -->
</div>
```
(若项目 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`。