diff --git a/src/modules/overseas-master-data/api/trips.test.ts b/src/modules/overseas-master-data/api/trips.test.ts index 1da9087f..b97d1dc0 100644 --- a/src/modules/overseas-master-data/api/trips.test.ts +++ b/src/modules/overseas-master-data/api/trips.test.ts @@ -147,3 +147,13 @@ it('rejects missing stable-reference arrays instead of silently falling back to await expect(getTrip(trip.tripNo)).rejects.toThrow() } }) + +it('keeps unknown actual load measurements unknown when reading the trip', async () => { + const unknown = structuredClone(confirmed) as unknown as { pallets: Array<{ weightKg: unknown; volumeCbm: unknown }>; segments: Array<{ occupied: { weightKg: unknown; volumeCbm: unknown }; remaining: { weightKg: unknown; volumeCbm: unknown } }> } + unknown.pallets.forEach(row => { row.weightKg = null; row.volumeCbm = null }) + unknown.segments.forEach(row => { row.occupied.weightKg = null; row.occupied.volumeCbm = null; row.remaining.weightKg = null; row.remaining.volumeCbm = null }) + vi.mocked(http.get).mockResolvedValueOnce(envelope({ tripNo: trip.tripNo, recordVersion: '3', cargo: [cargo], manifestHash: confirmed.manifestHash, confirmedLoad: unknown, blockReason: null })) + const result = await getTripLoading(trip.tripNo) + expect(result.confirmedLoad?.pallets[0]?.weightKg).toBeNull() + expect(result.confirmedLoad?.segments[0]?.remaining.volumeCbm).toBeNull() +}) diff --git a/src/modules/overseas-master-data/api/trips.ts b/src/modules/overseas-master-data/api/trips.ts index 521057f4..397d7e72 100644 --- a/src/modules/overseas-master-data/api/trips.ts +++ b/src/modules/overseas-master-data/api/trips.ts @@ -19,7 +19,7 @@ type WirePreviousDispatch = Omit & { reco type WireDetail = Omit & { trip: WireTrip; dispatches: WireBinding[]; loadingReview: WireReview; previousDispatches: WirePreviousDispatch[] } type WirePage = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric } type WireCargo = Omit & { packages: Numeric } -type WireLoad = { [K in keyof TripLoad]: Numeric } +type WireLoad = { palletSpaces: Numeric; weightKg: Numeric | null; volumeCbm: Numeric | null } type WireMeasurement = Omit & WireLoad & { deliveryIndex: Numeric } type WireConfirmed = Omit & { cargo: WireCargo[]; pallets: WireMeasurement[] @@ -56,7 +56,7 @@ export const normalizeTripDetail = (value: WireDetail): TripDetail => ({ ...valu 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) }) -const load = (value: WireLoad): TripLoad => ({ palletSpaces: integer(value.palletSpaces), weightKg: number(value.weightKg), volumeCbm: number(value.volumeCbm) }) +const load = (value: WireLoad): TripLoad => ({ palletSpaces: integer(value.palletSpaces), weightKg: value.weightKg == null ? null : number(value.weightKg), volumeCbm: value.volumeCbm == null ? null : number(value.volumeCbm) }) function confirmed(value: WireConfirmed | null): TripConfirmedLoad | null { return value == null ? null : { ...value, cargo: value.cargo.map(cargo), pallets: value.pallets.map(row => ({ ...row, ...load(row), deliveryIndex: integer(row.deliveryIndex) })), diff --git a/src/modules/overseas-master-data/components/TripLoadingPanel.vue b/src/modules/overseas-master-data/components/TripLoadingPanel.vue index eeeac374..1564d606 100644 --- a/src/modules/overseas-master-data/components/TripLoadingPanel.vue +++ b/src/modules/overseas-master-data/components/TripLoadingPanel.vue @@ -104,7 +104,7 @@ onBeforeUnmount(() => { sequence++ }) -
行驶区间占用托位重量(kg)体积(m³)剩余托位 / kg / m³
{{ trip.stops[segment.fromIndex]?.name }} → {{ trip.stops[segment.toIndex]?.name }}{{ segment.occupied.palletSpaces }} / {{ trip.maxPalletSpaces }}{{ segment.occupied.weightKg }} / {{ trip.maxLoadKg }}{{ segment.occupied.volumeCbm }} / {{ trip.maxVolumeCbm }}{{ segment.remaining.palletSpaces }} / {{ segment.remaining.weightKg }} / {{ segment.remaining.volumeCbm }}
+
行驶区间占用托位重量(kg)体积(m³)剩余托位 / kg / m³
{{ trip.stops[segment.fromIndex]?.name }} → {{ trip.stops[segment.toIndex]?.name }}{{ segment.occupied.palletSpaces }} / {{ trip.maxPalletSpaces }}{{ segment.occupied.weightKg ?? '—' }} / {{ trip.maxLoadKg }}{{ segment.occupied.volumeCbm ?? '—' }} / {{ trip.maxVolumeCbm }}{{ segment.remaining.palletSpaces }} / {{ segment.remaining.weightKg }} / {{ segment.remaining.volumeCbm }}
diff --git a/src/modules/overseas-master-data/tripTypes.ts b/src/modules/overseas-master-data/tripTypes.ts index 49310bbc..fecc3542 100644 --- a/src/modules/overseas-master-data/tripTypes.ts +++ b/src/modules/overseas-master-data/tripTypes.ts @@ -47,8 +47,8 @@ export interface TripCargo { dispatchNo: string; customerId: string; dispatchItemId: string; warehousePalletId: string | null palletNo: string | null; packages: number; destinationKey: string | null; destinationLabel: string | null } -export interface TripMeasurement { warehousePalletId: string; palletSpaces: number; weightKg: number; volumeCbm: number; deliveryIndex: number } -export interface TripLoad { palletSpaces: number; weightKg: number; volumeCbm: number } +export interface TripMeasurement { warehousePalletId: string; palletSpaces: number; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number } +export interface TripLoad { palletSpaces: number; weightKg: number | null; volumeCbm: number | null } export interface TripSegment { fromIndex: number; toIndex: number; occupied: TripLoad; remaining: TripLoad } export interface TripConfirmedLoad { manifestHash: string; cargo: TripCargo[]; pallets: TripMeasurement[]; segments: TripSegment[]; operatorId: string; confirmedAt: string } export interface TripPlannedLoad { manifestHash: string; cargo: TripCargo[]; pallets: TripMeasurement[]; segments: TripSegment[]; operatorId: string; confirmedAt: string } diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts index 22514b76..b5dacb3a 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts @@ -52,6 +52,17 @@ async function render(action: DispatchAction, operation?: 'ROUTE', files = false function field(wrapper: Pick, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! } describe('dispatch workflow native layout', () => { + it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('blocks %s approval for missing appointment but keeps rejection available', async action => { + vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { ...actions, [action]: { allowed: true, blockCode: 'APPROVAL_BLOCKED', blockReason: '预约收货时间尚未确认' } } })) + const wrapper = mount(DispatchWorkflowDialog, { attachTo: document.body, props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises() + const dialog = wrapper.getComponent(AppDialog) + expect(dialog.text()).toContain('预约收货时间尚未确认') + expect(dialog.text()).toContain('北京时间') + expect(dialog.findAll('button').find(button => button.text() === '审核通过')!.attributes('disabled')).toBeDefined() + await dialog.get('textarea').setValue('请客服补齐预约时间') + await dialog.findAll('button').find(button => button.text() === '驳回')!.trigger('click'); await flushPromises() + expect(action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading).toHaveBeenCalledWith('PC/1', expect.objectContaining({ decision: 'REJECTED', reason: '请客服补齐预约时间' })) + }) it('submits all edited resources, dates and zero version through the continuous fullscreen form', async () => { const { dialog } = await render('EDIT') expect(dialog.props('title')).toBe('维护车辆 · PC/1') @@ -210,10 +221,11 @@ describe('dispatch workflow native layout', () => { expect(api.getDispatchItems).not.toHaveBeenCalled() expect(api.listDispatchCandidates).not.toHaveBeenCalled() }) - it('keeps references separate from real loading measurements and submits the exact manifest', async () => { + it('confirms the scanned load in a compact dialog without measurement inputs', async () => { const { dialog } = await render('LOADING_COMPLETE') expect(dialog.get('[aria-label="确认装车完成"]').findAllComponents({ name: 'ElInputNumber' })).toHaveLength(0) - await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises() + expect(dialog.props('width')).toBe('560px') + expect(dialog.text()).not.toContain('测量依据') dialog.vm.$emit('confirm'); await flushPromises() expect(api.completeDispatchLoading).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), manifestHash: 'manifest', remark: '真实备注' }) }) diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue index d5ae9ec7..640f65dc 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue @@ -39,6 +39,8 @@ const vehicle = ref(blankVehicle()), driver = ref(blankDriver() const driverPhotos = computed({ get: () => driver.value.evidence?.photoFileIds || [], set: (ids: string[]) => { if (driver.value.evidence) driver.value.evidence.photoFileIds = ids } }) const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value)) const isReview = computed(() => ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value)) +const approvalBlock = computed(() => isReview.value && detail.value?.actions[currentAction.value]?.blockCode === 'APPROVAL_BLOCKED' ? detail.value.actions[currentAction.value]?.blockReason : null) +const reviewTime = (value: string | null | undefined) => value ? new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', dateStyle: 'short', timeStyle: 'short', hour12: false }).format(new Date(value)) : '未填写' const exception = reactive({ exceptionType: '' as DispatchExceptionType | '', returnDirection: '' as DispatchReturnDirection | '', description: '', imageFileIds: [] as string[] }) const form = reactive({ publishLooseboardDemand: true, plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' }) let generation = 0 @@ -48,7 +50,7 @@ const cargoEntryMessage = '货物安排统一在智能排车中修改;如有 const allowed = computed(() => !cargoAction.value && !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true)) const vehicleEditor = computed(() => currentOperation.value !== 'ROUTE' && (currentAction.value === 'EDIT' || props.action === 'EDIT' && currentAction.value === 'SUBMIT_REVIEW')) const submitting = ref(false) -const width = computed(() => vehicleEditor.value || currentAction.value === 'LOADING_COMPLETE' ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW', 'MARK_EXCEPTION'].includes(currentAction.value) ? '760px' : '560px') +const width = computed(() => vehicleEditor.value ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW', 'MARK_EXCEPTION'].includes(currentAction.value) ? '760px' : '560px') const frozen = computed(() => submitting.value || busy.value || Boolean(pending.value)) const evidenceBusy = reactive({ EXCEPTION: false, VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false }) const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean)) @@ -107,6 +109,7 @@ function payload(): object { return { ...common, exceptionType: exception.exceptionType, returnDirection: exception.returnDirection || null, description: exception.description.trim() || null, imageFileIds: [...exception.imageFileIds] } satisfies MarkDispatchException case 'EDIT': return { ...common, publishLooseboardDemand: form.publishLooseboardDemand, vehicle: vehicle.value, driver: driver.value, plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch case 'DISPATCH_REVIEW': case 'LOADING_REVIEW': + if (form.decision === 'APPROVED' && approvalBlock.value) throw new DispatchInputError(approvalBlock.value) if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因') return { ...common, decision: form.decision, reason: form.reason.trim() || null } satisfies ReviewCommand case 'CANCEL': case 'RETURN_TO_REVIEW': @@ -234,13 +237,14 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation], @@ -264,7 +268,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],