import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils' import VehicleCareFinanceLedger from './VehicleCareFinanceLedger.vue' import * as api from '../api/companyVehicleCosts' import { ApiResponseError } from '@/shared/api/http' const access = vi.hoisted(() => ({ permissions: new Set() })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.permissions.has(permission) }) })) vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } })) vi.mock('../api/companyVehicleCosts', async original => ({ ...(await original()), getCompanyCost: vi.fn(), commandCompanyCost: vi.fn(), payCompanyCost: vi.fn(), listCompanyCostHistory: vi.fn(), listCompanyCostPayments: vi.fn(), uploadCompanyCostVoucher: vi.fn(), downloadCompanyCostVoucher: vi.fn() })) const detail: api.CompanyCost = { costNo: 'VC-ONE', subjectCode: 'PLATFORM_DEFAULT', supplierName: '维修供应商', currency: 'CNY', status: 'APPROVED', recordVersion: '2', paidAmount: 0, outstandingAmount: 70, source: { sourceType: 'REPAIR', sourceNo: 'VR-ONE', sourceVersion: '1', vehicleId: '71', vehiclePlate: 'VEH-A', tripNo: null, supplierId: '8', feeName: '维修', businessDate: '2026-01-01', originalCurrency: 'USD', originalAmount: 10, exchangeRate: 7, exchangeRateMonth: '2026-01', amount: 70, remark: null } } const empty = { records: [], page: 1, size: 10, total: 0, pages: 0 } const voucher = { fileId: '11', fileName: 'payment.png', contentType: 'image/png', sizeBytes: 3 } const wrappers: VueWrapper[] = [] const input = { props: ['modelValue'], emits: ['update:modelValue'], template: '' } function mount() { const w = shallowMount(VehicleCareFinanceLedger, { props: { costNo: 'VC-ONE' }, global: { stubs: { ElButton: { props: ['disabled'], template: '' }, ElForm: { template: '
' }, ElFormItem: { template: '
' }, ElOption: true, ElInput: { ...input, name: 'ElInput' }, ElInputNumber: { ...input, name: 'ElInputNumber' }, ElSelect: input, ElDatePicker: input, DataTable: { name: 'DataTable', props: ['data', 'total', 'page'], emits: ['update:page'], template: '
' }, LoadErrorState: { props: ['message'], template: '

{{ message }}

