153 lines
12 KiB
TypeScript
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')
|
|
})
|
|
})
|