2026-07-28-product-lines-batch-fill.md 19 KB

入库明细复制首行 + 储物格起止批量填充 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: 纯函数放在 storage-slot-range-fill.ts(排序切区间、占用校验、取前 N);远程下拉透出 facilityNodeId;共享批量填格弹窗组件;两处业务页挂载按钮并调用。不新增后端区间 API,复用 fetchStorageSlotList({ facilityNodeId })。

Tech Stack: Vue 3、Element Plus、TypeScript、现有 storage-slot-options、tsx + Node node:test 测纯函数

Spec: docs/superpowers/specs/2026-07-28-product-lines-batch-fill-design.md


File Structure

File Responsibility
web-admin/src/utils/storage-slot-options.ts StorageSlotSelectOption 增加 facilityNodeId;远程映射透出
web-admin/src/utils/storage-slot-range-fill.ts 复制首行属性;起止区间解析与取格
web-admin/src/utils/storage-slot-range-fill.test.ts 纯函数单测
web-admin/src/components/business/product-lines-batch-slot-fill-dialog/index.vue 起止选择弹窗
web-admin/src/views/production/production-work-order/TaskOutputProductDialog.vue 入口按钮 + 调用
web-admin/src/views/production/cell-product/index.vue 同上

Task 1: 下拉选项透出 facilityNodeId

Files:

  • Modify: web-admin/src/utils/storage-slot-options.ts

  • [ ] Step 1: 扩展类型与映射

在 StorageSlotSelectOption 增加可选字段:

export type StorageSlotSelectOption = {
  id: string
  name: string
  available?: boolean
  facilityNodeId?: string
}

在 mapRemoteSlotSelectOption 中增加:

facilityNodeId: String(item.facilityNodeId || item.facility_node_id || '').trim() || undefined

ensureStorageSlotsInOptions 的 stub 允许传入 facilityNodeId 并写入 option(若有)。

  • Step 2: Commit
git add web-admin/src/utils/storage-slot-options.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): expose facilityNodeId on storage slot select options

Needed so batch start/end fill can verify slots belong to the same box.
EOF
)"

Task 2: 区间填充纯函数 + 单测

Files:

  • Create: web-admin/src/utils/storage-slot-range-fill.ts
  • Create: web-admin/src/utils/storage-slot-range-fill.test.ts

  • [ ] Step 1: 写失败单测

// web-admin/src/utils/storage-slot-range-fill.test.ts
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
  copyFirstProductLineAttrs,
  resolveStorageSlotRangeFill
} from './storage-slot-range-fill'

describe('copyFirstProductLineAttrs', () => {
  it('copies spec/expectedCellCount/viability to other rows', () => {
    const lines = [
      { specName: '1ml', expectedCellCount: 1.5, viability: 90, productCode: 'A' },
      { specName: '', expectedCellCount: undefined, viability: undefined, productCode: 'B' }
    ]
    const r = copyFirstProductLineAttrs(lines)
    assert.equal(r.ok, true)
    assert.equal(lines[1].specName, '1ml')
    assert.equal(lines[1].expectedCellCount, 1.5)
    assert.equal(lines[1].viability, 90)
    assert.equal(lines[1].productCode, 'B')
  })

  it('fails when fewer than 2 lines', () => {
    const r = copyFirstProductLineAttrs([{ specName: 'x' }])
    assert.equal(r.ok, false)
  })
})

