Files
admin-web/src/modules/overseas-master-data/api/trips.test.ts
T
2026-10-19 10:23:38 +08:00

150 lines
14 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError, http } from '@/shared/api/http'
import {
prepareTrip, listTrips, getTrip, createTrip, updateTripPlan, assignTrip, getTripResourceOccupancy, bindTripDispatch, unbindTripDispatch, cancelTrip,
getTripLoading, confirmTripLoading, getTripCompletion, completeTrip, releaseTripResource, listTripHistory, reviewTripDispatch, reviewTripLoading, getTripCapacityPlan, confirmTripCapacityPlan,
} from './trips'
vi.mock('@/shared/api/http', async importOriginal => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
const base = '/platform/v1/last-leg/fleet/trips'
const longId = '9223372036854775801'
const trip = { tripNo: 'TR/TEST', status: 'PLANNED', warehouseId: longId, warehouseCode: 'WH', warehouseName: '起运仓',
vehicleId: longId, vehiclePlate: 'TEST-1', driverId: longId, driverName: '司机', driverPhone: '123',
maxPalletSpaces: '30', maxLoadKg: '18000.125', maxVolumeCbm: '90.001', recordVersion: '0',
plannedDepartureAt: '2026-10-02T10:00:00Z', expectedDriverEndAt: '2026-10-03T10:00:00Z', departedAt: null, completedAt: null,
stops: [{ name: '起运仓' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }], remark: null, updatedAt: '2026-10-02T08:00:00Z', vehicleRelease: null, driverRelease: null }
const reviewState = { status: 'PENDING', reason: null, reviewerId: null, reviewedAt: null, recordVersion: null }
const detail = { trip, driverQualification: null, previousDispatches: [], appointmentHistoryUnavailableReason: null, loadingReview: reviewState, dispatches: [{ appointmentBatches: [], dispatchNo: 'DP-1', customerId: longId, packageCount: '12', boundBy: longId, boundAt: trip.updatedAt, review: reviewState }] }
const cargo = { dispatchNo: 'DP-1', customerId: longId, dispatchItemId: longId, warehousePalletId: longId, palletNo: 'PALLET-1', packages: '12', destinationKey: 'FBA:ONT8', destinationLabel: 'ONT8' }
const load = { palletSpaces: '1', weightKg: '100.125', volumeCbm: '1.005' }
const confirmed = { manifestHash: 'a'.repeat(64), cargo: [cargo], pallets: [{ ...load, warehousePalletId: longId, deliveryIndex: '1' }],
segments: [{ fromIndex: '0', toIndex: '1', occupied: load, remaining: { palletSpaces: '29', weightKg: '17899.875', volumeCbm: '88.995' } }], operatorId: longId, confirmedAt: trip.updatedAt }
const loading = { tripNo: trip.tripNo, recordVersion: '3', cargo: [cargo], manifestHash: confirmed.manifestHash, confirmedLoad: confirmed, blockReason: null }
const outcome = { dispatchNo: 'DP-1', deliveryNo: 'DL-1', customerId: longId, destination: 'ONT8', status: 'DELIVERED', packages: '12', signedPackages: '8', returnedPackages: '4', remainingPackages: '0', terminalAt: trip.updatedAt }
const envelope = <T>(data: T) => ({ code: '200', message: '成功', data })
describe('fleet trip API contract', () => {
beforeEach(() => vi.clearAllMocks())
it('batches occupancy queries without rounding long resource identifiers', async () => {
const occupancy = [{ resourceId: longId, occupiedTripNo: 'TR-OTHER', available: false }]
vi.mocked(http.get).mockResolvedValueOnce(envelope(occupancy))
expect(await getTripResourceOccupancy('TR/TEST', 'DRIVER', [longId, '9007199254740997'])).toEqual(occupancy)
expect(http.get).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/resource-occupancy`, { params: { kind: 'DRIVER', resourceIds: `${longId},9007199254740997` } })
expect(await getTripResourceOccupancy('TR/TEST', 'VEHICLE', [])).toEqual([])
expect(http.get).toHaveBeenCalledTimes(1)
})
it('uses one paged request and keeps long identifiers as exact strings', async () => {
vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [trip], page: '1', size: '20', total: '121', pages: '7' }))
const query = { page: 1, size: 20, keyword: 'TEST', warehouseId: longId }
const result = await listTrips(query)
expect(http.get).toHaveBeenCalledExactlyOnceWith(base, { params: query })
expect(result.total).toBe(121)
expect(result.records[0]).toMatchObject({ vehicleId: longId, driverId: longId, warehouseId: longId, recordVersion: 0, maxPalletSpaces: 30, maxLoadKg: 18000.125 })
})
it('encodes the business number and reads details without prefetching history or loading', async () => {
vi.mocked(http.get).mockResolvedValueOnce(envelope(detail))
const result = await getTrip(trip.tripNo)
expect(http.get).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST`)
expect(result.dispatches[0]).toMatchObject({ packageCount: 12, customerId: longId })
})
it('forwards explicit versions and stable idempotency keys for every write without extra reads', async () => {
vi.mocked(http.post).mockResolvedValue(envelope(detail)); vi.mocked(http.put).mockResolvedValue(envelope(detail))
const draft = { warehouseId: longId, plannedDepartureAt: trip.plannedDepartureAt, expectedDriverEndAt: trip.expectedDriverEndAt, stops: trip.stops, idempotencyKey: 'same-create-key' }
await createTrip(draft); await createTrip(draft)
expect(http.post).toHaveBeenNthCalledWith(1, base, draft); expect(http.post).toHaveBeenNthCalledWith(2, base, draft)
const plan = { recordVersion: 0, plannedDepartureAt: draft.plannedDepartureAt, expectedDriverEndAt: draft.expectedDriverEndAt, stops: draft.stops, reason: '调整' }
await updateTripPlan(trip.tripNo, plan)
expect(http.put).toHaveBeenCalledWith(`${base}/TR%2FTEST/plan`, plan)
const assignment = { recordVersion: 0, vehicleId: longId, driverId: longId, reason: '分配' }
await assignTrip(trip.tripNo, assignment)
expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/assignment`, assignment)
const binding = { recordVersion: 1, dispatchNo: 'DP-1', reason: '关联' }
await bindTripDispatch(trip.tripNo, binding); expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/dispatches/bind`, binding)
await unbindTripDispatch(trip.tripNo, binding); expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/dispatches/unbind`, binding)
await cancelTrip(trip.tripNo, { recordVersion: 2, reason: '取消' })
expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/cancel`, { recordVersion: 2, reason: '取消' })
const complete = { recordVersion: 4, idempotencyKey: 'complete-key', reason: '核对' }
await completeTrip(trip.tripNo, complete); expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/complete`, complete)
const release = { recordVersion: 5, resourceKind: 'DRIVER' as const, actualEndAt: trip.updatedAt, endLocation: '交接点', idempotencyKey: 'release-key', reason: '返程已结束' }
await releaseTripResource(trip.tripNo, release); expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/resources/release`, release)
const review = { recordVersion: 6, decision: 'APPROVED' as const }
await reviewTripDispatch(trip.tripNo, 'DP/1', review)
expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/dispatches/DP%2F1/review`, review)
await reviewTripLoading(trip.tripNo, review)
expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/loading-review`, review)
expect(http.get).not.toHaveBeenCalled()
})
it('keeps planned capacity separate from actual load and consumes source freshness', async () => {
const plan = { tripNo: trip.tripNo, recordVersion: '3', cargo: [cargo], manifestHash: confirmed.manifestHash, capacityPlan: confirmed, planValid: false, blockReason: null, availableSegments: confirmed.segments }
vi.mocked(http.get).mockResolvedValueOnce(envelope(plan)); vi.mocked(http.post).mockResolvedValueOnce(envelope({ ...plan, planValid: true, recordVersion: '4' }))
const result = await getTripCapacityPlan(trip.tripNo)
expect(result.planValid).toBe(false); expect(result.capacityPlan?.segments[0]?.remaining.palletSpaces).toBe(29)
expect(http.get).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/capacity-plan`)
const body = { recordVersion: 3, manifestHash: confirmed.manifestHash, pallets: [{ warehousePalletId: longId, palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 }], reason: '计划核对' }
expect((await confirmTripCapacityPlan(trip.tripNo, body)).recordVersion).toBe(4)
expect(http.post).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/capacity-plan`, body)
})
it('preserves source fingerprints and uses server capacity results without recomputing them', async () => {
vi.mocked(http.get).mockResolvedValueOnce(envelope(loading)); vi.mocked(http.post).mockResolvedValueOnce(envelope(loading))
const result = await getTripLoading(trip.tripNo)
expect(result.confirmedLoad?.segments[0]?.remaining).toEqual({ palletSpaces: 29, weightKg: 17899.875, volumeCbm: 88.995 })
expect(result.cargo[0]?.warehousePalletId).toBe(longId)
const body = { recordVersion: 3, manifestHash: confirmed.manifestHash, pallets: [{ warehousePalletId: longId, palletSpaces: 1, weightKg: 100.125, volumeCbm: 1.005, deliveryIndex: 1 }], reason: '核对实物' }
await confirmTripLoading(trip.tripNo, body)
expect(http.post).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/loading`, body)
})
it('loads completion and immutable history only when explicitly requested', async () => {
vi.mocked(http.get).mockResolvedValueOnce(envelope({ tripNo: trip.tripNo, recordVersion: '5', canComplete: false, blockReason: '另一票未签收', outcomes: [outcome] }))
expect(await getTripCompletion(trip.tripNo)).toMatchObject({ recordVersion: 5, canComplete: false, blockReason: '另一票未签收', outcomes: [{ packages: 12, signedPackages: 8, returnedPackages: 4, remainingPackages: 0 }] })
vi.mocked(http.get).mockResolvedValueOnce(envelope({ page: '2', size: '10', total: '12', pages: '2', records: [{ recordVersion: '5', action: 'COMPLETE', reason: null, operatorId: longId, occurredAt: trip.updatedAt,
snapshot: { trip, externalBookings: [], capacityPlan: null, loadingReview: reviewState, dispatches: detail.dispatches, confirmedLoad: confirmed, completion: { idempotencyKey: 'key', requestHash: 'hash', completedAt: trip.updatedAt, recordedAt: trip.updatedAt, operatorId: longId, outcomes: [outcome] } } }] }))
const history = await listTripHistory(trip.tripNo, 2, 10)
expect(http.get).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/history`, { params: { page: 2, size: 10 } })
expect(history.records[0]?.snapshot.completion?.outcomes[0]?.packages).toBe(12)
expect(history.records[0]?.snapshot.completion?.outcomes[0]).toMatchObject({ signedPackages: 8, returnedPackages: 4, remainingPackages: 0 })
expect(history.records[0]?.snapshot.trip.recordVersion).toBe(0)
})
it('rejects missing or unsafe versions and preserves backend conflict diagnostics', async () => {
for (const version of [null, '', '9007199254740993', '-1']) {
vi.mocked(http.get).mockResolvedValueOnce(envelope({ ...detail, trip: { ...trip, recordVersion: version } }))
await expect(getTrip(trip.tripNo)).rejects.toThrow()
}
const conflict = new ApiResponseError('车次已修改', '409', 200, null, 'request-1', 'trace-1')
vi.mocked(http.post).mockRejectedValueOnce(conflict)
await expect(cancelTrip(trip.tripNo, { recordVersion: 0, reason: '取消' })).rejects.toBe(conflict)
})
})
it('prepares the same trip from two real customer dispatches without issuing cargo creation or separate bind commands', async () => {
vi.mocked(http.post).mockResolvedValueOnce(envelope({ ...detail, dispatches: [{ ...detail.dispatches[0]!, dispatchNo: 'DS-60', customerId: 'customer-b', packageCount: '60' }, { ...detail.dispatches[0]!, dispatchNo: 'DS-40', customerId: 'customer-d', packageCount: '40' }] }))
const request = { tripNo: trip.tripNo, recordVersion: 4, plan: null, vehicleId: null, driverId: null, dispatchNos: ['DS-60', 'DS-40'], idempotencyKey: 'same-share-key' }
const value = await prepareTrip(request)
expect(http.post).toHaveBeenCalledExactlyOnceWith(`${base}/prepare`, request)
expect(value.dispatches.map(row => [row.dispatchNo, row.customerId, row.packageCount])).toEqual([['DS-60', 'customer-b', 60], ['DS-40', 'customer-d', 40]])
expect(http.get).not.toHaveBeenCalled()
})
it('preserves current and previous stable batch identities and normalizes only their numeric fields', async () => {
const reference = { batchKey: 'FBA_APPOINTMENT:9223372036854775801', batchNo: null, customerId: longId, sources: [{ sourceKey: 'ORDER:9223372036854775801', destinationKey: 'FBA:ONT8', packageCount: '40' }] }
vi.mocked(http.get).mockResolvedValueOnce(envelope({ ...detail, dispatches: [{ ...detail.dispatches[0]!, appointmentBatches: [reference] }], previousDispatches: [{ dispatchNo: 'DS-REMOVED', customerId: longId, recordVersion: '7', recordedAt: trip.updatedAt, appointmentBatches: [reference] }], appointmentHistoryUnavailableReason: '缺少部分历史来源' }))
const result = await getTrip(trip.tripNo)
expect(result.dispatches[0]!.appointmentBatches).toEqual([{ ...reference, sources: [{ ...reference.sources[0], packageCount: 40 }] }])
expect(result.previousDispatches[0]).toMatchObject({ dispatchNo: 'DS-REMOVED', customerId: longId, recordVersion: 7, appointmentBatches: result.dispatches[0]!.appointmentBatches })
expect(result.appointmentHistoryUnavailableReason).toBe('缺少部分历史来源')
})
it('rejects missing stable-reference arrays instead of silently falling back to legacy appointment numbers', async () => {
for (const invalid of [{ ...detail, previousDispatches: undefined }, { ...detail, dispatches: [{ ...detail.dispatches[0]!, appointmentBatches: undefined, fbaAppointmentNo: 'AP-OLD' }] }]) {
vi.mocked(http.get).mockResolvedValueOnce(envelope(invalid))
await expect(getTrip(trip.tripNo)).rejects.toThrow()
}
})