feat(appointment): complete customer receiving and trip continuation workflows

This commit is contained in:
2026-10-16 15:41:56 +08:00
parent 148085286d
commit 45f662ecf2
45 changed files with 2042 additions and 131 deletions
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError, http } from '@/shared/api/http'
import {
listTrips, getTrip, createTrip, updateTripPlan, assignTrip, bindTripDispatch, unbindTripDispatch, cancelTrip,
prepareTrip, listTrips, getTrip, createTrip, updateTripPlan, assignTrip, bindTripDispatch, unbindTripDispatch, cancelTrip,
getTripLoading, confirmTripLoading, getTripCompletion, completeTrip, releaseTripResource, listTripHistory, reviewTripDispatch, reviewTripLoading, getTripCapacityPlan, confirmTripCapacityPlan,
} from './trips'
@@ -14,7 +14,7 @@ const trip = { tripNo: 'TR/TEST', status: 'PLANNED', warehouseId: longId, wareho
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, loadingReview: reviewState, dispatches: [{ dispatchNo: 'DP-1', customerId: longId, packageCount: '12', boundBy: longId, boundAt: trip.updatedAt, review: reviewState }] }
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' }],
@@ -113,3 +113,28 @@ describe('fleet trip API contract', () => {
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()
}
})
@@ -2,7 +2,7 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeBooking, type WireBooking } from './tripBookings'
import type { PageResult } from './overseasMasterData'
import type {
FleetTrip, TripStatus, TripDetail, TripDraft, TripPlan, TripAssignment, TripBindingCommand, TripBinding,
TripPreviousDispatch, FleetTrip, TripStatus, TripDetail, TripDraft, TripPlan, TripAssignment, TripBindingCommand, TripBinding,
TripCargo, TripLoad, TripMeasurement, TripConfirmedLoad, TripLoading, TripLoadConfirmation,
TripOutcome, TripCompletionInspection, TripComplete, TripResourceRelease, TripHistory, TripReviewState, TripReviewCommand, TripCapacityPlanning,
} from '../tripTypes'
@@ -15,7 +15,8 @@ type WireTrip = Omit<FleetTrip, 'recordVersion' | 'maxPalletSpaces' | 'maxLoadKg
}
type WireReview = Omit<TripReviewState, 'recordVersion'> & { recordVersion: Numeric | null }
type WireBinding = Omit<TripBinding, 'packageCount' | 'review'> & { packageCount: Numeric; review: WireReview }
type WireDetail = Omit<TripDetail, 'trip' | 'dispatches' | 'loadingReview'> & { trip: WireTrip; dispatches: WireBinding[]; loadingReview: WireReview }
type WirePreviousDispatch = Omit<TripPreviousDispatch, 'recordVersion'> & { recordVersion: Numeric }
type WireDetail = Omit<TripDetail, 'trip' | 'dispatches' | 'loadingReview' | 'previousDispatches'> & { trip: WireTrip; dispatches: WireBinding[]; loadingReview: WireReview; previousDispatches: WirePreviousDispatch[] }
type WirePage<T> = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
type WireCargo = Omit<TripCargo, 'packages'> & { packages: Numeric }
type WireLoad = { [K in keyof TripLoad]: Numeric }
@@ -49,8 +50,9 @@ const trip = (value: WireTrip): FleetTrip => ({ ...value, recordVersion: integer
maxPalletSpaces: value.maxPalletSpaces == null ? null : integer(value.maxPalletSpaces),
maxLoadKg: value.maxLoadKg == null ? null : number(value.maxLoadKg), maxVolumeCbm: value.maxVolumeCbm == null ? null : number(value.maxVolumeCbm) })
const review = (value: WireReview): TripReviewState => ({ ...value, recordVersion: value.recordVersion == null ? null : integer(value.recordVersion) })
const binding = (value: WireBinding): TripBinding => ({ ...value, packageCount: integer(value.packageCount), review: review(value.review) })
const detail = (value: WireDetail): TripDetail => ({ ...value, trip: trip(value.trip), dispatches: value.dispatches.map(binding), loadingReview: review(value.loadingReview) })
const batchReferences = (values: TripBinding['appointmentBatches']) => values.map(reference => ({ ...reference, sources: reference.sources.map(source => ({ ...source, packageCount: integer(source.packageCount) })) }))
const binding = (value: WireBinding): TripBinding => ({ ...value, packageCount: integer(value.packageCount), appointmentBatches: batchReferences(value.appointmentBatches), review: review(value.review) })
const detail = (value: WireDetail): TripDetail => ({ ...value, trip: trip(value.trip), dispatches: value.dispatches.map(binding), previousDispatches: value.previousDispatches.map(row => ({ ...row, recordVersion: integer(row.recordVersion), appointmentBatches: batchReferences(row.appointmentBatches) })), loadingReview: review(value.loadingReview) })
const cargo = (value: WireCargo): TripCargo => ({ ...value, packages: integer(value.packages) })
const outcome = (value: WireOutcome): TripOutcome => ({ ...value, packages: integer(value.packages),
signedPackages: integer(value.signedPackages), returnedPackages: integer(value.returnedPackages), remainingPackages: integer(value.remainingPackages) })