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 1/2] =?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++ }) diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts index 71f66c16..4dae11c5 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts @@ -86,23 +86,54 @@ describe('dispatch workflow native layout', () => { it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('keeps %s facts read-only and requires a rejection reason', async action => { const { dialog } = await render(action) expect(dialog.get('[aria-label="本车审核依据"]').findAll('input')).toHaveLength(0) - expect(dialog.text()).toContain('审核通过不代替后续现场扫码') - field(dialog, '审核结果').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECTED'); await flushPromises() + expect(dialog.get('[aria-label="本车审核依据"]').find('h3').exists()).toBe(false) + expect(dialog.text()).not.toContain('请核对本车资源与本趟证据') + expect(dialog.findAll('button').some(button => button.text() === '取消')).toBe(false) + expect(dialog.findComponent({ name: 'ElRadioGroup' }).exists()).toBe(false) + expect(dialog.findAll('button').some(button => button.text() === '确认提交')).toBe(false) + const reject = dialog.findAll('button').find(button => button.text() === '驳回')! const review = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading - dialog.vm.$emit('confirm'); await flushPromises(); expect(review).not.toHaveBeenCalled() - await dialog.get('textarea').setValue(' 证据不完整 '); dialog.vm.$emit('confirm'); await flushPromises() + await reject.trigger('click'); await flushPromises(); expect(review).not.toHaveBeenCalled() + await vi.waitFor(() => expect(field(dialog, '驳回原因(必填)').text()).toContain('请填写驳回原因')) + await dialog.get('textarea').setValue(' '); await reject.trigger('click'); await flushPromises(); expect(review).not.toHaveBeenCalled() + await dialog.get('textarea').setValue(' 证据不完整 '); await reject.trigger('click'); await flushPromises() expect(review).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), decision: 'REJECTED', reason: '证据不完整' }) - expect(dialog.findAll('input[type="file"]')).toHaveLength(3) + expect(dialog.findAll('input[type="file"]')).toHaveLength(0) }) - it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('uploads three audit attachments with only %s permission and retries the same rejected review', async action => { - can.mockImplementation(code => code === (action === 'DISPATCH_REVIEW' ? 'platform:fleet:trip:dispatch-review' : 'platform:fleet:trip:loading-review')) + it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('approves %s directly and keeps an unknown result on the original request', async action => { const review = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading - vi.mocked(review).mockRejectedValueOnce(new Error('unknown review')).mockImplementationOnce(async (_, body) => command(action, body.idempotencyKey)) - const { wrapper, dialog } = await render(action, undefined, true) + vi.mocked(review).mockRejectedValueOnce(new Error('结果未知')) + const { dialog } = await render(action) + await dialog.findAll('button').find(button => button.text() === '审核通过')!.trigger('click') + await flushPromises() + expect(review).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), decision: 'APPROVED', reason: null }) + const original = structuredClone(vi.mocked(review).mock.calls[0]![1]) + expect(dialog.props('showCancel')).toBe(false) + expect(dialog.findAll('button').some(button => button.text() === '取消')).toBe(false) + expect(dialog.findAll('button').some(button => button.text() === '驳回')).toBe(false) + await dialog.findAll('button').find(button => button.text() === '核对并重试原请求')!.trigger('click') + await flushPromises() + expect(vi.mocked(review).mock.calls[1]![1]).toEqual(original) + }) + it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('disables both %s decisions when write permission is missing', async action => { + can.mockReturnValue(false) + const { dialog } = await render(action) + for (const label of ['驳回', '审核通过']) { + const button = dialog.findAll('button').find(item => item.text() === label)! + expect(button.attributes('disabled')).toBeDefined() + await button.trigger('click') + } + expect(api.reviewDispatch).not.toHaveBeenCalled() + expect(api.reviewDispatchLoading).not.toHaveBeenCalled() + }) + it('uploads all three photos during resource maintenance and retries the exact original edit', async () => { + can.mockImplementation(code => code === 'platform:overseas-warehouse:dispatch:edit') + vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('unknown edit')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey)) + const { wrapper, dialog } = await render('EDIT', undefined, true) const fields = dialog.findAllComponents(DispatchEvidenceFiles) expect(fields.map(item => item.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片']) - expect(fields[0]!.props('referenceIds')).toEqual(['301']) - expect(fields[0]!.findAll('button').some(button => button.text() === '替换')).toBe(false) + expect(fields[0]!.props('modelValue')).toEqual(['301']) + expect(dialog.get('.workflow-photo-grid').findAll('figure')).toHaveLength(3) for (const [index, files] of fields.entries()) { let resolve!: (value: DispatchEvidenceFile) => void vi.mocked(api.uploadDispatchEvidence).mockReturnValueOnce(new Promise(done => { resolve = done })) @@ -110,19 +141,32 @@ describe('dispatch workflow native layout', () => { Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }) await input.trigger('change'); await flushPromises() expect(dialog.props('confirmLoading')).toBe(true) - dialog.vm.$emit('confirm'); await flushPromises(); expect(review).not.toHaveBeenCalled() + dialog.vm.$emit('confirm'); await flushPromises(); expect(api.updateDispatch).not.toHaveBeenCalled() resolve(evidence(String(401 + index))); await flushPromises() } expect(vi.mocked(api.uploadDispatchEvidence).mock.calls.map(call => call[1])).toEqual(['VEHICLE', 'DRIVER_PHOTO', 'DRIVER']) - field(dialog, '审核结果').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECTED'); await flushPromises() - await dialog.get('textarea').setValue('需重新核对车牌') dialog.vm.$emit('confirm'); await flushPromises() - const original = structuredClone(vi.mocked(review).mock.calls[0]![1]) - expect(original).toMatchObject({ decision: 'REJECTED', reason: '需重新核对车牌', attachments: { vehicleFileIds: ['401'], driverPhotoFileIds: ['402'], licenseFileIds: ['403'] } }) + const original = structuredClone(vi.mocked(api.updateDispatch).mock.calls[0]![1]) + expect(original).toMatchObject({ vehicle: { evidenceFileIds: ['301', '401'] }, driver: { evidence: { photoFileIds: ['402'], fileIds: expect.arrayContaining(['403']) } } }) await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises() dialog.vm.$emit('confirm'); await flushPromises() - expect(vi.mocked(review).mock.calls[1]![1]).toEqual(original) - expect(api.updateDispatch).not.toHaveBeenCalled() + expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toEqual(original) + expect(api.reviewDispatch).not.toHaveBeenCalled() + expect(api.reviewDispatchLoading).not.toHaveBeenCalled() + }) + it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('shows saved photos and old audit evidence read-only in %s', async action => { + const { wrapper, dialog } = await render(action) + const value = detail({ actions }) + value.vehicleInfo.driver!.evidence!.photoFileIds = ['402'] + value.reviews.dispatchReview.attachments = { vehicleFileIds: [], driverPhotoFileIds: ['403'], licenseFileIds: [] } + vi.mocked(api.getDispatch).mockResolvedValueOnce(value) + await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises() + const fields = dialog.findAllComponents(DispatchEvidenceFiles) + expect(fields.map(item => item.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片']) + expect(fields.every(item => item.props('readonly') && item.props('photoCard'))).toBe(true) + expect(fields[1]!.props('modelValue')).toEqual(['402', '403']) + expect(dialog.findAll('input[type="file"]')).toHaveLength(0) + expect(api.uploadDispatchEvidence).not.toHaveBeenCalled() }) it.each(['CANCEL', 'RETURN_TO_REVIEW', 'SUBMIT_REVIEW'] as const)('preserves the %s confirmation branch and its exact reason or remark', async action => { const { dialog } = await render(action) @@ -170,7 +214,7 @@ describe('dispatch workflow native layout', () => { vi.mocked(api.uploadDispatchEvidence).mockReturnValue(new Promise(done => { resolve = done })) const { dialog } = await render('EDIT', undefined, true) const files = dialog.findAllComponents(DispatchEvidenceFiles)[0]! - await files.findAll('button').find(button => button.text() === '预览')!.trigger('click'); await flushPromises() + await files.get('button.evidence-photo-preview').trigger('click'); await flushPromises() const image = files.findComponent(AppDialog) expect(image.props('modelValue')).toBe(true) const preview = new DOMWrapper(document.querySelector('[aria-label="本车附件预览信息"]')!.closest('.el-dialog')!) diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue index 4c364875..db93e171 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue @@ -13,7 +13,7 @@ import * as api from '../api/dispatches' import type { MarkDispatchException, DispatchExceptionType, DispatchReturnDirection, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts' import type { DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts' import { DispatchInputError, requireVersion } from '../dispatchContracts' -import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, emptyReviewAttachments } from '../dispatchPresentation' +import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence } from '../dispatchPresentation' import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery' import DispatchResourceFields from './DispatchResourceFields.vue' import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue' @@ -36,8 +36,9 @@ const measurements = ref ({ mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] }) const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } }) const vehicle = ref(blankVehicle()), driver = ref(blankDriver()) -const reviewAttachments = ref(emptyReviewAttachments()) +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 exception = reactive({ exceptionType: '' as DispatchExceptionType | '', returnDirection: '' as DispatchReturnDirection | '', description: '', imageFileIds: [] as string[] }) const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' }) let generation = 0 @@ -62,7 +63,6 @@ async function load() { if (sequence !== generation) return if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开') detail.value = value - reviewAttachments.value = emptyReviewAttachments() Object.assign(exception, { exceptionType: '', returnDirection: '', description: '', imageFileIds: [] }) const oldVehicle = value.vehicleInfo.vehicle vehicle.value = oldVehicle ? { mode: oldVehicle.mode, vehicleId: oldVehicle.vehicleId, plate: oldVehicle.plate, carrierName: oldVehicle.carrierName, capacity: oldVehicle.capacity ? { ...oldVehicle.capacity } : null, evidenceFileIds: [...oldVehicle.evidenceFileIds] } : blankVehicle() @@ -106,7 +106,7 @@ function payload(): object { case 'EDIT': return { ...common, 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 === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因') - return { ...common, decision: form.decision, reason: form.reason.trim() || null, ...(Object.values(reviewAttachments.value).some(ids => ids.length) ? { attachments: reviewAttachments.value } : {}) } satisfies ReviewCommand + return { ...common, decision: form.decision, reason: form.reason.trim() || null } satisfies ReviewCommand case 'CANCEL': case 'RETURN_TO_REVIEW': if (!form.reason.trim()) throw new DispatchInputError('请填写操作原因') return { ...common, reason: form.reason.trim() } satisfies ReasonCommand @@ -133,6 +133,11 @@ async function send(command: PendingDispatchCommand): Promise { default: throw new DispatchInputError('此操作不属于当前后台作业入口') } } +async function submitReview(decision: 'APPROVED' | 'REJECTED') { + if (frozen.value || loading.value || uploadingEvidence.value || !allowed.value) return + form.decision = decision + await submit() +} async function submit() { if (busy.value || loading.value || uploadingEvidence.value) return error.value = null @@ -185,7 +190,7 @@ function moveStop(index: number, offset: number) { const target = index + offset watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true }) - - -

操作说明

+
+ + + +
+
+ +
+

操作说明

本次备注

+ diff --git a/src/modules/overseas-warehouse/components/TransportExecutionPanel.test.ts b/src/modules/overseas-warehouse/components/TransportExecutionPanel.test.ts index 3f252ea4..e2dbdbf1 100644 --- a/src/modules/overseas-warehouse/components/TransportExecutionPanel.test.ts +++ b/src/modules/overseas-warehouse/components/TransportExecutionPanel.test.ts @@ -20,19 +20,37 @@ it.each([false, true])('confirms the %s photo stage with its own evidence and cu 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() + await wrapper.findAll('button').find(button => button.text() === (arriving ? '上传到仓照片' : '上传装货照片'))!.trigger('click'); await flushPromises() + if (!arriving) { + const quantity = wrapper.get('input[role="spinbutton"]') + expect((quantity.element as HTMLInputElement).value).toBe('') + await quantity.setValue('3'); await quantity.trigger('change'); 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(commandTransport).toHaveBeenCalledWith('PC1', `pickup/C1/WT1/${arriving ? 'arrive' : 'picked'}`, expect.objectContaining({ recordVersion: 4, evidenceFileIds: ['88'], ...(arriving ? { reason: '上传到仓照片,确认取货派送完成' } : { actualPackages: 3 }) })) + if (arriving) expect(commandTransport.mock.calls[0]![2]).not.toHaveProperty('actualPackages') 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(wrapper.findAll('button').some(button => button.text() === '上传装货照片')).toBe(false) expect(commandTransport).not.toHaveBeenCalled(); wrapper.unmount() }) +it('requires an explicitly entered pickup quantity even after a photo has uploaded', async () => { + const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true } }); await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '上传装货照片')!.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() + const confirm = wrapper.findAll('button').find(button => button.text() === '确认取货')! + expect(confirm.attributes('disabled')).toBeDefined() + await confirm.trigger('click') + 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 @@ -47,3 +65,20 @@ it.each([false, true])('renders read-only transport facts without resource editi expect(commandTransport).not.toHaveBeenCalled() wrapper.unmount() }) +it('keeps pickup addresses and contacts on their own transfer rows', async () => { + const value = facts() + value.pickups[0] = { ...value.pickups[0]!, pickupAddress: '100 Test Pickup Avenue', contactName: '取货联系人甲', contactPhone: '0000000001' } + value.pickups.push({ ...value.pickups[0]!, transferNo: 'WT2', customerId: 'C2', pickupAddress: '200 Test Pickup Road', contactName: '取货联系人乙', contactPhone: '0000000002' }) + vi.mocked(getTransport).mockResolvedValue(value) + const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', readonly: true } }); await flushPromises() + const rows = wrapper.get('[aria-label="取货明细"]').findAll('tbody tr') + expect(rows[0]!.text()).toContain('100 Test Pickup Avenue') + expect(rows[0]!.text()).toContain('取货联系人甲') + expect(rows[0]!.text()).toContain('0000000001') + expect(rows[0]!.text()).not.toContain('200 Test Pickup Road') + expect(rows[1]!.text()).toContain('200 Test Pickup Road') + expect(rows[1]!.text()).toContain('取货联系人乙') + expect(rows[1]!.text()).toContain('0000000002') + expect(wrapper.findAll('button')).toHaveLength(0) + wrapper.unmount() +}) diff --git a/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue b/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue index e3aba57d..1d593f69 100644 --- a/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue +++ b/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue @@ -19,8 +19,9 @@ const props = defineProps<{ dispatchNo: string; pickupApproved?: boolean; pickup const access = useAccessPreviewStore(), detail = ref(null), error = ref(null), busy = ref(false) const vehicles = ref([]), drivers = ref([]), vehicleId = ref(''), driverId = ref(''), reason = ref('') const occupancy = ref>({}), candidateBusy = ref(false) -const selected = ref(null), pickupOpen = ref(false), actual = ref(1), evidence = ref([]), truckEvidence = ref([]) +const selected = ref(null), pickupOpen = ref(false), actual = ref(), evidence = ref([]), truckEvidence = ref([]) const arrivalMode = ref(false) +const validPickupQuantity = computed(() => typeof actual.value === 'number' && Number.isInteger(actual.value) && actual.value >= 1 && actual.value <= 5000) const editingResources = ref(props.editResources === true) const pickupPhotos = (row: PickupTask, phase: 'PICKUP' | 'ARRIVAL') => row.evidence.filter(file => ((file as PickupEvidence & { phase?: string }).phase || 'PICKUP') === phase) const pickupStage = computed(() => trip.value?.status === 'COMPLETED' ? '取货派送完成' : trip.value?.status === 'CANCELLED' ? '已取消' : detail.value?.pickups.some(row => row.status === 'ASSIGNED') ? props.pickupApproved ? '装货中' : ({ DRAFT: '待提交审核', DISPATCH_PENDING: '待调度审核', LOADING_PENDING: '待理货审核', REJECTED: '审核退回' }[props.pickupReviewState || 'DRAFT'] || '待审核') : '派送中') @@ -76,7 +77,8 @@ async function command(action: 'depart' | 'complete' | 'arrive' | 'cancel', row? pending.value = { suffix, kind: action === 'cancel' ? 'cancel' : undefined, body: { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() || message, ...(action === 'complete' && prefix === 'truck' ? { evidenceFileIds: truckEvidence.value.map(file => file.fileId) } : {}) } } await execute() } -function editPickup(row: PickupTask, arrival = false) { selected.value = row; arrivalMode.value = arrival; actual.value = Number(row.actualPackages ?? row.packageCount); evidence.value = []; pickupOpen.value = true } +/** 实际取货数量由现场填写,预报箱数不能自动作为实提事实。 */ +function editPickup(row: PickupTask, arrival = false) { selected.value = row; arrivalMode.value = arrival; actual.value = undefined; evidence.value = []; pickupOpen.value = true } async function download(file: PickupEvidence, row?: PickupTask) { try { const blob = await downloadTransportEvidence(props.dispatchNo, file.fileId, row); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) } catch (cause) { error.value = getApiErrorDetails(cause, '凭证下载失败') } @@ -91,7 +93,7 @@ async function upload(event: Event, pickup: boolean) { finally { busy.value = false; input.value = '' } } async function picked() { - if (!selected.value || !trip.value || !Number.isInteger(actual.value) || actual.value <= 0 || !evidence.value.length || locked.value) return + if (!selected.value || !trip.value || (!arrivalMode.value && !validPickupQuantity.value) || !evidence.value.length || locked.value) return const row = selected.value const common = { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), evidenceFileIds: evidence.value.map(file => file.fileId) } pending.value = { suffix: `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}/${arrivalMode.value ? 'arrive' : 'picked'}`, body: arrivalMode.value ? { ...common, reason: reason.value.trim() || '上传到仓照片,确认取货派送完成' } : { ...common, actualPackages: actual.value, remark: reason.value.trim() } }; await execute() @@ -138,10 +140,13 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输请求尚待核

{{ detail.pickups.length }} 单 · 预报 {{ detail.pickups.reduce((total, row) => total + row.packageCount, 0) }} 箱
- + + + + - + @@ -149,8 +154,18 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输请求尚待核

{{ file.fileName }}

{{ file.fileName }} - -

{{ selected?.transferNo }}

{{ file.fileName }}

+ +

{{ selected?.transferNo }}

+ + {{ selected?.packageCount ?? '—' }} + + {{ selected?.actualPackages ?? '—' }} + + + +

{{ file.fileName }}

+
+
diff --git a/src/modules/overseas-warehouse/dispatchContracts.ts b/src/modules/overseas-warehouse/dispatchContracts.ts index e4d75634..ee83adad 100644 --- a/src/modules/overseas-warehouse/dispatchContracts.ts +++ b/src/modules/overseas-warehouse/dispatchContracts.ts @@ -11,7 +11,7 @@ export interface Gate { allowed: boolean; blockCode: string | null; blockReason: export type DispatchActions = Partial> export interface Counts { itemCount: number; palletCount: number; packageCount: number; pickedPackageCount: number; shippedPackageCount: number } export interface Capacity { palletSpaces: number | null; maxLoadKg: number | null; maxVolumeCbm: number | null } -export interface DriverEvidence { licenseNo: string | null; licenseExpiresOn: string | null; fileIds: string[]; expectedTaskEndAt: string | null } +export interface DriverEvidence { licenseNo: string | null; licenseExpiresOn: string | null; fileIds: string[]; expectedTaskEndAt: string | null; photoFileIds?: string[] } export interface Vehicle { mode: 'MASTER' | 'EXTERNAL'; vehicleId: string | null; plate: string | null; carrierName: string | null; capacity: Capacity | null; evidenceFileIds: string[] } export interface Driver { mode: 'MASTER' | 'EXTERNAL'; driverId: string | null; name: string | null; phone: string | null; evidence: DriverEvidence | null } export interface VehicleSnapshot extends Vehicle { vehicleStatus: string | null } diff --git a/src/modules/overseas-warehouse/dispatchPresentation.ts b/src/modules/overseas-warehouse/dispatchPresentation.ts index 99db60bc..18d4935e 100644 --- a/src/modules/overseas-warehouse/dispatchPresentation.ts +++ b/src/modules/overseas-warehouse/dispatchPresentation.ts @@ -9,6 +9,7 @@ export function dispatchReviewEvidence(detail: DispatchDetail | null): ReviewAtt const result = emptyReviewAttachments() if (!detail) return result result.vehicleFileIds = [...(detail.vehicleInfo.vehicle?.evidenceFileIds || [])] + result.driverPhotoFileIds = [...(detail.vehicleInfo.driver?.evidence?.photoFileIds || [])] result.licenseFileIds = [...(detail.vehicleInfo.driver?.evidence?.fileIds || [])] for (const { key } of dispatchReviewAttachmentFields) { result[key] = [...new Set([...result[key], ...(detail.reviews.dispatchReview.attachments?.[key] || []), ...(detail.reviews.loadingReview.attachments?.[key] || [])])] diff --git a/src/modules/overseas-warehouse/pages/DispatchesView.test.ts b/src/modules/overseas-warehouse/pages/DispatchesView.test.ts index c7d7a218..f0b54586 100644 --- a/src/modules/overseas-warehouse/pages/DispatchesView.test.ts +++ b/src/modules/overseas-warehouse/pages/DispatchesView.test.ts @@ -51,7 +51,7 @@ it('confirms cancelling pickup and linked transfers using the transport version' expect(listDispatches).toHaveBeenCalledTimes(2) wrapper.unmount(); confirm.mockRestore() }) -it.each([['LOADING', '上传取货照片'], ['DELIVERING', '上传到仓照片'], ['DELIVERED', '结束资源任务']] as const)('opens pickup %s work from the list while detail stays separate', async (stage, label) => { +it.each([['LOADING', '上传装货照片'], ['DELIVERING', '上传到仓照片'], ['DELIVERED', '结束资源任务']] as const)('opens pickup %s work from the list while detail stays separate', async (stage, label) => { vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', stage, reviewState: 'APPROVED', actions: {} })])) const wrapper = mount(DispatchesView, options); await flushPromises() const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')! diff --git a/src/modules/overseas-warehouse/pages/DispatchesView.vue b/src/modules/overseas-warehouse/pages/DispatchesView.vue index f9b2371c..f2a559d3 100644 --- a/src/modules/overseas-warehouse/pages/DispatchesView.vue +++ b/src/modules/overseas-warehouse/pages/DispatchesView.vue @@ -108,7 +108,7 @@ function pickupExecutionLabel(row: DispatchListRow) { if (row.taskType !== 'PICKUP' || row.status !== 'ACTIVE' || !access.can(dispatchActionPermissions.EDIT)) return '' if (row.stage === 'DELIVERED') return '结束资源任务' if (row.reviewState !== 'APPROVED') return '' - return row.stage === 'LOADING' ? '上传取货照片' : row.stage === 'DELIVERING' ? '上传到仓照片' : '' + return row.stage === 'LOADING' ? '上传装货照片' : row.stage === 'DELIVERING' ? '上传到仓照片' : '' } function openTransport(row: DispatchListRow, resources = false) { transportResourceMode.value = resources; transportEditNo.value = row.dispatchNo