Files
admin-web/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts
T

387 lines
27 KiB
TypeScript

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<void>
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<void>
save(): Promise<void>
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<void>
save(): Promise<void>
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(/<el-button[^>]+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<HTMLElement>('.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<HTMLButtonElement>('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<HTMLInputElement>('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('<el-table :data="rows" border stripe')
})
it('spins only the refresh icon while fee rows are loading', () => {
expect(source).toContain('class="fee-refresh-button" circle aria-label="刷新费用明细"')
expect(source).toContain(':aria-busy="loading" :disabled="loading"')
expect(source).toContain(`<RefreshCw :size="16" :class="{ 'is-spinning': loading }" />`)
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<void>; 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<void>; selectVoucher(event: Event): Promise<void>; 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<void>; selectVoucher(event: Event): Promise<void>; 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\('费用明细已作废'\)/)
})
})