import ElementPlus from 'element-plus' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { createPinia, setActivePinia } from 'pinia' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' import { ApiResponseError } from '@/shared/api/http' import { adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail, listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeeSupplierOptions, reviewInboundFeeDetail, updateInboundFeeDetail, uploadInboundFeePaymentVoucher, voidInboundFeeDetail, } from '@/modules/unified-orders/api/inboundFeeDetails' import InboundFeeDetailManager from './InboundFeeDetailManager.vue' import type { InboundFeeDetail } from '@/modules/unified-orders/api/inboundFeeDetails' vi.mock('@/modules/unified-orders/api/inboundFeeDetails', () => ({ listInboundFeeDetails: vi.fn(), listInboundFeeItemOptions: vi.fn(), listInboundFeeSupplierOptions: vi.fn(), createInboundFeeDetail: vi.fn(), updateInboundFeeDetail: vi.fn(), deleteInboundFeeDetail: vi.fn(), reviewInboundFeeDetail: vi.fn(), adjustInboundFeeDetail: vi.fn(), uploadInboundFeePaymentVoucher: vi.fn(), voidInboundFeeDetail: vi.fn(), })) const pending: InboundFeeDetail = { id: '11', inboundId: '88', feeItemId: '31', sourceType: 'MANUAL', sourceFeeVersion: 1, inboundCode: 'RK-88', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, settlementMode: null, paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null, businessDate: '2026-08-12', customerName: '测试客户', feeTypeNameSnapshot: '操作费', unitPrice: 10, currency: 'CNY', amount: 100, remark: 'marker', reviewStatus: 'PENDING', reviewReason: null, reviewerName: null, reviewedAt: null, publicationStatus: 'DRAFT', } const approved: InboundFeeDetail = { ...pending, id: '12', sourceFeeVersion: 3, reviewStatus: 'APPROVED', publicationStatus: 'PUBLISHED' } const source = readFileSync(join(process.cwd(), 'src/modules/unified-orders/components/InboundFeeDetailManager.vue'), 'utf8') enableAutoUnmount(afterEach) function mountManager(permissions: string[]) { const pinia = createPinia(); setActivePinia(pinia) const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions useAccessPreviewStore().initializeAfterLogin() return mount(InboundFeeDetailManager, { attachTo: document.body, props: { accountId: '100', inboundId: '88' }, global: { plugins: [pinia, ElementPlus] } }) } describe('InboundFeeDetailManager', () => { it.each([80, null])('refreshes the default price on reopening the editor: %s', async (defaultPrice) => { const item = { id: '31', feeTypeName: '操作费', feeCode: 'OPERATION', defaultPrice: 60, supplierCategory: null, warehouseReceivable: true, warehousePayable: false } vi.mocked(listInboundFeeItemOptions).mockResolvedValue([item]) const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view']) await flushPromises() const state = (wrapper.vm.$ as unknown as { setupState: { openEditor(mode: string, row?: InboundFeeDetail): Promise editorOpen: boolean form: { feeItemId: string; unitPrice: number; amount: number } } }).setupState await state.openEditor('create'); await flushPromises() state.editorOpen = false; await flushPromises() vi.mocked(listInboundFeeItemOptions).mockResolvedValue([{ ...item, defaultPrice }]) await state.openEditor('create'); await flushPromises() const select = wrapper.findAllComponents({ name: 'ElFormItem' }).find((field) => field.props('prop') === 'feeItemId')!.findComponent({ name: 'ElSelect' }) select.vm.$emit('update:modelValue', '31'); select.vm.$emit('change', '31') await flushPromises() expect(state.form).toMatchObject({ unitPrice: defaultPrice ?? 0, amount: defaultPrice ?? 0 }) expect(listInboundFeeItemOptions).toHaveBeenCalledTimes(2) state.editorOpen = false; await flushPromises() await state.openEditor('edit', pending); await flushPromises() expect(state.form).toMatchObject({ unitPrice: 10, amount: 100 }) }) it('clears cached choices and blocks saving while refresh is pending or has failed', async () => { const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view']) await flushPromises() const state = (wrapper.vm.$ as unknown as { setupState: { openEditor(mode: string, row?: InboundFeeDetail): Promise save(): Promise editorOpen: boolean feeItems: unknown[] feeItemsLoading: boolean feeItemsError: { message: string } | null actionError: { message: string } | null form: { unitPrice: number; amount: number } } }).setupState await state.openEditor('edit', pending); await flushPromises() expect(state.feeItems).toHaveLength(1) state.editorOpen = false; await flushPromises() let rejectRefresh!: (reason: Error) => void vi.mocked(listInboundFeeItemOptions).mockImplementationOnce(() => new Promise((_, reject) => { rejectRefresh = reject })) const opening = state.openEditor('edit', pending) await flushPromises() expect(state.feeItems).toEqual([]) expect(state.feeItemsLoading).toBe(true) const select = wrapper.findAllComponents({ name: 'ElFormItem' }).find((field) => field.props('prop') === 'feeItemId')!.findComponent({ name: 'ElSelect' }) expect(select.props('disabled')).toBe(true) await state.save() expect(state.actionError?.message).toContain('正在加载') rejectRefresh(new Error('加载失败')) await opening; await flushPromises() expect(state.feeItems).toEqual([]) expect(state.feeItemsError).not.toBeNull() expect(state.form).toMatchObject({ unitPrice: 10, amount: 100 }) await state.save() expect(updateInboundFeeDetail).not.toHaveBeenCalled() state.feeItemsError = null await state.save() expect(updateInboundFeeDetail).not.toHaveBeenCalled() expect(state.actionError?.message).toContain('不可用') }) it('prefills only on user selection and saves a manually overridden price', async () => { vi.mocked(listInboundFeeItemOptions).mockResolvedValue([ { id: '31', feeTypeName: '操作费', feeCode: 'OPERATION', defaultPrice: 60, supplierCategory: null, warehouseReceivable: true, warehousePayable: false }, { id: '32', feeTypeName: '手工费', feeCode: 'MANUAL', defaultPrice: null, supplierCategory: null, warehouseReceivable: true, warehousePayable: false }, { id: '33', feeTypeName: '零元费', feeCode: 'ZERO', defaultPrice: 0, supplierCategory: null, warehouseReceivable: true, warehousePayable: false }, ]) const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view']) await flushPromises() const state = (wrapper.vm.$ as unknown as { setupState: { openEditor(mode: string, row?: InboundFeeDetail): Promise save(): Promise form: { feeItemId: string; unitPrice: number; amount: number } } }).setupState await state.openEditor('edit', pending) await flushPromises() expect(state.form).toMatchObject({ unitPrice: 10, amount: 100 }) await state.openEditor('create') await flushPromises() const select = wrapper.findAllComponents({ name: 'ElFormItem' }).find((item) => item.props('prop') === 'feeItemId')!.findComponent({ name: 'ElSelect' }) for (const [id, price] of [['31', 60], ['32', 0], ['33', 0]] as const) { select.vm.$emit('update:modelValue', id) select.vm.$emit('change', id) await flushPromises() expect(state.form).toMatchObject({ unitPrice: price, amount: price }) } select.vm.$emit('update:modelValue', '31') select.vm.$emit('change', '31') await flushPromises() state.form.unitPrice = 65; state.form.amount = 75 await state.save(); await flushPromises() expect(createInboundFeeDetail).toHaveBeenCalledWith('100', '88', expect.objectContaining({ feeItemId: '31', unitPrice: 65, amount: 75 })) }) beforeEach(() => { vi.clearAllMocks(); document.body.innerHTML = '' vi.mocked(listInboundFeeDetails).mockResolvedValue([pending, approved]) vi.mocked(listInboundFeeItemOptions).mockResolvedValue([{ id: '31', feeTypeName: '操作费', feeCode: 'OPERATION', defaultPrice: null, supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true }]) vi.mocked(listInboundFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '测试供应商', category: 'TRUCKING' }]) vi.mocked(createInboundFeeDetail).mockResolvedValue(pending) vi.mocked(updateInboundFeeDetail).mockResolvedValue(pending) vi.mocked(adjustInboundFeeDetail).mockResolvedValue({ ...approved, reviewStatus: 'PENDING' }) vi.mocked(uploadInboundFeePaymentVoucher).mockResolvedValue({ fileId: '9001', fileName: '付款凭证.pdf' }) vi.mocked(reviewInboundFeeDetail).mockResolvedValue(null) vi.mocked(deleteInboundFeeDetail).mockResolvedValue(null) vi.mocked(voidInboundFeeDetail).mockResolvedValue(null) }) it('renders actions from backend status and exact permissions', async () => { const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:delete', 'firstleg:inbound:fee-detail:review']) await flushPromises() expect(listInboundFeeDetails).toHaveBeenCalledWith('100', '88') const first = wrapper.findAll('.el-table__row')[0]!.text() const second = wrapper.findAll('.el-table__row')[1]!.text() const secondButtons = wrapper.findAll('.el-table__row')[1]!.findAll('button').map((button) => button.text()) expect(first).toContain('编辑'); expect(first).toContain('审核'); expect(first).toContain('驳回') expect(first).toContain('草稿') expect(second).toContain('已发布') expect(second).toContain('调整'); expect(second).toContain('作废'); expect(secondButtons).not.toContain('审核') expect(wrapper.find('th').text()).toContain('操作') expect(source).not.toMatch(/]+link[^>]+(?:编辑|审核|驳回|调整|作废)/) expect(wrapper.findAll('.el-table__row')[0]!.findAll('.density-actions button').map(button => button.attributes('data-tone'))).toEqual(['neutral', 'warning', 'warning', 'neutral']) expect(wrapper.findAll('.el-table__row')[1]!.findAll('.density-actions button').map(button => button.attributes('data-tone'))).toEqual(['neutral', 'danger']) }) it.each([ [[], [], false], [['delete'], ['删除'], false], [['edit', 'delete'], ['编辑', '删除'], false], [['review', 'delete'], ['审核', '驳回', '删除'], false], [['edit', 'review'], ['编辑', '审核', '驳回'], false], [['edit', 'review', 'delete'], ['编辑', '审核', '驳回', '更多'], true], ])('folds pending fee actions only when more than three are available: %j', async (actions, labels, overflow) => { vi.mocked(listInboundFeeDetails).mockResolvedValue([pending]) const wrapper = mountManager(['firstleg:inbound:fee-detail:view', ...actions.map(action => 'firstleg:inbound:fee-detail:' + action)]) await flushPromises() const row = wrapper.get('.el-table__row .density-actions') expect(row.findAll('button').map(button => button.text().trim())).toEqual(labels) expect(row.find('.el-dropdown').exists()).toBe(overflow) if (overflow) expect(row.get('.el-dropdown button').attributes('aria-label')).toContain('操作费') }) it.each(['编辑', '驳回'])('styles the native %s fee dialog at its own root', async action => { const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:review', 'firstleg:fee-item:view']) await flushPromises() await wrapper.get('.el-table__row .density-actions').findAll('button').find(button => button.text().trim() === action)!.trigger('click') await flushPromises() const dialog = document.querySelector('.el-dialog.admin-density-dialog')! expect(dialog).not.toBeNull() expect(dialog.querySelector('.density-form')).not.toBeNull() expect(dialog.style.getPropertyValue('--el-dialog-width')).toBe(action === '编辑' ? 'min(1000px, calc(100vw - 28px))' : 'min(880px, calc(100vw - 28px))') if (action === '驳回') { const confirm = [...dialog.querySelectorAll('button')].find(button => button.textContent?.trim() === '确认驳回')! confirm.click() await flushPromises() expect(reviewInboundFeeDetail).not.toHaveBeenCalled() expect(dialog.querySelector('textarea')?.value).toBe('') } else expect(dialog.textContent).toContain('业务发生日') }) it('keeps FBA_PRICING rows derived and read-only regardless of write permissions', async () => { vi.mocked(listInboundFeeDetails).mockResolvedValue([{ ...approved, sourceType: 'FBA_PRICING' }]) const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:delete', 'firstleg:inbound:fee-detail:review']) await flushPromises() const row = wrapper.find('.el-table__row') expect(row.text()).toContain('头程派生') const buttons = row.findAll('button').map((button) => button.text()) for (const action of ['编辑', '审核', '驳回', '调整', '作废', '删除']) expect(buttons).not.toContain(action) }) it('shows payable settlement meaning and a DIRECT_PAID summary without changing fee direction', async () => { vi.mocked(listInboundFeeDetails).mockResolvedValue([{ ...pending, direction: 'PAYABLE', supplierId: '41', supplierName: '测试供应商', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-11', paymentVoucherFileIds: ['9001'] }]) const wrapper = mountManager(['firstleg:inbound:fee-detail:view']) await flushPromises() expect(wrapper.text()).toContain('应付'); expect(wrapper.text()).toContain('已付成本'); expect(wrapper.text()).toContain('银行转账'); expect(wrapper.text()).toContain('BANK-11') }) it('does not request or expose fee rows without view permission', async () => { mountManager([]); await flushPromises() expect(listInboundFeeDetails).not.toHaveBeenCalled() expect(document.body.textContent).toContain('没有查看费用明细的权限') }) it.each(['', ' ', 'null', 'undefined'])('does not request fee rows for invalid inboundId %j', async (inboundId) => { const pinia = createPinia(); setActivePinia(pinia) const auth = useAuthStore(); auth.token = 'token'; auth.permissions = ['firstleg:inbound:fee-detail:view'] useAccessPreviewStore().initializeAfterLogin() mount(InboundFeeDetailManager, { props: { accountId: '100', inboundId }, global: { plugins: [pinia, ElementPlus] }, }) await flushPromises() expect(listInboundFeeDetails).not.toHaveBeenCalled() }) it('approves a pending fee then refreshes rows and emits changed', async () => { const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:review']) await flushPromises() wrapper.findAll('button').find((button) => button.text().includes('审核'))?.trigger('click') await flushPromises() expect(reviewInboundFeeDetail).toHaveBeenCalledWith('100', '88', '11', 'approve') expect(listInboundFeeDetails).toHaveBeenCalledTimes(2) expect(wrapper.emitted('changed')).toHaveLength(1) }) it('preserves request and trace IDs when a write fails', async () => { vi.mocked(reviewInboundFeeDetail).mockRejectedValue(new ApiResponseError('状态冲突', 'A0409', 409, null, 'request-fee', 'trace-fee')) const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:review']) await flushPromises() wrapper.findAll('button').find((button) => button.text().includes('审核'))?.trigger('click') await flushPromises() expect(document.body.textContent).toContain('request-fee') expect(document.body.textContent).toContain('trace-fee') }) it('keeps currency fixed to CNY in the form and write payload', async () => { const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view']) await flushPromises() await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click') await flushPromises() const currency = document.querySelector('input[aria-label="货币 CNY"]') expect(currency?.readOnly).toBe(true) expect(currency?.value).toBe('CNY') }) it('uses only the Element Plus table scroller without an outer horizontal scrollbar', async () => { const wrapper = mountManager(['firstleg:inbound:fee-detail:view']) await flushPromises() expect(wrapper.find('.fee-table-shell').exists()).toBe(true) expect(wrapper.find('.detail-table-wrap').exists()).toBe(false) expect(source).toContain(' { expect(source).toContain('class="fee-refresh-button" circle aria-label="刷新费用明细"') expect(source).toContain(':aria-busy="loading" :disabled="loading"') expect(source).toContain(``) expect(source).not.toMatch(/aria-label="刷新费用明细"[^>]*:loading=/) expect(source).toContain('@keyframes fee-refresh-spin') }) it('loads RECEIVABLE fee items without requesting suppliers', async () => { const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view']) await flushPromises() await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click') await flushPromises() expect(listInboundFeeItemOptions).toHaveBeenCalledTimes(1) expect(listInboundFeeSupplierOptions).not.toHaveBeenCalled() expect(document.body.textContent).toContain('缺少供应商查看权限') expect(document.body.textContent).toContain('应收费用不受影响') }) it('does not expose the editor without fee-item view permission', async () => { const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit']) await flushPromises() expect(wrapper.findAll('button').some((button) => button.text().includes('新增费用'))).toBe(false) expect(listInboundFeeItemOptions).not.toHaveBeenCalled() }) it('keeps fee items usable but blocks payable save when the supplier request fails', async () => { vi.mocked(listInboundFeeDetails).mockResolvedValue([{ ...pending, direction: 'PAYABLE', supplierId: '41', supplierName: '测试供应商', supplierCategory: 'TRUCKING', settlementMode: 'ON_ACCOUNT' }]) vi.mocked(listInboundFeeSupplierOptions).mockRejectedValue(new ApiResponseError('供应商不可用', 'A0403', 403, null, 'request-supplier', 'trace-supplier')) const wrapper = mountManager([ 'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list', ]) await flushPromises() await wrapper.findAll('button').find((button) => button.text().includes('编辑'))?.trigger('click') await flushPromises() expect(listInboundFeeItemOptions).toHaveBeenCalledTimes(1) expect(listInboundFeeSupplierOptions).toHaveBeenCalledWith('TRUCKING') expect(document.body.textContent).toContain('当前账号无权访问该数据') expect(document.body.textContent).toContain('A0403') expect(document.body.textContent).toContain('request-supplier') expect(document.body.textContent).toContain('trace-supplier') expect(document.querySelector('.fee-form .el-select')?.classList.contains('is-disabled')).toBe(false) }) it('allows any loaded ACTIVE supplier when a payable fee item has no supplier category', async () => { vi.mocked(listInboundFeeItemOptions).mockResolvedValue([{ id: '31', feeTypeName: '未映射费用', feeCode: 'UNMAPPED', defaultPrice: null, supplierCategory: null, warehouseReceivable: false, warehousePayable: true }]) vi.mocked(listInboundFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '测试供应商', category: 'CUSTOMS' }]) const wrapper = mountManager([ 'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list', ]) await flushPromises() await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click') await flushPromises() const state = (wrapper.vm.$ as unknown as { setupState: { save(): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; amount: number } } }).setupState state.form.direction = 'PAYABLE'; state.form.feeItemId = '31'; await flushPromises(); state.form.supplierId = '41'; state.form.settlementMode = 'ON_ACCOUNT'; state.form.amount = 100 await state.save(); await flushPromises() expect(listInboundFeeSupplierOptions).toHaveBeenCalledWith(null) expect(createInboundFeeDetail).toHaveBeenCalledWith('100', '88', expect.objectContaining({ direction: 'PAYABLE', supplierId: '41', settlementMode: 'ON_ACCOUNT', paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null })) }) it('clears settlement and paid fields when switching to RECEIVABLE or ON_ACCOUNT', async () => { const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list']) await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click'); await flushPromises() const state = (wrapper.vm.$ as unknown as { setupState: { form: { direction: string; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; paymentVoucherFileIds: string[] | null } } }).setupState Object.assign(state.form, { direction: 'PAYABLE', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'CASH', paymentReferenceNo: 'CASH-1', paymentVoucherFileIds: ['9001'] }); await flushPromises() state.form.settlementMode = 'ON_ACCOUNT'; await flushPromises() expect(state.form).toMatchObject({ settlementMode: 'ON_ACCOUNT', paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null }) state.form.direction = 'RECEIVABLE'; await flushPromises() expect(state.form).toMatchObject({ direction: 'RECEIVABLE', settlementMode: null, paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null }) }) it('requires complete DIRECT_PAID evidence and submits uploaded voucher IDs only after scoped upload succeeds', async () => { const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list']) await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click'); await flushPromises() const state = (wrapper.vm.$ as unknown as { setupState: { save(): Promise; selectVoucher(event: Event): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; amount: number } } }).setupState Object.assign(state.form, { direction: 'PAYABLE', feeItemId: '31' }); await flushPromises() Object.assign(state.form, { supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: null, amount: 100 }); await flushPromises() await state.save(); expect(createInboundFeeDetail).not.toHaveBeenCalled() const input = document.createElement('input'); Object.defineProperty(input, 'files', { value: [new File(['x'], '付款凭证.pdf')] }) await state.selectVoucher({ target: input } as unknown as Event); await state.save() expect(uploadInboundFeePaymentVoucher).toHaveBeenCalledWith('100', expect.objectContaining({ name: '付款凭证.pdf' })) expect(createInboundFeeDetail).toHaveBeenCalledWith('100', '88', expect.objectContaining({ settlementMode: 'DIRECT_PAID', paymentMethod: 'BANK_TRANSFER', paymentVoucherFileIds: ['9001'] })) }) it('keeps DIRECT_PAID save closed and exposes feedback when voucher upload fails', async () => { vi.mocked(uploadInboundFeePaymentVoucher).mockRejectedValue(new ApiResponseError('File服务不可用', 'A0503', 503, null, 'request-file', 'trace-file')) const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list']) await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click'); await flushPromises() const state = (wrapper.vm.$ as unknown as { setupState: { save(): Promise; selectVoucher(event: Event): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; amount: number } } }).setupState Object.assign(state.form, { direction: 'PAYABLE', feeItemId: '31' }); await flushPromises() Object.assign(state.form, { supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'ONLINE', paymentReferenceNo: 'ONLINE-1', amount: 100 }); await flushPromises() const input = document.createElement('input'); Object.defineProperty(input, 'files', { value: [new File(['x'], '失败凭证.png')] }) await state.selectVoucher({ target: input } as unknown as Event); await state.save(); await flushPromises() expect(createInboundFeeDetail).not.toHaveBeenCalled(); expect(document.body.textContent).toContain('request-file'); expect(document.body.textContent).toContain('trace-file') }) it('refreshes fee rows and the order detail after every successful write', () => { expect(source).toMatch(/async function finishAction\(message: string\) \{\r?\n await load\(\); emit\('changed'\)/) expect(source).toMatch(/createInboundFeeDetail[\s\S]*updateInboundFeeDetail[\s\S]*adjustInboundFeeDetail[\s\S]*await finishAction\(/) expect(source).toMatch(/deleteInboundFeeDetail[\s\S]*await finishAction\('费用明细已删除'\)/) expect(source).toMatch(/reviewInboundFeeDetail[\s\S]*await finishAction\('费用审核通过'\)/) expect(source).toMatch(/reviewInboundFeeDetail[\s\S]*rejectReason[\s\S]*await finishAction\('费用已驳回'\)/) expect(source).toMatch(/voidInboundFeeDetail[\s\S]*await finishAction\('费用明细已作废'\)/) }) })