From 5942feb06e4cfce8b7e6ef34385bb9b4e53b8df2 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Tue, 28 Jul 2026 13:31:49 +0800 Subject: [PATCH] docs: add product-lines batch fill design and plan Co-authored-by: Cursor --- .../2026-07-28-product-lines-batch-fill.md | 599 ++++++++++++++++++ ...6-07-28-product-lines-batch-fill-design.md | 176 +++++ 2 files changed, 775 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-28-product-lines-batch-fill.md create mode 100644 docs/superpowers/specs/2026-07-28-product-lines-batch-fill-design.md diff --git a/docs/superpowers/plans/2026-07-28-product-lines-batch-fill.md b/docs/superpowers/plans/2026-07-28-product-lines-batch-fill.md new file mode 100644 index 00000000..9c8a645d --- /dev/null +++ b/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 & { + 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[]) 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)) { + 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`,保证 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 +
+ + + 复制首行属性 + + + + 批量填储物格 + +
+``` + +图标从 `@element-plus/icons-vue` 显式导入(`CopyDocument`、`Grid` 或 `Box`)。 + +挂载: + +```vue + +``` + +- [ ] **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 }` diff --git a/docs/superpowers/specs/2026-07-28-product-lines-batch-fill-design.md b/docs/superpowers/specs/2026-07-28-product-lines-batch-fill-design.md new file mode 100644 index 00000000..60765693 --- /dev/null +++ b/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. 若明细行数 < 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. 可用格 < 空行:失败提示,明细不变 +7. 无空行时点批量填:提示无空行 +8. 提交时仍校验一格一物、表单内不重复 +9. 两处入口(任务产出 + 细胞产品新增)行为一致 + +## 9. 测试建议 + +- 手动:入库份数 5,先填 2 行手选格,再批量填同盒区间覆盖剩余 3 行 +- 手动:份数 8,盒 A 填 5、盒 B 填 3 +- 手动:故意选跨盒起止、区间含占用、区间过短,确认提示与数据未改 +- 回归:造血干一键填充、产品编码生成、代次、提交占用冲突提示