import { createPinia } from 'pinia' import ElementPlus, { ElFormItem, ElDatePicker, ElSelect, ElCheckbox } from 'element-plus' import AppDialog from '@/shared/components/AppDialog.vue' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import { cancelDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches' import DeliveryFeeCoveragePanel from '../components/DeliveryFeeCoveragePanel.vue' import DispatchesView from './DispatchesView.vue' vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn(async (query: { keyword: string }) => [{ id: query.keyword, warehouseCode: query.keyword, warehouseType: 'FBA' }]), getPlatformWarehouse: vi.fn(async (id: string) => ({ warehouse: { warehouseCode: id, warehouseType: id === '321' ? 'OVERSEAS' : 'FBA' }, addresses: [{ addressType: 'OPERATING', addressLine1: '100 Example Street', city: 'City', stateProvince: 'CA', countryCode: 'US' }], capabilities: [] })), })) const push = vi.hoisted(() => vi.fn()) vi.mock('vue-router', () => ({ useRouter: () => ({ push }), useRoute: () => ({ query: {} }) })) const canPermission = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true)) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) })) vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), removeDispatchItem: vi.fn(), getDispatchBolPdf: vi.fn(), getDispatchExternalFreight: vi.fn(), getDispatchFeeCoverage: vi.fn(), updateDispatch: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() })) class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} } function memoryStorage(): Storage { const values = new Map() return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value), } } enableAutoUnmount(afterEach) afterAll(() => vi.unstubAllGlobals()) describe('DispatchesView', () => { beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('localStorage', memoryStorage()) vi.mocked(listDispatches).mockReset().mockResolvedValue({ records: [{ id: '190000000000000099', dispatchNo: 'PC-1', appointmentBatches: [], vehiclePlate: 'CA-100', driverName: '张师傅', driverPhone: '555-0100', carrierName: 'Carrier A', orderNos: 'LM-1,LM-2', route: 'ONT8,LAX9', overseasWarehouseId: '321', overseasWarehouseName: '洛杉矶仓', departAt: '2026-08-20T09:00:00', fbaAppointmentNo: 'FBA-1', fbaAppointmentStatus: 'CONFIRMED', vehicleAppointmentStatus: 'BOOKED', estimatedArrivalAt: '2026-08-20T18:00:00', status: 'PENDING_DEPARTURE', palletCount: 12, packageCount: 80, createdAt: '2026-08-19T16:00:00', }], page: 1, size: 20, total: 1, pages: 1, }) vi.mocked(updateDispatch).mockReset() vi.mocked(getDispatchBolPdf).mockReset().mockResolvedValue(new Blob(['%PDF-test'], { type: 'application/pdf' })) vi.mocked(removeDispatchItem).mockReset() vi.mocked(cancelDispatch).mockReset() vi.mocked(transferDispatchToDelivery).mockReset() vi.mocked(getDispatchExternalFreight).mockReset().mockResolvedValue({ snapshotHash: 'own-hash', requiresDeliveryFee: true, quotes: [] }) vi.mocked(getDispatchFeeCoverage).mockReset().mockResolvedValue({ dispatchNo: 'PC-1', sourceComplete: true, blockReason: null, cargoHash: 'cargo-hash', cargo: [], feeSources: [] }) canPermission.mockReset().mockReturnValue(true) }) it('queries stages on the server and discards an older response after switching', async () => { let resolveOld!: (value: Awaited>) => void vi.mocked(listDispatches).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve })) const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }) await wrapper.get('[role="tablist"]').findAll('button')[2]!.trigger('click') await flushPromises() expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'LOADING', page: 1, status: undefined })) expect(wrapper.text()).toContain('PC-1') resolveOld({ records: [], total: 0, page: 1, size: 10, pages: 0 }) await flushPromises() expect(wrapper.text()).toContain('PC-1') await wrapper.get('[role="tablist"]').findAll('button')[3]!.trigger('click') await flushPromises() expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'AFTER_LOADING', page: 1 })) }) it('shows the shared route map only in the after-loading vehicle section without another query', async () => { const wrapper = mount(DispatchesView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } }) await flushPromises() expect(wrapper.text()).not.toContain('查看路线') await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[3]!.trigger('click') await flushPromises() const requests = vi.mocked(listDispatches).mock.calls.length await wrapper.findAll('button').find(button => button.text() === '查看路线')!.trigger('click') await flushPromises() expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('modelValue')).toBe(true) expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('origin')).toEqual({ kind: 'warehouse', label: '洛杉矶仓', warehouseId: '321' }) expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('destinations')).toEqual(['ONT8', 'LAX9'].map(code => ({ kind: 'warehouse', label: code, warehouseCode: code }))) expect(listDispatches).toHaveBeenCalledTimes(requests) await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[1]!.trigger('click') await flushPromises() await wrapper.get('[aria-label="排车业务分区"]').findAll('button')[1]!.trigger('click') expect(wrapper.findAll('button').some(button => button.text() === '查看路线')).toBe(false) }) it('shows review facts and opens the matching trip section with read permission', async () => { const result = await listDispatches({ page: 1, size: 10 }) result.records[0]!.workflow = { tripNo: 'TR-A', dispatchReview: { status: 'REJECTED', reason: '请核对货量', reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } } vi.mocked(listDispatches).mockClear().mockResolvedValue(result) canPermission.mockImplementation(code => code === 'platform:fleet:trip:list') const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }) await flushPromises() const sections = wrapper.get('[aria-label="排车业务分区"]') expect(sections.findAll('button').map(button => button.text())).toEqual(['车辆信息']) await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[1]!.trigger('click') await flushPromises() expect(sections.findAll('button').map(button => button.text())).toEqual(['车辆信息', '调度审核', '理货员审核']) expect(wrapper.findAll('[role="tablist"]')).toHaveLength(2) expect(wrapper.text()).not.toContain('请核对货量') await sections.findAll('button')[1]!.trigger('click') await flushPromises() expect(wrapper.text()).toContain('请核对货量') expect(wrapper.findAll('button').some(button => button.text() === '打印BOL')).toBe(false) await wrapper.findAll('button').find(button => button.text() === '查看审核')!.trigger('click') expect(push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-A' }, query: { tab: 'overview' } }) await sections.findAll('button')[2]!.trigger('click') await flushPromises() expect(wrapper.text()).not.toContain('请核对货量') expect(wrapper.text()).toContain('待审核') const links = wrapper.findAll('button').filter(button => button.text() === '查看审核') await links[0]!.trigger('click') expect(push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-A' }, query: { tab: 'loading' } }) expect(wrapper.text()).not.toContain('前往审核') expect(listDispatches).toHaveBeenCalledTimes(2) }) it.each([0, 2, 3, 4].flatMap(stageIndex => [1, 2].map(sectionIndex => [stageIndex, sectionIndex])))('leaving a review section for stage %s resets section %s to vehicle information', async (stageIndex, sectionIndex) => { const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }) await flushPromises() const stages = wrapper.get('[aria-label="排车执行阶段"]').findAll('button') const sections = wrapper.get('[aria-label="排车业务分区"]') await stages[1]!.trigger('click') await flushPromises() await sections.findAll('button')[sectionIndex]!.trigger('click') expect(wrapper.findAll('button').some(button => button.text() === '打印BOL')).toBe(false) await stages[stageIndex]!.trigger('click') await flushPromises() expect(sections.findAll('button').map(button => button.text())).toEqual(['车辆信息']) expect(sections.get('button').attributes('aria-selected')).toBe('true') expect(wrapper.findAll('button').some(button => button.text() === '打印BOL')).toBe(true) expect(wrapper.findAll('button').some(button => ['前往审核', '查看审核'].includes(button.text()))).toBe(false) await stages[1]!.trigger('click') await flushPromises() expect(sections.findAll('button').map(button => button.text())).toEqual(['车辆信息', '调度审核', '理货员审核']) expect(sections.get('button').attributes('aria-selected')).toBe('true') }) it.each([ [[], ['查看', '打印BOL'], false], [['edit'], ['查看', '打印BOL', '编辑'], false], [['cancel'], ['查看', '打印BOL', '取消'], false], [['transfer-delivery'], ['查看', '打印BOL', '转派送'], false], [['edit', 'transfer-delivery'], ['查看', '打印BOL', '转派送', '更多'], true], [['edit', 'cancel'], ['查看', '打印BOL', '编辑', '更多'], true], [['edit', 'cancel', 'transfer-delivery'], ['查看', '打印BOL', '转派送', '更多'], true], ])('uses the row action threshold for permissions %j', async (allowed, labels, overflow) => { canPermission.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:dispatch:' + action)) const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }) await flushPromises() const actions = wrapper.get('.el-table__body-wrapper .density-actions') expect(actions.findAll('button').map(button => button.text().trim())).toEqual(labels) expect(actions.find('.el-dropdown').exists()).toBe(overflow) }) it('keeps the integrated header and filters usable through failure and retry', async () => { vi.mocked(listDispatches).mockRejectedValueOnce(new Error('网络错误')) const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }) await flushPromises() const card = wrapper.get('.data-table--integrated') expect(card.get('.data-table__toolbar h1').text()).toBe('排车列表') expect(card.get('.data-table__toolbar button[aria-label="列设置"]').text()).toBe('字段设置') expect(card.get('.data-table__filters').findComponent(QueryToolbar).exists()).toBe(true) expect(card.find('.el-table').exists()).toBe(false) expect(card.find('.data-table__pagination').exists()).toBe(false) wrapper.findComponent(LoadErrorState).vm.$emit('retry') await flushPromises() expect(card.find('.data-table__state').exists()).toBe(false) expect(card.get('.el-table').text()).toContain('PC-1') expect(card.get('.data-table__pagination').text()).toContain('共 1 条排车记录') }) it('loads immediately and sends only supported page filters', async () => { const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }) await flushPromises() expect(listDispatches).toHaveBeenCalledWith({ page: 1, size: 10, dispatchNo: undefined, vehiclePlate: undefined, status: undefined, fbaAppointmentNo: undefined }) const model: QueryModel = { dispatchNo: 'PC-1', vehiclePlate: 'CA-100', status: 'PENDING_DEPARTURE', fbaAppointmentNo: 'FBA-1', } wrapper.findComponent(QueryToolbar).vm.$emit('search', model) await flushPromises() expect(listDispatches).toHaveBeenCalledWith({ page: 1, size: 10, dispatchNo: 'PC-1', vehiclePlate: 'CA-100', status: 'PENDING_DEPARTURE', fbaAppointmentNo: 'FBA-1', }) expect(wrapper.text()).toContain('PC-1') expect(wrapper.text()).toContain('待发车') expect(wrapper.text()).toContain('FBA-1 · 已确认') expect(wrapper.text()).toContain('已预约') expect(wrapper.get('.data-table').classes()).not.toContain('admin-text-actions') expect(wrapper.get('.query-toolbar').classes()).not.toContain('admin-text-actions') expect(wrapper.get('.data-table__toolbar button[aria-label="列设置"]').find('svg').exists()).toBe(true) expect(wrapper.get('.query-toolbar__actions .el-button--primary').text()).toBe('查询') expect(wrapper.get('button[aria-label="上一页"]').find('svg').exists()).toBe(true) expect(wrapper.findAll('.query-toolbar__actions button svg')).toHaveLength(3) expect(wrapper.get('.el-table__body-wrapper .row-actions').classes()).toContain('admin-text-actions') }) it('keeps long dispatch identifiers as strings', async () => { vi.mocked(listDispatches).mockResolvedValueOnce({ records: [{ id: '190000000000000099', dispatchNo: '190000000000000099', appointmentBatches: [] }], page: 1, size: 20, total: 1, pages: 1, }) const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }) await flushPromises() expect(wrapper.html()).toContain('190000000000000099') }) it('opens the server-generated BOL PDF with one request', async () => { const pdfWindow = { location: { href: '' }, close: vi.fn() } vi.spyOn(window, 'open').mockReturnValue(pdfWindow as unknown as Window) vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:dispatch-bol'), revokeObjectURL: vi.fn() }) const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }) await flushPromises() expect(getDispatchBolPdf).not.toHaveBeenCalled() await wrapper.findAll('.el-table__body-wrapper button').find((button) => button.text().trim() === '打印BOL')!.trigger('click') await flushPromises() expect(getDispatchBolPdf).toHaveBeenCalledOnce() expect(getDispatchBolPdf).toHaveBeenCalledWith('PC-1') expect(pdfWindow.location.href).toBe('blob:dispatch-bol') }) it('documents the backend feature flag required by a missing endpoint', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/DispatchesView.vue'), 'utf8') expect(source).toContain('请确认海外尾程排车接口已发布') }) it('loads external quotes only when opened and submits their hash without a duplicate delivery charge', async () => { vi.mocked(getDispatchExternalFreight).mockResolvedValue({ snapshotHash: 'external-hash', requiresDeliveryFee: false, quotes: [ { bookingNo: 'FB-1', recordVersion: '4', incomingNo: 'WT-1', inboundCode: 'IN-1', customerName: '客户甲', destinationKey: 'FBA:ONT8', currency: 'CNY', amount: 60, quantity: 2, quantityUnit: '托位', unitPrice: 30 }, ] }) const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }) await flushPromises() expect(getDispatchExternalFreight).not.toHaveBeenCalled() await wrapper.findAll('button').find(button => button.text() === '转派送')!.trigger('click') await flushPromises() expect(getDispatchExternalFreight).toHaveBeenCalledOnce() expect(getDispatchExternalFreight).toHaveBeenCalledWith('PC-1') const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '转派送')! expect(dialog.text()).toContain('FB-1') expect(dialog.text()).not.toContain('请输入派送费用') dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-03T12:00:00') for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'WAREHOUSE_PICKUP'], ['装车类型', 'PALLET']]) { dialog.findAllComponents(ElFormItem).find(item => item.props('label') === label)!.findComponent(ElSelect).vm.$emit('update:modelValue', value) } await flushPromises() dialog.vm.$emit('confirm') await flushPromises() expect(transferDispatchToDelivery).toHaveBeenCalledWith('PC-1', expect.objectContaining({ fees: [], externalFreightHash: 'external-hash' })) }) it('confirms an original delivery fee without submitting a new receivable', async () => { vi.mocked(getDispatchFeeCoverage).mockResolvedValue({ dispatchNo: 'PC-1', sourceComplete: true, blockReason: null, cargoHash: 'actual-cargo', cargo: [{ dispatchItemId: '201', warehousePalletId: '501', palletNo: 'PLT-1', orderId: '401', sourceRegion: 'FIRSTLEG', sourceInboundId: '11', inboundCode: 'IN-11', packages: 20, dispatchInboundCode: 'IN-11' }], feeSources: [{ financeFeeId: '711', sourceFeeId: '811', sourceType: 'INBOUND_FEE', sourceVersion: 3, customerId: '101', sourceInboundId: '11', inboundCode: 'IN-11', feeItemCode: 'FREIGHT', feeItemName: '原运费', currency: 'CNY', amount: 100, sourceHash: 'original-fee-hash', status: 'AVAILABLE' }], }) const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }) await flushPromises() expect(getDispatchFeeCoverage).not.toHaveBeenCalled() await wrapper.findAll('button').find(button => button.text() === '转派送')!.trigger('click') await flushPromises() expect(getDispatchFeeCoverage).toHaveBeenCalledTimes(1) const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '转派送')! await dialog.findComponent(DeliveryFeeCoveragePanel).findAllComponents(ElCheckbox).find(row => row.text() === '已含派送')!.find('input').setValue(true) dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-03T12:00:00') for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'WAREHOUSE_PICKUP'], ['装车类型', 'PALLET']]) { dialog.findAllComponents(ElFormItem).find(item => item.props('label') === label)!.findComponent(ElSelect).vm.$emit('update:modelValue', value) } await flushPromises() expect(dialog.text()).toContain('本次全部货物已覆盖:派送') dialog.vm.$emit('confirm') await flushPromises() expect(transferDispatchToDelivery).toHaveBeenCalledWith('PC-1', expect.objectContaining({ fees: [], feeCoverageCargoHash: 'actual-cargo', feeCoverage: [{ financeFeeId: '711', sourceVersion: 3, sourceHash: 'original-fee-hash', services: ['DELIVERY_FEE'] }] })) }) it('gates edit, cancel and transfer with independent permissions and service statuses', async () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/DispatchesView.vue'), 'utf8') expect(source).toContain('permission.dispatchEdit') expect(source).toContain('permission.dispatchCancel') expect(source).toContain('permission.dispatchTransferDelivery') expect(source).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'") expect(source).toContain("{ key: 'transfer', label: '转派送', priority: 250") expect(source).toMatch(/function isPendingDeparture\(row: DispatchListItem\)\s*{\s*return row\.statusCode === 'PENDING_DEPARTURE'/) expect(source).toMatch(/access\.can\(permission\.dispatchEdit\)\s*&&\s*isPendingDeparture\(row\)/) expect(source).toMatch(/access\.can\(permission\.dispatchTransferDelivery\)\s*&&\s*\['PENDING_DEPARTURE', 'PICKING', 'READY_TO_DEPART'\]\.includes\(row\.statusCode\)/) expect(source).toContain('未生成出库单前可移除托盘') expect(source).toContain('removeDispatchItem') expect(source).not.toContain('托盘箱唛确认') expect(source).not.toContain('palletCartonMarks') expect(source).toContain('v-model="editForm.route" readonly') expect(source).not.toContain('route: editForm.route.trim()') expect(source).toContain('dispatchVehicleSizeOptions') expect(source).not.toContain('departDispatch') canPermission.mockReturnValue(false) const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }) await flushPromises() expect(wrapper.text()).not.toContain('转派送') }) })