This commit is contained in:
2026-10-19 17:56:31 +08:00
parent 5a46198bf0
commit 5c28cd7c79
84 changed files with 3356 additions and 3630 deletions
@@ -1,161 +1,131 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, preArrangeDraftDispatch, releaseDispatchDraft, transferDispatchToDelivery, unlockDispatchDraftPallet, updateDispatch, getBatchDispatchCandidates } from './dispatches'
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
describe('dispatch overseas API', () => {
it('reads an immutable batch and preserves its exact per-source allocations on grouped submission', async () => {
const allocation = { planPalletId: '9007199254740993', batchKey: 'FBA_APPOINTMENT:9', customerId: '9007199254740999', sourceKey: 'BOOKING:2', destinationKey: 'FBA:ONT8', packageCount: 12 }
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { batchKey: allocation.batchKey, version: 4, candidates: [], blockReasons: [] } })
await getBatchDispatchCandidates(allocation.batchKey)
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/appointment-planning/batches/FBA_APPOINTMENT%3A9/dispatch-candidates')
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [] })
const request = { draftToken: 'draft-1', groups: [{ draftContainerId: 'container-1', planPalletIds: [allocation.planPalletId], returnReceiptIds: [], batchAllocations: [allocation] }] }
await preArrangeDraftDispatch(request)
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/pre-arrange', request)
})
it('submits all explicit draft groups once and preserves container and source identities', async () => {
const payload = { draftToken: 'original-draft', groups: [
{ draftContainerId: '190000000000000099', planPalletIds: ['11', '13'], returnReceiptIds: [] },
{ draftContainerId: '190000000000000100', planPalletIds: ['12'], returnReceiptIds: ['22'] },
] }
const result = payload.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: { id: String(index), dispatchNo: `DS-${index}` } }))
vi.mocked(http.post).mockResolvedValue({ code: '200', data: result })
expect(await preArrangeDraftDispatch(payload)).toEqual(result)
expect(http.post).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/dispatches/drafts/pre-arrange', payload)
})
it('propagates a batch rejection without falling back to a flat or repeated command', async () => {
vi.mocked(http.post).mockResolvedValue({ code: 'A0409', message: '草稿来源已变化', data: null })
await expect(preArrangeDraftDispatch({ draftToken: 'original-draft', groups: [
{ draftContainerId: 'container', planPalletIds: ['11'], returnReceiptIds: [] },
] })).rejects.toThrow('草稿来源已变化')
expect(http.post).toHaveBeenCalledTimes(1)
})
it('locks and submits actual return receipts without pretending they are plan pallets', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: null })
await lockDispatchDraftPallet('RETURN:190000000000000099', 'draft-return', 'container-1')
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/draft-locks/lock', {
sourceType: 'RETURN_RECEIPT', returnReceiptId: '190000000000000099', draftToken: 'draft-return', draftContainerId: 'container-1',
})
await unlockDispatchDraftPallet('RETURN:190000000000000099', 'draft-return')
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/draft-locks/unlock', {
sourceType: 'RETURN_RECEIPT', returnReceiptId: '190000000000000099', draftToken: 'draft-return',
})
await preArrangeDispatch({ planPalletIds: [], returnReceiptIds: ['190000000000000099'], draftToken: 'draft-return' })
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/pre-arrange', {
planPalletIds: [], returnReceiptIds: ['190000000000000099'], draftToken: 'draft-return',
})
})
it('reads external freight from the scoped dispatch without a supplied customer', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { snapshotHash: 'hash', requiresDeliveryFee: false, quotes: [] } })
expect(await getDispatchExternalFreight('DP/1')).toEqual({ snapshotHash: 'hash', requiresDeliveryFee: false, quotes: [] })
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/DP%2F1/external-freight')
})
it('loads per-shipment coverage without exposing a caller-supplied customer or losing long IDs', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { dispatchNo: 'DP/1', sourceComplete: true, blockReason: null, cargoHash: 'hash', cargo: [], feeSources: [{ financeFeeId: '190000000000000099', sourceFeeId: '190000000000000100', customerId: '101', sourceInboundId: '11', sourceVersion: '2', amount: '100.25' }] } })
const result = await getDispatchFeeCoverage('DP/1')
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/DP%2F1/fee-coverage')
expect(result.feeSources[0]).toMatchObject({ financeFeeId: '190000000000000099', sourceVersion: 2, amount: 100.25 })
})
beforeEach(() => {
vi.mocked(http.get).mockReset()
vi.mocked(http.post).mockReset()
vi.mocked(http.put).mockReset()
vi.mocked(http.delete).mockReset()
})
it('uses the platform dispatch page without customer scope', async () => {
vi.mocked(http.get).mockResolvedValue({
code: '200', message: 'success', data: { records: [], page: 2, size: 50, total: 0, pages: 0 },
})
await listDispatches({
page: 2, size: 50, dispatchNo: 'PC-1',
vehiclePlate: 'CA-100', status: 'PENDING_DEPARTURE', fbaAppointmentNo: 'FBA-1',
})
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches', {
params: {
page: 2, size: 50, dispatchNo: 'PC-1',
vehiclePlate: 'CA-100', status: 'PENDING_DEPARTURE', fbaAppointmentNo: 'FBA-1',
},
})
})
it('loads a platform dispatch detail by encoded dispatch number', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC/1' } })
await getDispatch('PC/1')
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1')
})
it('requests the generated BOL as a PDF blob', async () => {
vi.mocked(http.get).mockResolvedValue(new Blob(['%PDF-test']))
await getDispatchBolPdf('PC/1')
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/prints/bol', { responseType: 'blob' })
})
it('loads candidates and creates a pre-arrange dispatch in an explicit customer scope', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC-1' } })
await listDispatchCandidates({ planIds: '101,102', fbaWarehouseCode: 'ONT8', sourceStage: 'PLAN' })
await preArrangeDispatch({ planPalletIds: ['190000000000000099'] })
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { planIds: '101,102', fbaWarehouseCode: 'ONT8', sourceStage: 'PLAN' } })
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/pre-arrange', { planPalletIds: ['190000000000000099'] })
})
it('loads platform candidates without selected inbound plans', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
await listDispatchCandidates({ sourceStage: 'RECEIVED' })
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', {
params: { sourceStage: 'RECEIVED' },
})
})
it('locks, unlocks, and releases intelligent dispatch drafts through dedicated endpoints', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: null })
await lockDispatchDraftPallet('99', 'draft-1')
await unlockDispatchDraftPallet('99', 'draft-1')
await releaseDispatchDraft('draft-1')
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/dispatches/draft-locks/lock', { planPalletId: '99', draftToken: 'draft-1' })
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/dispatches/draft-locks/unlock', { planPalletId: '99', draftToken: 'draft-1' })
expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/dispatches/draft-locks/release', { draftToken: 'draft-1' })
})
it('edits and cancels only by dispatchNo without customer scope or status mutation', async () => {
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC/1' } })
vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
await updateDispatch('PC/1', { vehiclePlate: 'CA-100' })
await cancelDispatch('PC/1')
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', { vehiclePlate: 'CA-100' })
expect(http.put).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ status: expect.anything() }))
expect(http.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1')
})
it('transfers to delivery only by dispatchNo without customerId', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { dispatchNo: 'PC/1', outboundNo: 'OUT-1', outboundStatus: 'PICKED', palletCount: 2, packageCount: 20, deliveries: [{ deliveryNo: 'DEL-1', deliveryStatus: 'WAIT_DELIVERY', destinationWarehouseCode: 'ACY2', palletCount: 2, packageCount: 20 }], replayed: false } })
const payload = { idempotencyKey: 'transfer-1', plannedDeliveryAt: '2026-09-01T10:00:00', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费用', direction: 'RECEIVABLE' as const, currency: 'USD' as const, amount: 10 }] }
await transferDispatchToDelivery('PC/1', payload)
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/transfer-delivery', payload)
})
it('loads drafts and preserves long container ids in create and delete paths', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '190000000000000099', capacity: 30, palletIds: [] } })
vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
await listDispatchDrafts()
await createDispatchDraftContainer('draft/1', 30)
await deleteDispatchDraftContainer('draft/1', '190000000000000099')
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts')
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/containers', { draftToken: 'draft/1', capacity: 30 })
expect(http.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/draft%2F1/containers/190000000000000099')
})
import * as api from './dispatches'
import { candidate, command, detail, loadingPreview, page } from '../dispatchTestFixtures'
import type { ArrangeDelivery, CreateDispatch, DispatchBatchAllocation, DraftSelection, LoadingComplete, ReasonCommand, ReviewCommand, UpdateDispatch, Version, VersionCommand } from '../dispatchContracts'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data }))
const body = { recordVersion: 0, idempotencyKey: 'original-key', reason: '核对原因' }
const draft = () => ({ draftToken: 'server-token', recordVersion: 0, status: 'ACTIVE', warehouseId: '321', containers: [], sources: [candidate()], expiresAt: '' })
const file = () => ({ fileId: '9007199254740993', fileName: '证据.png', contentType: 'image/png', sizeBytes: 3 })
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(http.get).mockImplementation(async url => ({ data: url.endsWith('/items') ? { dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [] } : url.includes('/evidence/') ? file() : url.endsWith('/loading') ? loadingPreview() : url.includes('/commands/') ? command('EDIT') : detail() }))
vi.mocked(http.post).mockImplementation(async url => ({ data: url.endsWith('/pre-arrange') ? { draftToken: 'server-token', vehicles: [], replayed: false } : url.includes('/draft') ? draft() : url.includes('/evidence/') ? file() : command('EDIT') }))
vi.mocked(http.put).mockResolvedValue({ data: command('EDIT') })
vi.mocked(http.delete).mockResolvedValue({ data: draft() })
})
describe('native whole-vehicle dispatch API', () => {
it.each([0, '0', '9007199254740993'])('preserves authoritative batch version %s across direct create, add cargo and grouped conversion', async recordVersion => {
const allocation: DispatchBatchAllocation = { planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion }
const create: CreateDispatch = { warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [candidate().selection], batchAllocations: [allocation] }
await api.createDispatch(create); await api.addDispatchCargo('PC/1', { ...body, cargo: create.cargo, batchAllocations: [allocation] }); await api.convertDispatchDraft({ ...body, draftToken: 'draft', groups: [{ draftContainerId: 'container', dispatch: create }] })
expect(vi.mocked(http.post).mock.calls[0]![1]).toBe(create)
expect(vi.mocked(http.post).mock.calls[1]![1]).toMatchObject({ batchAllocations: [{ recordVersion }] })
expect(vi.mocked(http.post).mock.calls[2]![1]).toMatchObject({ groups: [{ dispatch: { batchAllocations: [{ recordVersion }] } }] })
})
it.each([undefined, null, -1, '', 'bad', 9007199254740992])('rejects missing or invalid nested batch version %s before any write or candidate handoff', async value => {
const allocation = { planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion: value } as unknown as DispatchBatchAllocation
const create: CreateDispatch = { warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [candidate().selection], batchAllocations: [allocation] }
await expect(Promise.resolve().then(() => api.createDispatch(create))).rejects.toThrow('重新读取候选')
await expect(Promise.resolve().then(() => api.addDispatchCargo('PC/1', { ...body, cargo: create.cargo, batchAllocations: [allocation] }))).rejects.toThrow('重新读取候选')
await expect(Promise.resolve().then(() => api.convertDispatchDraft({ ...body, draftToken: 'draft', groups: [{ draftContainerId: 'container', dispatch: create }] }))).rejects.toThrow('重新读取候选')
expect(http.post).not.toHaveBeenCalled()
vi.mocked(http.get).mockResolvedValueOnce({ data: { batchKey: allocation.batchKey, version: 5, candidates: [{ allocations: [allocation] }], blockReasons: [] } })
await expect(api.getBatchDispatchCandidates(allocation.batchKey)).rejects.toThrow('重新读取候选')
})
it('rejects inconsistent original versions of the same batch across conversion groups', () => {
const allocation = { planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion: '9007199254740993' }
const create: CreateDispatch = { warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [], batchAllocations: [allocation] }
expect(() => api.convertDispatchDraft({ ...body, draftToken: 'draft', groups: [{ draftContainerId: 'A', dispatch: create }, { draftContainerId: 'B', dispatch: { ...create, batchAllocations: [{ ...allocation, recordVersion: '9007199254740994' }] } }] })).toThrow('同一预约批次的版本不一致')
expect(http.post).not.toHaveBeenCalled()
})
it('uses actual item, route and evidence endpoints with native multipart and authenticated blob content', async () => {
await api.getDispatchItems('PC/1'); await api.getDispatchRoute('PC/1'); await api.getDispatchEvidence('PC/1', 'DRIVER', '9007199254740993')
expect(vi.mocked(http.get).mock.calls.map(call => call[0])).toEqual(['/platform/v1/last-leg/dispatches/PC%2F1/items', '/platform/v1/last-leg/dispatches/PC%2F1/route', '/platform/v1/last-leg/dispatches/PC%2F1/evidence/DRIVER/9007199254740993'])
const file = new File(['png'], '证据.png', { type: 'image/png' }); await api.uploadDispatchEvidence('PC/1', 'VEHICLE', file)
const upload = vi.mocked(http.post).mock.calls[0]!
expect(upload[0]).toBe('/platform/v1/last-leg/dispatches/PC%2F1/evidence/VEHICLE'); expect(upload[1]).toBeInstanceOf(FormData); expect((upload[1] as FormData).get('file')).toBe(file)
await api.getDispatchEvidenceContent('PC/1', 'VEHICLE', '301'); expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/evidence/VEHICLE/301/content', { responseType: 'blob' })
})
it('reorders only the supplied existing destination keys with explicit native version zero', async () => {
await expect(api.reorderDispatchRoute('PC/1', { recordVersion: null as unknown as number, idempotencyKey: 'key', destinationKeys: [], reason: '路线调整' })).rejects.toThrow('版本')
expect(http.put).not.toHaveBeenCalled()
const request = { recordVersion: 0, idempotencyKey: 'key', destinationKeys: ['CODE:ONT8', 'PRIVATE:actual'], reason: '现场卸货顺序调整' }
await api.reorderDispatchRoute('PC/1', request); expect(http.put).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/dispatches/PC%2F1/route', request)
})
it('queries five-stage rows with server review sections and preserves statistics', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ data: page() })
const query = { page: 2, size: 20, stage: 'BEFORE_LOADING' as const, beforeLoadingSection: 'DISPATCH_REVIEW' as const, reviewStatus: 'PENDING' as const, status: 'ACTIVE' as const }
const result = await api.listDispatches(query)
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches', { params: query }); expect(result.statistics.stageCounts.DELIVERING).toBe(1)
})
it('reads the four-object detail and loading without customer scope, encoding business numbers', async () => {
await api.getDispatch('PC/1'); await api.getDispatchLoading('PC/1'); await api.getDispatchCommand('PC/1', 'key/1')
expect(vi.mocked(http.get).mock.calls.map(call => call[0])).toEqual(['/platform/v1/last-leg/dispatches/PC%2F1', '/platform/v1/last-leg/dispatches/PC%2F1/loading', '/platform/v1/last-leg/dispatches/PC%2F1/commands/key%2F1'])
})
it('reads candidates with their exact sourceVersion and repeated plan query parameters', async () => {
const data = { candidates: [candidate()], snapshotAt: '2026-10-19T10:00:00Z', complete: true }
vi.mocked(http.get).mockResolvedValueOnce({ data })
expect(await api.listDispatchCandidates({ warehouseId: '321', planIds: ['11', '12'] })).toEqual(data)
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { warehouseId: '321', planIds: ['11', '12'] }, paramsSerializer: { indexes: null } })
})
it('creates an empty real dispatch without an artificial customer or cargo', async () => {
const request: CreateDispatch = { idempotencyKey: 'create', warehouseId: '321', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [], batchAllocations: [] }
await api.createDispatch(request); expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches', request)
})
it.each(['cancel', 'remove', 'review', 'loading-review', 'return', 'loading', 'depart', 'arrange', 'add', 'submit', 'draft-container', 'draft-select', 'draft-release', 'draft-convert', 'draft-snapshot'])('rejects missing/null/negative versions before %s requests and accepts zero', async action => {
const invoke = (value: unknown) => {
const request = { ...body, recordVersion: value as Version }
switch (action) {
case 'cancel': return api.cancelDispatch('PC/1', request)
case 'remove': return api.removeDispatchItem('PC/1', '11', request)
case 'review': return api.reviewDispatch('PC/1', request as unknown as ReviewCommand)
case 'loading-review': return api.reviewDispatchLoading('PC/1', request as unknown as ReviewCommand)
case 'return': return api.returnDispatchToReview('PC/1', request)
case 'loading': return api.completeDispatchLoading('PC/1', request as unknown as LoadingComplete)
case 'depart': return api.departDispatch('PC/1', { ...request, remark: null })
case 'arrange': return api.transferDispatchToDelivery('PC/1', request as unknown as ArrangeDelivery)
case 'add': return api.addDispatchCargo('PC/1', { ...request, cargo: [], batchAllocations: [] })
case 'submit': return api.submitDispatchReview('PC/1', { ...request, remark: null })
case 'draft-container': return api.createDispatchDraftContainer({ ...request, draftToken: 'draft', capacity: 30 })
case 'draft-select': return api.lockDispatchDraftPallet(request as unknown as DraftSelection)
case 'draft-release': return api.releaseDispatchDraft({ ...request, draftToken: 'draft' })
case 'draft-convert': return api.convertDispatchDraft({ ...request, draftToken: 'draft', groups: [] })
default: return api.saveDispatchDraftSnapshot({ ...request, draftToken: 'draft', planIds: [], containers: [] })
}
}
for (const invalid of [undefined, null, -1, '', 'bad', Number.MAX_SAFE_INTEGER + 1]) await expect(Promise.resolve().then<unknown>(() => invoke(invalid))).rejects.toThrow('版本')
expect(http.post).not.toHaveBeenCalled(); await invoke(0); expect(vi.mocked(http.post).mock.calls[0]![1]).toEqual(expect.objectContaining({ recordVersion: 0 }))
})
it('updates only an existing appointment reference and its explicit version', async () => {
const request: UpdateDispatch = { ...body, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, vehicleAppointment: { referenceNo: 'AP-1', recordVersion: 0 } }
await api.updateDispatch('PC/1', request); expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', request)
await expect(api.updateDispatch('PC/1', { ...request, vehicleAppointment: { referenceNo: 'AP-1', recordVersion: null as unknown as Version } })).rejects.toThrow('版本')
await expect(api.updateDispatch('PC/1', { ...request, recordVersion: undefined as unknown as Version })).rejects.toThrow('版本')
})
it('uses versioned removal and cancellation POSTs instead of unversioned deletes', async () => {
await api.cancelDispatch('PC/1', body); await api.removeDispatchItem('PC/1', 'item/1', body)
expect(vi.mocked(http.post).mock.calls.map(call => call[0])).toEqual(['/platform/v1/last-leg/dispatches/PC%2F1/cancel', '/platform/v1/last-leg/dispatches/PC%2F1/items/item%2F1/remove']); expect(http.delete).not.toHaveBeenCalled()
})
it('preserves complete grouped arrangement evidence and returns an immutable command envelope', async () => {
const outbound = (customerId: string) => ({ customerId, customerName: `客户${customerId}`, outboundNo: `OUT-${customerId}`, status: 'WAIT_OUTBOUND', packageCount: 2, palletCount: 1 })
const delivery = (customerId: string) => ({ ...outbound(customerId), groupKey: customerId, deliveryNo: `DEL-${customerId}`, destination: candidate().destination })
const data = { ...command('ARRANGE_DELIVERY'), result: { dispatchNo: 'PC/1', recordVersion: '9007199254740993', arrangementKey: 'arrange', cargoHash: 'cargo', arrangedAt: '2026-10-19T10:00:00Z', outbounds: [outbound('A'), outbound('B')], deliveries: [delivery('A'), delivery('B')] } }
vi.mocked(http.post).mockResolvedValueOnce({ data })
const request: ArrangeDelivery = { ...body, recordVersion: '9007199254740993', cargoHash: 'cargo', loadingCompletionHash: 'loaded', groups: [] }
expect(await api.transferDispatchToDelivery('PC/1', request)).toEqual(data); expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/transfer-delivery', request)
})
it('keeps draft version, server token and source evidence and converts explicit groups atomically', async () => {
const source = candidate().selection
await api.lockDispatchDraftPallet({ ...body, draftToken: 'server-token', draftContainerId: '190000000000000011', source })
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/draft-locks/lock', { ...body, draftToken: 'server-token', draftContainerId: '190000000000000011', source })
await api.convertDispatchDraft({ ...body, draftToken: 'server-token', groups: [] }); expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/drafts/pre-arrange', { ...body, draftToken: 'server-token', groups: [] })
})
it('deletes draft containers with the body version and never invents successful response data', async () => {
vi.mocked(http.delete).mockRejectedValueOnce(new Error('server failure'))
await expect(api.deleteDispatchDraftContainer('draft/1', 'container/1', body as ReasonCommand)).rejects.toThrow('server failure')
expect(http.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/draft%2F1/containers/container%2F1', { data: body })
})
it('reads BOL as a PDF blob', async () => { await api.getDispatchBolPdf('PC/1'); expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/prints/bol', { responseType: 'blob' }) })
})
+57 -338
View File
@@ -1,346 +1,65 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { DispatchEvidenceKind, DispatchRoute, DispatchRouteCommand } from '../dispatchContracts'
import { requireVersion, requireBatchVersions, type AddCargo, type ArrangeDelivery, type CandidateQuery, type ConvertDraft, type CreateDispatch, type DispatchQuery, type DraftContainer, type DraftSelection, type LoadingComplete, type ReasonCommand, type ReviewCommand, type UpdateDispatch, type VersionCommand, type DispatchBatchAllocation } from '../dispatchContracts'
import { normalizeArrangementPreview, normalizeCandidateList, normalizeConvertedDraft, normalizeDispatchCommand, normalizeDispatchDetail, normalizeDispatchDraft, normalizeDispatchItems, normalizeDispatchPage, normalizeEvidenceFile, normalizeLoadingPreview, type ArrangementPreviewWire, type CandidateListWire, type CommandResultWire, type ConvertedDraftWire, type DispatchDetailWire, type DispatchDraftWire, type DispatchEvidenceFileWire, type DispatchItemsWire, type DispatchPageWire, type LoadingPreviewWire } from '../dispatchWire'
export type { DispatchBatchReference, DispatchBatchAllocation } from '../dispatchContracts'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
export interface DispatchQuery {
page: number
size: number
dispatchNo?: string
vehiclePlate?: string
status?: string
fbaAppointmentNo?: string
stage?: 'BEFORE_LOADING' | 'LOADING' | 'AFTER_LOADING' | 'CANCELLED'
}
export interface DispatchReview { status: 'PENDING' | 'APPROVED' | 'REJECTED'; reason: string | null; reviewedAt: string | null }
export interface DispatchWorkflow { tripNo: string; dispatchReview: DispatchReview; loadingReview: DispatchReview }
export interface DispatchActions {
canEdit: boolean
editBlockReason: string | null
canCancel: boolean
cancelBlockReason: string | null
canRemovePallet: boolean
removePalletBlockReason: string | null
canTransfer: boolean
transferBlockReason: string | null
outboundNo: string | null
outboundStatus: string | null
}
export interface DispatchPalletCandidateDto {
sourceType?: 'INBOUND_PLAN' | 'RETURN_RECEIPT'
returnReceiptId?: string | null
returnReceiptNo?: string | null
planId?: string | null
planPalletId?: string | null
warehousePalletId?: string | null
orderId: string
customerId?: string | null
customerName?: string | null
planNo?: string | null
orderType?: string | null
volumeWeightKg?: number | null
grossWeightKg?: number | null
containerNo?: string | null
appointmentAt?: string | null
expectedLoadingAt?: string | null
overseasWarehouseId?: string | null
overseasWarehouseCode?: string | null
overseasWarehouseName?: string | null
orderNo?: string | null
orderRefs?: string | null
inboundCode?: string | null
palletNo?: string | null
destinationWarehouseId?: string | null
destinationWarehouseCode?: string | null
destinationWarehouseName?: string | null
fbaWarehouseCode?: string | null
packageCount?: number | null
volumeCbm?: number | null
sourceStage?: 'PLAN' | 'RECEIVED' | 'RELABEL' | null
cartonMarks?: string | null
relabelCartonMarks?: string | null
destinationType?: 'FBA' | 'PRIVATE' | null
deliveryAddress?: string | null
postalCode?: string | null
contactName?: string | null
contactPhone?: string | null
}
export interface DispatchCandidateQuery {
planIds?: string
orderNo?: string
fbaWarehouseCode?: string
sourceStage?: 'PLAN' | 'RECEIVED' | 'RELABEL'
draftToken?: string
}
export interface DispatchDraftContainerDto { id: string; capacity: number; palletIds: string[] }
export interface DispatchDraftDto { draftToken: string; containers?: DispatchDraftContainerDto[] }
export interface PreArrangeDispatchPayload {
batchAllocations?: DispatchBatchAllocation[]
planPalletIds: string[]
returnReceiptIds?: string[]
fbaAppointmentNo?: string
fbaAppointmentStatus?: string
vehicleAppointmentStatus?: string
draftToken?: string
relabelNo?: string
}
export interface PreArrangeDispatchDraftGroup {
batchAllocations?: DispatchBatchAllocation[]
draftContainerId: string
planPalletIds: string[]
returnReceiptIds: string[]
}
export interface PreArrangeDispatchDraftPayload {
draftToken: string
groups: PreArrangeDispatchDraftGroup[]
}
export interface PreArrangeDispatchDraftResult {
draftContainerId: string
dispatch: DispatchSummaryDto
}
export interface DispatchBatchAllocation { planPalletId: string; batchKey: string; customerId: string; sourceKey: string; destinationKey: string; packageCount: number }
export interface BatchDispatchCandidate { planPalletId: string; palletNo: string; planId: string; planNo: string; customerId: string; sourceStage: string; packageCount: number | null; allocations: DispatchBatchAllocation[]; blockReasons: string[] }
export interface BatchDispatchCandidates { batchKey: string; version: number; candidates: BatchDispatchCandidate[]; blockReasons: string[] }
export async function getBatchDispatchCandidates(batchKey: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<BatchDispatchCandidates>, ApiEnvelope<BatchDispatchCandidates>>(`/platform/v1/last-leg/appointment-planning/batches/${encodeURIComponent(batchKey)}/dispatch-candidates`))
}
export interface DispatchBatchReference { batchKey: string; batchNo: string | null; customerId: string; sources: Array<{ sourceKey: string; destinationKey: string; packageCount: number }> }
export interface DispatchSummaryDto {
actions?: DispatchActions
appointmentBatches: DispatchBatchReference[]
workflow?: DispatchWorkflow | null
id: string
dispatchNo: string
vehiclePlate?: string | null
driverName?: string | null
driverPhone?: string | null
carrierName?: string | null
orderNos?: string | null
route?: string | null
overseasWarehouseId?: string | null
overseasWarehouseName?: string | null
departAt?: string | null
fbaAppointmentNo?: string | null
fbaAppointmentStatus?: string | null
vehicleAppointmentStatus?: string | null
estimatedArrivalAt?: string | null
status?: string | null
itemCount?: number | null
palletCount?: number | null
packageCount?: number | null
pickedPackageCount?: number | null
shippedPackageCount?: number | null
remainingPackageCount?: number | null
partialCompletionReason?: string | null
createdAt?: string | null
items?: DispatchItemDto[]
bolLines?: DispatchBolLineDto[]
}
export interface DispatchBolLineDto {
containerNo?: string | null
palletNo?: string | null
fbaWarehouseCode?: string | null
destinationWarehouseName?: string | null
warehouseNo?: string | null
fbaShipmentId?: string | null
poReferenceNo?: string | null
packageCount: number
}
export interface UpdateDispatchPayload {
vehiclePlate?: string | null
driverName?: string | null
driverPhone?: string | null
carrierName?: string | null
departAt?: string | null
fbaAppointmentNo?: string | null
fbaAppointmentStatus?: string | null
vehicleAppointmentStatus?: string | null
estimatedArrivalAt?: string | null
}
export interface DispatchItemDto {
id: string
planPalletId: string
warehousePalletId?: string | null
palletNo?: string | null
overseasWarehouseId?: string | null
overseasWarehouseName?: string | null
orderNo?: string | null
orderRefs?: string | null
inboundCode?: string | null
destinationWarehouseCode?: string | null
destinationWarehouseName?: string | null
fbaWarehouseCode?: string | null
packageCount?: number | null
grossWeightKg?: number | null
volumeWeightKg?: number | null
volumeCbm?: number | null
sourceStage?: string | null
}
const base = '/platform/v1/last-leg/dispatches'
const path = (dispatchNo: string) => `${base}/${encodeURIComponent(dispatchNo)}`
async function get<T>(url: string) { return unwrapApiResponse(await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(url)) }
async function post<T>(url: string, body: object) { return unwrapApiResponse(await http.post<ApiEnvelope<T>, ApiEnvelope<T>>(url, body)) }
const postCommand = (url: string, body: object) => post<CommandResultWire>(url, body).then(normalizeDispatchCommand)
const postDraft = (url: string, body: object) => post<DispatchDraftWire>(url, body).then(normalizeDispatchDraft)
function versioned<T extends VersionCommand>(body: T): T { requireVersion(body.recordVersion); return body }
export async function listDispatches(query: DispatchQuery) {
const response = await http.get<ApiEnvelope<OverseasPagePayload<DispatchSummaryDto>>, ApiEnvelope<OverseasPagePayload<DispatchSummaryDto>>>(
'/platform/v1/last-leg/dispatches',
{ params: query },
)
return normalizeOverseasPage(unwrapApiResponse(response))
return normalizeDispatchPage(unwrapApiResponse(await http.get<ApiEnvelope<DispatchPageWire>, ApiEnvelope<DispatchPageWire>>(base, { params: query })))
}
export const getDispatch = (dispatchNo: string) => get<DispatchDetailWire>(path(dispatchNo)).then(normalizeDispatchDetail)
export const getDispatchItems = (no: string) => get<DispatchItemsWire>(`${path(no)}/items`).then(normalizeDispatchItems)
export const getDispatchRoute = (no: string) => get<DispatchRoute>(`${path(no)}/route`)
export async function reorderDispatchRoute(no: string, body: DispatchRouteCommand) { return normalizeDispatchCommand(unwrapApiResponse(await http.put<ApiEnvelope<CommandResultWire>, ApiEnvelope<CommandResultWire>>(`${path(no)}/route`, versioned(body)))) }
const evidencePath = (no: string, kind: DispatchEvidenceKind, id?: string) => `${path(no)}/evidence/${kind}${id ? `/${encodeURIComponent(id)}` : ''}`
export async function uploadDispatchEvidence(no: string, kind: DispatchEvidenceKind, file: File) { const form = new FormData(); form.append('file', file); return post<DispatchEvidenceFileWire>(evidencePath(no, kind), form).then(normalizeEvidenceFile) }
export const getDispatchEvidence = (no: string, kind: DispatchEvidenceKind, id: string) => get<DispatchEvidenceFileWire>(evidencePath(no, kind, id)).then(normalizeEvidenceFile)
export const getDispatchEvidenceContent = (no: string, kind: DispatchEvidenceKind, id: string) => http.get<Blob, Blob>(`${evidencePath(no, kind, id)}/content`, { responseType: 'blob' })
export const getDispatchLoading = (dispatchNo: string) => get<LoadingPreviewWire>(`${path(dispatchNo)}/loading`).then(normalizeLoadingPreview)
export const getDispatchCommand = (dispatchNo: string, key: string) => get<CommandResultWire>(`${path(dispatchNo)}/commands/${encodeURIComponent(key)}`).then(normalizeDispatchCommand)
export const createDispatch = (body: CreateDispatch) => { requireBatchVersions(body.batchAllocations); return postCommand(base, body) }
export async function updateDispatch(dispatchNo: string, body: UpdateDispatch) {
versioned(body)
if (body.vehicleAppointment) requireVersion(body.vehicleAppointment.recordVersion)
return normalizeDispatchCommand(unwrapApiResponse(await http.put<ApiEnvelope<CommandResultWire>, ApiEnvelope<CommandResultWire>>(path(dispatchNo), body)))
}
export const addDispatchCargo = (no: string, body: AddCargo) => { versioned(body); requireBatchVersions(body.batchAllocations); return postCommand(`${path(no)}/items`, body) }
export const removeDispatchItem = (no: string, itemId: string, body: ReasonCommand) => postCommand(`${path(no)}/items/${encodeURIComponent(itemId)}/remove`, versioned(body))
export const cancelDispatch = (no: string, body: ReasonCommand) => postCommand(`${path(no)}/cancel`, versioned(body))
export const submitDispatchReview = (no: string, body: VersionCommand & { remark: string | null }) => postCommand(`${path(no)}/submit-review`, versioned(body))
export const reviewDispatch = (no: string, body: ReviewCommand) => postCommand(`${path(no)}/dispatch-review`, versioned(body))
export const reviewDispatchLoading = (no: string, body: ReviewCommand) => postCommand(`${path(no)}/loading-review`, versioned(body))
export const returnDispatchToReview = (no: string, body: ReasonCommand) => postCommand(`${path(no)}/return-to-review`, versioned(body))
export const completeDispatchLoading = (no: string, body: LoadingComplete) => postCommand(`${path(no)}/loading-complete`, versioned(body))
export const departDispatch = (no: string, body: VersionCommand & { remark: string | null }) => postCommand(`${path(no)}/depart`, versioned(body))
export const getDispatchArrangement = (no: string) => get<ArrangementPreviewWire>(`${path(no)}/delivery-arrangement`).then(normalizeArrangementPreview)
export const transferDispatchToDelivery = (no: string, body: ArrangeDelivery) => postCommand(`${path(no)}/transfer-delivery`, versioned(body))
export const getDispatchBolPdf = (no: string) => http.get<Blob, Blob>(`${path(no)}/prints/bol`, { responseType: 'blob' })
export async function getDispatch(dispatchNo: string) {
const response = await http.get<ApiEnvelope<DispatchSummaryDto>, ApiEnvelope<DispatchSummaryDto>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`,
)
return unwrapApiResponse(response)
export async function listDispatchCandidates(query: CandidateQuery) {
return normalizeCandidateList(unwrapApiResponse(await http.get<ApiEnvelope<CandidateListWire>, ApiEnvelope<CandidateListWire>>(`${base}/candidates`, { params: query, paramsSerializer: { indexes: null } })))
}
export const listDispatchDrafts = () => get<DispatchDraftWire[]>(`${base}/drafts`).then(values => values.map(normalizeDispatchDraft))
export const createDispatchDraft = (body: { idempotencyKey: string; warehouseId: string }) => postDraft(`${base}/drafts`, body)
export const createDispatchDraftContainer = (body: VersionCommand & { draftToken: string; capacity: number }) => postDraft(`${base}/drafts/containers`, versioned(body))
export const saveDispatchDraftSnapshot = (body: VersionCommand & { draftToken: string; planIds: string[]; containers: DraftContainer[] }) => postDraft(`${base}/drafts/snapshot`, versioned(body))
export const lockDispatchDraftPallet = (body: DraftSelection) => postDraft(`${base}/draft-locks/lock`, versioned(body))
export const unlockDispatchDraftPallet = (body: DraftSelection) => postDraft(`${base}/draft-locks/unlock`, versioned(body))
export const releaseDispatchDraft = (body: VersionCommand & { draftToken: string }) => postDraft(`${base}/draft-locks/release`, versioned(body))
export async function deleteDispatchDraftContainer(token: string, id: string, body: ReasonCommand) {
return normalizeDispatchDraft(unwrapApiResponse(await http.delete<ApiEnvelope<DispatchDraftWire>, ApiEnvelope<DispatchDraftWire>>(`${base}/drafts/${encodeURIComponent(token)}/containers/${encodeURIComponent(id)}`, { data: versioned(body) })))
}
export const convertDispatchDraft = (body: ConvertDraft) => { versioned(body); requireBatchVersions(body.groups.flatMap(group => group.dispatch.batchAllocations)); return post<ConvertedDraftWire>(`${base}/drafts/pre-arrange`, body).then(normalizeConvertedDraft) }
export async function getDispatchBolPdf(dispatchNo: string) {
return http.get<Blob, Blob>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/prints/bol`,
{ responseType: 'blob' },
)
}
export async function updateDispatch(dispatchNo: string, payload: UpdateDispatchPayload) {
const response = await http.put<ApiEnvelope<DispatchSummaryDto>, ApiEnvelope<DispatchSummaryDto>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`, payload,
)
return unwrapApiResponse(response)
}
export async function cancelDispatch(dispatchNo: string) {
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`,
)
return unwrapApiResponse(response)
}
export async function removeDispatchItem(dispatchNo: string, itemId: string) {
const response = await http.delete<ApiEnvelope<DispatchSummaryDto>, ApiEnvelope<DispatchSummaryDto>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(itemId)}`,
)
return unwrapApiResponse(response)
}
export async function listDispatchCandidates(query: DispatchCandidateQuery) {
const response = await http.get<ApiEnvelope<DispatchPalletCandidateDto[]>, ApiEnvelope<DispatchPalletCandidateDto[]>>(
'/platform/v1/last-leg/dispatches/candidates',
{ params: query },
)
return unwrapApiResponse(response)
}
export async function preArrangeDispatch(payload: PreArrangeDispatchPayload) {
const response = await http.post<ApiEnvelope<DispatchSummaryDto>, ApiEnvelope<DispatchSummaryDto>>(
'/platform/v1/last-leg/dispatches/pre-arrange',
payload,
)
return unwrapApiResponse(response)
}
export async function preArrangeDraftDispatch(payload: PreArrangeDispatchDraftPayload) {
const response = await http.post<ApiEnvelope<PreArrangeDispatchDraftResult[]>, ApiEnvelope<PreArrangeDispatchDraftResult[]>>(
'/platform/v1/last-leg/dispatches/drafts/pre-arrange', payload,
)
return unwrapApiResponse(response)
}
function draftSource(selection: string) {
return selection.startsWith('RETURN:')
? { sourceType: 'RETURN_RECEIPT', returnReceiptId: selection.slice(7) }
: { planPalletId: selection }
}
export async function lockDispatchDraftPallet(selection: string, draftToken: string, draftContainerId?: string) {
const response = await http.post<ApiEnvelope<null>, ApiEnvelope<null>>(
'/platform/v1/last-leg/dispatches/draft-locks/lock', { ...draftSource(selection), draftToken, ...(draftContainerId ? { draftContainerId } : {}) },
)
return unwrapApiResponse(response)
}
export async function listDispatchDrafts() {
const response = await http.get<ApiEnvelope<DispatchDraftDto[]>, ApiEnvelope<DispatchDraftDto[]>>('/platform/v1/last-leg/dispatches/drafts')
return unwrapApiResponse(response)
}
export async function createDispatchDraftContainer(draftToken: string, capacity: number) {
const response = await http.post<ApiEnvelope<DispatchDraftContainerDto>, ApiEnvelope<DispatchDraftContainerDto>>('/platform/v1/last-leg/dispatches/drafts/containers', { draftToken, capacity })
return unwrapApiResponse(response)
}
export async function deleteDispatchDraftContainer(draftToken: string, containerId: string) {
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`/platform/v1/last-leg/dispatches/drafts/${encodeURIComponent(draftToken)}/containers/${encodeURIComponent(containerId)}`)
return unwrapApiResponse(response)
}
export async function unlockDispatchDraftPallet(selection: string, draftToken: string) {
const response = await http.post<ApiEnvelope<null>, ApiEnvelope<null>>(
'/platform/v1/last-leg/dispatches/draft-locks/unlock', { ...draftSource(selection), draftToken },
)
return unwrapApiResponse(response)
}
export async function releaseDispatchDraft(draftToken: string) {
const response = await http.post<ApiEnvelope<null>, ApiEnvelope<null>>(
'/platform/v1/last-leg/dispatches/draft-locks/release', { draftToken },
)
return unwrapApiResponse(response)
}
export interface TransferDeliveryItem { deliveryNo: string; deliveryStatus: string; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; palletCount: number; packageCount: number }
export interface TransferDeliveryResult { dispatchNo: string; outboundNo: string; outboundStatus: string; palletCount: number; packageCount: number; deliveries: TransferDeliveryItem[]; replayed: boolean }
export interface TransferDeliveryPayload {
feeCoverageCargoHash?: string
feeCoverage?: import('../feeCoverageTypes').FeeCoverageSelection[]
externalFreightHash?: string
idempotencyKey: string
plannedDeliveryAt: string
deliveryMethod: string
waybillNo?: string
carrierName?: string
pickupMethod: string
loadingType: string
vehicleType?: string
vehicleSize?: string
driverName?: string
driverPhone?: string
vehiclePlate?: string
appointmentAccount?: string
sellerName?: string
remark?: string
fees: Array<{ feeType: string; feeName: string; direction: 'RECEIVABLE' | 'PAYABLE'; currency: 'CNY' | 'USD'; amount: number
supplierId?: string; supplierName?: string; supplierCategory?: string; settlementMode?: 'ON_ACCOUNT' | 'DIRECT_PAID'
paidAt?: string; paymentMethod?: 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER'; paymentReferenceNo?: string
paymentVoucherFileIds?: string[]; chargeableQuantity?: number; quantityUnit?: string; unitPrice?: number
periodStart?: string; periodEnd?: string; remark?: string }>
}
export async function transferDispatchToDelivery(dispatchNo: string, payload: TransferDeliveryPayload) {
const response = await http.post<ApiEnvelope<TransferDeliveryResult>, ApiEnvelope<TransferDeliveryResult>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/transfer-delivery`, payload,
)
return unwrapApiResponse(response)
}
export interface ExternalFreightPreview {
snapshotHash: string
requiresDeliveryFee: boolean
quotes: Array<{ bookingNo: string; recordVersion: string | number; incomingNo: string; inboundCode: string;
customerName: string; destinationKey: string; currency: string; amount: number; quantity: number; quantityUnit: string; unitPrice: number }>
}
export async function getDispatchExternalFreight(dispatchNo: string): Promise<ExternalFreightPreview> {
const response = await http.get<ApiEnvelope<ExternalFreightPreview>, ApiEnvelope<ExternalFreightPreview>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/external-freight`,
)
return unwrapApiResponse(response)
}
export async function getDispatchFeeCoverage(dispatchNo: string): Promise<import('../feeCoverageTypes').FeeCoveragePreview> {
const response = await http.get<ApiEnvelope<import('../feeCoverageTypes').FeeCoveragePreview>, ApiEnvelope<import('../feeCoverageTypes').FeeCoveragePreview>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/fee-coverage`,
)
const data = unwrapApiResponse(response)
return { ...data, cargo: data.cargo.map(row => ({ ...row, dispatchItemId: String(row.dispatchItemId), warehousePalletId: String(row.warehousePalletId), orderId: row.orderId == null ? null : String(row.orderId), sourceInboundId: row.sourceInboundId == null ? null : String(row.sourceInboundId), packages: Number(row.packages) })), feeSources: data.feeSources.map(row => ({ ...row, financeFeeId: String(row.financeFeeId), sourceFeeId: String(row.sourceFeeId), customerId: String(row.customerId), sourceInboundId: String(row.sourceInboundId), sourceVersion: Number(row.sourceVersion), amount: Number(row.amount) })) }
}
// Existing appointment-domain provider; its consumer handoff remains with the coordinator.
export interface BatchDispatchCandidate { planPalletId: string; palletNo: string; planId: string; planNo: string; customerId: string; sourceStage: string; packageCount: number | null; allocations: DispatchBatchAllocation[]; blockReasons: string[] }
export interface BatchDispatchCandidates { batchKey: string; version: number; candidates: BatchDispatchCandidate[]; blockReasons: string[] }
export const getBatchDispatchCandidates = (key: string) => get<BatchDispatchCandidates>(`/platform/v1/last-leg/appointment-planning/batches/${encodeURIComponent(key)}/dispatch-candidates`).then(value => { requireBatchVersions(value.candidates.flatMap(row => row.allocations)); return value })