224 lines
12 KiB
TypeScript
224 lines
12 KiB
TypeScript
import ElementPlus, { ElMessage, ElMessageBox } from 'element-plus'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
|
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
|
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
import { useAuthStore } from '@/core/auth/auth'
|
|
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
|
import { ApiResponseError } from '@/shared/api/http'
|
|
import {
|
|
actionFinanceSettlement,
|
|
actionReconciliationStatement,
|
|
createFinanceReceipt,
|
|
getFinanceSettlement,
|
|
getReconciliationStatement,
|
|
listFinancePayableSettlements,
|
|
listFinanceReceivableSettlements,
|
|
uploadFinanceVoucher,
|
|
} from '../api/finance'
|
|
import type { FinanceReconciliationDetail, FinanceSettlementDetail, FinanceStatementReadModel } from '../types'
|
|
import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue'
|
|
import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'
|
|
|
|
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
|
vi.mock('../api/finance', () => ({
|
|
actionFinanceSettlement: vi.fn(), actionReconciliationStatement: vi.fn(), createFinanceReceipt: vi.fn(), createFinanceSettlementPayment: vi.fn(),
|
|
createPayableSettlement: vi.fn(), createReceivableSettlement: vi.fn(), createReconciliationStatement: vi.fn(),
|
|
getFinanceSettlement: vi.fn(), getReconciliationStatement: vi.fn(), listFinanceFeePool: vi.fn(),
|
|
listFinancePayableSettlements: vi.fn(), listFinanceReceivableSettlements: vi.fn(), listFinanceStatements: vi.fn(),
|
|
uploadFinanceVoucher: vi.fn(),
|
|
}))
|
|
|
|
class ResizeObserverStub implements ResizeObserver {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
}
|
|
|
|
function createMemoryStorage(): Storage {
|
|
const values = new Map<string, string>()
|
|
return {
|
|
get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null,
|
|
key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value),
|
|
}
|
|
}
|
|
|
|
function piniaWithPermissions(permissions: string[]) {
|
|
const pinia = createPinia()
|
|
setActivePinia(pinia)
|
|
const auth = useAuthStore()
|
|
auth.token = 'token'
|
|
auth.permissions = permissions
|
|
useAccessPreviewStore().initializeAfterLogin()
|
|
return pinia
|
|
}
|
|
|
|
const statement: FinanceStatementReadModel = {
|
|
id: '501', customerId: '10001', billNo: 'REC-501', customerName: '客户A', billDate: '2026-08-31',
|
|
dueDate: '2026-09-30', totalAmount: 300, receivedAmount: 0, outstandingAmount: 300,
|
|
currency: 'CNY', status: 'ISSUED', version: 3,
|
|
}
|
|
|
|
const reconciliation: FinanceReconciliationDetail = {
|
|
...statement, paidAmount: 0, periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, settled: false,
|
|
remark: '', lines: [
|
|
{ statementLineId: '601', sourceFeeId: '41', sourceFeeVersion: 1, orderId: null, inboundId: '91', feeItemName: '运费', amount: 100 },
|
|
{ statementLineId: '602', sourceFeeId: '42', sourceFeeVersion: 1, orderId: null, inboundId: '92', feeItemName: '操作费', amount: 200 },
|
|
], paymentHistory: [], revisions: [],
|
|
}
|
|
|
|
const receivable: FinanceSettlementDetail = {
|
|
id: '701', settlementNo: 'AR-701', customerId: '10001', reconciliationStatementId: '501', direction: 'RECEIVABLE',
|
|
amount: 300, paidAmount: 0, outstandingAmount: 300, currency: 'CNY', status: 'ISSUED', version: 0,
|
|
lines: [], transactions: [], revisions: [],
|
|
}
|
|
|
|
enableAutoUnmount(afterEach)
|
|
afterAll(() => vi.unstubAllGlobals())
|
|
|
|
describe('finance G3/G4 page operations', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
|
vi.stubGlobal('localStorage', createMemoryStorage())
|
|
vi.stubGlobal('sessionStorage', createMemoryStorage())
|
|
document.body.innerHTML = ''
|
|
const customer = { id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource
|
|
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 })
|
|
vi.mocked(actionReconciliationStatement).mockReset().mockResolvedValue(reconciliation)
|
|
vi.mocked(actionFinanceSettlement).mockReset().mockResolvedValue({ ...receivable, status: 'VOIDED', version: 1 })
|
|
vi.mocked(getReconciliationStatement).mockReset().mockResolvedValue(reconciliation)
|
|
vi.mocked(getFinanceSettlement).mockReset().mockResolvedValue(receivable)
|
|
vi.mocked(listFinanceReceivableSettlements).mockReset().mockResolvedValue([])
|
|
vi.mocked(listFinancePayableSettlements).mockReset().mockResolvedValue([])
|
|
vi.mocked(uploadFinanceVoucher).mockReset().mockResolvedValue({ fileId: '9001', customerId: '10001', fileName: 'receipt.png', contentType: 'image/png', sizeBytes: 100 })
|
|
vi.mocked(createFinanceReceipt).mockReset().mockResolvedValue({ id: '801', customerId: '10001', customerName: '客户A', receivedDate: '2026-08-11', amount: 300, currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: ['9001'], allocationCount: 1 })
|
|
})
|
|
|
|
it('loads frozen G3 detail and submits multiple selected adjustment lines', async () => {
|
|
const pinia = piniaWithPermissions(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust'])
|
|
const router = createRouter({ history: createMemoryHistory(), routes: [
|
|
{ path: '/finance/customer-statements', name: 'finance-customer-statements', component: FinanceCustomerStatementsView },
|
|
{ path: '/finance/customer-statements/:statementId', name: 'finance-customer-statement-detail', component: FinanceCustomerStatementsView },
|
|
] })
|
|
await router.push('/finance/customer-statements'); await router.isReady()
|
|
const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } })
|
|
await flushPromises()
|
|
const vm = wrapper.vm as unknown as {
|
|
openAdjustment(row: FinanceStatementReadModel): Promise<void>
|
|
submitAdjustment(): Promise<void>
|
|
adjustReason: string
|
|
adjustLines: Array<{ selected: boolean; adjustedAmount: number; reason: string }>
|
|
}
|
|
|
|
await vm.openAdjustment(statement)
|
|
vm.adjustReason = '账期复核调整'
|
|
vm.adjustLines[0]!.selected = true; vm.adjustLines[0]!.adjustedAmount = 90; vm.adjustLines[0]!.reason = '运费更正'
|
|
vm.adjustLines[1]!.selected = true; vm.adjustLines[1]!.adjustedAmount = 180; vm.adjustLines[1]!.reason = '操作费更正'
|
|
await vm.submitAdjustment()
|
|
|
|
expect(getReconciliationStatement).toHaveBeenCalledWith('501')
|
|
expect(actionReconciliationStatement).toHaveBeenCalledWith('501', {
|
|
action: 'ADJUST', reason: '账期复核调整', expectedVersion: 3,
|
|
adjustmentLines: [
|
|
{ statementLineId: '601', adjustedAmount: 90, reason: '运费更正' },
|
|
{ statementLineId: '602', adjustedAmount: 180, reason: '操作费更正' },
|
|
],
|
|
})
|
|
}, 15_000)
|
|
|
|
it('loads AR linkage, uploads a voucher and allocates a real receipt to its statement', async () => {
|
|
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:receipt:create'])
|
|
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
|
await flushPromises()
|
|
const vm = wrapper.vm as unknown as {
|
|
openReceipt(row: Record<string, unknown>): Promise<void>
|
|
submitReceipt(): Promise<void>
|
|
receiptFile: File | null
|
|
receiptForm: { receivedDate: string; amount: number; paymentMethod: 'BANK_TRANSFER'; remark: string }
|
|
}
|
|
|
|
await vm.openReceipt({ id: '701' })
|
|
vm.receiptFile = new File(['receipt'], 'receipt.png', { type: 'image/png' })
|
|
vm.receiptForm.receivedDate = '2026-08-11'; vm.receiptForm.amount = 300; vm.receiptForm.remark = '全额到账'
|
|
await vm.submitReceipt()
|
|
|
|
expect(uploadFinanceVoucher).toHaveBeenCalledWith('10001', vm.receiptFile)
|
|
expect(createFinanceReceipt).toHaveBeenCalledWith({
|
|
customerId: '10001', receivedDate: '2026-08-11', amount: 300, paymentMethod: 'BANK_TRANSFER', remark: '全额到账',
|
|
allocations: [{ billId: '501', amount: 300 }],
|
|
}, ['9001'])
|
|
})
|
|
|
|
it('shows receipt errors as dismissible light messages and rejects non-image vouchers before upload', async () => {
|
|
const messageSpy = vi.spyOn(ElMessage, 'error')
|
|
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:receipt:create'])
|
|
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
|
await flushPromises()
|
|
const vm = wrapper.vm as unknown as {
|
|
openReceipt(row: Record<string, unknown>): Promise<void>
|
|
selectReceiptFile(file: { raw: File }): void
|
|
submitReceipt(): Promise<void>
|
|
receiptFile: File | null
|
|
receiptOpen: boolean
|
|
receiptValidationError: string | null
|
|
receiptError: { message: string } | null
|
|
}
|
|
|
|
vm.selectReceiptFile({ raw: new File(['not an image'], 'receipt.pdf', { type: 'application/pdf' }) })
|
|
expect(vm.receiptFile).toBeNull()
|
|
expect(vm.receiptValidationError).toBe('收款凭证仅支持图片文件')
|
|
|
|
await vm.openReceipt({ id: '701' })
|
|
vm.selectReceiptFile({ raw: new File(['receipt'], 'receipt.png', { type: 'image/png' }) })
|
|
vi.mocked(uploadFinanceVoucher).mockRejectedValueOnce(new ApiResponseError('FIN_400: 凭证校验失败', 'FIN_400', 400, undefined, 'request-1', 'trace-1'))
|
|
await vm.submitReceipt()
|
|
await flushPromises()
|
|
|
|
expect(vm.receiptOpen).toBe(true)
|
|
expect(vm.receiptError?.message).toContain('FIN_400')
|
|
expect(messageSpy).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('FIN_400'), showClose: true, duration: 4500 }))
|
|
expect(wrapper.text()).not.toContain('FIN_400')
|
|
expect(wrapper.find('[aria-label="结算单应收列表"]').exists()).toBe(true)
|
|
})
|
|
|
|
it('emits payable detail navigation and displays customer names without internal IDs', async () => {
|
|
const pinia = piniaWithPermissions(['platform:finance:settlement-ap:list'])
|
|
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'payable' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
|
await flushPromises()
|
|
const vm = wrapper.vm as unknown as { openDetail(row: Record<string, unknown>): void; customerName(customerId: string, snapshot?: string): string }
|
|
|
|
vi.mocked(getFinanceSettlement).mockClear()
|
|
vm.openDetail({ id: '801' })
|
|
|
|
expect(wrapper.emitted('openDetail')).toEqual([['801']])
|
|
expect(getFinanceSettlement).not.toHaveBeenCalled()
|
|
expect(vm.customerName('2088', '上海客户有限公司')).toBe('上海客户有限公司')
|
|
expect(vm.customerName('2088', '客户2088')).toBe('—')
|
|
expect(vm.customerName('2088')).toBe('—')
|
|
})
|
|
|
|
it('keeps settled settlements readable and exportable while hiding receipt actions', async () => {
|
|
vi.mocked(getFinanceSettlement).mockResolvedValue({ ...receivable, status: 'PAID', paidAmount: 300, outstandingAmount: 0 })
|
|
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:settlement-ar:export', 'platform:finance:receipt:create', 'platform:download:list'])
|
|
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable', settlementId: '701' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('导出')
|
|
expect(wrapper.text()).not.toContain('部分收款')
|
|
})
|
|
|
|
it('submits settlement void and reopen actions with the current version and reason', async () => {
|
|
const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue({ value: '账务冲销' } as never)
|
|
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:settlement:void'])
|
|
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
|
await flushPromises()
|
|
const vm = wrapper.vm as unknown as { performAction(row: Record<string, unknown>, action: 'VOID' | 'REOPEN'): Promise<void> }
|
|
|
|
await vm.performAction({ id: '701', version: 3 }, 'VOID')
|
|
|
|
expect(actionFinanceSettlement).toHaveBeenCalledWith('701', { action: 'VOID', reason: '账务冲销', expectedVersion: 3 })
|
|
prompt.mockRestore()
|
|
})
|
|
})
|