Files
admin-web/src/modules/overseas-business/procurementPhysicalForm.ts
T

121 lines
13 KiB
TypeScript

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<ProcurementCommand>(['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<string, number>(); const names = new Set<string>()
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<string>()
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 } : {}) }
}