' }, ApiErrorMeta: true, } } }); wrappers.push(w); return w } const button = (w: VueWrapper, label: string) => w.findAll('button').find(value => value.text() === label)! async function files(w: VueWrapper, names = ['payment.png']) { const element = w.get('input[type="file"]') Object.defineProperty(element.element, 'files', { configurable: true, value: names.map(name => new File(['png'], name, { type: 'image/png' })) }) await element.trigger('change'); await flushPromises() } beforeEach(() => { vi.resetAllMocks(); access.permissions = new Set(Object.values(api.companyCostPermissions)) vi.mocked(api.getCompanyCost).mockResolvedValue(structuredClone(detail)) vi.mocked(api.commandCompanyCost).mockResolvedValue({ ...detail, status: 'VOIDED', outstandingAmount: null, recordVersion: '3' }) vi.mocked(api.payCompanyCost).mockResolvedValue({ ...detail, status: 'PARTIALLY_PAID', paidAmount: 20, outstandingAmount: 50, recordVersion: '3' }) vi.mocked(api.listCompanyCostHistory).mockResolvedValue(empty); vi.mocked(api.listCompanyCostPayments).mockResolvedValue(empty) vi.mocked(api.uploadCompanyCostVoucher).mockResolvedValue(voucher) }) afterEach(() => wrappers.splice(0).forEach(w => w.unmount())) it('requests detail once and reads separately paged history only on demand', async () => { const w = mount(); await flushPromises() expect(api.getCompanyCost).toHaveBeenCalledExactlyOnceWith('VC-ONE') expect(api.listCompanyCostHistory).not.toHaveBeenCalled(); expect(api.listCompanyCostPayments).not.toHaveBeenCalled() await button(w, '操作历史').trigger('click'); await flushPromises() expect(api.listCompanyCostHistory).toHaveBeenCalledExactlyOnceWith('VC-ONE', 1) w.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises() expect(api.listCompanyCostHistory).toHaveBeenLastCalledWith('VC-ONE', 2) await button(w, '付款流水').trigger('click'); await flushPromises() expect(api.listCompanyCostPayments).toHaveBeenCalledExactlyOnceWith('VC-ONE', 1) }) it('has no requests without Finance list permission and does not give readers write buttons', async () => { access.permissions.clear(); const denied = mount(); await flushPromises(); expect(api.getCompanyCost).not.toHaveBeenCalled() expect(denied.text()).toContain('暂无公司成本财务查询权限') access.permissions.add(api.companyCostPermissions.list); const w = mount(); await flushPromises() expect(button(w, '登记付款')).toBeUndefined(); expect(button(w, '作废费用')).toBeUndefined(); expect(button(w, '确认入账')).toBeUndefined() }) it('keeps one immutable payment request and key after an unknown response', async () => { vi.mocked(api.payCompanyCost).mockRejectedValueOnce(new Error('unknown outcome')).mockResolvedValueOnce({ ...detail, status: 'PARTIALLY_PAID', paidAmount: 20, outstandingAmount: 50, recordVersion: '3' }) const w = mount(); await flushPromises(); await button(w, '登记付款').trigger('click'); await flushPromises() w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20); await files(w) await button(w, '确认操作').trigger('click'); await flushPromises() const first = vi.mocked(api.payCompanyCost).mock.calls[0]! expect(first).toEqual(['VC-ONE', expect.any(String), expect.objectContaining({ expectedVersion: '2', amount: 20, voucherFileIds: ['11'] })]) expect(w.text()).toContain('上次操作结果尚未确认') w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 60) await button(w, '重试原操作').trigger('click'); await flushPromises() expect(vi.mocked(api.payCompanyCost).mock.calls[1]).toEqual(first) expect(w.text()).toContain('部分付款'); expect(w.text()).toContain('未付 CNY 50.00') expect(api.uploadCompanyCostVoucher).toHaveBeenCalledTimes(1); expect(w.emitted('changed')).toHaveLength(1) }) it('preserves successfully uploaded vouchers and resumes remaining uploads without paying', async () => { vi.mocked(api.uploadCompanyCostVoucher).mockResolvedValueOnce(voucher).mockRejectedValueOnce(new Error('upload failed')) .mockResolvedValueOnce({ ...voucher, fileId: '12', fileName: 'second.png' }) const w = mount(); await flushPromises(); await button(w, '登记付款').trigger('click'); await flushPromises() await files(w, ['payment.png', 'second.png']) expect(w.text()).toContain('已上传:payment.png'); expect(w.text()).toContain('1 张凭证待完成上传') expect(api.payCompanyCost).not.toHaveBeenCalled() await button(w, '重试剩余凭证').trigger('click'); await flushPromises() expect(api.uploadCompanyCostVoucher).toHaveBeenCalledTimes(3) expect((vi.mocked(api.uploadCompanyCostVoucher).mock.calls[2]![1]).name).toBe('second.png') expect(w.text()).toContain('已上传:second.png'); expect(api.payCompanyCost).not.toHaveBeenCalled() }) it('refuses zero, excess or unproven payments locally and sends void only after explicit confirmation', async () => { const w = mount(); await flushPromises(); await button(w, '登记付款').trigger('click'); await flushPromises() w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 0); await button(w, '确认操作').trigger('click'); await flushPromises() await files(w); w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 71) await button(w, '确认操作').trigger('click'); await flushPromises(); expect(api.payCompanyCost).not.toHaveBeenCalled() await button(w, '取消操作').trigger('click'); await button(w, '作废费用').trigger('click'); await flushPromises() expect(api.commandCompanyCost).not.toHaveBeenCalled() await button(w, '确认操作').trigger('click'); await flushPromises() expect(api.commandCompanyCost).toHaveBeenCalledWith('VC-ONE', 'void', expect.any(String), { expectedVersion: '2', reason: null }) expect(w.text()).toContain('已作废') }) it('cannot void or pay again when the server says paid, and booking is an independent action', async () => { vi.mocked(api.getCompanyCost).mockResolvedValueOnce({ ...detail, status: 'PAID', paidAmount: 70, outstandingAmount: 0 }) const paid = mount(); await flushPromises(); expect(button(paid, '作废费用')).toBeUndefined(); expect(button(paid, '登记付款')).toBeUndefined() vi.mocked(api.getCompanyCost).mockResolvedValueOnce({ ...detail, status: 'PENDING', recordVersion: '0', outstandingAmount: null }) const pending = mount(); await flushPromises(); await button(pending, '确认入账').trigger('click'); await button(pending, '确认操作').trigger('click'); await flushPromises() expect(api.commandCompanyCost).toHaveBeenCalledWith('VC-ONE', 'approve', expect.any(String), { expectedVersion: '0', reason: null }) expect(api.payCompanyCost).not.toHaveBeenCalled() }) it('does not carry another cost payment draft into the next record', async () => { const w = mount(); await flushPromises(); await button(w, '登记付款').trigger('click'); await flushPromises() w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20) await w.get('input[placeholder="填写银行流水号或支付凭据号"]').setValue('previous-cost-reference') await files(w) vi.mocked(api.getCompanyCost).mockResolvedValueOnce({ ...detail, costNo: 'VC-TWO' }) await w.setProps({ costNo: 'VC-TWO' }); await flushPromises() await button(w, '登记付款').trigger('click'); await flushPromises() expect(w.findComponent({ name: 'ElInputNumber' }).props('modelValue')).toBeNull() expect((w.get('input[placeholder="填写银行流水号或支付凭据号"]').element as HTMLInputElement).value).toBe('') expect(w.text()).not.toContain('已上传:payment.png') }) it.each(['A0422', 'FIN_409'])('unlocks a definite HTTP200 %s rejection while retaining entered payment and vouchers', async code => { vi.mocked(api.payCompanyCost).mockRejectedValueOnce(new ApiResponseError('明确拒绝', code, 200)) const w = mount(); await flushPromises(); await button(w, '登记付款').trigger('click'); await flushPromises() w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20); await files(w) await button(w, '确认操作').trigger('click'); await flushPromises() const first = vi.mocked(api.payCompanyCost).mock.calls[0]! expect(w.text()).not.toContain('上次操作结果尚未确认'); expect(w.text()).toContain('已上传:payment.png') w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 10) await button(w, '确认操作').trigger('click'); await flushPromises() const second = vi.mocked(api.payCompanyCost).mock.calls[1]! expect(second[1]).not.toBe(first[1]); expect(second[2].amount).toBe(10) expect(api.uploadCompanyCostVoucher).toHaveBeenCalledTimes(1) }) it('ignores an old detail response after another cost is opened', async () => { let finish!: (value: api.CompanyCost) => void vi.mocked(api.getCompanyCost).mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) const w = mount() vi.mocked(api.getCompanyCost).mockResolvedValueOnce({ ...detail, costNo: 'VC-TWO' }) await w.setProps({ costNo: 'VC-TWO' }); await flushPromises() finish(detail); await flushPromises() expect(w.text()).toContain('VC-TWO'); expect(w.text()).not.toContain('VC-ONE') })