describe('resolveStorageSlotRangeFill', () => {
  const box = 'node-1'
  const slots = [
    { id: 's1', orderNum: 1001, facilityNodeId: box, slotStatus: '空', occupiedCount: 0, slotCode: '1-1' },
    { id: 's2', orderNum: 1002, facilityNodeId: box, slotStatus: '空', occupiedCount: 0, slotCode: '1-2' },
    { id: 's3', orderNum: 1003, facilityNodeId: box, slotStatus: '占用', occupiedCount: 1, slotCode: '1-3' },
    { id: 's4', orderNum: 1004, facilityNodeId: box, slotStatus: '空', occupiedCount: 0, slotCode: '1-4' }
  ]

  it('fails when start/end different boxes', () => {
    const r = resolveStorageSlotRangeFill({
      slots: [
        { ...slots[0] },
        { ...slots[1], facilityNodeId: 'other', id: 'sx' }
      ],
      startSlotId: 's1',
      endSlotId: 'sx',
      emptyLineCount: 1,
      formUsedSlotIds: []
    })
    assert.equal(r.ok, false)
    assert.match(r.message, /同一盒子/)
  })

  it('fails when range contains occupied', () => {
    const r = resolveStorageSlotRangeFill({
      slots,
      startSlotId: 's1',
      endSlotId: 's4',
      emptyLineCount: 2,
      formUsedSlotIds: []
    })
    assert.equal(r.ok, false)
    assert.match(r.message, /已占用/)
  })

  it('takes first N available in normalized range and skips form-used', () => {
    const emptySlots = slots.filter((s) => s.id !== 's3')
    const r = resolveStorageSlotRangeFill({
      slots: emptySlots,
      startSlotId: 's4',
      endSlotId: 's1',
      emptyLineCount: 2,
      formUsedSlotIds: ['s2']
    })
    assert.equal(r.ok, true)
    if (r.ok) {
      assert.deepEqual(
        r.slotIds,
        ['s1', 's4']
      )
    }
  })

  it('fails when available count < emptyLineCount', () => {
    const emptySlots = slots.filter((s) => s.id !== 's3')
    const r = resolveStorageSlotRangeFill({
      slots: emptySlots,
      startSlotId: 's1',
      endSlotId: 's2',
      emptyLineCount: 3,
      formUsedSlotIds: []
    })
    assert.equal(r.ok, false)
    assert.match(r.message, /不足/)
  })
})
  • Step 2: 运行确认失败

Run:

cd web-admin && pnpm exec tsx --test src/utils/storage-slot-range-fill.test.ts

Expected: FAIL(模块不存在)

  • Step 3: 实现纯函数
// web-admin/src/utils/storage-slot-range-fill.ts
import {
  isStorageSlotAvailable,
  sortStorageFacilitySlots
} from '@/utils/storage-slot-options'

export type ProductLineAttrRow = {
  specName?: string
  expectedCellCount?: number
  viability?: number
  [key: string]: unknown
}

export type CopyFirstAttrsResult =
  | { ok: true; filledCount: number }
  | { ok: false; message: string }

export function copyFirstProductLineAttrs(
  lines: ProductLineAttrRow[]
): CopyFirstAttrsResult {
  if (!lines || lines.length < 2) {
    return { ok: false, message: '至少两行明细才能复制' }
  }
  const first = lines[0]
  for (let i = 1; i < lines.length; i++) {
    lines[i].specName = first.specName ?? ''
    lines[i].expectedCellCount = first.expectedCellCount
    lines[i].viability = first.viability
  }
  return { ok: true, filledCount: lines.length - 1 }
}

export type RangeFillSlotRow = Record<string, unknown> & {
  id?: string
  facilityNodeId?: string
  facility_node_id?: string
  orderNum?: number
  order_num?: number
  slotStatus?: string
  slot_status?: string
  occupiedCount?: number
  occupied_count?: number
  slotCode?: string
  slot_code?: string
}

export type ResolveRangeFillInput = {
  slots: RangeFillSlotRow[]
  startSlotId: string
  endSlotId: string
  emptyLineCount: number
  formUsedSlotIds: string[]
}

export type ResolveRangeFillResult =
  | {
      ok: true
      slotIds: string[]
      options: Array<{ id: string; name: string; facilityNodeId?: string }>
    }
  | { ok: false; message: string }

function slotFacilityNodeId(slot: RangeFillSlotRow): string {
  return String(slot.facilityNodeId ?? slot.facility_node_id ?? '').trim()
}

