284 lines
20 KiB
TypeScript
284 lines
20 KiB
TypeScript
import { createRequire } from 'node:module'
|
|
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { ElMessageBox } from 'element-plus'
|
|
import { ApiResponseError } from '@/shared/api/http'
|
|
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
|
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
|
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
|
import * as api from '../api/finance'
|
|
import type { FinanceFeePoolItem, FinancePayableSettlement, FinanceReceivableSettlement, FinanceSettlementDetail, FinanceStatementReadModel } from '../types'
|
|
import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'
|
|
|
|
const { default: ElementPlus, ElDialog } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
|
|
const can = vi.hoisted(() => vi.fn((_permission: string) => true))
|
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
|
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
|
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn(), categoryLabels: { CERTIFICATE: '证书服务' } }))
|
|
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
|
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
|
|
vi.mock('../api/finance', () => ({
|
|
actionFinanceSettlement: vi.fn(), createFinanceReceipt: vi.fn(), createFinanceSettlementPayment: vi.fn(),
|
|
createPayableSettlement: vi.fn(), createReceivableSettlement: vi.fn(), getFinanceSettlement: vi.fn(),
|
|
listFinanceFeePool: vi.fn(), listFinancePayableSettlements: vi.fn(), listFinanceReceivableSettlements: vi.fn(),
|
|
listFinanceStatements: vi.fn(), uploadFinanceVoucher: vi.fn(),
|
|
}))
|
|
const customer: PlatformTenantResource = {
|
|
id: '9223372036854775001', tenantCode: 'C-TEST', tenantName: '测试客户', status: 'ACTIVE', legalName: null, legalRepresentative: null,
|
|
unifiedSocialCreditCode: null, contactName: null, contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, salesOwnerName: null,
|
|
paymentTermDays: null, enterpriseType: null, establishedDate: null, registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null,
|
|
internalRemark: null, createdAt: null, approvedAt: null,
|
|
}
|
|
const supplier: Supplier = {
|
|
id: '9223372036854775002', tenantId: '0', name: '测试供应商', category: 'CERTIFICATE', truckingScope: null, carrierCodes: [], region: '',
|
|
legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE',
|
|
settlementPeriod: 'PER_TRANSACTION', remark: null, createdBy: null, createdAt: '', updatedBy: null, updatedAt: '', deleted: false, version: 1,
|
|
}
|
|
const ar: FinanceReceivableSettlement = {
|
|
id: '9223372036854775010', customerId: String(customer.id), customerName: customer.tenantName, settlementNo: 'AR202610200001',
|
|
reconciliationStatementId: '9223372036854775020', reconciliationStatementNo: 'REC202610200001', billDate: '2026-10-20', dueDate: '2026-11-20',
|
|
receivableAmount: 300, receivedAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 3,
|
|
}
|
|
const ap: FinancePayableSettlement = {
|
|
id: '9223372036854775011', customerId: String(customer.id), customerName: customer.tenantName, settlementNo: 'AP202610200001',
|
|
supplierId: supplier.id, supplierName: supplier.name, costType: 'SINGLE_ORDER', orderId: null, inboundId: null, containerId: null, feeItemName: '操作费',
|
|
payableAmount: 300, paidAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', createdAt: '', version: 4,
|
|
}
|
|
const statement: FinanceStatementReadModel = {
|
|
id: ar.reconciliationStatementId!, customerId: String(customer.id), customerName: customer.tenantName, billNo: 'REC202610200001',
|
|
billDate: '2026-10-20', dueDate: '', totalAmount: 300, receivedAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 6,
|
|
}
|
|
const fee: FinanceFeePoolItem = {
|
|
poolItemId: '9223372036854775030', feeSourceId: '9223372036854775031', customerId: String(customer.id), customerName: customer.tenantName,
|
|
direction: 'PAYABLE', sourceType: 'MANUAL', sourceId: '9223372036854775032', sourceVersion: 1, orderId: null, inboundId: null,
|
|
containerId: null, feeItemName: '操作费', amount: 100, currency: 'CNY', status: 'AVAILABLE', createdAt: '',
|
|
}
|
|
function settlement(kind: 'receivable' | 'payable'): FinanceSettlementDetail {
|
|
return { id: kind === 'receivable' ? ar.id : ap.id, settlementNo: kind === 'receivable' ? ar.settlementNo : ap.settlementNo,
|
|
customerId: String(customer.id), customerName: customer.tenantName, supplierId: supplier.id, supplierName: supplier.name,
|
|
reconciliationStatementId: statement.id, reconciliationStatementNo: statement.billNo, direction: kind === 'receivable' ? 'RECEIVABLE' : 'PAYABLE',
|
|
amount: 300, paidAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 8, lines: [], transactions: [], revisions: [] }
|
|
}
|
|
const failure = () => new ApiResponseError('测试操作失败', 'TEST_REJECTED', 409, null, 'settlement-test-request')
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void
|
|
let reject!: (reason: unknown) => void
|
|
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no })
|
|
return { promise, resolve, reject }
|
|
}
|
|
async function mountPage(kind: 'receivable' | 'payable', detail = false) {
|
|
vi.mocked(api.getFinanceSettlement).mockResolvedValue(settlement(kind))
|
|
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] })
|
|
await router.push('/'); await router.isReady()
|
|
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind, ...(detail ? { settlementId: kind === 'receivable' ? ar.id : ap.id } : {}) }, attachTo: document.body, global: { plugins: [ElementPlus, router] } })
|
|
await flushPromises()
|
|
return wrapper
|
|
}
|
|
function activeDialog(wrapper: VueWrapper) {
|
|
const dialog = wrapper.findAllComponents(ElDialog).find(item => item.props('modelValue'))
|
|
expect(dialog).toBeDefined()
|
|
return dialog!
|
|
}
|
|
async function click(wrapper: VueWrapper, text: string, selector = 'button') {
|
|
const button = wrapper.findAll(selector).find(item => item.text().trim() === text)
|
|
expect(button, text).toBeDefined()
|
|
await button!.trigger('click')
|
|
await flushPromises()
|
|
}
|
|
async function openCreate(kind: 'receivable' | 'payable') {
|
|
const wrapper = await mountPage(kind)
|
|
await click(wrapper, kind === 'receivable' ? '生成应收结算单' : '生成应付结算单')
|
|
return { wrapper, dialog: activeDialog(wrapper) }
|
|
}
|
|
async function openCashier(kind: 'receivable' | 'payable', detail = false) {
|
|
const wrapper = await mountPage(kind, detail)
|
|
const label = kind === 'receivable' ? '收款' : '付款'
|
|
await click(wrapper, detail ? '部分' + label : label, detail ? 'button' : '.el-table__body-wrapper button')
|
|
return { wrapper, dialog: activeDialog(wrapper) }
|
|
}
|
|
async function chooseFile(dialog: VueWrapper, name = '测试凭证.png', type = 'image/png') {
|
|
const file = Object.assign(new File(['test image'], name, { type }), { uid: 100 })
|
|
const input = dialog.get('input[type="file"]')
|
|
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
|
|
await input.trigger('change')
|
|
await flushPromises()
|
|
return file
|
|
}
|
|
function errorDetails(wrapper: VueWrapper) {
|
|
return wrapper.findAllComponents(OperationErrorToast).find(toast => toast.props('details'))?.props('details')
|
|
}
|
|
enableAutoUnmount(afterEach)
|
|
beforeEach(() => {
|
|
vi.resetAllMocks()
|
|
can.mockReturnValue(true)
|
|
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
|
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 })
|
|
vi.mocked(listSuppliers).mockResolvedValue({ records: [supplier], page: 1, size: 200, total: 1, pages: 1 })
|
|
vi.mocked(api.listFinanceReceivableSettlements).mockResolvedValue([ar])
|
|
vi.mocked(api.listFinancePayableSettlements).mockResolvedValue([ap])
|
|
vi.mocked(api.listFinanceStatements).mockResolvedValue([statement, { ...statement, id: 'draft-statement', status: 'DRAFT' }])
|
|
vi.mocked(api.listFinanceFeePool).mockResolvedValue([fee, { ...fee, feeSourceId: 'direct-paid', settlementMode: 'DIRECT_PAID' }, { ...fee, feeSourceId: 'locked-fee', status: 'LOCKED' }])
|
|
vi.mocked(api.createReceivableSettlement).mockResolvedValue(settlement('receivable'))
|
|
vi.mocked(api.createPayableSettlement).mockResolvedValue(settlement('payable'))
|
|
vi.mocked(api.createFinanceSettlementPayment).mockResolvedValue(settlement('payable'))
|
|
vi.mocked(api.actionFinanceSettlement).mockResolvedValue(settlement('receivable'))
|
|
vi.mocked(api.uploadFinanceVoucher).mockResolvedValue({ fileId: '9223372036854775040', customerId: String(customer.id), fileName: '测试凭证.png', contentType: 'image/png', sizeBytes: 10 })
|
|
vi.mocked(api.createFinanceReceipt).mockResolvedValue({ id: '9223372036854775041', customerId: String(customer.id), customerName: customer.tenantName, receivedDate: '2026-10-20', amount: 50, currency: 'CNY', paymentMethod: 'CASH', voucherFileIds: ['9223372036854775040'], allocationCount: 1 })
|
|
})
|
|
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
|
|
|
|
describe('formal settlement native dialogs', () => {
|
|
it('creates receivables from issued statements with original long IDs and CNY', async () => {
|
|
const { dialog } = await openCreate('receivable')
|
|
expect(dialog.props('width')).toContain('600px')
|
|
const selects = dialog.findAllComponents({ name: 'ElSelect' })
|
|
selects[0]!.vm.$emit('update:modelValue', String(customer.id))
|
|
await flushPromises()
|
|
expect(api.listFinanceStatements).toHaveBeenCalledWith(String(customer.id))
|
|
expect(selects[1]!.findAllComponents({ name: 'ElOption' }).map(item => item.props('value'))).toEqual([statement.id])
|
|
selects[1]!.vm.$emit('update:modelValue', statement.id)
|
|
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
|
await click(dialog, '确定')
|
|
expect(api.createReceivableSettlement).toHaveBeenCalledWith({ reconciliationStatementId: statement.id, currency: 'CNY' })
|
|
})
|
|
it('keeps active suppliers and excludes directly paid or locked payable sources', async () => {
|
|
const { dialog } = await openCreate('payable')
|
|
const selects = dialog.findAllComponents({ name: 'ElSelect' })
|
|
selects[0]!.vm.$emit('update:modelValue', String(customer.id))
|
|
selects[1]!.vm.$emit('update:modelValue', supplier.id)
|
|
await flushPromises()
|
|
expect(api.listFinanceFeePool).toHaveBeenLastCalledWith(String(customer.id), 'PAYABLE', supplier.id)
|
|
expect(selects[2]!.findAllComponents({ name: 'ElOption' }).map(item => item.props('value'))).toEqual([fee.feeSourceId])
|
|
selects[2]!.vm.$emit('update:modelValue', [fee.feeSourceId])
|
|
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2099-12-31')
|
|
await click(dialog, '确定')
|
|
expect(api.createPayableSettlement).toHaveBeenCalledWith({ customerId: String(customer.id), supplierId: supplier.id, feeSourceIds: [fee.feeSourceId], dueDate: '2099-12-31', currency: 'CNY' })
|
|
})
|
|
it.each(['receivable', 'payable'] as const)('keeps %s creation validation before mutation', async kind => {
|
|
const { dialog } = await openCreate(kind)
|
|
await click(dialog, '确定')
|
|
expect(api.createReceivableSettlement).not.toHaveBeenCalled()
|
|
expect(api.createPayableSettlement).not.toHaveBeenCalled()
|
|
})
|
|
it('shows dependency load errors without an unhandled customer-selection request', async () => {
|
|
vi.mocked(api.listFinanceStatements).mockRejectedValueOnce(failure())
|
|
const { wrapper, dialog } = await openCreate('receivable')
|
|
dialog.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', String(customer.id))
|
|
await flushPromises()
|
|
expect(errorDetails(wrapper)).toMatchObject({ message: '测试操作失败', requestId: 'settlement-test-request' })
|
|
expect(dialog.props('modelValue')).toBe(true)
|
|
})
|
|
it('locks creation controls and retains a rejected selection', async () => {
|
|
const pending = deferred<FinanceSettlementDetail>()
|
|
vi.mocked(api.createReceivableSettlement).mockReturnValue(pending.promise)
|
|
const { wrapper, dialog } = await openCreate('receivable')
|
|
const selects = dialog.findAllComponents({ name: 'ElSelect' })
|
|
selects[0]!.vm.$emit('update:modelValue', String(customer.id))
|
|
await flushPromises()
|
|
selects[1]!.vm.$emit('update:modelValue', statement.id)
|
|
await click(dialog, '确定')
|
|
expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
|
expect(dialog.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true)
|
|
pending.reject(failure())
|
|
await flushPromises()
|
|
expect(errorDetails(wrapper)).toMatchObject({ requestId: 'settlement-test-request' })
|
|
expect(selects[1]!.props('modelValue')).toBe(statement.id)
|
|
})
|
|
it.each([false, true])('keeps payable balances plain and posts the expected version from detail=%s', async detail => {
|
|
const { dialog } = await openCashier('payable', detail)
|
|
expect(dialog.props('title')).toBe('登记付款 · ' + ap.settlementNo)
|
|
expect(dialog.props('width')).toContain('760px')
|
|
expect(dialog.get('[data-tone="summary"]').text()).toContain('¥100.00')
|
|
expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false)
|
|
const amount = dialog.getComponent({ name: 'ElInputNumber' })
|
|
expect(amount.props('max')).toBe(200)
|
|
amount.vm.$emit('update:modelValue', 50.25)
|
|
dialog.getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'CASH')
|
|
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-20')
|
|
await dialog.get('input[placeholder="请输入付款流水号"]').setValue('BANK-TEST')
|
|
await dialog.get('textarea').setValue('付款备注')
|
|
const file = await chooseFile(dialog)
|
|
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
|
expect(dialog.text()).toContain('¥149.75')
|
|
await click(dialog, '确定')
|
|
expect(api.uploadFinanceVoucher).toHaveBeenCalledWith(String(customer.id), file)
|
|
expect(api.createFinanceSettlementPayment).toHaveBeenCalledWith(ap.id, { transactionDate: '2026-10-20', amount: 50.25, method: 'CASH', referenceNo: 'BANK-TEST', remark: '付款备注', currency: 'CNY', voucherFileIds: ['9223372036854775040'], expectedVersion: detail ? 8 : 4 })
|
|
})
|
|
it.each([false, true])('allocates a real receipt to its loaded statement from detail=%s', async detail => {
|
|
const { dialog } = await openCashier('receivable', detail)
|
|
expect(dialog.props('title')).toBe('登记收款 · ' + ar.settlementNo)
|
|
expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false)
|
|
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 50)
|
|
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-20')
|
|
dialog.getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'CASH')
|
|
await dialog.get('textarea').setValue('收款备注')
|
|
const file = await chooseFile(dialog)
|
|
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
|
expect(dialog.text()).toContain('¥150.00')
|
|
await click(dialog, '确定')
|
|
expect(api.uploadFinanceVoucher).toHaveBeenCalledWith(String(customer.id), file)
|
|
expect(api.createFinanceReceipt).toHaveBeenCalledWith({ customerId: String(customer.id), receivedDate: '2026-10-20', amount: 50, paymentMethod: 'CASH', remark: '收款备注', allocations: [{ billId: statement.id, amount: 50 }] }, ['9223372036854775040'])
|
|
})
|
|
it.each(['receivable', 'payable'] as const)('requires a selected voucher for %s before uploading', async kind => {
|
|
const { dialog } = await openCashier(kind)
|
|
await click(dialog, '确定')
|
|
expect(api.uploadFinanceVoucher).not.toHaveBeenCalled()
|
|
expect(dialog.props('modelValue')).toBe(true)
|
|
})
|
|
it('retains non-image receipt rejection and clears the invalid file view', async () => {
|
|
const { dialog } = await openCashier('receivable')
|
|
await chooseFile(dialog, 'invalid.pdf', 'application/pdf')
|
|
await click(dialog, '确定')
|
|
expect(api.uploadFinanceVoucher).not.toHaveBeenCalled()
|
|
expect(dialog.text()).not.toContain('invalid.pdf')
|
|
})
|
|
it.each(['receivable', 'payable'] as const)('locks %s upload and form controls then retains rejected input', async kind => {
|
|
const pending = deferred<Awaited<ReturnType<typeof api.uploadFinanceVoucher>>>()
|
|
vi.mocked(api.uploadFinanceVoucher).mockReturnValue(pending.promise)
|
|
const { wrapper, dialog } = await openCashier(kind)
|
|
const filename = '很长的凭证名称'.repeat(12) + '.png'
|
|
await chooseFile(dialog, filename)
|
|
await dialog.get('textarea').setValue('保留备注')
|
|
await click(dialog, '确定')
|
|
expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
|
expect(dialog.getComponent({ name: 'ElUpload' }).props('disabled')).toBe(true)
|
|
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
|
|
pending.reject(failure())
|
|
await flushPromises()
|
|
expect(errorDetails(wrapper)).toMatchObject({ requestId: 'settlement-test-request' })
|
|
expect(dialog.text()).toContain(filename)
|
|
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留备注')
|
|
expect(dialog.props('modelValue')).toBe(true)
|
|
})
|
|
it.each(['receivable', 'payable'] as const)('clears the previous %s voucher filename when reopened', async kind => {
|
|
const { wrapper, dialog } = await openCashier(kind)
|
|
await chooseFile(dialog, 'old-voucher.png')
|
|
expect(dialog.text()).toContain('old-voucher.png')
|
|
await click(dialog, '取消')
|
|
await click(wrapper, kind === 'receivable' ? '收款' : '付款', '.el-table__body-wrapper button')
|
|
expect(activeDialog(wrapper).text()).not.toContain('old-voucher.png')
|
|
await click(activeDialog(wrapper), '确定')
|
|
expect(api.uploadFinanceVoucher).not.toHaveBeenCalled()
|
|
})
|
|
it.each(['receivable', 'payable'] as const)('keeps %s cashier actions behind their independent permission', async kind => {
|
|
can.mockImplementation(permission => permission.endsWith(':list'))
|
|
const wrapper = await mountPage(kind)
|
|
const labels = wrapper.get('.el-table__body-wrapper').findAll('button').map(button => button.text().trim())
|
|
expect(labels).not.toContain(kind === 'receivable' ? '收款' : '付款')
|
|
})
|
|
it.each([
|
|
['receivable', 'VOID', 'ISSUED'], ['receivable', 'REOPEN', 'VOIDED'],
|
|
['payable', 'VOID', 'ISSUED'], ['payable', 'REOPEN', 'VOIDED'],
|
|
] as const)('identifies %s %s prompts with its settlement number and original version', async (kind, action, status) => {
|
|
if (kind === 'receivable') vi.mocked(api.listFinanceReceivableSettlements).mockResolvedValue([{ ...ar, status }])
|
|
else vi.mocked(api.listFinancePayableSettlements).mockResolvedValue([{ ...ap, status }])
|
|
const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue(Object.assign('confirm', { value: ' 测试原因 ', action: 'confirm' as const }))
|
|
const wrapper = await mountPage(kind)
|
|
await click(wrapper, action === 'VOID' ? '作废' : '重开', '.el-table__body-wrapper button')
|
|
const current = kind === 'receivable' ? ar : ap
|
|
expect(prompt).toHaveBeenCalledWith('请输入操作原因', expect.stringContaining(current.settlementNo), expect.objectContaining({ customClass: 'admin-density-confirm', inputPattern: /\S+/ }))
|
|
expect(api.actionFinanceSettlement).toHaveBeenCalledWith(current.id, { action, reason: '测试原因', expectedVersion: current.version })
|
|
})
|
|
})
|