diff --git a/src/modules/overseas-business/components/TruckOrderActions.vue b/src/modules/overseas-business/components/TruckOrderActions.vue index 66ebcfb4..bec76d5a 100644 --- a/src/modules/overseas-business/components/TruckOrderActions.vue +++ b/src/modules/overseas-business/components/TruckOrderActions.vue @@ -12,7 +12,7 @@ import { availableTruckCommands, truckCommandHints, truckCommandLabels, type Tru import type { LocalTransportDetail } from '../types' const props = defineProps<{ detail: LocalTransportDetail }>() -const emit = defineEmits<{ updated: [detail: LocalTransportDetail] }>() +const emit = defineEmits<{ updated: [detail: LocalTransportDetail]; busy: [value: boolean] }>() const access = useAccessPreviewStore() const commands = computed(() => access.can(overseasBusinessPermissions.truckStatus) ? availableTruckCommands(props.detail) : []) const open = ref(false), saving = ref(false) @@ -23,7 +23,8 @@ const pending = ref(null) const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', actualPalletCount: 1, packageCount: undefined as number | undefined, palletIds: [] as string[], remark: '' }) const selectablePallets = computed(() => props.detail.pallets.filter(pallet => pallet.status === 'RECEIVED')) const review = computed(() => active.value === 'review' || active.value === 'review-pallet-overage') -const { captureBaseline } = useUnsavedWorkRegistration({ key: 'truck-order-command', label: computed(() => active.value ? truckCommandLabels[active.value] : '卡车单据操作'), isOpen: open, model: form, requiredPermission: computed(() => overseasBusinessPermissions.truckStatus) }) +const { captureBaseline } = useUnsavedWorkRegistration({ key: 'truck-order-command', label: computed(() => active.value ? truckCommandLabels[active.value] : '卡车单据操作'), isOpen: computed(() => open.value || !!pending.value), model: reactive({ form, pending }), requiredPermission: computed(() => overseasBusinessPermissions.truckStatus) }) +watch(saving, value => emit('busy', value)) watch(() => [props.detail.customerId, props.detail.orderNo, props.detail.version], () => { pending.value = null; open.value = false; active.value = null; error.value = null @@ -42,7 +43,7 @@ function start(command: TruckCommand) { function buildOperation(): TruckOperation | null { if (!active.value) return null - const base = { version: props.detail.version, idempotencyKey: crypto.randomUUID(), remark: form.remark.trim() || undefined } + const base = { version: props.detail.version, idempotencyKey: globalThis.crypto?.randomUUID?.() || `truck-${Date.now()}-${Math.random().toString(36).slice(2)}`, remark: form.remark.trim() || undefined } if ((active.value === 'cancel' || (review.value && form.decision === 'REJECT')) && !base.remark) { validationError.value = '请填写拒绝或取消原因'; return null } @@ -57,8 +58,8 @@ function buildOperation(): TruckOperation | null { } return { command: active.value, payload: { ...base, actualPalletCount: form.actualPalletCount, ...(form.packageCount == null ? {} : { packageCount: form.packageCount }) } } case 'allocate-pallets': - if (!form.palletIds.length || form.palletIds.some(id => !selectablePallets.value.some(pallet => pallet.id === id))) { - validationError.value = '请至少选择一个已实收且未分配的托盘'; return null + if (!form.palletIds.length || form.palletIds.length > 500 || form.palletIds.some(id => !selectablePallets.value.some(pallet => pallet.id === id))) { + validationError.value = '请选择本单已实收且未分配的托盘,每次最多分配 500 托'; return null } return { command: active.value, payload: { ...base, palletIds: [...form.palletIds] } } case 'cancel': return { command: active.value, payload: { ...base, status: 'CANCELLED' } } @@ -98,14 +99,14 @@ async function submit() { 通过拒绝 {{ pallet.palletNo }}({{ pallet.packageCount ?? '未登记' }} 件)

暂无可分配托盘,请刷新核对实收记录。

- +
diff --git a/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts b/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts index 21bc6827..c6c3a83a 100644 --- a/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts +++ b/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts @@ -295,6 +295,14 @@ describe('overseas business mounted pages', () => { }) it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('15拖'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() }) + it('opens the requested truck review from the list using its customer scope', async () => { + const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises() + const auth = useAuthStore(); auth.permissions.push('platform:overseas-business:truck:status') + await wrapper.vm.$nextTick() + const review = wrapper.findAll('button').find(button => button.text() === '审核') + expect(review).toBeDefined(); await review!.trigger('click'); await flushPromises() + expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-truck-detail', params: { orderId: 'TK-200' }, query: { customerId: '10002' } }) + }) it('renders truck load failure without samples', async () => { vi.mocked(listUsTruckOrders).mockRejectedValue(new Error('hidden')); const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(wrapper.text()).toContain('本土卡车订单加载失败'); expect(wrapper.text()).not.toContain('TK-200') }) it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, vehicleType: 'PALLET_15', originContactName: 'Bob', destinationContactName: 'Alice', pickupAt: '2026-08-28T09:30:00' })) }) it('keeps truck edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; vi.mocked(updateUsTruckOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('Ontario-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, destination: 'Ontario-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('Ontario-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() }) diff --git a/src/modules/overseas-business/pages/TruckOrderDetailView.test.ts b/src/modules/overseas-business/pages/TruckOrderDetailView.test.ts index a7b24d61..81590d90 100644 --- a/src/modules/overseas-business/pages/TruckOrderDetailView.test.ts +++ b/src/modules/overseas-business/pages/TruckOrderDetailView.test.ts @@ -83,7 +83,7 @@ describe('truck review and fulfillment', () => { await click(wrapper, '登记实收'); Object.assign(formModel(wrapper), { actualPalletCount: 2, packageCount: 20 }); await confirm(wrapper) await click(wrapper, '分配托盘'); formModel(wrapper).palletIds = current.pallets.map(row => row.id); await confirm(wrapper) for (const label of ['确认待派车', '确认已派车', '确认发运', '确认完成']) { await click(wrapper, label); await confirm(wrapper) } - expect(wrapper.findComponent(TruckOrderActions).findAll('button')).toHaveLength(0) + expect(wrapper.find('.truck-order-actions').exists()).toBe(false) expect(current.status).toBe('COMPLETED') expect(vi.mocked(executeUsTruckCommand).mock.calls.map(call => call[2].payload.version)).toEqual([1, 2, 3, 4, 5, 6, 7]) expect(vi.mocked(executeUsTruckCommand).mock.calls[2][2]).toMatchObject({ command: 'allocate-pallets', payload: { palletIds: ['9007199254741001', '9007199254741002'] } }) @@ -97,16 +97,16 @@ describe('truck review and fulfillment', () => { vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'REJECTED', version: 2 })) await confirm(wrapper) expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'review', payload: expect.objectContaining({ decision: 'REJECT', remark: '取送时间需重新确认', version: 1 }) })) - expect(wrapper.findComponent(TruckOrderActions).findAll('button')).toHaveLength(0) + expect(wrapper.find('.truck-order-actions').exists()).toBe(false) }) it('preserves one immutable request and retries it after an ambiguous failure, then refreshes the detail', async () => { - vi.mocked(executeUsTruckCommand).mockRejectedValueOnce(new ApiResponseError('订单版本冲突', 'A0409', 409, 'TRUCK-409')).mockResolvedValueOnce(order({ status: 'WAITING_INBOUND', version: 2 })) + vi.mocked(executeUsTruckCommand).mockRejectedValueOnce(new ApiResponseError('订单版本冲突', 'A0409', 409, undefined, 'TRUCK-409')).mockResolvedValueOnce(order({ status: 'WAITING_INBOUND', version: 2 })) const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核') formModel(wrapper).remark = '核对完成'; await confirm(wrapper) expect(wrapper.text()).toContain('订单版本冲突') expect(wrapper.findComponent({ name: 'ElForm' }).props('disabled')).toBe(true) - const original = structuredClone(vi.mocked(executeUsTruckCommand).mock.calls[0][2]) + const original = JSON.parse(JSON.stringify(vi.mocked(executeUsTruckCommand).mock.calls[0][2])) wrapper.findComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises() await click(wrapper, '审核'); await confirm(wrapper) expect(vi.mocked(executeUsTruckCommand).mock.calls[1][2]).toEqual(original) @@ -185,7 +185,7 @@ describe('truck review and fulfillment', () => { }) it('retains error metadata and retries a failed detail read', async () => { - vi.mocked(getUsTruckOrder).mockRejectedValueOnce(new ApiResponseError('读取失败', 'A0409', 409, 'DETAIL-409')).mockResolvedValueOnce(order()) + vi.mocked(getUsTruckOrder).mockRejectedValueOnce(new ApiResponseError('读取失败', 'A0409', 409, undefined, 'DETAIL-409')).mockResolvedValueOnce(order()) const wrapper = mountDetail(); await flushPromises() expect(wrapper.text()).toContain('读取失败'); expect(wrapper.text()).toContain('DETAIL-409') await wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises() diff --git a/src/modules/overseas-business/pages/TruckOrderDetailView.vue b/src/modules/overseas-business/pages/TruckOrderDetailView.vue index bc588082..ee564f85 100644 --- a/src/modules/overseas-business/pages/TruckOrderDetailView.vue +++ b/src/modules/overseas-business/pages/TruckOrderDetailView.vue @@ -18,7 +18,7 @@ import type { LocalTransportDetail, LocalTransportPallet, OverseasBusinessHistor const route = useRoute(), router = useRouter() const orderNo = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '') const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '') -const detail = ref(null), loading = ref(false) +const detail = ref(null), loading = ref(false), saving = ref(false) const error = ref(null) let requestSequence = 0 const dateTime = (value: string | null | undefined) => value ? value.replace('T', ' ').slice(0, 16) : '—' @@ -33,7 +33,7 @@ const fields = computed(() => detail.value ? [ ['已确认托数', detail.value.palletCount], ['累计实收托数', detail.value.receivedPalletCount], ['已分配托数', detail.value.allocatedPalletCount], ['创建时间', dateTime(detail.value.createdAt)], ['备注', detail.value.remark], ] : []) -const palletLabels: Record = { PLANNED: '待实收', RECEIVED: '已实收', ALLOCATED: '已分配', OVERAGE_PENDING_REVIEW: '超托待审', OVERAGE_REJECTED: '超托拒收' } +const palletLabels: Record = { EXPECTED: '待实收', PLANNED: '待实收', RECEIVED: '已实收', ALLOCATED: '已分配', OVERAGE_PENDING_REVIEW: '超托待审', OVERAGE_REJECTED: '超托拒收' } const palletColumns: DataTableColumn[] = [ { key: 'palletNo', prop: 'palletNo', label: '托盘编号', minWidth: 180 }, { key: 'status', label: '状态', width: 130, formatter: (row: LocalTransportPallet) => palletLabels[row.status] || row.status }, @@ -49,6 +49,7 @@ const historyColumns: DataTableColumn[] = [ { key: 'operatorId', prop: 'operatorId', label: '操作人', minWidth: 140 }, { key: 'remark', prop: 'remark', label: '审核意见/操作说明', minWidth: 240 }, ] async function load() { + if (saving.value) return const sequence = ++requestSequence detail.value = null; error.value = null if (!orderNo.value || !customerId.value || customerId.value === '0') { @@ -64,13 +65,13 @@ watch([orderNo, customerId], load, { immediate: true })