function slotDisplayName(slot: RangeFillSlotRow): string {
  return String(
    slot.fullSlotCode ?? slot.full_slot_code ?? slot.slotCode ?? slot.slot_code ?? slot.id ?? ''
  )
}

export function resolveStorageSlotRangeFill(
  input: ResolveRangeFillInput
): ResolveRangeFillResult {
  const startId = String(input.startSlotId || '').trim()
  const endId = String(input.endSlotId || '').trim()
  const need = Math.max(0, Number(input.emptyLineCount) || 0)
  if (!startId || !endId) {
    return { ok: false, message: '请选择起始与结束储物格' }
  }
  if (need <= 0) {
    return { ok: false, message: '没有可填充的空行' }
  }

  const byId = new Map(
    (input.slots || [])
      .map((s) => [String(s.id || '').trim(), s] as const)
      .filter(([id]) => !!id)
  )
  const start = byId.get(startId)
  const end = byId.get(endId)
  if (!start || !end) {
    return { ok: false, message: '起始或结束储物格不在当前盒子中' }
  }
  const startNode = slotFacilityNodeId(start)
  const endNode = slotFacilityNodeId(end)
  if (!startNode || !endNode || startNode !== endNode) {
    return { ok: false, message: '起始与结束须在同一盒子内' }
  }

  const sameBox = (input.slots || []).filter((s) => slotFacilityNodeId(s) === startNode)
  const sorted = sortStorageFacilitySlots(sameBox as Record<string, unknown>[]) as RangeFillSlotRow[]
  const iStart = sorted.findIndex((s) => String(s.id) === startId)
  const iEnd = sorted.findIndex((s) => String(s.id) === endId)
  if (iStart < 0 || iEnd < 0) {
    return { ok: false, message: '起始或结束储物格不在当前盒子中' }
  }
  const from = Math.min(iStart, iEnd)
  const to = Math.max(iStart, iEnd)
  const range = sorted.slice(from, to + 1)

  for (const slot of range) {
    if (!isStorageSlotAvailable(slot as Record<string, unknown>)) {
      return { ok: false, message: '区间内存在已占用储物格,请调整起止范围' }
    }
  }

  const used = new Set(
    (input.formUsedSlotIds || []).map((id) => String(id || '').trim()).filter(Boolean)
  )
  const available = range.filter((s) => {
    const id = String(s.id || '').trim()
    return id && !used.has(id)
  })
  if (available.length < need) {
    return {
      ok: false,
      message: `区间内可用储物格不足(需要 ${need} 个,实际 ${available.length} 个)`
    }
  }

  const picked = available.slice(0, need)
  return {
    ok: true,
    slotIds: picked.map((s) => String(s.id)),
    options: picked.map((s) => ({
      id: String(s.id),
      name: slotDisplayName(s),
      facilityNodeId: startNode
    }))
  }
}

/** 将 slotIds 依次写入空行(原地修改),返回填充行数 */
export function applySlotIdsToEmptyLines(
  lines: Array<{ storageSlotId?: string }>,
  slotIds: string[]
): number {
  let k = 0
  for (const line of lines) {
    if (String(line.storageSlotId || '').trim()) continue
    if (k >= slotIds.length) break
    line.storageSlotId = slotIds[k++]
  }
  return k
}

注意:sortStorageFacilitySlots / isStorageSlotAvailable 已从 storage-slot-options.ts 导出;若 @/ 在 tsx --test 下无法解析,测试文件改用相对导入 ./storage-slot-options,实现文件保持 @/(与项目一致)。若 tsx 解析 @ 失败,在测试运行命令前确认 vite/tsconfig paths,或测试内只测本文件并用相对路径改写实现文件的 import 为相对路径(实现文件优先相对导入 ./storage-slot-options,避免测跑路径问题)。

  • Step 4: 跑通单测
cd web-admin && pnpm exec tsx --test src/utils/storage-slot-range-fill.test.ts

Expected: 全部 PASS

