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:
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
|
||||
}
|
||||
Reference in new issue
Block a user