From 215cb7c6d51d724c881e74a0978d8faf7299f38a Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 21 Oct 2026 16:12:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TransportExecutionPanel.test.ts | 49 ++++++++ .../components/TransportExecutionPanel.vue | 112 +++++++++++++----- .../pages/DispatchDetailView.test.ts | 60 +++------- .../pages/DispatchDetailView.vue | 19 +-- .../pages/DispatchesView.test.ts | 61 +++++++++- .../pages/DispatchesView.vue | 47 ++++++-- src/styles/adminDensity.test.ts | 1 + 7 files changed, 245 insertions(+), 104 deletions(-) create mode 100644 src/modules/overseas-warehouse/components/TransportExecutionPanel.test.ts diff --git a/src/modules/overseas-warehouse/components/TransportExecutionPanel.test.ts b/src/modules/overseas-warehouse/components/TransportExecutionPanel.test.ts new file mode 100644 index 00000000..3f252ea4 --- /dev/null +++ b/src/modules/overseas-warehouse/components/TransportExecutionPanel.test.ts @@ -0,0 +1,49 @@ +import { mount, flushPromises } from '@vue/test-utils' +import ElementPlus from 'element-plus' +import { beforeEach, expect, it, vi } from 'vitest' +import TransportExecutionPanel from './TransportExecutionPanel.vue' +import { commandTransport, getTransport, uploadTransport, type TransportDetail } from '../api/transportTasks' + +vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) +vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), pickupStatusLabels: { ASSIGNED: '待取货', PICKED: '已取货', ARRIVED: '已到仓' } })) +vi.mock('../api/transportTasks', () => ({ getTransport: vi.fn(), assignTransport: vi.fn(), transportOccupancy: vi.fn(), cancelTransport: vi.fn(), releaseTransport: vi.fn(), commandTransport: vi.fn(), uploadTransport: vi.fn(), downloadTransportEvidence: vi.fn(), transportLabels: { PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' } })) +const facts = (arriving = false): TransportDetail => ({ dispatchNo: 'PC1', taskType: 'PICKUP', execution: { trip: { status: arriving ? 'IN_TRANSIT' : 'PLANNED', recordVersion: 4, warehouseName: '洛杉矶仓', vehicleId: '1', vehiclePlate: 'CA-1', driverId: '2', driverName: '司机甲', driverPhone: '555' } }, pickups: [{ customerId: 'C1', transferNo: 'WT1', packageCount: 5, actualPackages: arriving ? 5 : null, status: arriving ? 'PICKED' : 'ASSIGNED', evidence: [] }], completionEvidence: [] }) as unknown as TransportDetail +const options = { global: { plugins: [ElementPlus] } } +beforeEach(() => { + vi.clearAllMocks(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) + vi.mocked(getTransport).mockResolvedValue(facts()) + vi.mocked(uploadTransport).mockResolvedValue({ fileId: '88', fileName: '现场.jpg', contentType: 'image/jpeg', sizeBytes: 10 }) +}) +it.each([false, true])('confirms the %s photo stage with its own evidence and current transport version', async arriving => { + vi.mocked(getTransport).mockResolvedValue(facts(arriving)) + const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true } }); await flushPromises() + expect(wrapper.text()).toContain(arriving ? '派送中' : '装货中') + expect(wrapper.findAll('button').some(button => button.text() === '确认发车')).toBe(false) + await wrapper.findAll('button').find(button => button.text() === (arriving ? '上传到仓照片' : '上传取货照片'))!.trigger('click'); await flushPromises() + const input = wrapper.get('input[type="file"]') + Object.defineProperty(input.element, 'files', { value: [new File(['photo'], '现场.jpg', { type: 'image/jpeg' })], configurable: true }) + await input.trigger('change'); await flushPromises() + await wrapper.findAll('button').find(button => button.text() === (arriving ? '确认到仓' : '确认取货'))!.trigger('click'); await flushPromises() + expect(commandTransport).toHaveBeenCalledWith('PC1', `pickup/C1/WT1/${arriving ? 'arrive' : 'picked'}`, expect.objectContaining({ recordVersion: 4, evidenceFileIds: ['88'], ...(arriving ? { reason: '上传到仓照片,确认取货派送完成' } : { actualPackages: 5 }) })) + expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount() +}) +it('keeps photo confirmation unavailable until both reviews are approved', async () => { + const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: false } }); await flushPromises() + expect(wrapper.findAll('button').some(button => button.text() === '上传取货照片')).toBe(false) + expect(commandTransport).not.toHaveBeenCalled(); wrapper.unmount() +}) +it.each([false, true])('renders read-only transport facts without resource editing or execution controls (%s)', async arriving => { + const value = facts(arriving) + value.execution.trip.vehicleId = null + value.execution.trip.driverId = null + vi.mocked(getTransport).mockResolvedValue(value) + const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, readonly: true } }); await flushPromises() + expect(wrapper.text()).toContain('WT1') + expect(wrapper.text()).toContain('洛杉矶仓') + expect(wrapper.findAll('input, select, textarea')).toHaveLength(0) + expect(wrapper.findAll('button')).toHaveLength(0) + expect(wrapper.findAll('th').map(column => column.text())).not.toContain('操作') + expect(commandTransport).not.toHaveBeenCalled() + wrapper.unmount() +}) diff --git a/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue b/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue index 031f644d..e3aba57d 100644 --- a/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue +++ b/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue @@ -1,7 +1,10 @@ + + diff --git a/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts b/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts index 1f1ca599..2f869e1a 100644 --- a/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts +++ b/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts @@ -39,17 +39,15 @@ describe('whole-dispatch four-tab detail', () => { const wrapper = mount(DispatchDetailView, options); await flushPromises() expect(wrapper.text()).toContain('已退回客服处理(车辆信息)') expect(wrapper.text()).toContain('司机资料需要更正') - vi.mocked(getDispatch).mockResolvedValue(detail({ recordVersion: 2 })) - wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).vm.$emit('saved'); await flushPromises() - expect(wrapper.text()).not.toContain('司机资料需要更正') + expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false) wrapper.unmount() }) - it('keeps cargo additions and removals out of the detail even when authorized', async () => { + it('keeps every write action out of the detail even when authorized', async () => { can.mockReturnValue(true) vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null }, REMOVE_CARGO: { allowed: true, blockCode: null, blockReason: null } } })) const wrapper = mount(DispatchDetailView, options); await flushPromises() - const actions = wrapper.get('[aria-label="排车操作"]').findAll('button').map(button => button.text()) - expect(actions).toContain('维护车辆信息') + const actions = wrapper.findAll('button').map(button => button.text()) + expect(actions).not.toContain('维护车辆信息') expect(actions).not.toContain('添加货物') expect(actions).not.toContain('移除货物') wrapper.unmount() @@ -58,6 +56,7 @@ describe('whole-dispatch four-tab detail', () => { vi.mocked(getDispatch).mockResolvedValueOnce({ ...detail(), taskType }) const wrapper = mount(DispatchDetailView, options); await flushPromises() expect(wrapper.get('transport-execution-panel-stub').attributes('dispatchno')).toBe('PC/1') + expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('readonly')).toBe(true) expect(wrapper.findAll('[role="tab"]')).toHaveLength(0) expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false); wrapper.unmount() }) @@ -124,7 +123,7 @@ describe('whole-dispatch four-tab detail', () => { const wrapper = mount(DispatchDetailView, options); await flushPromises() expect(wrapper.text()).not.toContain('尚未安排派送'); expect(wrapper.findAll('button').some(button => button.text() === '确认发车')).toBe(false); wrapper.unmount() }) - it('keeps the allowed Admin action without presenting unrelated stage or PDA permission refusals as page warnings', async () => { + it('shows stage facts without execution actions or unrelated permission refusals', async () => { can.mockReturnValue(true) vi.mocked(getDispatch).mockResolvedValueOnce(detail({ stage: 'LOADING', actions: { LOADING_COMPLETE: { allowed: true, blockCode: null, blockReason: null }, @@ -136,8 +135,8 @@ describe('whole-dispatch four-tab detail', () => { expect(wrapper.text()).not.toContain('当前阶段或作业事实不满足操作条件') expect(wrapper.text()).not.toContain('当前无此操作权限') expect(wrapper.findAll('button').some(button => button.text() === '维护车辆信息')).toBe(false) - await wrapper.findAll('button').find(button => button.text() === '确认装车完成')!.trigger('click') - expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, dispatchNo: 'PC/1', action: 'LOADING_COMPLETE' }) + expect(wrapper.findAll('button').some(button => button.text() === '确认装车完成')).toBe(false) + expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false) wrapper.unmount() }) it('retains a real attachment A0404 failure and its request diagnostics in the vehicle tab', async () => { @@ -161,45 +160,16 @@ describe('whole-dispatch four-tab detail', () => { Object.assign(query, { fleetOfferNo: 'FO-1', fleetBookingNo: 'FB-2' }); const wrapper = mount(DispatchDetailView, options); await flushPromises(); await wrapper.get('button.density-back').trigger('click') expect(push).toHaveBeenCalledWith({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'FO-1' }, query: { inquiry: 'FB-2' } }); wrapper.unmount() }) - it.each(['saved', 'rejected'] as const)('refreshes detail actions after the workflow %s event and retains a real rejection after closure', async event => { + it('does not mount review or mutation workflows for an authorized administrator', async () => { can.mockReturnValue(true) - vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { SUBMIT_REVIEW: { allowed: true, blockCode: null, blockReason: null } } })) + const allowed = { allowed: true, blockCode: null, blockReason: null } + vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { EDIT: allowed, CANCEL: allowed, SUBMIT_REVIEW: allowed, DISPATCH_REVIEW: allowed, LOADING_REVIEW: allowed } })) const wrapper = mount(DispatchDetailView, options); await flushPromises() - await wrapper.findAll('button').find(button => button.text() === '提交调度审核')!.trigger('click') - vi.mocked(getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '已提交' } } })) - const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' }) - if (event === 'rejected') dialog.vm.$emit('rejected', { message: '本轮审核已经提交', code: 'A0409', requestId: 'detail-reject-request' }) - else dialog.vm.$emit('saved') - dialog.vm.$emit('update:modelValue', false) - await flushPromises() - expect(getDispatch).toHaveBeenCalledTimes(2) - expect(wrapper.findAll('button').some(button => button.text() === '提交调度审核')).toBe(false) + expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false) + expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false) + expect(wrapper.findAll('input, textarea')).toHaveLength(0) expect(wrapper.findAll('[role="tab"]')).toHaveLength(4) - if (event === 'rejected') { - expect(wrapper.text()).toContain('本轮审核已经提交') - expect(wrapper.text()).toContain('A0409') - expect(wrapper.text()).toContain('detail-reject-request') - } - expect(wrapper.text()).not.toContain('结果尚待核对') - wrapper.unmount() - }) - it('retains a workflow rejection when the parent detail refresh also fails', async () => { - const wrapper = mount(DispatchDetailView, options); await flushPromises() - vi.mocked(getDispatch).mockRejectedValueOnce(new Error('detail read failed')) - wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).vm.$emit('rejected', { message: '已提交,请刷新', code: 'A0409' }) - await flushPromises() - expect(wrapper.text()).toContain('已提交,请刷新') - expect(wrapper.text()).toContain('A0409') - expect(wrapper.text()).toContain('排车详情加载失败') - wrapper.unmount() - }) - it('clears an earlier input rejection after the corrected workflow succeeds', async () => { - const wrapper = mount(DispatchDetailView, options); await flushPromises() - const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' }) - dialog.vm.$emit('rejected', { message: '请补齐本趟资料', code: 'A0400' }); await flushPromises() - expect(wrapper.text()).toContain('请补齐本趟资料') - dialog.vm.$emit('saved'); await flushPromises() - expect(wrapper.text()).not.toContain('请补齐本趟资料') + expect(getDispatch).toHaveBeenCalledTimes(1) wrapper.unmount() }) }) diff --git a/src/modules/overseas-warehouse/pages/DispatchDetailView.vue b/src/modules/overseas-warehouse/pages/DispatchDetailView.vue index ccd61e8b..c41d0c82 100644 --- a/src/modules/overseas-warehouse/pages/DispatchDetailView.vue +++ b/src/modules/overseas-warehouse/pages/DispatchDetailView.vue @@ -11,11 +11,10 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import { getDispatch } from '../api/dispatches' -import type { DispatchAction, DispatchDetail } from '../dispatchContracts' +import type { DispatchDetail } from '../dispatchContracts' import { stageLabel } from '../dispatchContracts' -import { canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation' +import { dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation' import TransportExecutionPanel from '../components/TransportExecutionPanel.vue' -import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue' import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue' import OverseasDetailShell from '../components/OverseasDetailShell.vue' import { formatOverseasDateTime as time } from '../lastMilePresentation' @@ -25,11 +24,6 @@ const route = useRoute(), router = useRouter(), access = useAccessPreviewStore() const dispatchNo = computed(() => String(route.params.dispatchNo || '')) const detail = ref(null), activeTab = ref('vehicle'), loading = ref(false), loadError = ref(null) const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value)) -const rejectionError = ref(null) -const operation = ref<'ROUTE' | undefined>() -const action = ref('EDIT'), actionOpen = ref(false) -const detailActions: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'CANCEL'] -const visibleActions = computed(() => detailActions.filter(value => !(detail.value?.taskType === 'PICKUP' && value === 'EDIT') && canDispatchAction(detail.value?.actions, value, access.can))) const vehicleStatus = computed(() => { const status = detail.value?.vehicleInfo.vehicle?.vehicleStatus return status && Object.hasOwn(vehicleStatusLabels, status) ? vehicleStatusLabels[status as keyof typeof vehicleStatusLabels] : '—' @@ -72,20 +66,15 @@ async function load() { const appointmentReturn = computed(() => appointmentReturnTarget(route.query)) const backLabel = computed(() => appointmentReturn.value ? '返回预约日历' : inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表') function back() { void router.push(appointmentReturn.value || inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' }) } -function openAction(value: DispatchAction, requestedOperation?: 'ROUTE') { if (value === 'ADD_CARGO' || value === 'REMOVE_CARGO') return; if (!canDispatchAction(detail.value?.actions, value, access.can)) return; rejectionError.value = null; operation.value = requestedOperation; action.value = value; actionOpen.value = true } -async function handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() } -async function handleSaved() { rejectionError.value = null; await load() } watch(dispatchNo, load, { immediate: true }) onBeforeUnmount(() => { generation++ })