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() 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 submitAdjustment(): Promise 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): Promise submitReceipt(): Promise 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): Promise selectReceiptFile(file: { raw: File }): void submitReceipt(): Promise 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): 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, action: 'VOID' | 'REOPEN'): Promise } await vm.performAction({ id: '701', version: 3 }, 'VOID') expect(actionFinanceSettlement).toHaveBeenCalledWith('701', { action: 'VOID', reason: '账务冲销', expectedVersion: 3 }) prompt.mockRestore() }) })