|
|
@@ -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 }`
|