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

224 lines
12 KiB
TypeScript

import ElementPlus, { ElMessage, ElMessageBox } 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 { ApiResponseError } from '@/shared/api/http'
import {
actionFinanceSettlement,
actionReconciliationStatement,
createFinanceReceipt,
getFinanceSettlement,
getReconciliationStatement,
listFinancePayableSettlements,
listFinanceReceivableSettlements,
uploadFinanceVoucher,
} from '../api/finance'
import type { FinanceReconciliationDetail, FinanceSettlementDetail, FinanceStatementReadModel } from '../types'
import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue'
import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../api/finance', () => ({
actionFinanceSettlement: vi.fn(), actionReconciliationStatement: vi.fn(), createFinanceReceipt: vi.fn(), createFinanceSettlementPayment: vi.fn(),
createPayableSettlement: vi.fn(), createReceivableSettlement: vi.fn(), createReconciliationStatement: vi.fn(),
getFinanceSettlement: vi.fn(), getReconciliationStatement: vi.fn(), listFinanceFeePool: vi.fn(),
listFinancePayableSettlements: vi.fn(), listFinanceReceivableSettlements: vi.fn(), listFinanceStatements: vi.fn(),
uploadFinanceVoucher: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function createMemoryStorage(): 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),
}
}
function piniaWithPermissions(permissions: string[]) {
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
return pinia
}
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 reconciliation: FinanceReconciliationDetail = {
...statement, paidAmount: 0, periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, settled: false,
remark: '', lines: [
{ statementLineId: '601', sourceFeeId: '41', sourceFeeVersion: 1, orderId: null, inboundId: '91', feeItemName: '运费', amount: 100 },
{ statementLineId: '602', sourceFeeId: '42', sourceFeeVersion: 1, orderId: null, inboundId: '92', feeItemName: '操作费', amount: 200 },
], paymentHistory: [], revisions: [],
}
const receivable: FinanceSettlementDetail = {
id: '701', settlementNo: 'AR-701', customerId: '10001', reconciliationStatementId: '501', direction: 'RECEIVABLE',
amount: 300, paidAmount: 0, outstandingAmount: 300, currency: 'CNY', status: 'ISSUED', version: 0,
lines: [], transactions: [], revisions: [],
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('finance G3/G4 page 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(actionReconciliationStatement).mockReset().mockResolvedValue(reconciliation)
vi.mocked(actionFinanceSettlement).mockReset().mockResolvedValue({ ...receivable, status: 'VOIDED', version: 1 })
vi.mocked(getReconciliationStatement).mockReset().mockResolvedValue(reconciliation)
vi.mocked(getFinanceSettlement).mockReset().mockResolvedValue(receivable)
vi.mocked(listFinanceReceivableSettlements).mockReset().mockResolvedValue([])
vi.mocked(listFinancePayableSettlements).mockReset().mockResolvedValue([])
vi.mocked(uploadFinanceVoucher).mockReset().mockResolvedValue({ fileId: '9001', customerId: '10001', fileName: 'receipt.png', contentType: 'image/png', sizeBytes: 100 })
vi.mocked(createFinanceReceipt).mockReset().mockResolvedValue({ id: '801', customerId: '10001', customerName: '客户A', receivedDate: '2026-08-11', amount: 300, currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: ['9001'], allocationCount: 1 })
})
it('loads frozen G3 detail and submits multiple selected adjustment lines', async () => {
const pinia = piniaWithPermissions(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust'])
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(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } })
await flushPromises()
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[0]!.selected = true; vm.adjustLines[0]!.adjustedAmount = 90; vm.adjustLines[0]!.reason = '运费更正'
vm.adjustLines[1]!.selected = true; vm.adjustLines[1]!.adjustedAmount = 180; vm.adjustLines[1]!.reason = '操作费更正'
await vm.submitAdjustment()
expect(getReconciliationStatement).toHaveBeenCalledWith('501')
expect(actionReconciliationStatement).toHaveBeenCalledWith('501', {
action: 'ADJUST', reason: '账期复核调整', expectedVersion: 3,
adjustmentLines: [
{ statementLineId: '601', adjustedAmount: 90, reason: '运费更正' },
{ statementLineId: '602', adjustedAmount: 180, reason: '操作费更正' },
],
})
}, 15_000)
it('loads AR linkage, uploads a voucher and allocates a real receipt to its statement', async () => {
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:receipt:create'])
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
const vm = wrapper.vm as unknown as {
openReceipt(row: Record<string, unknown>): Promise<void>
submitReceipt(): Promise<void>
receiptFile: File | null
receiptForm: { receivedDate: string; amount: number; paymentMethod: 'BANK_TRANSFER'; remark: string }
}
await vm.openReceipt({ id: '701' })
vm.receiptFile = new File(['receipt'], 'receipt.png', { type: 'image/png' })
vm.receiptForm.receivedDate = '2026-08-11'; vm.receiptForm.amount = 300; vm.receiptForm.remark = '全额到账'
await vm.submitReceipt()
expect(uploadFinanceVoucher).toHaveBeenCalledWith('10001', vm.receiptFile)
expect(createFinanceReceipt).toHaveBeenCalledWith({
customerId: '10001', receivedDate: '2026-08-11', amount: 300, paymentMethod: 'BANK_TRANSFER', remark: '全额到账',
allocations: [{ billId: '501', amount: 300 }],
}, ['9001'])
})
it('shows receipt errors as dismissible light messages and rejects non-image vouchers before upload', async () => {
const messageSpy = vi.spyOn(ElMessage, 'error')
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:receipt:create'])
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
const vm = wrapper.vm as unknown as {
openReceipt(row: Record<string, unknown>): Promise<void>
selectReceiptFile(file: { raw: File }): void
submitReceipt(): Promise<void>
receiptFile: File | null
receiptOpen: boolean
receiptValidationError: string | null
receiptError: { message: string } | null
}
vm.selectReceiptFile({ raw: new File(['not an image'], 'receipt.pdf', { type: 'application/pdf' }) })
expect(vm.receiptFile).toBeNull()
expect(vm.receiptValidationError).toBe('收款凭证仅支持图片文件')
await vm.openReceipt({ id: '701' })
vm.selectReceiptFile({ raw: new File(['receipt'], 'receipt.png', { type: 'image/png' }) })
vi.mocked(uploadFinanceVoucher).mockRejectedValueOnce(new ApiResponseError('FIN_400: 凭证校验失败', 'FIN_400', 400, undefined, 'request-1', 'trace-1'))
await vm.submitReceipt()
await flushPromises()
expect(vm.receiptOpen).toBe(true)
expect(vm.receiptError?.message).toContain('FIN_400')
expect(messageSpy).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('FIN_400'), showClose: true, duration: 4500 }))
expect(wrapper.text()).not.toContain('FIN_400')
expect(wrapper.find('[aria-label="结算单应收列表"]').exists()).toBe(true)
})
it('emits payable detail navigation and displays customer names without internal IDs', async () => {
const pinia = piniaWithPermissions(['platform:finance:settlement-ap:list'])
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'payable' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
const vm = wrapper.vm as unknown as { openDetail(row: Record<string, unknown>): void; customerName(customerId: string, snapshot?: string): string }
vi.mocked(getFinanceSettlement).mockClear()
vm.openDetail({ id: '801' })
expect(wrapper.emitted('openDetail')).toEqual([['801']])
expect(getFinanceSettlement).not.toHaveBeenCalled()
expect(vm.customerName('2088', '上海客户有限公司')).toBe('上海客户有限公司')
expect(vm.customerName('2088', '客户2088')).toBe('—')
expect(vm.customerName('2088')).toBe('—')
})
it('keeps settled settlements readable and exportable while hiding receipt actions', async () => {
vi.mocked(getFinanceSettlement).mockResolvedValue({ ...receivable, status: 'PAID', paidAmount: 300, outstandingAmount: 0 })
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:settlement-ar:export', 'platform:finance:receipt:create', 'platform:download:list'])
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable', settlementId: '701' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('导出')
expect(wrapper.text()).not.toContain('部分收款')
})
it('submits settlement void and reopen actions with the current version and reason', async () => {
const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue({ value: '账务冲销' } as never)
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:settlement:void'])
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
const vm = wrapper.vm as unknown as { performAction(row: Record<string, unknown>, action: 'VOID' | 'REOPEN'): Promise<void> }
await vm.performAction({ id: '701', version: 3 }, 'VOID')
expect(actionFinanceSettlement).toHaveBeenCalledWith('701', { action: 'VOID', reason: '账务冲销', expectedVersion: 3 })
prompt.mockRestore()
})
})