import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import ElementPlus 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 { ApiResponseError } from '@/shared/api/http' import { createPlatformExportTask } from '@/modules/downloads' import { listPlatformTenants } from '@/modules/iam' import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog' import { createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance' import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel } from '../types' import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue' vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() })) vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() })) vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() })) vi.mock('../api/finance', () => ({ actionReconciliationStatement: vi.fn(), createReconciliationStatement: vi.fn(), getReconciliationStatement: vi.fn(), listFinanceFeeSources: vi.fn(), listFinanceStatements: vi.fn(), })) class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} } function memoryStorage(): 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) } } const statement: FinanceStatementReadModel = { id: '501', customerId: '10001', billNo: 'REC-501', customerName: '客户A', billDate: '2026-08-31', dueDate: '', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, lineCount: 1, sourceCount: 1, sourceAmount: 320, adjustmentAmount: -20, settled: false, lastReceiptAt: '2026-08-20 10:00:00', submittedAt: '2026-08-02 09:00:00', reviewedAt: '2026-08-03 09:00:00', issuedAt: '2026-08-04 09:00:00', remark: '八月账单', totalAmount: 300, receivedAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 3, customerConfirmationStatus: 'CONFIRMED', customerConfirmedBy: '17', customerConfirmedByName: '客户确认人', customerConfirmedAt: '2026-08-05 10:00:00', createdBy: '7', createdByName: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '8', updatedByName: '更新人B', updatedAt: '2026-08-20 11:00:00', } const detail: FinanceReconciliationDetail = { ...statement, paidAmount: 100, periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, settled: false, remark: '八月账单', createdBy: '7', createdByName: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '8', updatedByName: '更新人B', updatedAt: '2026-08-20 11:00:00', lines: [{ statementLineId: '601', sourceFeeId: '81', sourceFeeVersion: 2, sourceHash: 'hash-81', direction: 'RECEIVABLE', sourceType: 'CONTAINER_FEE', sourceNo: 'CONT-31', orderId: '11', inboundId: '21', inboundCode: 'IN-21', containerId: '31', containerNo: 'CONT-31', businessDate: '2026-08-15', quantity: 10, unitPrice: 30, feeItemName: '运费', sourceAmount: 320, amount: 300, adjustmentReason: '优惠调整' }], paymentHistory: [{ transactionId: '701', transactionType: 'RECEIPT', transactionDate: '2026-08-20', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', voucherFileIds: ['9001'], createdBy: '9', createdByName: '财务员', occurredAt: '2026-08-20 10:00:00' }], revisions: [{ revisionNo: 1, action: 'ADJUST', beforeAmount: 320, afterAmount: 300, reason: '优惠调整', operatorId: '10', operatorName: '调整人', occurredAt: '2026-08-20 09:00:00' }], } const eligibleFeeSource: FinanceFeeSourceSummary = { poolItemId: '42', feeSourceId: '42', sourceSystem: 'FIRSTLEG', sourceType: 'INBOUND_FEE', sourceBusinessId: 'IN-21', sourceId: '81', sourceVersion: 2, customerId: '10001', customerName: '客户A', direction: 'RECEIVABLE', supplierId: null, supplierName: '', orderId: '11', inboundId: '21', inboundCode: 'IN-21', containerId: null, containerNo: '', businessDate: '2026-08-15', feeItemName: '运费', quantity: 10, unitPrice: 30, amount: 320, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'AVAILABLE', statementGenerated: false, status: 'AVAILABLE', createdAt: '2026-08-15', } async function mountPage(permissions = ['platform:finance:customer-statement:list'], initialPath = '/finance/customer-statements') { const pinia = createPinia(); setActivePinia(pinia) const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions useAccessPreviewStore().initializeAfterLogin() 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 }, { path: '/downloads', name: 'download-center', component: { template: '
下载中心
' } }, ] }) await router.push(initialPath); await router.isReady() const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } }) await flushPromises() return { wrapper, router } } enableAutoUnmount(afterEach) afterAll(() => vi.unstubAllGlobals()) describe('FinanceCustomerStatementsView', () => { beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('localStorage', memoryStorage()) vi.stubGlobal('sessionStorage', memoryStorage()) vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:statement-export'), revokeObjectURL: vi.fn() }) document.body.innerHTML = '' vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: '10001', tenantName: '客户A', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 }) vi.mocked(listFinanceStatements).mockResolvedValue([statement]) vi.mocked(getReconciliationStatement).mockResolvedValue(detail) vi.mocked(listFinanceFeeSources).mockReset().mockResolvedValue({ records: [eligibleFeeSource], total: 1, pages: 1 }) vi.mocked(createReconciliationStatement).mockReset().mockResolvedValue({ ...detail, id: '502', billNo: 'REC-502', status: 'DRAFT' }) vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ taskName: '客户对账单', taskNo: 'EXP-STMT-1' } as never) vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false) }) it('opens statement details on a dedicated route instead of below the list', async () => { const { wrapper, router } = await mountPage() await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise }).openDetail(statement) await flushPromises() expect(router.currentRoute.value.name).toBe('finance-customer-statement-detail') expect(router.currentRoute.value.params.statementId).toBe('501') expect(document.body.textContent).toContain('账单明细表') expect(document.body.textContent).toContain('账单支付历史表') expect(document.body.textContent).toContain('REC-501') expect(document.body.textContent).toContain('CONT-31') expect(document.body.textContent).toContain('IN-21') expect(document.body.textContent).not.toContain('来源版本') expect(document.body.textContent).not.toContain('hash-81') expect(document.body.textContent).toContain('优惠调整') expect(document.body.textContent).toContain('创建人A') expect(document.body.textContent).toContain('调整人') expect(document.body.textContent).toContain('2026-08-20 11:00') expect(document.querySelector('[aria-label="客户对账单详情"]')).toBeTruthy() expect(document.querySelector('[aria-label="客户对账单列表"]')).toBeNull() expect(document.querySelector('[aria-label="所选对账单联动明细"]')).toBeNull() expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('200.00 CNY') expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(20) expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('客户确认人') expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('2026-08-05 10:00') expect(document.querySelector('.density-detail-heading')?.textContent).toContain('REC-501') expect(document.querySelector('[aria-label="客户对账单账单明细"]')).toBeTruthy() expect(document.querySelector('.detail-table-scroll [aria-label="客户对账单账单明细"]')).toBeTruthy() expect(document.querySelector('.el-drawer')).toBeNull() }) it('uses the shared four-group summary grid with long remarks spanning two groups', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8') const shared = readFileSync(resolve(process.cwd(), 'src/styles/adminDensity.css'), 'utf8') const moduleCss = readFileSync(resolve(process.cwd(), 'src/modules/finance/financeDensity.css'), 'utf8') expect(source).toContain('class="density-fields statement-summary-grid"') expect(shared).toContain('repeat(4, minmax(0, 1fr))') expect(moduleCss).toContain('.statement-summary-item--remark { grid-column: span 2; }') }) it('uses entity buttons for row actions and only folds the fourth action into more', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8') const actions = source.slice(source.indexOf('