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.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-28 13:33:32 +08:00
1 parent 5942feb06e
commit eae8743826
2 files changed
+289

No files matched your search

@@ -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, /不足/)
})
})
@@ -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<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
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
}