import ElementPlus from 'element-plus' import { createPinia, setActivePinia } from 'pinia' 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 } from '@/modules/iam' import { getFinanceFeeSource, getReconciliationStatement, listFinanceStatements } from '../api/finance' import type { FinanceReconciliationDetail, FinanceStatementReadModel } from '../types' import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue' vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() })) vi.mock('../api/finance', () => ({ actionReconciliationStatement: vi.fn(), createReconciliationStatement: vi.fn(), getFinanceFeeSource: vi.fn(), getReconciliationStatement: 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: '', totalAmount: 300, receivedAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 3, createdBy: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '更新人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: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '更新人B', updatedAt: '2026-08-20 11:00:00', lines: [{ statementLineId: '601', sourceFeeId: '81', sourceFeeVersion: 2, sourceHash: 'hash-81', orderId: '11', inboundId: '21', containerId: '31', feeItemName: '运费', sourceAmount: 320, amount: 300, adjustmentReason: '优惠调整' }], paymentHistory: [{ transactionId: '701', transactionType: 'RECEIPT', transactionDate: '2026-08-20', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', createdBy: '财务员', occurredAt: '2026-08-20 10:00:00' }], revisions: [], } async function mountPage(permissions = ['platform:finance:customer-statement:list']) { const pinia = createPinia(); setActivePinia(pinia) const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions useAccessPreviewStore().initializeAfterLogin() const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() return wrapper } 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(getFinanceFeeSource).mockResolvedValue({ poolItemId: '81', feeSourceId: '81', customerId: '10001', customerName: '客户A', supplierId: null, supplierName: '', direction: 'RECEIVABLE', sourceType: 'CONTAINER_FEE', sourceId: 'FEE-81', sourceFeeId: 'FEE-81', sourceVersion: 2, orderId: '11', inboundId: '21', inboundCode: 'IN-21', containerId: '31', containerNo: 'CONT-31', businessDate: '2026-08-15', feeItemName: '运费', quantity: 10, unitPrice: 30, amount: 300, currency: 'CNY', reviewStatus: 'APPROVED', settlementIncluded: true, sourceStatus: 'AVAILABLE', status: 'AVAILABLE', statementGenerated: true, operationVersion: 2, createdAt: '2026-08-15', }) }) it('renders the main table and linked detail/payment tables on the same page', async () => { const wrapper = await mountPage() await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise }).openDetail(statement) await flushPromises() 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('2026-08-20 11:00') expect(document.querySelector('.el-drawer')).toBeNull() }) it('resolves customer names from the active customer list and never exposes internal customer or operator ids', async () => { const internalCustomerId = '2088123456789' const internalOperatorId = '1000001' vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: internalCustomerId, tenantName: '虫虫跨境科技', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 }) const internalStatement = { ...statement, customerId: internalCustomerId, customerName: `客户${internalCustomerId}`, createdBy: internalOperatorId, createdAt: '2026-08-14T03:27:34Z', updatedBy: internalOperatorId, updatedAt: '2026-08-14T04:28:35Z', } const internalDetail = { ...detail, ...internalStatement, paymentHistory: [{ ...detail.paymentHistory[0]!, createdBy: internalOperatorId, occurredAt: '2026-08-14T04:28:35Z' }], } vi.mocked(listFinanceStatements).mockResolvedValue([internalStatement]) vi.mocked(getReconciliationStatement).mockResolvedValue(internalDetail) const wrapper = await mountPage(['platform:finance:customer-statement:list', 'platform:customer:list', 'platform:finance:reconciliation:adjust']) await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise }).openDetail(internalStatement) await flushPromises() const text = document.body.textContent || '' expect(text).toContain('虫虫跨境科技') expect(document.querySelector('[aria-label="客户对账单列表"]')?.textContent).toContain('虫虫跨境科技') expect(document.querySelector('[aria-label="所选对账单联动明细"]')?.textContent).toContain('虫虫跨境科技') expect(text).not.toContain(`客户${internalCustomerId}`) expect(text).not.toContain(internalOperatorId) expect(text).toContain('操作人信息缺失') expect(text).toContain('2026-08-14 03:27') expect(text).not.toContain('2026-08-14T03:27:34Z') await (wrapper.vm as unknown as { openAdjustment(row: FinanceStatementReadModel): Promise }).openAdjustment(internalStatement) await flushPromises() expect(document.body.textContent).toContain('调整客户对账单') expect(document.body.textContent).toContain('运费') }) it('filters hydrated statements by status, bill number, container, period, settlement and payment date', async () => { const wrapper = await mountPage() const vm = wrapper.vm as unknown as { load(model: Record): Promise; filters: Record; filteredRows: FinanceStatementReadModel[] } await vm.load({ status: 'ISSUED', billNo: '501', customerId: '', containerNo: 'CONT-31', periodStartRange: ['2026-08-01', '2026-08-02'], periodEndRange: ['2026-08-30', '2026-09-01'], settled: 'false', lastPaymentRange: ['2026-08-20', '2026-08-20'], }) await flushPromises() expect(vm.filteredRows).toHaveLength(1) vm.filters.containerNo = 'CONT-99' await flushPromises() expect(vm.filteredRows).toHaveLength(0) }) it('hydrates and exports the currently filtered real statement rows as CSV', async () => { const clicked = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) const wrapper = await mountPage() await (wrapper.vm as unknown as { exportStatements(): Promise }).exportStatements() expect(getReconciliationStatement).toHaveBeenCalledWith('501') expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob)) expect(clicked).toHaveBeenCalledOnce() }) it('disables statement generation when customer selection permission is missing', async () => { vi.mocked(listPlatformTenants).mockClear() await mountPage(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:create']) const button = [...document.querySelectorAll('button')].find((item) => item.textContent?.includes('生成对账单')) expect(button).toBeTruthy() expect(button?.disabled).toBe(true) expect(listPlatformTenants).not.toHaveBeenCalled() }) it('uses statement list permission for fee-source details without exposing technical trace fields', async () => { const wrapper = await mountPage(['platform:finance:customer-statement:list']) await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise }).openDetail(statement) await flushPromises() expect(getFinanceFeeSource).toHaveBeenCalledWith('81') expect(document.body.textContent).toContain('CONT-31') expect(document.body.textContent).not.toContain('hash-81') }) it('surfaces detail hydration failures instead of silently returning an empty filtered list', async () => { const wrapper = await mountPage() vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new Error('detail unavailable')) await (wrapper.vm as unknown as { load(model: Record): Promise }).load({ status: 'ISSUED' }) await flushPromises() expect(document.body.textContent).toContain('筛选字段加载失败') }) it('keeps the failed selected row visible and exposes a detail retry state', async () => { const wrapper = await mountPage() vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new Error('detail unavailable')) await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise }).openDetail(statement) await flushPromises() expect(document.body.textContent).toContain('已选择 1 项') expect(document.body.textContent).toContain('明细加载失败') expect(document.body.textContent).toContain('重试') }) })