76 lines
7.8 KiB
TypeScript
76 lines
7.8 KiB
TypeScript
import type { PlatformProcurement, ProcurementAddress, ProcurementFile } from './procurementTypes'
|
|
import type { ProcurementCommand, ProcurementCommandBody } from './api/procurementCommands'
|
|
import { dimension, whole, type Leg } from './procurementPhysicalForm'
|
|
export const transportCommands = new Set<ProcurementCommand>(['labels', 'international-estimate', 'labels/apply', 'domestic-repack', 'overseas-shipment', 'customer-shipment'])
|
|
export interface TransportForm {
|
|
leg: Leg; receiver: ProcurementAddress; addressVoucherFileId: string
|
|
labels: { cartonId: string; fileId: string; page: number | undefined; carrier: string; trackingNo: string }[]
|
|
amount: string; businessDate: string; occurredAt: string; cartonIds: string[]; cartonId: string
|
|
lengthCm: string; widthCm: string; heightCm: string; grossWeightKg: string
|
|
}
|
|
export function transportCargo(detail: PlatformProcurement, leg: Leg) { return leg === 'OVERSEAS' ? detail.transport.overseasCargo : detail.transport.domesticCargo }
|
|
export function transportForm(detail: PlatformProcurement, leg: Leg = detail.purchaseFacts.overseas_shipped_at ? 'DOMESTIC' : 'OVERSEAS'): TransportForm {
|
|
const previous = leg === 'OVERSEAS' ? detail.transport.overseasLabels : detail.transport.domesticLabels
|
|
const order = detail.customerView.order
|
|
const receiver = previous?.receiver ?? (leg === 'DOMESTIC' ? { name: order.contactName, phone: order.contactPhone, address: order.receivingAddress } : { name: '', phone: '', address: '' })
|
|
return { leg, receiver: { ...receiver }, addressVoucherFileId: '', labels: transportCargo(detail, leg).map(box => {
|
|
const old = previous?.labels.find(row => row.cartonId === box.cartonId)
|
|
return { cartonId: box.cartonId, fileId: old?.fileId ?? '', page: old?.page ?? 1, carrier: old?.carrier ?? '', trackingNo: old?.trackingNo ?? '' }
|
|
}), amount: '', businessDate: '', occurredAt: '', cartonIds: [], cartonId: '', lengthCm: '', widthCm: '', heightCm: '', grossWeightKg: '' }
|
|
}
|
|
function requireText(value: string, max: number, label: string) { const result = value.trim(); if (!result || result.length > max) throw new Error(`请填写${label},最多 ${max} 个字符`); return result }
|
|
function realTime(value: string, earliest?: string | null) {
|
|
const time = Date.parse(value)
|
|
if (!value || !Number.isFinite(time) || time > Date.now()) throw new Error('请填写不晚于当前时间的实际操作时间')
|
|
if (earliest && time < Date.parse(earliest)) throw new Error('实际操作时间不能早于上一节点')
|
|
return value
|
|
}
|
|
export function buildTransport(command: ProcurementCommand, form: TransportForm, detail: PlatformProcurement, files: ProcurementFile[], base: ProcurementCommandBody, voucherFileId: string): ProcurementCommandBody {
|
|
const leg = command === 'customer-shipment' || command === 'domestic-repack' ? 'DOMESTIC' : command === 'labels' ? form.leg : 'OVERSEAS'
|
|
const cargo = transportCargo(detail, leg)
|
|
if (!cargo.length) throw new Error('当前运输段没有可寄货物')
|
|
const labels = leg === 'OVERSEAS' ? detail.transport.overseasLabels : detail.transport.domesticLabels
|
|
if (command === 'labels') {
|
|
if (detail.physical.packingStatus !== 'CONFIRMED' || !detail.physical.packingVersion) throw new Error('请先确认装箱清单')
|
|
if (leg === 'DOMESTIC' && !detail.purchaseFacts.domestic_received_at) throw new Error('国内收货完成后才能制作寄客户面单')
|
|
if (detail.purchaseFacts[leg === 'OVERSEAS' ? 'overseas_shipped_at' : 'customer_shipped_at']) throw new Error('本段已交寄,不能修改原面单')
|
|
const receiver = { name: requireText(form.receiver.name, 128, '收件人'), phone: form.receiver.phone.trim(), address: requireText(form.receiver.address, 1000, '完整收件地址') }
|
|
if (!/^[+0-9 ()-]{5,64}$/.test(receiver.phone)) throw new Error('请填写有效快递联系电话')
|
|
const original = detail.customerView.order
|
|
const changed = leg === 'DOMESTIC' && (receiver.name !== original.contactName || receiver.phone !== original.contactPhone || receiver.address !== original.receivingAddress)
|
|
if (changed && !files.some(file => file.fileId === form.addressVoucherFileId && file.category === 'EXCEPTION')) throw new Error('最终收件信息已变更,请关联本单客户确认凭证')
|
|
const unique = new Set(form.labels.map(row => row.cartonId))
|
|
if (unique.size !== cargo.length || form.labels.length !== cargo.length || cargo.some(box => !unique.has(box.cartonId))) throw new Error('面单必须完整覆盖本批全部原箱,不可重复或跨单')
|
|
const rows = form.labels.map(row => {
|
|
const file = files.find(file => file.fileId === row.fileId && file.category === 'LABEL')
|
|
if (!file || !['application/pdf', 'image/png', 'image/jpeg'].includes(file.contentType || '')) throw new Error('请为每箱选择本单 PDF、JPG 或 PNG 面单')
|
|
const page = whole(row.page, '面单页码', 1)
|
|
if (file.contentType !== 'application/pdf' && page !== 1) throw new Error('图片面单只能对应第 1 页')
|
|
return { cartonId: row.cartonId, fileId: row.fileId, page, carrier: requireText(row.carrier, 128, '承运商'), trackingNo: requireText(row.trackingNo, 128, '运单号') }
|
|
})
|
|
return { ...base, leg, packingVersion: detail.physical.packingVersion, receiver, ...(changed ? { addressVoucherFileId: form.addressVoucherFileId } : {}), labels: rows }
|
|
}
|
|
if (command === 'domestic-repack') {
|
|
if (!cargo.some(box => box.cartonId === form.cartonId)) throw new Error('请选择本单国内可寄原箱')
|
|
if (!files.some(file => file.fileId === voucherFileId && ['RECEIPT', 'EXCEPTION'].includes(file.category))) throw new Error('请关联本单换包装凭证')
|
|
return { ...base, cartonId: form.cartonId, lengthCm: dimension(form.lengthCm, '长', 2, 10), widthCm: dimension(form.widthCm, '宽', 2, 10), heightCm: dimension(form.heightCm, '高', 2, 10), grossWeightKg: dimension(form.grossWeightKg, '毛重', 3, 9), voucherFileId }
|
|
}
|
|
if (!labels) throw new Error('请先保存本段面单')
|
|
if (command === 'international-estimate') {
|
|
if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(form.amount.trim())) throw new Error('请明确填写人民币预估金额,包括零元')
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(form.businessDate) || !Number.isFinite(Date.parse(form.businessDate))) throw new Error('请选择预估业务日期')
|
|
if ((base.reason?.length || 0) > 500) throw new Error('预估核对说明最多 500 个字符')
|
|
return { ...base, labelVersion: labels.version, amount: form.amount.trim(), businessDate: form.businessDate }
|
|
}
|
|
if (command === 'labels/apply') {
|
|
if (!form.cartonIds.length || new Set(form.cartonIds).size !== form.cartonIds.length || form.cartonIds.some(id => !labels.labels.some(row => row.cartonId === id && !row.appliedAt))) throw new Error('请选择当前面单中尚未确认贴单的箱')
|
|
return { ...base, labelVersion: labels.version, cartonIds: [...form.cartonIds], appliedAt: realTime(form.occurredAt, detail.purchaseFacts.overseas_received_at) }
|
|
}
|
|
const action = detail.actions.find(row => row.key === (leg === 'OVERSEAS' ? 'SHIP_OVERSEAS' : 'SHIP_CUSTOMER'))
|
|
if (!action?.allowed) throw new Error(action?.reason || '当前不满足交寄条件')
|
|
if (labels.labels.length !== cargo.length || cargo.some(box => !labels.labels.some(label => label.cartonId === box.cartonId))) throw new Error('当前面单与可寄原箱不一致,请重新核对')
|
|
const latestApplied = labels.labels.reduce<string | null>((latest, row) => row.appliedAt && (!latest || row.appliedAt > latest) ? row.appliedAt : latest, null)
|
|
if (leg === 'OVERSEAS' && labels.labels.some(row => !row.appliedAt)) throw new Error('本批全部箱须完成贴单')
|
|
return { ...base, leg, labelVersion: labels.version, cartonIds: cargo.map(box => box.cartonId), shippedAt: realTime(form.occurredAt, leg === 'OVERSEAS' ? latestApplied : detail.purchaseFacts.domestic_received_at) }
|
|
}
|