Files
admin-web/src/modules/finance/pages/FinanceG3G4Views.test.ts
T

153 lines
12 KiB
TypeScript

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 { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { ApiResponseError } from '@/shared/api/http'
import {
actionReconciliationStatement,
createFinanceSettlementPayment,
createPayableSettlement,
createReceivableSettlement,
getFinanceSettlement,
getReconciliationStatement,
listFinanceFeePool,
listFinancePayableSettlements,
listFinanceReceivableSettlements,
listFinanceStatements,
uploadFinanceVoucher,
} from '../api/finance'
import type { FinancePayableSettlement, FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue'
import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('@/modules/suppliers', () => ({
listSuppliers: vi.fn(),
categoryLabels: { BOOKING: '订舱', CUSTOMS: '报关', TRUCKING: '拖车', CERTIFICATE: '证书' },
}))
vi.mock('../api/finance', () => ({
actionReconciliationStatement: vi.fn(), createFinanceSettlementPayment: vi.fn(), createPayableSettlement: vi.fn(),
createReceivableSettlement: vi.fn(), getFinanceSettlement: vi.fn(), getReconciliationStatement: vi.fn(),
listFinanceFeePool: vi.fn(), listFinancePayableSettlements: vi.fn(), listFinanceReceivableSettlements: vi.fn(),
listFinanceStatements: vi.fn(), uploadFinanceVoucher: vi.fn(), createReconciliationStatement: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
function memoryStorage(): Storage {
const values = new Map<string, string>()
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: '2026-09-30', totalAmount: 300, receivedAmount: 0, outstandingAmount: 300, currency: 'CNY', status: 'ISSUED', version: 3 }
const statementDetail: FinanceReconciliationDetail = {
...statement, paidAmount: 0, remark: '', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, settled: false, paymentHistory: [], revisions: [],
lines: [
{ statementLineId: '601', sourceFeeId: '81', sourceFeeVersion: 1, orderId: '11', inboundId: '21', feeItemName: '运费', amount: 100 },
{ statementLineId: '602', sourceFeeId: '82', sourceFeeVersion: 1, orderId: '12', inboundId: '22', feeItemName: '操作费', amount: 200 },
],
}
const payable: FinancePayableSettlement = { id: '701', customerId: '10001', settlementNo: 'AP-701', costType: 'SINGLE_ORDER', orderId: '11', inboundId: null, containerId: null, supplierName: '供应商A', feeItemName: '拖车费', payableAmount: 200, paidAmount: 0, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', createdAt: '2026-08-11', version: 4 }
const settlementDetail = { id: '701', settlementNo: 'AP-701', customerId: '10001', supplierId: '301', direction: 'PAYABLE' as const, amount: 200, paidAmount: 0, outstandingAmount: 200, currency: 'CNY' as const, status: 'ISSUED', version: 4, lines: [], transactions: [], revisions: [] }
async function mountWithAccess(component: typeof FinanceCustomerStatementsView | typeof FinanceFormalSettlementPage, permissions: string[], props: Record<string, unknown> = {}) {
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 },
] })
await router.push('/finance/customer-statements'); await router.isReady()
const wrapper = mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus, router] } })
await flushPromises()
return wrapper
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('Finance G3/G4 views', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
vi.stubGlobal('sessionStorage', memoryStorage())
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(listSuppliers).mockReset().mockResolvedValue({ records: [{ id: '301', tenantId: '0', name: '拖车供应商A', category: 'TRUCKING', carrierCodes: [], scope: 'DOMESTIC', region: '宁波', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-01', updatedBy: null, updatedAt: '2026-08-01', deleted: false, version: 0 } as Supplier], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listFinanceStatements).mockReset().mockResolvedValue([statement])
vi.mocked(getReconciliationStatement).mockReset().mockResolvedValue(statementDetail)
vi.mocked(actionReconciliationStatement).mockReset().mockResolvedValue(statementDetail)
vi.mocked(listFinanceFeePool).mockReset().mockResolvedValue([{ poolItemId: 'PAYABLE:81', feeSourceId: '81', customerId: '10001', direction: 'PAYABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceVersion: 1, orderId: '11', inboundId: '21', containerId: null, feeItemName: '拖车费', amount: 200, currency: 'CNY', status: 'AVAILABLE', createdAt: '2026-08-11' }])
vi.mocked(listFinancePayableSettlements).mockReset().mockResolvedValue([payable])
vi.mocked(listFinanceReceivableSettlements).mockReset().mockResolvedValue([])
vi.mocked(createPayableSettlement).mockReset().mockResolvedValue(settlementDetail)
vi.mocked(createReceivableSettlement).mockReset().mockResolvedValue({ ...settlementDetail, id: '801', settlementNo: 'AR-801', direction: 'RECEIVABLE', supplierId: undefined })
vi.mocked(uploadFinanceVoucher).mockReset().mockResolvedValue({ fileId: '9001', customerId: '10001', fileName: 'voucher.png', contentType: 'image/png', sizeBytes: 1 })
vi.mocked(createFinanceSettlementPayment).mockReset().mockResolvedValue({ ...settlementDetail, paidAmount: 100, outstandingAmount: 100, status: 'PARTIALLY_PAID', version: 5 })
})
it('hides G3/G4 commands when list permissions are absent', async () => {
const statements = await mountWithAccess(FinanceCustomerStatementsView, [])
const settlements = await mountWithAccess(FinanceFormalSettlementPage, [], { kind: 'payable' })
expect(statements.text()).not.toContain('生成对账单')
expect(settlements.text()).toContain('无权访问结算单应付')
expect(createPayableSettlement).not.toHaveBeenCalled()
expect(actionReconciliationStatement).not.toHaveBeenCalled()
})
it('submits every selected adjustment line with the detail version', async () => {
const wrapper = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust'])
const vm = wrapper.vm as unknown as { openAdjustment(row: FinanceStatementReadModel): Promise<void>; submitAdjustment(): Promise<void>; adjustReason: string; adjustLines: Array<{ selected: boolean; adjustedAmount: number; reason: string }> }
await vm.openAdjustment(statement)
vm.adjustReason = '月末核对更正'
vm.adjustLines.forEach((line, index) => { line.selected = true; line.adjustedAmount = index === 0 ? 90 : 180; line.reason = `明细${index + 1}更正` })
await vm.submitAdjustment()
expect(actionReconciliationStatement).toHaveBeenCalledWith('501', { action: 'ADJUST', reason: '月末核对更正', expectedVersion: 3, adjustmentLines: [{ statementLineId: '601', adjustedAmount: 90, reason: '明细1更正' }, { statementLineId: '602', adjustedAmount: 180, reason: '明细2更正' }] })
})
it('creates a payable settlement and registers a voucher-backed payment', async () => {
const wrapper = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:settlement-ap:list', 'platform:finance:settlement-ap:create', 'platform:finance:payment:create'], { kind: 'payable' })
const vm = wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void>; openCreate(): Promise<void>; submitCreate(): Promise<void>; openPayment(row: FinancePayableSettlement): void; submitPayment(): Promise<void>; createForm: { customerId: string; supplierId: string; dueDate: string; feeSourceIds: string[] }; paymentFile: File | null; paymentForm: { amount: number; transactionDate: string; method: 'BANK_TRANSFER'; referenceNo: string; remark: string } }
await vm.load({ customerId: '10001' }); await vm.openCreate()
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' })
expect(document.body.textContent).toContain('拖车供应商A · 拖车')
Object.assign(vm.createForm, { customerId: '10001', supplierId: '301', dueDate: '2026-09-30', feeSourceIds: ['81'] })
await vm.submitCreate()
expect(createPayableSettlement).toHaveBeenCalledWith({ customerId: '10001', supplierId: '301', feeSourceIds: ['81'], dueDate: '2026-09-30', currency: 'CNY' })
vm.openPayment(payable); vm.paymentFile = new File(['x'], 'voucher.png', { type: 'image/png' }); vm.paymentForm.amount = 100
await vm.submitPayment()
expect(createFinanceSettlementPayment).toHaveBeenCalledWith('701', expect.objectContaining({ amount: 100, voucherFileIds: ['9001'], expectedVersion: 4 }))
})
it('renders request and trace ids when G3 detail loading fails', async () => {
vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new ApiResponseError('对账单不可用', 'FIN_404', 404, undefined, 'req-g3-error', 'trace-g3-error'))
const wrapper = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:customer-statement:list'])
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }
await vm.openDetail(statement); await flushPromises()
expect(document.body.textContent).toContain('req-g3-error')
expect(document.body.textContent).toContain('trace-g3-error')
})
it('loads all customers by default and applies explicit customer filters', async () => {
const statements = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:customer-statement:list'])
const receivables = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:settlement-ar:list'], { kind: 'receivable' })
const payables = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:settlement-ap:list'], { kind: 'payable' })
expect(listFinanceStatements).toHaveBeenCalledWith(undefined)
expect(listFinanceReceivableSettlements).toHaveBeenCalledWith(undefined)
expect(listFinancePayableSettlements).toHaveBeenCalledWith(undefined)
await (statements.vm as unknown as { load(model: Record<string, string>): Promise<void> }).load({ customerId: '10001' })
await (receivables.vm as unknown as { load(model: Record<string, string>): Promise<void> }).load({ customerId: '10001' })
await (payables.vm as unknown as { load(model: Record<string, string>): Promise<void> }).load({ customerId: '10001' })
expect(listFinanceStatements).toHaveBeenLastCalledWith('10001')
expect(listFinanceReceivableSettlements).toHaveBeenLastCalledWith('10001')
expect(listFinancePayableSettlements).toHaveBeenLastCalledWith('10001')
})
})