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 { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { createReconciliationStatement, getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance' import type { FinanceFeeSourceDetail } from '../types' import FinanceCustomerArapView from './FinanceCustomerArapView.vue' vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() })) vi.mock('../api/finance', () => ({ listFinanceFeePool: vi.fn(), getFinanceFeeSource: vi.fn(), reviewFinanceFeeSource: vi.fn(), updateFinanceFeeSourceSettlementInclusion: vi.fn(), createReconciliationStatement: 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), } } const detail: FinanceFeeSourceDetail = { poolItemId: '42', feeSourceId: '42', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceFeeId: '81', sourceVersion: 3, customerId: '10001', customerName: '客户A', supplierId: null, supplierName: '', orderId: null, inboundId: '91', inboundCode: 'IB-91', containerId: null, containerNo: '', businessDate: '2026-08-11', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'PENDING', settlementIncluded: false, sourceStatus: 'AVAILABLE', status: 'AVAILABLE', statementGenerated: false, operationVersion: 3, createdAt: '2026-08-11', operationLogs: [], } async function mountView(permissions: string[], initialPath = '/finance/customer-arap') { 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-arap', name: 'finance-customer-arap', component: FinanceCustomerArapView }, { path: '/finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: FinanceCustomerArapView }, ] }) await router.push(initialPath); await router.isReady() const wrapper = mount(FinanceCustomerArapView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } }) await flushPromises() return Object.assign(wrapper, { router }) } async function loadRows(wrapper: Awaited>) { const vm = wrapper.vm as unknown as { load(model: Record): Promise } await vm.load({ customerId: '10001', direction: '', status: '', keyword: '' }) await flushPromises() } enableAutoUnmount(afterEach) afterAll(() => vi.unstubAllGlobals()) describe('FinanceCustomerArapView G2 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(listFinanceFeePool).mockReset().mockResolvedValue([{ poolItemId: 'RECEIVABLE:42', feeSourceId: '42', customerId: '10001', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceVersion: 3, orderId: null, inboundId: '91', containerId: null, feeItemName: '运费', amount: 100, currency: 'CNY', status: 'AVAILABLE', createdAt: '2026-08-11' }]) vi.mocked(getFinanceFeeSource).mockReset().mockResolvedValue(detail) vi.mocked(reviewFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'APPROVED', operationVersion: 4 }) vi.mocked(updateFinanceFeeSourceSettlementInclusion).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'APPROVED', settlementIncluded: true, operationVersion: 4 }) vi.mocked(createReconciliationStatement).mockReset().mockResolvedValue({} as never) }) it('shows detail from the list permission but keeps G2 operations independent', async () => { const wrapper = await mountView(['platform:finance:customer-arap:list']) await loadRows(wrapper) const buttonLabels = wrapper.findAll('button').map((button) => button.text()) expect(buttonLabels).not.toContain('审核') expect(buttonLabels).not.toContain('结算') const vm = wrapper.vm as unknown as { openDetail(row: Record): Promise } await vm.openDetail({ id: 'RECEIVABLE:42', feeSourceId: '42' }) expect(getFinanceFeeSource).toHaveBeenCalledWith('42') expect(reviewFinanceFeeSource).not.toHaveBeenCalled() expect(updateFinanceFeeSourceSettlementInclusion).not.toHaveBeenCalled() }) it('loads all customers by default and keeps explicit customer filtering', async () => { const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:customer:list']) expect(listFinanceFeePool).toHaveBeenCalledWith(undefined, undefined) const vm = wrapper.vm as unknown as { load(model: Record): Promise } await vm.load({ customerId: '10001', direction: 'RECEIVABLE', status: '', keyword: '' }) expect(listFinanceFeePool).toHaveBeenLastCalledWith('10001', 'RECEIVABLE') expect(wrapper.text()).not.toContain('全客户查询暂不可用') }) it('maps each all-customer fee row with its backend customerId', async () => { const customerB = { id: '10002', tenantName: '客户B', status: 'ACTIVE' } as PlatformTenantResource vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource, customerB], page: 1, size: 200, total: 2, pages: 1 }) vi.mocked(listFinanceFeePool).mockResolvedValue([ { poolItemId: 'RECEIVABLE:42', feeSourceId: '42', customerId: '10001', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceVersion: 3, orderId: null, inboundId: '91', containerId: null, feeItemName: '运费', amount: 100, currency: 'CNY', status: 'AVAILABLE', createdAt: '2026-08-11' }, { poolItemId: 'PAYABLE:43', feeSourceId: null, customerId: '10002', direction: 'PAYABLE', sourceType: 'MANUAL_COST', sourceId: '43', sourceVersion: 1, orderId: '11', inboundId: null, containerId: null, feeItemName: '拖车费', amount: 200, currency: 'CNY', status: 'POSTED', createdAt: '2026-08-10' }, ]) const wrapper = await mountView(['platform:finance:customer-arap:list']) const vm = wrapper.vm as unknown as { load(model: Record): Promise } await vm.load({ customerId: '', direction: '', status: '', keyword: '' }) await flushPromises() expect(wrapper.text()).toContain('客户A') expect(wrapper.text()).toContain('客户B') }) it('loads detail and submits review and settlement changes with the returned version', async () => { const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:fee-source:view', 'platform:finance:fee-source:review', 'platform:finance:fee-source:settlement']) await loadRows(wrapper) const vm = wrapper.vm as unknown as { openDetail(row: Record): Promise openReview(row: Record): Promise submitReview(): Promise openInclusion(row: Record): Promise submitInclusion(): Promise reviewForm: { action: 'APPROVE' | 'REJECT'; reason: string } inclusionForm: { included: boolean; reason: string } } const row = { id: 'RECEIVABLE:42', feeSourceId: '42' } await vm.openDetail(row); await flushPromises() expect(getFinanceFeeSource).toHaveBeenCalledWith('42') expect(document.body.textContent).toContain('IB-91') expect(document.body.textContent).toContain('100.00 CNY') await vm.openReview(row); vm.reviewForm.action = 'APPROVE'; vm.reviewForm.reason = '核对无误'; await vm.submitReview() expect(reviewFinanceFeeSource).toHaveBeenCalledWith('42', { action: 'APPROVE', reason: '核对无误', expectedVersion: 3 }) await vm.openInclusion(row); vm.inclusionForm.included = true; vm.inclusionForm.reason = '本期结算'; await vm.submitInclusion() expect(updateFinanceFeeSourceSettlementInclusion).toHaveBeenCalledWith('42', { included: true, reason: '本期结算', expectedVersion: 3 }) }) it('renders request and trace ids when detail loading fails', async () => { vi.mocked(getFinanceFeeSource).mockRejectedValueOnce(new ApiResponseError('费用来源不可用', 'FIN-FEE-404', 404, undefined, 'req-g2-error', 'trace-g2-error')) const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:fee-source:view']) await loadRows(wrapper) const vm = wrapper.vm as unknown as { openDetail(row: Record): Promise } await vm.openDetail({ id: 'RECEIVABLE:42', feeSourceId: '42' }); await flushPromises() expect(document.body.textContent).toContain('费用来源不可用') expect(document.body.textContent).toContain('req-g2-error') expect(document.body.textContent).toContain('trace-g2-error') }) it('restores the independent detail route from its fee source id', async () => { const wrapper = await mountView(['platform:finance:customer-arap:list'], '/finance/customer-arap/42') expect(getFinanceFeeSource).toHaveBeenCalledWith('42') expect(wrapper.text()).toContain('费用来源详情') expect(wrapper.text()).toContain('IB-91') expect(wrapper.router.currentRoute.value.name).toBe('finance-customer-arap-detail') }) it('creates a reconciliation draft from selected eligible receivables', async () => { const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:reconciliation:create']) await loadRows(wrapper) const vm = wrapper.vm as unknown as { selectedRows: Array> generationForm: { mode: 'BY_CUTOFF' | 'SELECTED'; customerId: string; period: string[]; remark: string } submitGeneration(): Promise } vm.selectedRows = [{ id: 'RECEIVABLE:42', feeSourceId: '42', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', settlementIncluded: true, statementGenerated: false, amount: 100 }] Object.assign(vm.generationForm, { mode: 'SELECTED', customerId: '10001', period: ['2026-08-01', '2026-08-31'], remark: '八月费用' }) await vm.submitGeneration() expect(createReconciliationStatement).toHaveBeenCalledWith({ customerId: '10001', currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: ['42'], periodStart: '2026-08-01', periodEnd: '2026-08-31', remark: '八月费用' }) }) })