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

23 KiB
Raw Blame History

临时质检 · 外部质检对象编号 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

-- 临时质检:外部质检对象编号(手写,可空)
-- 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
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

"""临时质检对象:系统产品 / 外部编号互斥归一化。"""

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)
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:

    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
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
cd backend && source venv/bin/activate && python -m pytest tests/test_temp_qc_target_resolve.py -v

Expected: PASS

  • Step 6: Commit
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:

    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:

    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(一侧为空串)。实现:

    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:

    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(编辑弹窗总是带两侧):

    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:

        "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 = [...]),追加:

                BizQcWorkOrder.external_target_code.like(like),

Also in list_temp_qc_work_orders if still used:

            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:

            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
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
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:

  externalTargetCode?: string
  • Step 2: Extend create/update payloads
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
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:

          <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
  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)。

  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:

    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
      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

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

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 数组靠前插入(产品名称之前):

  {
    label: '质检对象',
    getValue: (row) => String(row.externalTargetCode || '').trim() || '—',
    visible: (row) => !!(row.externalTargetCode || '').trim()
  },

保留产品名称/类型字段(外部单会显示 —,可接受)。

  • Step 3: buildTemporaryViewSections

After 工单来源 fields,分支:

  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:

    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
  if (tab.key === 'temporary') return '工单号 / 外部编号'
  • Step 6: Commit
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: 执行库表变更(本地/测试库)
# 使用你们现有方式执行 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
./scripts/update-graph.sh all
  • Step 4: Commit graph if changed
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。