320 lines
11 KiB
TypeScript
320 lines
11 KiB
TypeScript
/**
|
||
* 储物格下拉选项:设施树 DFS(order_num)+ 盒内格位按行/列升序
|
||
*/
|
||
import { buildTreePathLabelFromFlat } from '@/utils/tree-path'
|
||
import { fetchStorageSlotSelectOptions } from '@/api/admin'
|
||
|
||
export type StorageSlotSelectOption = { id: string; name: string }
|
||
|
||
export type LoadStorageSlotSelectOptions = {
|
||
/** 仅返回空格子(编辑时可配合 includeSlotIds 保留当前格) */
|
||
availableOnly?: boolean
|
||
/** 额外保留在选项中的格子 ID(如编辑态当前储物格) */
|
||
includeSlotIds?: string[]
|
||
}
|
||
|
||
export function getStorageFacilityOrderNum(item: Record<string, unknown>): number {
|
||
const raw = item?.orderNum ?? item?.order_num ?? item?.sortOrder ?? item?.sort_order ?? 0
|
||
const parsed = Number(raw)
|
||
return Number.isFinite(parsed) ? parsed : 0
|
||
}
|
||
|
||
export function getStorageSlotOrderNum(slot: Record<string, unknown>): number {
|
||
const raw = slot?.orderNum ?? slot?.order_num ?? 0
|
||
const parsed = Number(raw)
|
||
return Number.isFinite(parsed) ? parsed : 0
|
||
}
|
||
|
||
/** 货架布局 order_num 步长(row * STRIDE + col) */
|
||
export const SLOT_LAYOUT_ROW_STRIDE = 1000
|
||
|
||
export function decodeSlotLayoutOrder(orderNum: number): { row: number; col: number } {
|
||
const order = Number(orderNum) || 0
|
||
if (order >= SLOT_LAYOUT_ROW_STRIDE) {
|
||
return { row: Math.trunc(order / SLOT_LAYOUT_ROW_STRIDE), col: order % SLOT_LAYOUT_ROW_STRIDE }
|
||
}
|
||
return { row: 0, col: order }
|
||
}
|
||
|
||
/** 解析旧版 slotCode「{prefix}{row}-{col}」;纯数字序号返回 null */
|
||
export function parseLegacyRowColSlotCode(code: string): { row: number; col: number; prefix: string } | null {
|
||
const match = (code || '').trim().match(/^(.*?)(\d+)-(\d+)$/)
|
||
if (!match) return null
|
||
return { prefix: match[1], row: parseInt(match[2], 10), col: parseInt(match[3], 10) }
|
||
}
|
||
|
||
/** @deprecated 兼容旧调用;新数据请用 decodeSlotLayoutOrder + 序号 slotCode */
|
||
export function parseStorageSlotCode(code: string): { row: number; col: number } | null {
|
||
const legacy = parseLegacyRowColSlotCode(code)
|
||
if (legacy) return { row: legacy.row, col: legacy.col }
|
||
return null
|
||
}
|
||
|
||
/** 从根到节点的 node_code 路径,用 `-` 连接 */
|
||
export function buildNodeCodePathFromFlat(
|
||
nodeId: string,
|
||
flatList: Record<string, unknown>[]
|
||
): string {
|
||
const byId = new Map<string, Record<string, unknown>>()
|
||
for (const item of flatList || []) {
|
||
const id = String(item?.id ?? '').trim()
|
||
if (id) byId.set(id, item)
|
||
}
|
||
const parts: string[] = []
|
||
let curId = String(nodeId ?? '').trim()
|
||
const visited = new Set<string>()
|
||
while (curId && curId !== '0' && !visited.has(curId)) {
|
||
visited.add(curId)
|
||
const node = byId.get(curId)
|
||
if (!node) break
|
||
const code = String(node.nodeCode ?? node.node_code ?? '').trim()
|
||
if (code) parts.unshift(code)
|
||
curId = String(node.parentId ?? node.parent_id ?? '').trim()
|
||
}
|
||
return parts.join('-')
|
||
}
|
||
|
||
/** 完整储物格编号:祖先 node_code + 本地 slot_code */
|
||
export function buildFullSlotCodeFromFlat(
|
||
facilityNodeId: string,
|
||
slotCode: string,
|
||
flatList: Record<string, unknown>[]
|
||
): string {
|
||
const path = buildNodeCodePathFromFlat(facilityNodeId, flatList)
|
||
const local = String(slotCode ?? '').trim()
|
||
if (path && local) return `${path}-${local}`
|
||
return local || path
|
||
}
|
||
|
||
/** 盒内格位:order_num → 布局行列 → 序号 */
|
||
export function compareStorageFacilitySlots(
|
||
a: Record<string, unknown>,
|
||
b: Record<string, unknown>
|
||
): number {
|
||
const orderDiff = getStorageSlotOrderNum(a) - getStorageSlotOrderNum(b)
|
||
if (orderDiff !== 0) return orderDiff
|
||
const oa = getStorageSlotOrderNum(a)
|
||
const ob = getStorageSlotOrderNum(b)
|
||
if (oa >= SLOT_LAYOUT_ROW_STRIDE && ob >= SLOT_LAYOUT_ROW_STRIDE) {
|
||
const da = decodeSlotLayoutOrder(oa)
|
||
const db = decodeSlotLayoutOrder(ob)
|
||
if (da.row !== db.row) return da.row - db.row
|
||
if (da.col !== db.col) return da.col - db.col
|
||
}
|
||
const codeA = String(a.slotCode ?? a.slot_code ?? '')
|
||
const codeB = String(b.slotCode ?? b.slot_code ?? '')
|
||
const pa = parseLegacyRowColSlotCode(codeA)
|
||
const pb = parseLegacyRowColSlotCode(codeB)
|
||
if (pa && pb) {
|
||
if (pa.row !== pb.row) return pa.row - pb.row
|
||
if (pa.col !== pb.col) return pa.col - pb.col
|
||
} else if (pa && !pb) return -1
|
||
else if (!pa && pb) return 1
|
||
const codeCmp = codeA.localeCompare(codeB, 'zh-Hans-CN', { numeric: true })
|
||
if (codeCmp !== 0) return codeCmp
|
||
return String(a.id ?? '').localeCompare(String(b.id ?? ''))
|
||
}
|
||
|
||
export function sortStorageFacilitySlots<T extends Record<string, unknown>>(slots: T[]): T[] {
|
||
return [...slots].sort(compareStorageFacilitySlots)
|
||
}
|
||
|
||
export function isStorageSlotAvailable(slot: Record<string, unknown>): boolean {
|
||
const status = String(slot.slotStatus ?? slot.slot_status ?? '')
|
||
const occupied = Number(slot.occupiedCount ?? slot.occupied_count ?? 0)
|
||
return status === '空' || occupied === 0
|
||
}
|
||
|
||
/**
|
||
* 按设施树顺序收集叶子节点(罐 → 层 → 盒…),同级按 order_num、名称排序。
|
||
*/
|
||
export function collectLeafFacilityNodesInTreeOrder(
|
||
flatList: Record<string, unknown>[]
|
||
): Record<string, unknown>[] {
|
||
const map = new Map<string, Record<string, unknown> & { children: unknown[] }>()
|
||
const roots: (Record<string, unknown> & { children: unknown[] })[] = []
|
||
|
||
for (const item of flatList || []) {
|
||
const id = String(item?.id ?? '').trim()
|
||
if (!id) continue
|
||
map.set(id, { ...item, children: [] })
|
||
}
|
||
for (const item of flatList || []) {
|
||
const id = String(item?.id ?? '').trim()
|
||
const node = map.get(id)
|
||
if (!node) continue
|
||
const parentId = String(item?.parentId ?? item?.parent_id ?? '').trim()
|
||
if (parentId && parentId !== '0' && map.has(parentId)) {
|
||
;(map.get(parentId)!.children as typeof roots).push(node)
|
||
} else {
|
||
roots.push(node)
|
||
}
|
||
}
|
||
|
||
const sortNodes = (nodes: (typeof roots)[number][]) => {
|
||
nodes.sort((a, b) => {
|
||
const orderDiff = getStorageFacilityOrderNum(a) - getStorageFacilityOrderNum(b)
|
||
if (orderDiff !== 0) return orderDiff
|
||
return String(a.nodeName ?? '').localeCompare(String(b.nodeName ?? ''), 'zh-Hans-CN')
|
||
})
|
||
nodes.forEach((node) => {
|
||
const ch = node.children
|
||
if (Array.isArray(ch) && ch.length) sortNodes(ch as typeof roots)
|
||
})
|
||
}
|
||
sortNodes(roots)
|
||
|
||
const leaves: Record<string, unknown>[] = []
|
||
const walk = (nodes: typeof roots) => {
|
||
for (const n of nodes) {
|
||
const isLeaf = n.isLeaf === 1 || n.is_leaf === 1
|
||
const ch = n.children
|
||
if (isLeaf) {
|
||
leaves.push(n)
|
||
} else if (Array.isArray(ch) && ch.length) {
|
||
walk(ch as typeof roots)
|
||
}
|
||
}
|
||
}
|
||
walk(roots)
|
||
return leaves
|
||
}
|
||
|
||
export function mapSlotsToSelectOptions(
|
||
facilityNodeId: string,
|
||
facilities: Record<string, unknown>[],
|
||
slots: Record<string, unknown>[],
|
||
opts?: LoadStorageSlotSelectOptions
|
||
): StorageSlotSelectOption[] {
|
||
const includeSet = new Set((opts?.includeSlotIds || []).map((id) => String(id).trim()).filter(Boolean))
|
||
const nodePath = buildTreePathLabelFromFlat(facilityNodeId, facilities, {
|
||
nameKey: 'nodeName'
|
||
})
|
||
return sortStorageFacilitySlots(slots)
|
||
.filter((slot) => {
|
||
const id = String(slot.id ?? '').trim()
|
||
if (includeSet.has(id)) return true
|
||
if (opts?.availableOnly) return isStorageSlotAvailable(slot)
|
||
return true
|
||
})
|
||
.map((slot) => {
|
||
const id = String(slot.id ?? '').trim()
|
||
const code = String(slot.slotCode ?? slot.slot_code ?? '')
|
||
const fullFromApi = String(slot.fullSlotCode ?? slot.full_slot_code ?? '').trim()
|
||
const fullCode =
|
||
fullFromApi || buildFullSlotCodeFromFlat(facilityNodeId, code, facilities)
|
||
const name = fullCode || code || nodePath
|
||
return { id, name }
|
||
})
|
||
}
|
||
|
||
export async function loadStorageSlotSelectOptions(
|
||
facilities: Record<string, unknown>[],
|
||
fetchStorageSlotList: (params: {
|
||
facilityNodeId: string
|
||
availableOnly?: boolean
|
||
}) => Promise<unknown>,
|
||
opts?: LoadStorageSlotSelectOptions
|
||
): Promise<StorageSlotSelectOption[]> {
|
||
const leafNodes = collectLeafFacilityNodesInTreeOrder(facilities)
|
||
if (!leafNodes.length) return []
|
||
|
||
const results = await Promise.allSettled(
|
||
leafNodes.map(async (node) => {
|
||
const nodeId = String(node.id ?? '').trim()
|
||
const res: any = await fetchStorageSlotList({
|
||
facilityNodeId: nodeId,
|
||
availableOnly: opts?.availableOnly ? true : undefined
|
||
})
|
||
const slots = (res?.list || res || []) as Record<string, unknown>[]
|
||
return mapSlotsToSelectOptions(nodeId, facilities, slots, opts)
|
||
})
|
||
)
|
||
|
||
const options: StorageSlotSelectOption[] = []
|
||
const seen = new Set<string>()
|
||
results.forEach((r) => {
|
||
if (r.status === 'fulfilled') {
|
||
r.value.forEach((opt) => {
|
||
if (!seen.has(opt.id)) {
|
||
seen.add(opt.id)
|
||
options.push(opt)
|
||
}
|
||
})
|
||
}
|
||
})
|
||
return options
|
||
}
|
||
|
||
export async function remoteSearchStorageSlotSelectOptions(
|
||
query: string,
|
||
opts?: LoadStorageSlotSelectOptions
|
||
): Promise<StorageSlotSelectOption[]> {
|
||
const includeSlotIds = (opts?.includeSlotIds || [])
|
||
.map((id) => String(id).trim())
|
||
.filter(Boolean)
|
||
.join(',')
|
||
try {
|
||
const res = await fetchStorageSlotSelectOptions({
|
||
keyword: query?.trim() || undefined,
|
||
availableOnly: opts?.availableOnly ? true : undefined,
|
||
includeSlotIds: includeSlotIds || undefined,
|
||
page: 1,
|
||
pageSize: 100
|
||
})
|
||
return (res?.list || []).map((item) => ({
|
||
id: String(item.id || ''),
|
||
name: String(item.name || item.fullSlotCode || item.slotCode || item.id || '')
|
||
}))
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
export function ensureStorageSlotsInOptions(
|
||
options: StorageSlotSelectOption[],
|
||
stubs: Array<{ id?: string; name?: string }>
|
||
): StorageSlotSelectOption[] {
|
||
const next = [...options]
|
||
const seen = new Set(next.map((o) => o.id))
|
||
for (const stub of stubs) {
|
||
const id = String(stub.id || '').trim()
|
||
if (!id || seen.has(id)) continue
|
||
seen.add(id)
|
||
next.unshift({
|
||
id,
|
||
name: String(stub.name || id)
|
||
})
|
||
}
|
||
return next
|
||
}
|
||
|
||
/** 下拉选项中排除已占用/已选格子,保留当前行已选格便于展示 */
|
||
export function filterStorageSlotSelectOptions(
|
||
options: StorageSlotSelectOption[],
|
||
excludeSlotIds: Array<string | undefined | null>,
|
||
keepSlotId?: string
|
||
): StorageSlotSelectOption[] {
|
||
const exclude = new Set(
|
||
excludeSlotIds.map((id) => String(id ?? '').trim()).filter(Boolean)
|
||
)
|
||
const keep = String(keepSlotId ?? '').trim()
|
||
if (keep) exclude.delete(keep)
|
||
return options.filter((opt) => !exclude.has(opt.id))
|
||
}
|
||
|
||
/** 校验表单内储物格不可重复(一格一物) */
|
||
export function validateUniqueStorageSlotIds(
|
||
slotIds: Array<string | undefined | null>,
|
||
message = '同一表单内不能选择重复的储物格,每份产品须使用不同储物格'
|
||
): boolean {
|
||
const seen = new Set<string>()
|
||
for (const raw of slotIds) {
|
||
const id = String(raw ?? '').trim()
|
||
if (!id) continue
|
||
if (seen.has(id)) return false
|
||
seen.add(id)
|
||
}
|
||
return true
|
||
}
|