Files
admin-web/src/modules/overseas-master-data/components/VehicleCareFinanceLedger.test.ts
T

139 lines
12 KiB
TypeScript

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<string>() }))
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<typeof import('../api/companyVehicleCosts')>()), 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: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)"/>' }
function mount() {
const w = shallowMount(VehicleCareFinanceLedger, { props: { costNo: 'VC-ONE' }, global: { stubs: {
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot/></button>' },
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, 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: '<div/>' },
LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, 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('releases failed payment and submits the current amount with a new key', 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()).not.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[0], expect.any(String), { ...first[2], amount: 60 }]); expect(vi.mocked(api.payCompanyCost).mock.calls[1]![1]).not.toBe(first[1])
expect(w.text()).toContain('部分付款'); expect(w.text()).toContain('未付 CNY 50.00')
expect(api.uploadCompanyCostVoucher).toHaveBeenCalledTimes(1); expect(w.emitted('changed')).toHaveLength(1)
})
it('preserves successful vouchers and lets the user reselect failed 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('凭证上传未完成')
expect(api.payCompanyCost).not.toHaveBeenCalled()
await files(w, ['second.png'])
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')
})