Parcourir la source

docs: add product-lines batch fill design and plan

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu il y a 3 mois
Parent
commit
5942feb06e

+ 599 - 0
docs/superpowers/plans/2026-07-28-product-lines-batch-fill.md

@@ -0,0 +1,599 @@
+# 入库明细复制首行 + 储物格起止批量填充 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` 增加可选字段:
+
+```ts
+export type StorageSlotSelectOption = {
+  id: string
+  name: string
+  available?: boolean
+  facilityNodeId?: string
+}
+```
+
+在 `mapRemoteSlotSelectOption` 中增加:
+
+```ts
+facilityNodeId: String(item.facilityNodeId || item.facility_node_id || '').trim() || undefined
+```
+
+`ensureStorageSlotsInOptions` 的 stub 允许传入 `facilityNodeId` 并写入 option(若有)。
+
+- [ ] **Step 2: Commit**
+
+```bash
+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: 写失败单测**
+
+```ts
+// 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:
+
+```bash
+cd web-admin && pnpm exec tsx --test src/utils/storage-slot-range-fill.test.ts
+```
+
+Expected: FAIL(模块不存在)
+
+- [ ] **Step 3: 实现纯函数**
+
+```ts
+// 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: 跑通单测**
+
+```bash
+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**
+
+```bash
+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`
+
+推荐弹窗内完成,减少两页重复:
+
+```ts
+emit('filled', { slotIds: string[], options: Array<{id,name,facilityNodeId?}> })
+```
+
+Props:
+
+```ts
+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**
+
+```bash
+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`):
+
+```vue
+<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`)。
+
+挂载:
+
+```vue
+<ProductLinesBatchSlotFillDialog
+  v-model="batchSlotFillVisible"
+  :empty-line-count="emptyStorageSlotLineCount"
+  :form-used-slot-ids="formUsedStorageSlotIds"
+  :occupied-slot-ids="occupiedSlotIds || []"
+  @filled="onBatchSlotFilled"
+/>
+```
+
+- [ ] **Step 2: script 逻辑**
+
+```ts
+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**
+
+```bash
+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**
+
+```bash
+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: 更新知识图谱**
+
+```bash
+./scripts/update-graph.sh frontend
+```
+
+- [ ] **Step 3: 若有未提交改动则提交图谱**
+
+```bash
+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 }`

+ 176 - 0
docs/superpowers/specs/2026-07-28-product-lines-batch-fill-design.md