若 formUsedSlotIds: ['s2'] 且区间 s1..s4(无占用)时期望 ['s1','s4']:有序跳过 s2 后取前 2 个。确认 Step 1 用例与实现一致。

  • Step 5: Commit
git add web-admin/src/utils/storage-slot-range-fill.ts web-admin/src/utils/storage-slot-range-fill.test.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add storage slot range-fill helpers for product lines

Pure functions for copy-first-row attrs and same-box start/end slot pick.
EOF
)"

Task 3: 共享「批量填储物格」弹窗

Files:

  • Create: web-admin/src/components/business/product-lines-batch-slot-fill-dialog/index.vue

  • [ ] Step 1: 实现弹窗

行为:

  • v-model 控制显隐;emptyLineCount prop(打开时若为 0,父级可不打开,或弹窗内提示)
  • 起始 / 结束:远程 el-select,复用 remoteSearchStorageSlotSelectOptions、STORAGE_SLOT_REMOTE_SEARCH_DEBOUNCE_MS、isStorageSlotSelectOptionDisabled
  • 选中后从 option 读取 facilityNodeId;若缺失,用 fetchStorageSlotList 不可行(无 id 查询),改为在 confirm 时:用两格的 facilityNodeId(option 上)校验同盒后,再 fetchStorageSlotList({ facilityNodeId })
  • 确定:emit confirm 携带 { startSlotId, endSlotId, facilityNodeId },由父级拉列表并调用 resolveStorageSlotRangeFill;或弹窗内完成拉取与 resolve,成功 emit { slotIds, options },失败 ElMessage.warning

推荐弹窗内完成,减少两页重复:

emit('filled', { slotIds: string[], options: Array<{id,name,facilityNodeId?}> })

Props:

modelValue: boolean
emptyLineCount: number
/** 本表单已占用格(含非空行),传给 resolve */
formUsedSlotIds: string[]
/** 任务级已占用(可选),并入 formUsed */
occupiedSlotIds?: string[]

模板要点:class="dialog-form-layout"、align-center、append-to-body、modal-class="protocol-dialog-mask"、label-position="top"、宽约 520px;footer Close/Check;提示文案见 spec §4.2。

选中 option 时缓存 Map<id, StorageSlotSelectOption>,保证 confirm 时能取到 facilityNodeId。

  • Step 2: Commit
git add web-admin/src/components/business/product-lines-batch-slot-fill-dialog/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): add product-lines batch storage slot fill dialog

Pick same-box start/end slots and resolve fill targets for empty lines.
EOF
)"

Task 4: 接入 TaskOutputProductDialog

Files:

  • Modify: web-admin/src/views/production/production-work-order/TaskOutputProductDialog.vue

  • [ ] Step 1: 标题区按钮

在 product-lines-head 内标题与 tip 之后增加工具按钮组(margin-left: auto 或独立 product-lines-actions):

<div class="product-lines-actions">
  <el-button class="btn-ghost" @click="handleCopyFirstLineAttrs">
    <el-icon><CopyDocument /></el-icon>
    复制首行属性
  </el-button>
  <el-button class="btn-ghost" @click="openBatchSlotFill">
    <el-icon><Grid /></el-icon>
    批量填储物格
  </el-button>
</div>

图标从 @element-plus/icons-vue 显式导入(CopyDocument、Grid 或 Box)。

挂载:

<ProductLinesBatchSlotFillDialog
  v-model="batchSlotFillVisible"
  :empty-line-count="emptyStorageSlotLineCount"
  :form-used-slot-ids="formUsedStorageSlotIds"
  :occupied-slot-ids="occupiedSlotIds || []"
  @filled="onBatchSlotFilled"
/>
  • Step 2: script 逻辑
import ProductLinesBatchSlotFillDialog from '@/components/business/product-lines-batch-slot-fill-dialog/index.vue'
import {
  copyFirstProductLineAttrs,
  applySlotIdsToEmptyLines
} from '@/utils/storage-slot-range-fill'
import { ensureStorageSlotsInOptions } from '@/utils/storage-slot-options'

