131 lines
15 KiB
TypeScript
131 lines
15 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { http } from '@/shared/api/http'
|
|
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 = { total: 1, page: 1, size: 20, pages: 1, 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'], page: 2, size: 20, warehouseTab: 'NYC' })).toEqual(data)
|
|
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { warehouseId: '321', planIds: ['11', '12'], page: 2, size: 20, warehouseTab: 'NYC' }, 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 vehicle resources and timing with the explicit dispatch version', async () => {
|
|
const request: UpdateDispatch = { ...body, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null }
|
|
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, 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' }) })
|
|
})
|