@@ -0,0 +1,176 @@
+# 入库明细:复制首行属性 + 储物格起止批量填充
+
+> 日期: 2026-07-28  
+> 状态: 已确认  
+> 范围: web-admin(前端为主,复用现有储物格列表接口)  
+> 关联模块: 生产任务产出(细胞产品)、细胞产品管理
+
+## 1. 背景
+
+生产任务「任务执行」→「产出(细胞产品)」新增弹窗中,入库明细按「入库份数」生成多行。业务侧常出现:
+
+1. 多行规格、预计细胞数量、活率相同,逐行填写费时
+2. 储物格需按盒内连续位置分配,且可能跨多个盒子
+
+现有能力:每行独立远程下拉选格;表单内不可重复;已占用格置灰;支持「造血干一键填充」(与本需求无关)。
+
+## 2. 目标
+
+在**新增**入库明细区域提供两类快捷操作:
+
+| 能力 | 说明 |
+|------|------|
+| 复制首行属性 | 将第 1 行的规格、预计细胞数量、活率复制到其余行 |
+| 储物格起止批量填充 | 选择同盒起始格与结束格,填充当前仍为空的储物格行;支持多次执行以跨盒 |
+
+编辑单条产出时不展示入库明细表,故**不提供**上述按钮。
+
+## 3. 需求确认
+
+| 维度 | 决策 |
+|------|------|
+| 储物格填充交互 | **起始 + 结束** |
+| 份数与区间数量 | 区间可用格数 ≥ 空行数 N 时取前 N 格;不足则失败提示 |
+| 区间内占用格 | **不允许**;区间内任一占用则整段失败 |
+| 跨盒 | 单次必须同盒;跨盒通过**多次填充**(每次只填空行) |
+| 已选储物格的行 | **不覆盖**;只填 `storageSlotId` 为空的行 |
+| 复制字段 | 仅 `specName`、`expectedCellCount`、`viability` |
+| 不复制 | 产品编码、代次、储物格 |
+| 落地页面 | ① `TaskOutputProductDialog.vue` ② `cell-product/index.vue` 新增弹窗 |
+
+## 4. 界面
+
+### 4.1 入口位置
+
+「入库明细」标题行右侧(`product-lines-head`),新增两个描边按钮(带图标,符合按钮图标规范):
+
+1. **复制首行属性**
+2. **批量填储物格**
+
+仅 `!isEdit` 且存在入库明细表时显示。
+
+### 4.2 批量填储物格弹层
+
+小型 `el-dialog`(或内嵌确认区),字段:
+
+- 起始储物格:与现有行内一致的远程 `el-select`(可检索编号)
+- 结束储物格:同上
+- 提示文案:须同盒;区间内不可有占用格;只填充当前空行;可多次操作以跨盒
+
+底栏:取消 / 确定(Close / Check 图标)。
+
+遮罩无背景模糊;`label-position="top"`。
+
+## 5. 行为细则
+
+### 5.1 复制首行属性
+
+1. 若明细行数 &lt; 2:提示「至少两行明细才能复制」
+2. 取第 1 行 `specName`、`expectedCellCount`、`viability`,写入第 2…n 行
+3. 成功提示:已复制到其余行
+
+允许首行三字段为空(仍复制空值,便于统一清空)。
+
+### 5.2 批量填储物格(单次)
+
+```
+N = 明细中 storageSlotId 为空的行数
+若 N = 0 → 提示「没有可填充的空行」并结束
+
+用户选择 startId、endId
+若未选齐 → 校验提示
+
+通过 facilityNodeId 判断 start、end 是否同盒
+若不同盒 → 提示「起始与结束须在同一盒子内」
+
+请求 fetchStorageSlotList({ facilityNodeId }) 获取同盒全部格子
+按现有盒内排序(order_num / 行列,与 storage-slot-options 一致)排序
+
+在有序列表中定位 start、end 下标
+若找不到 → 提示格不存在
+若 startIndex > endIndex → 交换或按「从起始到结束」失败提示(推荐:自动按较小下标到较大下标取闭区间,避免用户选反)
+
+range = slots[from..to](闭区间)
+若 range 中任一格不可用(非空 / occupied)→ 提示「区间内存在已占用储物格,请调整起止范围」
+
+fromFormUsed = 本表单所有行已选的 storageSlotId(含非空行)
+availableInRange = range 中 id 不在 fromFormUsed 的格
+(注:因只填空行,fromFormUsed 主要排除其他行已选;区间本身已要求全部空闲)
+
+若 availableInRange.length < N → 提示「区间内可用储物格不足(需要 N 个,实际 M 个)」
+
+将 availableInRange 前 N 个依次赋给空行(按明细行从上到下)
+将选中格 ensure 进各行下拉 options,保证回显
+成功提示:已为 N 行填充储物格
+```
+
+### 5.3 多次填充(跨盒)
+
+用户可再次点击「批量填储物格」,对新的同盒起止重复 5.2。  
+每次 `N` 只统计**当前仍为空**的行,直至全部填完或用户停止。
+
+### 5.4 与现有校验的关系
+
+提交前仍走:
+
+- 表单内储物格不可重复(`validateUniqueStorageSlotIds`)
+- 行间重复选格自动清空的 watch 保持不变
+
+批量填充赋值后不应触发「误清空」:一次写入互不重复的 id。
+
+## 6. 技术方案
+
+### 6.1 方案选择
+
+**前端计算区间**(复用 `GET /web/admin/storage-facility-slots?facilityNodeId=`),不新增区间专用 API。
+
+理由:单次限制同盒,盒内格数量通常有限;规则变更成本低。
+
+### 6.2 代码落点
+
+| 项 | 路径 |
+|----|------|
+| 生产任务产出弹窗 | `web-admin/src/views/production/production-work-order/TaskOutputProductDialog.vue` |
+| 细胞产品新增 | `web-admin/src/views/production/cell-product/index.vue` |
+| 可选抽取 | `web-admin/src/utils/storage-slot-range-fill.ts`(排序、切区间、校验、取前 N) |
+
+两页 UI 与逻辑保持一致;优先抽公共函数避免分叉。
+
+### 6.3 起止下标方向
+
+**采用:自动规范化闭区间**(`from = min(iStart,iEnd)`, `to = max(...)`)。  
+用户先选编号较大的格再选较小的格也能工作,减少操作摩擦。
+
+### 6.4 起始/结束格的 facilityNodeId
+
+远程下拉选项当前可能只有 `id`/`name`。实现时需保证能得到 `facilityNodeId`:
+
+- 方案:选中后按 id `ensure`/`fetch` 补全;或扩展 select-options 返回 `facilityNodeId`(若响应已有则直接用,见 CRUD `_slot_to_dict` / select-options 已含该字段时可透出到前端 option 类型)。
+
+## 7. 非目标
+
+- 不改编辑态单条产出表单
+- 不自动修改「入库份数」
+- 不支持单次跨盒区间
+- 不跳过区间内占用格(占用即失败)
+- 不覆盖已填写的储物格
+- 不改后端占用模型与提交接口契约
+
+## 8. 验收要点
+
+1. 复制首行:两行以上时,其余行规格/细胞数/活率与首行一致;编码与储物格不变
+2. 同盒连续空格、空行数 ≤ 区间长度:空行被按序填满;已有格的行不变
+3. 跨盒:两次操作可分别填两盒,最终空行可全部填完
+4. 不同盒起止:失败提示,明细不变
+5. 区间含占用:失败提示,明细不变
+6. 可用格 &lt; 空行:失败提示,明细不变
+7. 无空行时点批量填:提示无空行
+8. 提交时仍校验一格一物、表单内不重复
+9. 两处入口(任务产出 + 细胞产品新增)行为一致
+
+## 9. 测试建议
+
+- 手动:入库份数 5,先填 2 行手选格,再批量填同盒区间覆盖剩余 3 行
+- 手动:份数 8,盒 A 填 5、盒 B 填 3
+- 手动:故意选跨盒起止、区间含占用、区间过短,确认提示与数据未改
+- 回归:造血干一键填充、产品编码生成、代次、提交占用冲突提示