Files
admin-web/src/modules/overseas-warehouse/dispatchContracts.ts
T
ouyangxq 1028b715ab TEST Admin dispatch dialog proposal 2026-10-20
Seed tree 270cbc0780a7598ea2f95fb5a279077028b9de6b; parent remains live cbd849444f.
Preserve the frozen 18-path scope and eleven explicit dialog/style/test overrides.
Local commit-tree proposal only; not pushed, tagged, deployed, or runtime-verified.
2026-10-20 08:42:35 +08:00

145 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Models after the dispatch API boundary: Long quantities are validated safe numbers.
// dispatchWire.ts declares their HTTP string/number representation; IDs/versions remain lossless.
export type Version = string | number
export class DispatchInputError extends Error {}
export type DispatchStatus = 'ACTIVE' | 'CANCELLED'
export type DispatchStage = 'BEFORE_LOADING' | 'LOADING' | 'AFTER_LOADING' | 'DELIVERING' | 'DELIVERED'
export type DispatchAction = 'CREATE' | 'EDIT' | 'ADD_CARGO' | 'REMOVE_CARGO' | 'CANCEL' | 'SUBMIT_REVIEW' | 'DISPATCH_REVIEW' | 'LOADING_REVIEW' | 'RETURN_TO_REVIEW' | 'PICK' | 'LOADING_COMPLETE' | 'ARRANGE_DELIVERY' | 'DEPART'
export type ReviewStatus = 'NOT_SUBMITTED' | 'PENDING' | 'APPROVED' | 'REJECTED'
export type BeforeLoadingSection = 'VEHICLE_INFO' | 'DISPATCH_REVIEW' | 'LOADING_REVIEW'
export interface Gate { allowed: boolean; blockCode: string | null; blockReason: string | null }
export type DispatchActions = Partial<Record<DispatchAction, Gate>>
export interface Counts { itemCount: number; palletCount: number; packageCount: number; pickedPackageCount: number; shippedPackageCount: number }
export interface Capacity { palletSpaces: number | null; maxLoadKg: number | null; maxVolumeCbm: number | null }
export interface DriverEvidence { licenseNo: string | null; licenseExpiresOn: string | null; fileIds: string[]; expectedTaskEndAt: string | null }
export interface Vehicle { mode: 'MASTER' | 'EXTERNAL'; vehicleId: string | null; plate: string | null; carrierName: string | null; capacity: Capacity | null; evidenceFileIds: string[] }
export interface Driver { mode: 'MASTER' | 'EXTERNAL'; driverId: string | null; name: string | null; phone: string | null; evidence: DriverEvidence | null }
export interface VehicleSnapshot extends Vehicle { vehicleStatus: string | null }
export interface Resources { vehicle: VehicleSnapshot | null; driver: Driver | null }
export interface Destination { key: string; type: string; warehouseId: string | null; warehouseCode: string | null; warehouseName: string | null; address: string | null; postalCode: string | null; contactName: string | null; contactPhone: string | null }
export interface Review { status: ReviewStatus; reason: string | null; reviewerId: string | null; reviewedAt: string | null; reviewRound: Version; basisHash: string | null; invalidatedReason: string | null }
export interface Reviews { dispatchReview: Review; loadingReview: Review }
export interface DispatchBatchReference { batchKey: string; batchNo: string | null; customerId: string; sources: Array<{ sourceKey: string; destinationKey: string; packageCount: number }>; recordVersion: Version }
export interface DispatchBatchAllocation { planPalletId: string; batchKey: string; customerId: string; sourceKey: string; destinationKey: string; packageCount: number; recordVersion: Version }
export interface DispatchQuery { page: number; size: number; dispatchNo?: string; vehiclePlate?: string; warehouseId?: string; customerId?: string; fbaAppointmentNo?: string; status?: DispatchStatus | 'ALL'; stage?: DispatchStage; beforeLoadingSection?: BeforeLoadingSection; reviewStatus?: ReviewStatus }
export interface ReviewCounts { notSubmitted: number; pending: number; approved: number; rejected: number }
export interface DispatchStatistics { matchedTotal: number; activeTotal: number; cancelledTotal: number; stageCounts: Record<DispatchStage, number>; beforeLoadingReviewCounts: { vehicleInfoTotal: number; dispatchReview: ReviewCounts; loadingReview: ReviewCounts } }
export interface DispatchListRow { dispatchNo: string; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; resources: Resources; warehouseId: string; warehouseCode: string; warehouseName: string; plannedDepartureAt: string | null; departedAt: string | null; counts: Counts; pickingReconciled: boolean; arranged: boolean; actions: DispatchActions }
export interface DispatchPage { records: DispatchListRow[]; total: number; page: number; size: number; pages: number; statistics: DispatchStatistics; snapshotAt: string }
export interface OutboundReference { customerId: string; customerName: string; outboundNo: string; status: string; packageCount: number; palletCount: number }
export interface DeliveryReference { groupKey: string; customerId: string; customerName: string; outboundNo: string; deliveryNo: string; destination: Destination; status: string; packageCount: number; palletCount: number }
export interface FbaAppointment { batchKey: string; batchNo: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null }
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string; sourceBusinessType: string | null; sourceBusinessNo: string | null }
export interface DispatchDetail { dispatchNo: string; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
export interface CargoSelection { sourceType: 'INBOUND_PLAN' | 'RETURN_RECEIPT' | 'RELABEL'; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; sourceVersion: string }
export interface CreateDispatch { idempotencyKey: string; warehouseId: string; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
export interface VersionCommand { recordVersion: Version; idempotencyKey: string }
export interface UpdateDispatch extends VersionCommand { vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null }
export interface AddCargo extends VersionCommand { cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
export interface ReasonCommand extends VersionCommand { reason: string }
export interface ReviewCommand extends VersionCommand { decision: 'APPROVED' | 'REJECTED'; reason: string | null }
export interface LoadingMeasurement { warehousePalletId: string; palletSpaces: number; weightKg: number; volumeCbm: number; deliveryIndex: number }
export interface ConfirmedLoad { manifestHash: string; pallets: LoadingMeasurement[]; operatorId: string; confirmedAt: string }
export interface LoadingComplete extends VersionCommand { manifestHash: string; measurements: LoadingMeasurement[]; remark: string | null }
export interface LoadingPreview { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; manifestHash: string; pickingReconciled: boolean; resources: Resources; pallets: Array<{ warehousePalletId: string; palletNo: string; scheduledPackages: number; pickedPackages: number; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; missingFields: string[]; evidence: Array<{ field: string; sourceType: string; sourceBusinessNo: string | null; observedAt: string | null; value: number | null; actual: boolean }> }>; confirmedLoad: ConfirmedLoad | null; loadingComplete: Gate; missingFields: string[] }
export interface SelectionGroupSource { selection: CargoSelection; sourceKey: string; customerId: string; customerName: string; sourceBusinessType: string | null; sourceBusinessNo: string | null; packages: number }
export interface SelectionGroupPallet { warehousePalletId: string | null; palletNo: string | null; sourceKeys: string[]; packageCount: number }
export interface SelectionGroup { groupKey: string; groupVersion: string; sources: SelectionGroupSource[]; pallets: SelectionGroupPallet[]; complete: boolean; blockReasons: string[] }
export interface Candidate { selection: CargoSelection; sourceKey: string; sourceVersion: string; customerId: string; customerName: string; warehouseId: string; warehouseCode: string; warehouseName: string; orderNo: string | null; inboundCode: string | null; planNo: string | null; palletNo: string | null; containerNo: string | null; destination: Destination; availablePackages: number; suggestedWeightKg: number | null; suggestedVolumeCbm: number | null; measurementSource: string | null; appointmentBatches: DispatchBatchReference[]; blockReasons: string[]; selectionGroup: SelectionGroup }
export interface CandidateQuery { warehouseId?: string; planIds?: string[]; orderNo?: string; destinationWarehouseCode?: string; sourceStage?: string; draftToken?: string }
export interface DispatchCargoItem { dispatchItemId: string; customerId: string; customerName: string; sourceKey: string; sourceVersion: string; sourceType: CargoSelection['sourceType']; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; palletNo: string | null; orderNo: string | null; inboundCode: string | null; destination: Destination; scheduledPackages: number; pickedPackages: number; remove: Gate }
export interface DispatchItems { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; items: DispatchCargoItem[] }
export interface DispatchRouteStop { index: number; name: string; address: string | null; destinationKey: string | null }
export interface DispatchRoute { dispatchNo: string; recordVersion: Version; stops: DispatchRouteStop[]; edit: Gate }
export interface DispatchRouteCommand extends VersionCommand { destinationKeys: string[]; reason: string }
export type DispatchEvidenceKind = 'VEHICLE' | 'DRIVER'
export interface DispatchEvidenceFile { fileId: string; fileName: string; contentType: string; sizeBytes: number }
export interface CandidateList { candidates: Candidate[]; snapshotAt: string; complete: boolean }
export interface DraftContainer { draftContainerId: string; sequenceNo: number; capacity: number; sourceKeys: string[] }
export interface DispatchDraft { draftToken: string; recordVersion: Version; status: string; warehouseId: string; containers: DraftContainer[]; sources: Candidate[]; expiresAt: string }
export interface DraftSelection extends VersionCommand { draftToken: string; draftContainerId: string; source: CargoSelection }
export interface ConvertDraft extends VersionCommand { draftToken: string; groups: Array<{ draftContainerId: string; dispatch: CreateDispatch }> }
export interface ConvertedDraft { draftToken: string; vehicles: Array<{ draftContainerId: string; dispatch: CommandResult }>; replayed: boolean }
export interface CoverageSelection { financeFeeId: string; sourceVersion: number; sourceHash: string; services: string[] }
export interface FeeItem { feeType: string; feeName: string; direction: string; currency: string; amount: number | null; supplierId?: string | null; supplierName?: string | null; supplierCategory?: string | null; settlementMode?: string | null; paidAt?: string | null; paymentMethod?: string | null; paymentReferenceNo?: string | null; paymentVoucherFileIds?: string[]; chargeableQuantity?: number | null; quantityUnit?: string | null; unitPrice?: number | null; periodStart?: string | null; periodEnd?: string | null; remark?: string | null }
export interface ArrangeGroup { groupKey: string; groupCargoHash: string; plannedDeliveryAt: string; deliveryMethod: string; waybillNo: string | null; carrierName: string | null; pickupMethod: string; loadingType: string; vehicleType: string | null; vehicleSize: string | null; appointmentAccount: string | null; sellerName: string | null; remark: string | null; externalFreightHash: string; feeCoverageCargoHash: string; feeCoverage: CoverageSelection[]; fees: FeeItem[] }
export interface ArrangeDelivery extends VersionCommand { cargoHash: string; loadingCompletionHash: string; groups: ArrangeGroup[] }
export interface ArrangementResult { dispatchNo: string; recordVersion: Version; arrangementKey: string; cargoHash: string; arrangedAt: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[] }
export interface SourceOrigin { dispatchItemSourceId: string | null; relabelApplicationId: string | null; relabelItemId: string | null; sourceInventoryBalanceId: string | null; sourceInboundOrderId: string | null; sourceInboundOrderNo: string | null; sourceInboundCode: string | null; productQuantity: number | null }
export interface CargoLine { dispatchItemId: string; customerId: string; customerName: string; orderId: string | null; orderNo: string | null; sourceKey: string; sourceStage: string; sourceRegion: string | null; sourceInboundId: string | null; inboundCode: string | null; planPalletId: string | null; warehousePalletId: string | null; palletNo: string | null; pickId: string | null; attributionId: string | null; inventoryPositionId: string | null; receiptId: string | null; returnReceiptId: string | null; returnCartonId: string | null; cartonMark: string | null; relabelNo: string | null; destination: Destination; packages: number; productQuantity: number; grossWeightKg: number | null; volumeCbm: number | null; batchAllocations: DispatchBatchAllocation[]; sourceOrigin: SourceOrigin | null }
export interface PreviewGroup { groupKey: string; customerId: string; customerName: string; destination: Destination; groupCargoHash: string; cargo: CargoLine[]; externalFreightHash: string; externalQuotes: Array<{ customerId: string; groupKey: string; bookingNo: string; inboundCode: string; currency: string; amount: number; sourceHash: string }>; feeCoverageCargoHash: string; feeSources: Array<{ financeFeeId: string; customerId: string; currency: string; amount: number; sourceVersion: number; sourceHash: string; status: string; services: string[]; sourceKeys: string[] }>; requiredServices: string[]; blockReasons: string[] }
export interface ArrangementPreview { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; cargoHash: string; loadingCompletionHash: string; arrange: Gate; groups: PreviewGroup[]; arrangement: ArrangementResult | null }
interface CommandBase { dispatchNo: string; idempotencyKey: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; occurredAt: string; effect: 'APPLIED' | 'EXISTING'; counts: Counts; replayed: boolean; alreadyArranged: boolean }
export type CommandResult = CommandBase & (
{ action: 'PICK'; result: { dispatchItemId: string; positionId: string | null; returnCartonId: string | null; packages: number; pickedPackages: number; pickingReconciled: boolean } }
| { action: 'LOADING_COMPLETE'; result: { loadingCompletionHash: string; loadingCompletedAt: string; confirmedLoad: ConfirmedLoad } }
| { action: 'DEPART'; result: { departedAt: string; shippedPackages: number; outbounds: OutboundReference[]; deliveries: DeliveryReference[] } }
| { action: 'ARRANGE_DELIVERY'; result: ArrangementResult }
| { action: Exclude<DispatchAction, 'PICK' | 'LOADING_COMPLETE' | 'DEPART' | 'ARRANGE_DELIVERY'>; result: { dispatchNo: string; changedItemIds: string[] } }
)
export const dispatchStages: Array<{ value: DispatchStage; label: string }> = [
{ value: 'BEFORE_LOADING', label: '装车前' }, { value: 'LOADING', label: '装车中' },
{ value: 'AFTER_LOADING', label: '装车后' }, { value: 'DELIVERING', label: '派送中' }, { value: 'DELIVERED', label: '派送完成' },
]
export const stageLabel = (stage: DispatchStage) => dispatchStages.find(row => row.value === stage)?.label || '—'
export const reviewLabels: Record<ReviewStatus, string> = { NOT_SUBMITTED: '未提交', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' }
export function requireVersion(value: unknown): Version {
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return value
if (typeof value === 'string' && /^\d+$/.test(value)) return value
throw new DispatchInputError('操作版本缺失或无效,请刷新单据')
}
export function requireBatchVersions(values: readonly { batchKey: string; recordVersion: Version }[]): void {
const versions = new Map<string, string>()
for (const value of values) {
let version: Version
try { version = requireVersion(value.recordVersion) }
catch { throw new DispatchInputError('预约批次版本缺失或无效,请重新读取候选') }
const canonical = String(version).replace(/^0+(?=\d)/, '')
if (versions.has(value.batchKey) && versions.get(value.batchKey) !== canonical) throw new DispatchInputError('同一预约批次的版本不一致,请重新读取候选')
versions.set(value.batchKey, canonical)
}
}
function selectionIdentity(value: CargoSelection): string {
return JSON.stringify([value.sourceType, value.planPalletId, value.returnReceiptId, value.relabelNo, value.warehousePalletId])
}
export function requireSelectionGroup(row: Candidate): SelectionGroup {
const group = row.selectionGroup
if (!group || !group.groupKey || !group.groupVersion || !Array.isArray(group.sources) || !Array.isArray(group.pallets) || !Array.isArray(group.blockReasons)) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选')
if (group.complete !== true || group.blockReasons.length || row.blockReasons.length) throw new DispatchInputError([...row.blockReasons, ...group.blockReasons].join(';') || '选货组来源尚未完整,请重新读取候选')
if (!group.sources.length || group.sources.some(source => !source.sourceKey || !source.customerId || !source.selection?.sourceVersion || !Number.isSafeInteger(source.packages) || source.packages <= 0 || source.selection.sourceType === 'RELABEL' && (source.sourceBusinessType !== 'RELABEL_ORDER' || !source.sourceBusinessNo || source.sourceBusinessNo !== source.selection.relabelNo))) throw new DispatchInputError('选货组来源或数量不完整,请重新读取候选')
const palletKeys = group.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo)
if (!palletKeys.length || palletKeys.some(key => !key) || new Set(palletKeys).size !== palletKeys.length || group.pallets.some(pallet => !Array.isArray(pallet.sourceKeys) || !pallet.sourceKeys.length || pallet.sourceKeys.some(key => !group.sources.some(source => source.sourceKey === key)) || !Number.isSafeInteger(pallet.packageCount) || pallet.packageCount <= 0)) throw new DispatchInputError('选货组实托事实缺失或重复,请重新读取候选')
if (!group.sources.some(source => source.sourceKey === row.sourceKey && source.customerId === row.customerId && selectionIdentity(source.selection) === selectionIdentity(row.selection) && source.selection.sourceVersion === row.sourceVersion && row.selection.sourceVersion === row.sourceVersion)) throw new DispatchInputError('候选与完整选货组来源不一致,请重新读取候选')
return group
}
export function selectionGroupBlockReason(row: Candidate): string {
try { requireSelectionGroup(row); return '' } catch (cause) { return cause instanceof Error ? cause.message : '完整选货组无效,请重新读取候选' }
}
export function assertSelectionGroupVersions(rows: readonly Candidate[], latest: readonly Candidate[]): void {
const versions = new Map<string, string>()
for (const row of rows) {
const group = row.selectionGroup
if (!group?.groupKey || !group.groupVersion) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选')
if (versions.has(group.groupKey) && versions.get(group.groupKey) !== group.groupVersion) throw new DispatchInputError('选货组组成已变化,请重新读取草稿并选择货物')
versions.set(group.groupKey, group.groupVersion)
}
for (const row of latest) {
const group = row.selectionGroup, selected = rows.find(value => value.sourceKey === row.sourceKey)
if (selected && selected.selectionGroup.groupKey !== group?.groupKey || group && versions.has(group.groupKey) && versions.get(group.groupKey) !== group.groupVersion) throw new DispatchInputError('选货组组成已变化,请重新读取草稿并选择货物')
}
}
export function expandSelectionGroups(rows: readonly Candidate[]): CargoSelection[] {
assertSelectionGroupVersions(rows, [])
const sources = new Map<string, SelectionGroupSource>(), selections = new Map<string, CargoSelection>()
for (const row of rows) for (const source of requireSelectionGroup(row).sources) {
const previous = sources.get(source.sourceKey), identity = selectionIdentity(source.selection), selected = selections.get(identity)
if (previous && (selectionIdentity(previous.selection) !== identity || previous.selection.sourceVersion !== source.selection.sourceVersion || previous.customerId !== source.customerId || previous.packages !== source.packages)) throw new DispatchInputError('完整选货组来源事实不一致,请重新读取候选')
if (selected && selected.sourceVersion !== source.selection.sourceVersion) throw new DispatchInputError('完整选货组来源版本不一致,请重新读取候选')
sources.set(source.sourceKey, source); selections.set(identity, source.selection)
}
return [...selections.values()]
}