import type { PlatformProcurement } from './procurementTypes' import type { ProcurementCommand, ProcurementCommandBody, ProcurementPackingCarton } from './api/procurementCommands' export type Leg = 'OVERSEAS' | 'DOMESTIC' const closedStatuses = new Set(['REJECTED', 'CANCELLED', 'EXCEPTION_CLOSED', 'SHIPPED_TO_CUSTOMER']) export function receiptCorrectionReason(detail: PlatformProcurement, leg: Leg): string | null { if (closedStatuses.has(detail.customerView.order.status)) return '订单已结束,只能追加事实说明,不能更正实收数量' if (detail.purchaseFacts[leg === 'OVERSEAS' ? 'overseas_shipped_at' : 'customer_shipped_at']) return '本段已实际交寄,只能追加更正说明,不能改写数量' if (leg === 'OVERSEAS' && detail.physical.packingStatus === 'CONFIRMED') return '须先撤回尚未交寄的确认清单,再更正海外实收' return null } export function dispositionReason(detail: PlatformProcurement, leg: Leg): string | null { if (closedStatuses.has(detail.customerView.order.status)) return '订单已结束,不能修改差异处置' if (detail.purchaseFacts[leg === 'OVERSEAS' ? 'overseas_received_at' : 'domestic_received_at']) return '本段收货已完成,须先受控更正,不能直接改差异结果' if (leg === 'OVERSEAS' && !detail.purchaseFacts.purchased_at) return '尚无本单真实购买来源' if (leg === 'DOMESTIC' && !detail.purchaseFacts.overseas_shipped_at) return '尚无本单国际交寄来源' return null } export const managementOptions: { value: ProcurementCommand; label: string }[] = [ { value: 'dispositions', label: '记录差异处置' }, { value: 'receipts/correct', label: '更正原实收数量' }, { value: 'dispositions/revoke', label: '撤销原差异处置' }, { value: 'packing/withdraw', label: '撤回未交寄的确认清单' }, { value: 'control-notes', label: '追加事实说明或阻断' }, { value: 'control-notes/resolve', label: '解除已处理的阻断' }, { value: 'actual-warehouse/correct', label: '实际仓受控更正' }, ] export function managementReason(command: ProcurementCommand, detail: PlatformProcurement): string | null { if (command === 'receipts/correct' || command === 'dispositions/revoke') { const records = command === 'receipts/correct' ? detail.physical.receipts : detail.physical.dispositions const active = records.filter(row => row.active) if (!active.length) return command === 'receipts/correct' ? '暂无有效实收记录' : '暂无有效差异处置' return active.some(row => !receiptCorrectionReason(detail, row.leg)) ? null : receiptCorrectionReason(detail, active[0]!.leg) } if (command === 'dispositions') return dispositionReason(detail, 'OVERSEAS') && dispositionReason(detail, 'DOMESTIC') if (command === 'packing/withdraw') { if (closedStatuses.has(detail.customerView.order.status)) return '订单已结束,不能撤回装箱事实' if (detail.purchaseFacts.overseas_shipped_at) return '国际段已交寄,不能撤回装箱事实' if (detail.physical.packingStatus !== 'CONFIRMED') return '暂无已确认清单可撤回' } if (command === 'control-notes/resolve' && !detail.controls.notes.some(row => row.blocking && !row.resolved_at)) return '暂无待处理阻断' if (command === 'actual-warehouse/correct' && !detail.purchaseFacts.actual_warehouse_id) return '尚无实际收货仓,请修改计划仓' return null } export const physicalCommands = new Set(['overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'packing', 'packing/confirm', 'packing/withdraw', 'dispositions', 'receipts/correct', 'dispositions/revoke']) export interface PackingDraft { cartonNo: string; lengthCm: string; widthCm: string; heightCm: string; grossWeightKg: string; sources: { receiptId: string; quantity: number | undefined }[] } export interface PhysicalForm { leg: Leg; sourceCartonId: string; receiptId: string; dispositionId: string; goodQuantity: number | undefined; damagedQuantity: number | undefined; extraQuantity: number | undefined; receivedAt: string; kind: 'SHORTAGE' | 'DAMAGED' | 'EXTRA'; quantity: number | undefined; cartons: PackingDraft[] } export function newCarton(): PackingDraft { return { cartonNo: '', lengthCm: '', widthCm: '', heightCm: '', grossWeightKg: '', sources: [{ receiptId: '', quantity: undefined }] } } export function physicalForm(detail?: PlatformProcurement): PhysicalForm { return { leg: 'OVERSEAS', sourceCartonId: '', receiptId: '', dispositionId: '', goodQuantity: 0, damagedQuantity: 0, extraQuantity: 0, receivedAt: '', kind: 'SHORTAGE', quantity: undefined, cartons: detail?.physical.cartons.length ? detail.physical.cartons.map(row => ({ cartonNo: row.cartonNo, lengthCm: String(row.lengthCm), widthCm: String(row.widthCm), heightCm: String(row.heightCm), grossWeightKg: String(row.grossWeightKg), sources: row.sources.map(source => ({ ...source })) })) : [newCarton()] } } export function shippedCartons(detail: PlatformProcurement) { const numbers = new Set(detail.customerView.shipments.filter(row => row.leg === 'OVERSEAS').map(row => row.cartonNo)); return detail.physical.cartons.filter(row => numbers.has(row.cartonNo)) } export function whole(value: number | undefined, label: string, min = 0): number { if (value === undefined || !Number.isSafeInteger(value) || value < min) throw new Error(`${label}必须为${min ? '正' : '非负'}整数`); return value } export function dimension(value: string, label: string, scale: number, digits: number) { if (!new RegExp(`^\\d{1,${digits}}(?:\\.\\d{1,${scale}})?$`).test(value.trim()) || Number(value) <= 0) throw new Error(`${label}必须为正数,最多 ${scale} 位小数`); return value.trim() } export function buildPacking(cartons: PackingDraft[], detail: PlatformProcurement): ProcurementPackingCarton[] { if (!cartons.length) throw new Error('请至少建立一个装货箱') const used = new Map(); const names = new Set() return cartons.map(carton => { const cartonNo = carton.cartonNo.trim(); if (!cartonNo || cartonNo.length > 64 || names.has(cartonNo)) throw new Error('箱号必填、不可重复且不超过 64 个字符'); names.add(cartonNo) if (!carton.sources.length) throw new Error('每箱必须分配实收来源') const inCarton = new Set() const sources = carton.sources.map(source => { const receipt = detail.physical.receipts.find(row => row.id === source.receiptId && row.active && row.leg === 'OVERSEAS') if (!receipt || inCarton.has(source.receiptId)) throw new Error('请选择有效海外实收来源,同箱来源不可重复') inCarton.add(source.receiptId) const quantity = whole(source.quantity, '装箱件数', 1); const total = (used.get(source.receiptId) || 0) + quantity if (!Number.isSafeInteger(total) || total > receipt.goodQuantity) throw new Error(`装箱件数超过实收 ${receipt.receiptNo} 的合格数量`) used.set(source.receiptId, total); return { receiptId: source.receiptId, quantity } }) return { cartonNo, lengthCm: dimension(carton.lengthCm, '长', 2, 10), widthCm: dimension(carton.widthCm, '宽', 2, 10), heightCm: dimension(carton.heightCm, '高', 2, 10), grossWeightKg: dimension(carton.grossWeightKg, '毛重', 3, 9), sources } }) } export function buildPhysical(command: ProcurementCommand, form: PhysicalForm, detail: PlatformProcurement, base: ProcurementCommandBody, warehouseId: string, voucherFileId: string): ProcurementCommandBody { const blocked = managementReason(command, detail) if (blocked) throw new Error(blocked) const closed = ['REJECTED', 'CANCELLED', 'EXCEPTION_CLOSED', 'SHIPPED_TO_CUSTOMER'].includes(detail.customerView.order.status) if (closed) throw new Error('订单已结束,不能改写实物记录') if (command === 'packing') return { ...base, cartons: buildPacking(form.cartons, detail) } if (command === 'packing/confirm') { if (detail.physical.packingStatus !== 'DRAFT' || !detail.physical.packingVersion) throw new Error('请先保存当前装箱草稿') return { ...base, packingVersion: detail.physical.packingVersion } } if (command === 'packing/withdraw') { if (detail.physical.packingStatus !== 'CONFIRMED' || detail.purchaseFacts.overseas_shipped_at) throw new Error('仅允许撤回尚未交寄的已确认清单') return base } if (command === 'dispositions/revoke') { const row = detail.physical.dispositions.find(row => row.id === form.dispositionId && row.active) if (!row) throw new Error('请选择有效差异处置') const reason = receiptCorrectionReason(detail, row.leg) if (reason) throw new Error(reason) return { ...base, dispositionId: form.dispositionId, voucherFileId } } if (command.endsWith('/complete')) return { ...base, leg: command.startsWith('overseas') ? 'OVERSEAS' : 'DOMESTIC' } const correction = command === 'receipts/correct' const old = correction ? detail.physical.receipts.find(row => row.id === form.receiptId && row.active) : undefined if (correction && !old) throw new Error('请选择有效原实收记录') const leg: Leg = old?.leg ?? (command === 'overseas-receipts' ? 'OVERSEAS' : command === 'domestic-receipts' ? 'DOMESTIC' : form.leg) const reason = correction ? receiptCorrectionReason(detail, leg) : command === 'dispositions' ? dispositionReason(detail, leg) : null if (reason) throw new Error(reason) const sourceCartonId = old?.sourceCartonId || form.sourceCartonId if (leg === 'DOMESTIC' && !shippedCartons(detail).some(row => row.id === sourceCartonId)) throw new Error('请选择本单已国际交寄的原箱') if (command === 'dispositions') { const quantity = whole(form.quantity, '处置件数', 1) const source = leg === 'DOMESTIC' ? sourceCartonId : null const receipt = detail.physical.receipts.find(row => row.id === form.receiptId && row.active && row.leg === leg && row.sourceCartonId === source) const records = detail.physical.receipts.filter(row => row.active && row.leg === leg && row.sourceCartonId === source) const resolved = detail.physical.dispositions.filter(row => row.active && row.leg === leg && row.source_carton_id === source && row.kind === form.kind && (form.kind === 'SHORTAGE' || row.receipt_id === form.receiptId)).reduce((sum, row) => sum + row.quantity, 0) const expected = leg === 'OVERSEAS' ? detail.purchaseFacts.purchased_quantity : shippedCartons(detail).find(row => row.id === source)?.quantity const available = form.kind === 'SHORTAGE' ? Number(expected) - records.reduce((sum, row) => sum + row.goodQuantity + row.damagedQuantity, 0) - resolved : receipt ? (form.kind === 'DAMAGED' ? receipt.damagedQuantity : receipt.extraQuantity) - resolved : -1 if (!Number.isSafeInteger(available) || quantity > available) throw new Error('处置件数超过当前来源的未处理差异') const decisions = { SHORTAGE: 'ACCEPT_SHORTAGE', DAMAGED: 'DISPOSE_DAMAGED', EXTRA: 'RETURN_EXTRA' } as const return { ...base, leg, ...(leg === 'DOMESTIC' ? { sourceCartonId } : {}), ...(form.kind === 'SHORTAGE' ? {} : { receiptId: form.receiptId }), kind: form.kind, quantity, decision: decisions[form.kind], voucherFileId } } const goodQuantity = whole(form.goodQuantity, '合格件数'), damagedQuantity = whole(form.damagedQuantity, '破损件数'), extraQuantity = whole(form.extraQuantity, '多货件数') if (!Number.isSafeInteger(goodQuantity + damagedQuantity + extraQuantity) || goodQuantity + damagedQuantity + extraQuantity <= 0) throw new Error('本次实收总件数必须大于零') if (!form.receivedAt || !Number.isFinite(Date.parse(form.receivedAt)) || Date.parse(form.receivedAt) > Date.now()) throw new Error('请填写不晚于当前时间的真实实收时间') const origin = leg === 'OVERSEAS' ? detail.purchaseFacts.purchased_at : detail.purchaseFacts.overseas_shipped_at if (!origin || Date.parse(form.receivedAt) < Date.parse(origin)) throw new Error('实收时间不能早于购买或国际交寄时间') const source = leg === 'DOMESTIC' ? sourceCartonId : null const counted = detail.physical.receipts.filter(row => row.active && row.leg === leg && row.sourceCartonId === source && row.id !== old?.id).reduce((sum, row) => sum + row.goodQuantity + row.damagedQuantity, 0) const expected = leg === 'OVERSEAS' ? detail.purchaseFacts.purchased_quantity : shippedCartons(detail).find(row => row.id === sourceCartonId)?.quantity if (expected == null || goodQuantity + damagedQuantity + counted > expected) throw new Error('合格及破损实收超过来源件数,多到货物请填入多货') if (leg === 'OVERSEAS' && (!warehouseId || (detail.purchaseFacts.actual_warehouse_id && detail.purchaseFacts.actual_warehouse_id !== warehouseId))) throw new Error('实际收货仓必须与本单已冻结的仓库一致') return { ...base, leg, ...(leg === 'OVERSEAS' ? { warehouseId } : { sourceCartonId }), goodQuantity, damagedQuantity, extraQuantity, receivedAt: form.receivedAt, ...(correction ? { receiptId: form.receiptId, voucherFileId } : {}) } }