const batchSlotFillVisible = ref(false)

const emptyStorageSlotLineCount = computed(
  () => productLines.value.filter((l) => !String(l.storageSlotId || '').trim()).length
)

const formUsedStorageSlotIds = computed(() =>
  productLines.value.map((l) => l.storageSlotId).filter((id) => String(id || '').trim())
)

function handleCopyFirstLineAttrs() {
  const r = copyFirstProductLineAttrs(productLines.value)
  if (!r.ok) {
    ElMessage.warning(r.message)
    return
  }
  ElMessage.success(`已将首行属性复制到其余 ${r.filledCount} 行`)
}

function openBatchSlotFill() {
  if (emptyStorageSlotLineCount.value <= 0) {
    ElMessage.warning('没有可填充的空行')
    return
  }
  batchSlotFillVisible.value = true
}

function onBatchSlotFilled(payload: {
  slotIds: string[]
  options: Array<{ id: string; name: string; facilityNodeId?: string }>
}) {
  const n = applySlotIdsToEmptyLines(productLines.value, payload.slotIds)
  storageSlotOptions.value = ensureStorageSlotsInOptions(
    storageSlotOptions.value,
    payload.options
  )
  ElMessage.success(`已为 ${n} 行填充储物格`)
}

样式:.product-lines-head 改为 align-items: center;.product-lines-actions flex + gap 8px。

  • Step 3: Commit
git add web-admin/src/views/production/production-work-order/TaskOutputProductDialog.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): enable copy-first and batch slot fill on task output dialog

Speed up multi-line cell product inbound entry in production task execution.
EOF
)"

Task 5: 接入细胞产品管理新增弹窗

Files:

  • Modify: web-admin/src/views/production/cell-product/index.vue

  • [ ] Step 1: 与 Task 4 对称改动

在 product-lines-head 增加同样两个按钮与 ProductLinesBatchSlotFillDialog。

复用同一套 computed / handlers(变量名对齐 Task 4)。注意该页 storageSlotOptions 类型可能是 { id, name }[],ensureStorageSlotsInOptions 后仍兼容。

occupiedSlotIds:若新增页无任务占用,传 [] 或不传。

  • Step 2: Commit
git add web-admin/src/views/production/cell-product/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): enable copy-first and batch slot fill on cell product create

Keep inbound product-lines UX consistent with task output dialog.
EOF
)"

Task 6: 手动验收 + 图谱更新

  • Step 1: 手动验收(对照 spec §8)
  1. /#/production-group/production/my-production-task(或生产工单任务执行)→ 产出新增 → 入库份数 ≥ 2 → 复制首行属性
  2. 批量填储物格:同盒起止、只填空行;再换另一盒填剩余空行
  3. 跨盒起止 / 区间含占用 / 格数不足 → 应失败且明细不变
  4. 细胞产品管理新增页重复上述关键路径
  5. 回归:造血干一键填充、提交一格一物校验
  • Step 2: 更新知识图谱
./scripts/update-graph.sh frontend
  • Step 3: 若有未提交改动则提交图谱
git add web-admin/graphify-out
git commit -m "$(cat <<'EOF'
chore(web-admin): update graphify after product-lines batch fill
EOF
)"

Spec coverage(自检)

Spec 要求 Task
复制首行三字段 Task 2 + 4 + 5
起止同盒、占用失败、取前 N、只填空行、可多次 Task 2 + 3 + 4 + 5
起止下标自动规范化 Task 2 min/max
facilityNodeId Task 1 + 3
两处页面 Task 4 + 5
编辑态不展示 现有 v-if="!isEdit" 不变
验收 Task 6

Placeholder scan

无 TBD /「类似 Task N」省略实现。

Type consistency

  • resolveStorageSlotRangeFill → { ok, slotIds, options }
  • 弹窗 emit filled 同结构
  • applySlotIdsToEmptyLines(lines, slotIds)
  • copyFirstProductLineAttrs → { ok, filledCount } | { ok:false, message }