import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import ElementPlus, { ElMessage } from 'element-plus' import { createPinia, setActivePinia } from 'pinia' import { flushPromises, mount } from '@vue/test-utils' import { createMemoryHistory, createRouter } from 'vue-router' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' import { createPlatformExportTask } from '@/modules/downloads' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog' import { listFinanceAccountLedger, listFinanceAccounts } from '../api/finance' import FinanceCustomerAccountsView from './FinanceCustomerAccountsView.vue' vi.mock('../api/finance', () => ({ listFinanceAccounts: vi.fn(), listFinanceAccountLedger: vi.fn(), getFinanceCustomerTerms: vi.fn(), updateFinanceCustomerTerms: vi.fn(), })) vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() })) vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() })) vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: 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) } } async function mountAccountsView(permissions: string[], initialPath = '/finance/customer-accounts') { 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-accounts', name: 'finance-customer-accounts', component: FinanceCustomerAccountsView }, { path: '/finance/customer-accounts/:customerId', name: 'finance-customer-account-detail', component: FinanceCustomerAccountsView }, { path: '/downloads', name: 'download-center', component: { template: '
下载中心
' } }, ] }) await router.push(initialPath); await router.isReady() const wrapper = mount(FinanceCustomerAccountsView, { global: { plugins: [pinia, ElementPlus, router] } }) await flushPromises() return Object.assign(wrapper, { router }) } describe('FinanceCustomerAccountsView delivery', () => { const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerAccountsView.vue'), 'utf8') const routes = readFileSync(resolve(process.cwd(), 'src/modules/finance/routes.ts'), 'utf8') it('keeps the account detail as a full page with traceable ledger controls', () => { expect(page).toContain('v-else-if="!detailOpen"') expect(page).toContain('返回客户账户') expect(page).toContain('') expect(page).toContain('') expect(page).not.toContain('class="detail-toolbar"') expect(page).not.toContain(' { for (const marker of ['class="term-section"', 'class="term-editor"', '账期天数', '#footer', '保存账期']) expect(page).toContain(marker) expect(page).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))') expect(page).toContain('grid-template-columns: 120px minmax(0, 220px)') expect(page).toContain('justify-content: start') expect(page).toContain('class="surface-panel finance-account-detail__surface"') expect(page).toContain('min-height: 64px') expect(page).toContain('padding: 0 0 12px') expect(page).toContain('class="detail-table-scroll"') expect(page).toContain('class="ledger-identifier"') expect(page).toContain('class="ledger-document"') expect(page).toContain('text-overflow: ellipsis') expect(page).toContain('white-space: nowrap') }) it('keeps account detail and term editing readable across the 1920 workspace', () => { expect(page).toContain('width: min(100%, 1920px)') expect(page).toContain('@media (min-width: 1920px)') expect(page).toContain('.finance-account-detail__layout :deep(.detail-page-layout__content)') expect(page).toContain('padding: 0 14px 10px') expect(page).not.toContain('max-width: 520px') }) it('shows only derived CNY summaries and no wallet actions', () => { for (const marker of ['未收', '待出账', '本期已收', '应付', 'CNY']) expect(page).toContain(marker) for (const forbidden of ['手动充值', '钱包', '外币', '发票', '利润']) expect(page).not.toContain(forbidden) }) it('selects an active customer by name from master data', () => { expect(page).toContain("key: 'customerId', type: 'select'") expect(page).toContain("label: '客户名称'") expect(page).toContain('item.tenantName') expect(page).not.toContain('tenantCode') expect(page).toContain('listPlatformTenants') expect(page).not.toContain("key: 'keyword', type: 'input'") }) it('supports a refreshable customer detail route without inventing an API parameter', () => { expect(routes).toContain("path: 'finance/customer-accounts/:customerId'") expect(page).toContain("name: 'finance-customer-account-detail'") expect(page).toContain('restoreAccountDetail(customerId)') expect(page).toContain("listFinanceAccounts({ page: currentPage, size: 200 })") expect(page).toContain('customerId: String(filters.value.customerId ||') }) beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('localStorage', memoryStorage()) vi.stubGlobal('sessionStorage', memoryStorage()) vi.mocked(listFinanceAccounts).mockReset().mockResolvedValue({ records: [{ customerId: '10001', customerCode: 'C-001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 100, pendingBilling: 20, receivedInPeriod: 30, outstandingPayable: 40, status: 'ACTIVE', lastChangedAt: '2026-08-13 10:00:00' }], total: 1, pages: 1 }) vi.mocked(listFinanceAccountLedger).mockReset().mockResolvedValue({ records: [{ ledgerId: '1', occurredAt: '2026-08-13T10:00:00', businessType: 'REC', documentType: 'RECEIPT', documentId: '2', documentNo: 'REC-2', receivableChange: -10, payableChange: 0, outstandingReceivableAfter: 90, operatorName: 'operator-1000001', remark: '收款' }], total: 1, pages: 1 }) vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource], page: 1, size: 200, total: 1, pages: 1 }) vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ id: 'exp-1', taskNo: 'EXP-1', taskName: '客户账户', status: 'PENDING' } as never) vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false) }) it('restores account summary and ledger after opening the detail URL directly', async () => { const pinia = createPinia(); setActivePinia(pinia) const auth = useAuthStore(); auth.token = 'token'; auth.permissions = ['platform:finance:customer-account:list'] useAccessPreviewStore().initializeAfterLogin() const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/finance/customer-accounts', name: 'finance-customer-accounts', component: FinanceCustomerAccountsView }, { path: '/finance/customer-accounts/:customerId', name: 'finance-customer-account-detail', component: FinanceCustomerAccountsView }, ] }) await router.push('/finance/customer-accounts/10001'); await router.isReady() const wrapper = mount(FinanceCustomerAccountsView, { global: { plugins: [pinia, ElementPlus, router] } }) await flushPromises() expect(wrapper.text()).toContain('客户A') expect(wrapper.text()).toContain('C-001') expect(wrapper.text()).toContain('张三') expect(wrapper.text()).toContain('13800000000') expect(listFinanceAccounts).toHaveBeenCalledWith({ page: 1, size: 200 }) expect(listFinanceAccountLedger).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 })) wrapper.unmount() }) it('shows explicit master-data empty states without exposing the customer id', async () => { vi.mocked(listFinanceAccounts).mockResolvedValue({ records: [{ customerId: '10001', customerCode: '', customerName: '客户A', contactName: '', contactPhone: '', outstandingReceivable: 0, pendingBilling: 0, receivedInPeriod: 0, outstandingPayable: 0, status: 'ACTIVE', lastChangedAt: '' }], total: 1, pages: 1 }) const wrapper = await mountAccountsView(['platform:finance:customer-account:list']) expect(wrapper.text()).toContain('客户编号') expect(wrapper.text()).toContain('联系人') expect(wrapper.text()).toContain('联系电话') expect(wrapper.text().match(/未维护/g)?.length).toBeGreaterThanOrEqual(3) expect(wrapper.text()).not.toContain('客户10001') wrapper.unmount() }) it('formats known ledger codes, operator ids and timestamps for display', async () => { vi.mocked(listFinanceAccountLedger).mockResolvedValue({ records: [{ ledgerId: '2087867025978863617', occurredAt: '2026-08-13T10:00:00', businessType: 'SETTLEMENT_ISSUED', documentType: 'BILL', documentId: '2', documentNo: 'REC-00000000000000000001', receivableChange: 10, payableChange: 0, outstandingReceivableAfter: 90, operatorName: 'operator-1000001', remark: '结算单生效' }], total: 1, pages: 1 }) const pinia = createPinia(); setActivePinia(pinia) const auth = useAuthStore(); auth.token = 'token'; auth.permissions = ['platform:finance:customer-account:list'] useAccessPreviewStore().initializeAfterLogin() const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/finance/customer-accounts', name: 'finance-customer-accounts', component: FinanceCustomerAccountsView }, { path: '/finance/customer-accounts/:customerId', name: 'finance-customer-account-detail', component: FinanceCustomerAccountsView }, ] }) await router.push('/finance/customer-accounts/10001'); await router.isReady() const wrapper = mount(FinanceCustomerAccountsView, { global: { plugins: [pinia, ElementPlus, router] } }) await flushPromises() expect(wrapper.text()).toContain('结算单生效') expect(wrapper.text()).toContain('REC-00000000000000000001') expect(wrapper.text()).toContain('名称数据缺失') expect(wrapper.text()).not.toContain('operator-1000001') expect(wrapper.text()).not.toContain('1000001') expect(wrapper.text()).not.toContain('2026-08-13T10:00:00') wrapper.unmount() }) it('creates account and ledger export tasks from the complete active filters without page parameters', async () => { const permissions = ['platform:finance:customer-account:list', 'platform:finance:customer-account:export', 'platform:download:list'] const listWrapper = await mountAccountsView(permissions) const listVm = listWrapper.vm as unknown as { filters: Record; exportAccounts(): Promise } Object.assign(listVm.filters, { customerId: '10001', status: 'ACTIVE' }) await listVm.exportAccounts() expect(createPlatformExportTask).toHaveBeenLastCalledWith({ exportType: 'FINANCE_CUSTOMER_ACCOUNT_LIST', format: 'XLSX', taskName: '客户账户', parameters: { customerId: '10001', status: 'ACTIVE' }, }) expect(createPlatformExportTask).not.toHaveBeenCalledWith(expect.objectContaining({ parameters: expect.objectContaining({ page: expect.anything() }) })) listWrapper.unmount() vi.mocked(createPlatformExportTask).mockResolvedValueOnce({ id: 'exp-2', taskNo: 'EXP-2', taskName: '客户账户流水', status: 'PENDING' } as never) const detailWrapper = await mountAccountsView(permissions, '/finance/customer-accounts/10001') const detailVm = detailWrapper.vm as unknown as { ledgerFilters: Record; exportLedger(): Promise } Object.assign(detailVm.ledgerFilters, { businessType: 'RECEIPT', occurredRange: ['2026-08-01', '2026-08-31'] }) await detailVm.exportLedger() expect(createPlatformExportTask).toHaveBeenLastCalledWith({ exportType: 'FINANCE_CUSTOMER_ACCOUNT_LEDGER', format: 'XLSX', taskName: '客户账户流水', parameters: { customerId: '10001', businessType: 'RECEIPT', startDate: '2026-08-01', endDate: '2026-08-31' }, }) expect(showExportTaskCreatedDialog).toHaveBeenLastCalledWith({ taskName: '客户账户流水', taskNo: 'EXP-2' }) detailWrapper.unmount() }) it('requires both the finance export permission and download-center access', async () => { const wrapper = await mountAccountsView(['platform:finance:customer-account:list', 'platform:finance:customer-account:export']) expect(wrapper.findAll('button').map((button) => button.text())).not.toContain('导出Excel') expect(createPlatformExportTask).not.toHaveBeenCalled() wrapper.unmount() }) it('opens the download center after confirmation and reports task creation failures', async () => { const permissions = ['platform:finance:customer-account:list', 'platform:finance:customer-account:export', 'platform:download:list'] vi.mocked(showExportTaskCreatedDialog).mockResolvedValueOnce(true) const wrapper = await mountAccountsView(permissions) await (wrapper.vm as unknown as { exportAccounts(): Promise }).exportAccounts() expect(wrapper.router.currentRoute.value.name).toBe('download-center') wrapper.unmount() const errorSpy = vi.spyOn(ElMessage, 'error').mockImplementation(() => undefined as never) vi.mocked(createPlatformExportTask).mockRejectedValueOnce(new Error('导出服务不可用')) const failedWrapper = await mountAccountsView(permissions) await (failedWrapper.vm as unknown as { exportAccounts(): Promise }).exportAccounts() expect(errorSpy).toHaveBeenCalledWith('客户账户导出任务创建失败') expect(showExportTaskCreatedDialog).toHaveBeenCalledTimes(1) failedWrapper.unmount() errorSpy.mockRestore() }) }) afterAll(() => vi.unstubAllGlobals())