diff --git a/web-admin/src/utils/storage-slot-range-fill.test.ts b/web-admin/src/utils/storage-slot-range-fill.test.ts new file mode 100644 index 00000000..3b8d01ac --- /dev/null +++ b/web-admin/src/utils/storage-slot-range-fill.test.ts @@ -0,0 +1,116 @@ +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, /不足/) + }) +}) diff --git a/web-admin/src/utils/storage-slot-range-fill.ts b/web-admin/src/utils/storage-slot-range-fill.ts new file mode 100644 index 00000000..ef5a9c4a --- /dev/null +++ b/web-admin/src/utils/storage-slot-range-fill.ts @@ -0,0 +1,173 @@ +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 + fullSlotCode?: string + full_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 ?? '' + ) +} + +function slotOrderNum(slot: RangeFillSlotRow): number { + const raw = slot.orderNum ?? slot.order_num ?? 0 + const parsed = Number(raw) + return Number.isFinite(parsed) ? parsed : 0 +} + +/** 与 storage-slot-options.isStorageSlotAvailable 一致 */ +function isSlotAvailable(slot: RangeFillSlotRow): boolean { + const status = String(slot.slotStatus ?? slot.slot_status ?? '') + const occupied = Number(slot.occupiedCount ?? slot.occupied_count ?? 0) + return status === '空' || occupied === 0 +} + +/** 盒内按 order_num 升序(同盒批量填充足够;与货架布局 order 一致) */ +function sortBoxSlots(slots: RangeFillSlotRow[]): RangeFillSlotRow[] { + return [...slots].sort((a, b) => { + const orderDiff = slotOrderNum(a) - slotOrderNum(b) + if (orderDiff !== 0) return orderDiff + return String(a.id ?? '').localeCompare(String(b.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 = sortBoxSlots(sameBox) + 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 (!isSlotAvailable(slot)) { + 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 +}