2026-08-17 19:13:25 +08:00
|
|
|
import { readFileSync } from 'node:fs'
|
|
|
|
|
import { resolve } from 'node:path'
|
2026-08-13 11:57:44 +08:00
|
|
|
import ElementPlus from 'element-plus'
|
|
|
|
|
import { createPinia, setActivePinia } from 'pinia'
|
2026-08-17 16:45:46 +08:00
|
|
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
2026-08-13 11:57:44 +08:00
|
|
|
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'
|
2026-08-25 22:08:59 +08:00
|
|
|
import { ApiResponseError } from '@/shared/api/http'
|
2026-08-17 17:49:10 +08:00
|
|
|
import { createPlatformExportTask } from '@/modules/downloads'
|
2026-08-13 11:57:44 +08:00
|
|
|
import { listPlatformTenants } from '@/modules/iam'
|
2026-08-17 17:49:10 +08:00
|
|
|
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
2026-08-20 16:13:55 +08:00
|
|
|
import { createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
|
|
|
|
|
import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
|
2026-08-13 11:57:44 +08:00
|
|
|
import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue'
|
|
|
|
|
|
|
|
|
|
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
2026-08-17 17:49:10 +08:00
|
|
|
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
|
|
|
|
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
|
2026-08-13 11:57:44 +08:00
|
|
|
vi.mock('../api/finance', () => ({
|
|
|
|
|
actionReconciliationStatement: vi.fn(), createReconciliationStatement: vi.fn(),
|
2026-08-20 16:13:55 +08:00
|
|
|
getReconciliationStatement: vi.fn(), listFinanceFeeSources: vi.fn(), listFinanceStatements: vi.fn(),
|
2026-08-13 11:57:44 +08:00
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
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-08-17 19:52:04 +08:00
|
|
|
periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, lineCount: 1, sourceCount: 1,
|
|
|
|
|
sourceAmount: 320, adjustmentAmount: -20, settled: false, lastReceiptAt: '2026-08-20 10:00:00',
|
|
|
|
|
submittedAt: '2026-08-02 09:00:00', reviewedAt: '2026-08-03 09:00:00', issuedAt: '2026-08-04 09:00:00', remark: '八月账单',
|
2026-08-13 11:57:44 +08:00
|
|
|
totalAmount: 300, receivedAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 3,
|
2026-08-19 09:14:35 +08:00
|
|
|
customerConfirmationStatus: 'CONFIRMED', customerConfirmedBy: '17', customerConfirmedByName: '客户确认人', customerConfirmedAt: '2026-08-05 10:00:00',
|
2026-08-17 16:45:46 +08:00
|
|
|
createdBy: '7', createdByName: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '8', updatedByName: '更新人B', updatedAt: '2026-08-20 11:00:00',
|
2026-08-13 11:57:44 +08:00
|
|
|
}
|
|
|
|
|
const detail: FinanceReconciliationDetail = {
|
|
|
|
|
...statement, paidAmount: 100, periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, settled: false, remark: '八月账单',
|
2026-08-17 16:45:46 +08:00
|
|
|
createdBy: '7', createdByName: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '8', updatedByName: '更新人B', updatedAt: '2026-08-20 11:00:00',
|
2026-08-17 19:52:04 +08:00
|
|
|
lines: [{ statementLineId: '601', sourceFeeId: '81', sourceFeeVersion: 2, sourceHash: 'hash-81', direction: 'RECEIVABLE', sourceType: 'CONTAINER_FEE', sourceNo: 'CONT-31', orderId: '11', inboundId: '21', inboundCode: 'IN-21', containerId: '31', containerNo: 'CONT-31', businessDate: '2026-08-15', quantity: 10, unitPrice: 30, feeItemName: '运费', sourceAmount: 320, amount: 300, adjustmentReason: '优惠调整' }],
|
|
|
|
|
paymentHistory: [{ transactionId: '701', transactionType: 'RECEIPT', transactionDate: '2026-08-20', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', voucherFileIds: ['9001'], createdBy: '9', createdByName: '财务员', occurredAt: '2026-08-20 10:00:00' }],
|
2026-08-17 16:45:46 +08:00
|
|
|
revisions: [{ revisionNo: 1, action: 'ADJUST', beforeAmount: 320, afterAmount: 300, reason: '优惠调整', operatorId: '10', operatorName: '调整人', occurredAt: '2026-08-20 09:00:00' }],
|
2026-08-13 11:57:44 +08:00
|
|
|
}
|
2026-08-20 16:13:55 +08:00
|
|
|
const eligibleFeeSource: FinanceFeeSourceSummary = {
|
|
|
|
|
poolItemId: '42', feeSourceId: '42', sourceSystem: 'FIRSTLEG', sourceType: 'INBOUND_FEE', sourceBusinessId: 'IN-21',
|
|
|
|
|
sourceId: '81', sourceVersion: 2, customerId: '10001', customerName: '客户A', direction: 'RECEIVABLE', supplierId: null,
|
|
|
|
|
supplierName: '', orderId: '11', inboundId: '21', inboundCode: 'IN-21', containerId: null, containerNo: '', businessDate: '2026-08-15',
|
|
|
|
|
feeItemName: '运费', quantity: 10, unitPrice: 30, amount: 320, currency: 'CNY', reviewStatus: 'APPROVED',
|
|
|
|
|
sourceStatus: 'AVAILABLE', statementGenerated: false, status: 'AVAILABLE', createdAt: '2026-08-15',
|
|
|
|
|
}
|
2026-08-13 11:57:44 +08:00
|
|
|
|
2026-08-17 16:45:46 +08:00
|
|
|
async function mountPage(permissions = ['platform:finance:customer-statement:list'], initialPath = '/finance/customer-statements') {
|
2026-08-13 11:57:44 +08:00
|
|
|
const pinia = createPinia(); setActivePinia(pinia)
|
|
|
|
|
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
|
|
|
|
useAccessPreviewStore().initializeAfterLogin()
|
2026-08-17 16:45:46 +08:00
|
|
|
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 },
|
2026-08-17 17:49:10 +08:00
|
|
|
{ path: '/downloads', name: 'download-center', component: { template: '<div>下载中心</div>' } },
|
2026-08-17 16:45:46 +08:00
|
|
|
] })
|
|
|
|
|
await router.push(initialPath); await router.isReady()
|
|
|
|
|
const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } })
|
2026-08-13 11:57:44 +08:00
|
|
|
await flushPromises()
|
2026-08-17 16:45:46 +08:00
|
|
|
return { wrapper, router }
|
2026-08-13 11:57:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enableAutoUnmount(afterEach)
|
|
|
|
|
afterAll(() => vi.unstubAllGlobals())
|
|
|
|
|
|
|
|
|
|
describe('FinanceCustomerStatementsView', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
|
|
|
|
vi.stubGlobal('localStorage', memoryStorage())
|
|
|
|
|
vi.stubGlobal('sessionStorage', memoryStorage())
|
|
|
|
|
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:statement-export'), revokeObjectURL: vi.fn() })
|
|
|
|
|
document.body.innerHTML = ''
|
|
|
|
|
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: '10001', tenantName: '客户A', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
|
|
|
|
vi.mocked(listFinanceStatements).mockResolvedValue([statement])
|
|
|
|
|
vi.mocked(getReconciliationStatement).mockResolvedValue(detail)
|
2026-08-20 16:13:55 +08:00
|
|
|
vi.mocked(listFinanceFeeSources).mockReset().mockResolvedValue({ records: [eligibleFeeSource], total: 1, pages: 1 })
|
|
|
|
|
vi.mocked(createReconciliationStatement).mockReset().mockResolvedValue({ ...detail, id: '502', billNo: 'REC-502', status: 'DRAFT' })
|
2026-08-17 17:49:10 +08:00
|
|
|
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ taskName: '客户对账单', taskNo: 'EXP-STMT-1' } as never)
|
|
|
|
|
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
|
2026-08-13 11:57:44 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-17 16:45:46 +08:00
|
|
|
it('opens statement details on a dedicated route instead of below the list', async () => {
|
|
|
|
|
const { wrapper, router } = await mountPage()
|
2026-08-13 11:57:44 +08:00
|
|
|
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(statement)
|
|
|
|
|
await flushPromises()
|
2026-08-17 16:45:46 +08:00
|
|
|
expect(router.currentRoute.value.name).toBe('finance-customer-statement-detail')
|
|
|
|
|
expect(router.currentRoute.value.params.statementId).toBe('501')
|
2026-08-13 11:57:44 +08:00
|
|
|
expect(document.body.textContent).toContain('账单明细表')
|
|
|
|
|
expect(document.body.textContent).toContain('账单支付历史表')
|
|
|
|
|
expect(document.body.textContent).toContain('REC-501')
|
|
|
|
|
expect(document.body.textContent).toContain('CONT-31')
|
|
|
|
|
expect(document.body.textContent).toContain('IN-21')
|
2026-08-15 09:47:23 +08:00
|
|
|
expect(document.body.textContent).not.toContain('来源版本')
|
|
|
|
|
expect(document.body.textContent).not.toContain('hash-81')
|
2026-08-13 12:22:41 +08:00
|
|
|
expect(document.body.textContent).toContain('优惠调整')
|
2026-08-13 11:57:44 +08:00
|
|
|
expect(document.body.textContent).toContain('创建人A')
|
2026-08-17 16:45:46 +08:00
|
|
|
expect(document.body.textContent).toContain('调整人')
|
2026-08-14 14:27:03 +08:00
|
|
|
expect(document.body.textContent).toContain('2026-08-20 11:00')
|
2026-08-17 12:31:27 +08:00
|
|
|
expect(document.querySelector('[aria-label="客户对账单详情"]')).toBeTruthy()
|
2026-08-17 16:45:46 +08:00
|
|
|
expect(document.querySelector('[aria-label="客户对账单列表"]')).toBeNull()
|
|
|
|
|
expect(document.querySelector('[aria-label="所选对账单联动明细"]')).toBeNull()
|
|
|
|
|
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('200.00 CNY')
|
2026-08-19 09:14:35 +08:00
|
|
|
expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(20)
|
|
|
|
|
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('客户确认人')
|
|
|
|
|
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('2026-08-05 10:00')
|
2026-09-15 09:58:15 +08:00
|
|
|
expect(document.querySelector('.density-detail-heading')?.textContent).toContain('REC-501')
|
2026-08-17 12:31:27 +08:00
|
|
|
expect(document.querySelector('[aria-label="客户对账单账单明细"]')).toBeTruthy()
|
|
|
|
|
expect(document.querySelector('.detail-table-scroll [aria-label="客户对账单账单明细"]')).toBeTruthy()
|
2026-08-13 11:57:44 +08:00
|
|
|
expect(document.querySelector('.el-drawer')).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-15 09:58:15 +08:00
|
|
|
it('uses the shared four-group summary grid with long remarks spanning two groups', () => {
|
2026-08-17 19:13:25 +08:00
|
|
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
|
2026-09-15 09:58:15 +08:00
|
|
|
const shared = readFileSync(resolve(process.cwd(), 'src/styles/adminDensity.css'), 'utf8')
|
|
|
|
|
const moduleCss = readFileSync(resolve(process.cwd(), 'src/modules/finance/financeDensity.css'), 'utf8')
|
|
|
|
|
expect(source).toContain('class="density-fields statement-summary-grid"')
|
|
|
|
|
expect(shared).toContain('repeat(4, minmax(0, 1fr))')
|
|
|
|
|
expect(moduleCss).toContain('.statement-summary-item--remark { grid-column: span 2; }')
|
2026-08-17 19:13:25 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-18 11:33:01 +08:00
|
|
|
it('uses entity buttons for row actions and only folds the fourth action into more', () => {
|
|
|
|
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
|
|
|
|
|
const actions = source.slice(source.indexOf('<template #actions="{ row }">'), source.indexOf('<template #status="{ row }">'))
|
|
|
|
|
expect(actions).not.toContain(' link')
|
|
|
|
|
for (const label of ['详情', '提交', '通过', '驳回', '出账', '调整', '重开']) {
|
|
|
|
|
const actionLine = actions.split('\n').find((line) => line.includes(`${label}</el-button>`))
|
2026-09-15 09:58:15 +08:00
|
|
|
expect(actionLine).toContain('class="density-action"')
|
|
|
|
|
expect(actionLine).toContain('size="small"')
|
2026-08-18 11:33:01 +08:00
|
|
|
}
|
2026-09-15 09:58:15 +08:00
|
|
|
expect(actions).toContain('data-tone="danger"')
|
2026-08-18 11:33:01 +08:00
|
|
|
expect(actions).toContain('aria-label="更多对账单操作" title="更多对账单操作"')
|
|
|
|
|
expect(actions).toContain("row.status === 'PENDING_REVIEW' && canReview && canVoid")
|
|
|
|
|
expect(actions.match(/<el-dropdown-item/g)).toHaveLength(1)
|
|
|
|
|
expect(actions).toContain('class="danger-menu-item"')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps three available row actions visible with primary and danger semantics', async () => {
|
|
|
|
|
const { wrapper } = await mountPage([
|
|
|
|
|
'platform:finance:customer-statement:list',
|
|
|
|
|
'platform:finance:reconciliation:adjust',
|
|
|
|
|
'platform:finance:reconciliation:void',
|
|
|
|
|
])
|
|
|
|
|
const actionButtons = [...document.querySelectorAll('button')].filter((button) => ['详情', '调整', '作废'].includes(button.textContent?.trim() || ''))
|
|
|
|
|
const detailButton = actionButtons.find((button) => button.textContent?.trim() === '详情')
|
|
|
|
|
const adjustButton = actionButtons.find((button) => button.textContent?.trim() === '调整')
|
|
|
|
|
const voidButton = actionButtons.find((button) => button.textContent?.trim() === '作废')
|
|
|
|
|
expect(new Set(actionButtons.map((button) => button.textContent?.trim()))).toEqual(new Set(['详情', '调整', '作废']))
|
|
|
|
|
expect(detailButton?.classList).toContain('el-button--small')
|
2026-09-15 09:58:15 +08:00
|
|
|
expect(detailButton?.classList).toContain('density-action')
|
|
|
|
|
expect(detailButton?.getAttribute('data-tone')).toBe('neutral')
|
|
|
|
|
expect(adjustButton?.classList).toContain('density-action')
|
|
|
|
|
expect(voidButton?.getAttribute('data-tone')).toBe('danger')
|
2026-08-18 11:33:01 +08:00
|
|
|
|
|
|
|
|
const vm = wrapper.vm as unknown as { actionBusy: boolean; adjustBusy: boolean }
|
|
|
|
|
vm.actionBusy = true
|
|
|
|
|
vm.adjustBusy = true
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(adjustButton?.hasAttribute('disabled')).toBe(true)
|
|
|
|
|
expect(voidButton?.hasAttribute('disabled')).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-17 12:31:27 +08:00
|
|
|
it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => {
|
2026-08-17 16:45:46 +08:00
|
|
|
const { wrapper } = await mountPage()
|
2026-08-17 12:31:27 +08:00
|
|
|
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void>; activeDetailTab: 'lines' | 'payments' }
|
|
|
|
|
await vm.openDetail(statement)
|
|
|
|
|
vm.activeDetailTab = 'payments'
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
expect(document.querySelector('[aria-label="客户对账单支付历史"]')?.textContent).toContain('PAY-701')
|
2026-08-17 16:45:46 +08:00
|
|
|
expect(document.querySelectorAll('.statement-detail-page')).toHaveLength(1)
|
2026-08-17 12:31:27 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-14 14:27:03 +08:00
|
|
|
it('resolves customer names from the active customer list and never exposes internal customer or operator ids', async () => {
|
|
|
|
|
const internalCustomerId = '2088123456789'
|
|
|
|
|
const internalOperatorId = '1000001'
|
|
|
|
|
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: internalCustomerId, tenantName: '虫虫跨境科技', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
|
|
|
|
const internalStatement = {
|
|
|
|
|
...statement, customerId: internalCustomerId, customerName: `客户${internalCustomerId}`,
|
2026-08-17 16:45:46 +08:00
|
|
|
createdBy: internalOperatorId, createdByName: '', createdAt: '2026-08-14T03:27:34Z', updatedBy: internalOperatorId, updatedByName: '', updatedAt: '2026-08-14T04:28:35Z',
|
2026-08-14 14:27:03 +08:00
|
|
|
}
|
|
|
|
|
const internalDetail = {
|
|
|
|
|
...detail, ...internalStatement,
|
2026-08-17 16:45:46 +08:00
|
|
|
paymentHistory: [{ ...detail.paymentHistory[0]!, createdBy: internalOperatorId, createdByName: '', occurredAt: '2026-08-14T04:28:35Z' }],
|
|
|
|
|
revisions: [{ ...detail.revisions[0]!, operatorId: internalOperatorId, operatorName: '' }],
|
2026-08-14 14:27:03 +08:00
|
|
|
}
|
|
|
|
|
vi.mocked(listFinanceStatements).mockResolvedValue([internalStatement])
|
|
|
|
|
vi.mocked(getReconciliationStatement).mockResolvedValue(internalDetail)
|
|
|
|
|
|
2026-08-17 16:45:46 +08:00
|
|
|
const { wrapper } = await mountPage(['platform:finance:customer-statement:list', 'platform:customer:list', 'platform:finance:reconciliation:adjust'])
|
2026-08-14 14:27:03 +08:00
|
|
|
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(internalStatement)
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
const text = document.body.textContent || ''
|
|
|
|
|
expect(text).toContain('虫虫跨境科技')
|
2026-08-17 16:45:46 +08:00
|
|
|
expect(document.querySelector('[aria-label="客户对账单详情"]')?.textContent).toContain('虫虫跨境科技')
|
2026-08-14 14:27:03 +08:00
|
|
|
expect(text).not.toContain(`客户${internalCustomerId}`)
|
|
|
|
|
expect(text).not.toContain(internalOperatorId)
|
2026-08-17 16:45:46 +08:00
|
|
|
expect(text).toContain('名称数据缺失')
|
2026-08-14 14:27:03 +08:00
|
|
|
expect(text).toContain('2026-08-14 03:27')
|
|
|
|
|
expect(text).not.toContain('2026-08-14T03:27:34Z')
|
|
|
|
|
|
|
|
|
|
await (wrapper.vm as unknown as { openAdjustment(row: FinanceStatementReadModel): Promise<void> }).openAdjustment(internalStatement)
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(document.body.textContent).toContain('调整客户对账单')
|
|
|
|
|
expect(document.body.textContent).toContain('运费')
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-13 11:57:44 +08:00
|
|
|
it('filters hydrated statements by status, bill number, container, period, settlement and payment date', async () => {
|
2026-08-17 16:45:46 +08:00
|
|
|
const { wrapper } = await mountPage()
|
2026-08-13 11:57:44 +08:00
|
|
|
const vm = wrapper.vm as unknown as { load(model: Record<string, unknown>): Promise<void>; filters: Record<string, unknown>; filteredRows: FinanceStatementReadModel[] }
|
|
|
|
|
await vm.load({
|
|
|
|
|
status: 'ISSUED', billNo: '501', customerId: '', containerNo: 'CONT-31',
|
|
|
|
|
periodStartRange: ['2026-08-01', '2026-08-02'], periodEndRange: ['2026-08-30', '2026-09-01'],
|
|
|
|
|
settled: 'false', lastPaymentRange: ['2026-08-20', '2026-08-20'],
|
|
|
|
|
})
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(vm.filteredRows).toHaveLength(1)
|
|
|
|
|
vm.filters.containerNo = 'CONT-99'
|
|
|
|
|
await flushPromises()
|
2026-08-27 21:48:15 +08:00
|
|
|
expect(vm.filteredRows).toHaveLength(1)
|
|
|
|
|
await vm.load({
|
|
|
|
|
status: 'ISSUED', billNo: '501', customerId: '', containerNo: 'CONT-99',
|
|
|
|
|
periodStartRange: ['2026-08-01', '2026-08-02'], periodEndRange: ['2026-08-30', '2026-09-01'],
|
|
|
|
|
settled: 'false', lastPaymentRange: ['2026-08-20', '2026-08-20'],
|
|
|
|
|
})
|
|
|
|
|
await flushPromises()
|
2026-08-13 11:57:44 +08:00
|
|
|
expect(vm.filteredRows).toHaveLength(0)
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-09 15:26:43 +08:00
|
|
|
it('does not issue one detail request per statement for an ordinary query', async () => {
|
|
|
|
|
vi.mocked(listFinanceStatements).mockResolvedValue(Array.from({ length: 60 }, (_, index) => ({
|
|
|
|
|
...statement,
|
|
|
|
|
id: String(501 + index),
|
|
|
|
|
billNo: `REC-${501 + index}`,
|
|
|
|
|
})))
|
|
|
|
|
const { wrapper } = await mountPage()
|
|
|
|
|
vi.mocked(listFinanceStatements).mockClear()
|
|
|
|
|
vi.mocked(getReconciliationStatement).mockClear()
|
|
|
|
|
|
|
|
|
|
await (wrapper.vm as unknown as { load(model: Record<string, unknown>): Promise<void> }).load({ status: 'ISSUED' })
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
expect(listFinanceStatements).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(getReconciliationStatement).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('uses only the statement list request for an ordinary search', async () => {
|
|
|
|
|
const { wrapper } = await mountPage()
|
|
|
|
|
vi.mocked(listFinanceStatements).mockClear()
|
|
|
|
|
vi.mocked(getReconciliationStatement).mockClear()
|
|
|
|
|
|
|
|
|
|
await (wrapper.vm as unknown as { load(model: Record<string, unknown>): Promise<void> }).load({
|
|
|
|
|
status: 'ISSUED', billNo: '501', customerId: '', containerNo: '',
|
|
|
|
|
periodStartRange: [], periodEndRange: [], settled: '', lastPaymentRange: [], createdRange: [],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(listFinanceStatements).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(getReconciliationStatement).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-27 21:48:15 +08:00
|
|
|
it('keeps statement status filter options aligned with the list status column', async () => {
|
|
|
|
|
const { wrapper } = await mountPage()
|
|
|
|
|
const vm = wrapper.vm as unknown as {
|
|
|
|
|
fields: Array<{ key: string; label: string; placeholder?: string; options?: Array<{ label: string; value: string }> }>
|
|
|
|
|
columns: Array<{ key: string; label: string }>
|
|
|
|
|
}
|
|
|
|
|
const statusField = vm.fields.find((field) => field.key === 'status')
|
|
|
|
|
const statusColumn = vm.columns.find((column) => column.key === 'status')
|
|
|
|
|
expect(statusField).toMatchObject({ label: '对账单状态', placeholder: '请选择对账单状态' })
|
|
|
|
|
expect(statusField?.options).toEqual([
|
|
|
|
|
{ label: '草稿', value: 'DRAFT' },
|
|
|
|
|
{ label: '待审核', value: 'PENDING_REVIEW' },
|
|
|
|
|
{ label: '已审核', value: 'APPROVED' },
|
|
|
|
|
{ label: '已驳回', value: 'REJECTED' },
|
|
|
|
|
{ label: '已出账', value: 'ISSUED' },
|
|
|
|
|
{ label: '部分结清', value: 'PARTIALLY_PAID' },
|
|
|
|
|
{ label: '已结清', value: 'PAID' },
|
|
|
|
|
{ label: '已作废', value: 'VOIDED' },
|
|
|
|
|
])
|
|
|
|
|
expect(statusColumn).toEqual({ key: 'status', label: '对账单状态', width: 120, slotName: 'status' })
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-17 17:49:10 +08:00
|
|
|
it('creates a formal statement export task with only non-empty filters', async () => {
|
2026-08-17 16:45:46 +08:00
|
|
|
const { wrapper } = await mountPage()
|
2026-08-17 17:49:10 +08:00
|
|
|
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; exportStatements(): Promise<void> }
|
|
|
|
|
vm.filters = {
|
|
|
|
|
status: 'ISSUED', billNo: ' REC-501 ', customerId: '10001', containerNo: ' CONT-31 ',
|
|
|
|
|
periodStartRange: ['2026-08-01', '2026-08-05'], periodEndRange: [], settled: 'false',
|
|
|
|
|
lastPaymentRange: ['2026-08-20', '2026-08-21'], createdRange: [],
|
|
|
|
|
}
|
2026-08-13 12:22:41 +08:00
|
|
|
await (wrapper.vm as unknown as { exportStatements(): Promise<void> }).exportStatements()
|
2026-08-17 17:49:10 +08:00
|
|
|
expect(createPlatformExportTask).toHaveBeenCalledWith({
|
|
|
|
|
exportType: 'FINANCE_RECONCILIATION_STATEMENT_LIST', format: 'XLSX', taskName: '客户对账单',
|
|
|
|
|
parameters: {
|
|
|
|
|
customerId: '10001', status: 'ISSUED', billNo: 'REC-501', containerNo: 'CONT-31',
|
|
|
|
|
periodStartFrom: '2026-08-01', periodStartTo: '2026-08-05', settled: false,
|
|
|
|
|
lastPaymentFrom: '2026-08-20', lastPaymentTo: '2026-08-21',
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-09-15 09:58:15 +08:00
|
|
|
expect(showExportTaskCreatedDialog).toHaveBeenCalledWith({ taskName: '客户对账单', taskNo: 'EXP-STMT-1', dense: true })
|
2026-08-17 17:49:10 +08:00
|
|
|
expect(getReconciliationStatement).not.toHaveBeenCalledWith('501')
|
|
|
|
|
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('requires both statement export and download-center list permissions for the export action', async () => {
|
|
|
|
|
await mountPage(['platform:finance:customer-statement:list', 'platform:finance:customer-statement:export'])
|
|
|
|
|
expect([...document.querySelectorAll('button')].some((item) => item.textContent?.includes('导出'))).toBe(false)
|
|
|
|
|
|
|
|
|
|
document.body.innerHTML = ''
|
|
|
|
|
await mountPage(['platform:finance:customer-statement:list', 'platform:finance:customer-statement:export', 'platform:download:list'])
|
|
|
|
|
expect([...document.querySelectorAll('button')].some((item) => item.textContent?.includes('导出'))).toBe(true)
|
2026-08-13 11:57:44 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('disables statement generation when customer selection permission is missing', async () => {
|
|
|
|
|
vi.mocked(listPlatformTenants).mockClear()
|
2026-08-13 14:37:59 +08:00
|
|
|
await mountPage(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:create'])
|
2026-08-13 11:57:44 +08:00
|
|
|
const button = [...document.querySelectorAll('button')].find((item) => item.textContent?.includes('生成对账单'))
|
|
|
|
|
expect(button).toBeTruthy()
|
|
|
|
|
expect(button?.disabled).toBe(true)
|
|
|
|
|
expect(listPlatformTenants).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-20 16:13:55 +08:00
|
|
|
it('accepts selected fee sources from customer arap and creates them in the statement page', async () => {
|
|
|
|
|
const permissions = [
|
|
|
|
|
'platform:finance:customer-statement:list',
|
|
|
|
|
'platform:finance:customer-arap:list',
|
|
|
|
|
'platform:finance:reconciliation:create',
|
|
|
|
|
'platform:customer:list',
|
|
|
|
|
]
|
|
|
|
|
const { wrapper, router } = await mountPage(permissions, '/finance/customer-statements?createMode=SELECTED&customerId=10001&feeSourceIds=42&periodStart=2026-08-15&periodEnd=2026-08-15')
|
|
|
|
|
const vm = wrapper.vm as unknown as {
|
|
|
|
|
createOpen: boolean
|
|
|
|
|
createForm: { generationMode: string; customerId: string; feeSourceIds: string[]; periodStart: string; periodEnd: string }
|
|
|
|
|
createStatement(): Promise<void>
|
|
|
|
|
}
|
|
|
|
|
expect(vm.createOpen).toBe(true)
|
|
|
|
|
expect(vm.createForm).toMatchObject({ generationMode: 'SELECTED', customerId: '10001', feeSourceIds: ['42'], periodStart: '2026-08-15', periodEnd: '2026-08-15' })
|
|
|
|
|
expect(listFinanceFeeSources).toHaveBeenCalledWith(expect.objectContaining({
|
|
|
|
|
customerId: '10001', direction: 'RECEIVABLE', status: 'AVAILABLE', reviewStatus: 'APPROVED', statementGenerated: false,
|
|
|
|
|
businessDateFrom: '2026-08-15', businessDateTo: '2026-08-15',
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
await vm.createStatement()
|
|
|
|
|
expect(createReconciliationStatement).toHaveBeenCalledWith({
|
|
|
|
|
customerId: '10001', currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: ['42'],
|
|
|
|
|
periodStart: '2026-08-15', periodEnd: '2026-08-15', remark: undefined,
|
|
|
|
|
})
|
|
|
|
|
expect(router.currentRoute.value).toMatchObject({ name: 'finance-customer-statement-detail', params: { statementId: '502' } })
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-25 22:08:59 +08:00
|
|
|
it('keeps the generation dialog open with a customer-friendly message when no billable fees exist', async () => {
|
|
|
|
|
const permissions = ['platform:finance:customer-statement:list', 'platform:finance:reconciliation:create', 'platform:customer:list']
|
|
|
|
|
const { wrapper } = await mountPage(permissions)
|
|
|
|
|
const vm = wrapper.vm as unknown as {
|
|
|
|
|
createOpen: boolean
|
|
|
|
|
createError: { message: string } | null
|
|
|
|
|
createForm: { customerId: string; periodStart: string; periodEnd: string; generationMode: string; feeSourceIds: string[] }
|
|
|
|
|
openCreate(): void
|
|
|
|
|
createStatement(): Promise<void>
|
|
|
|
|
}
|
|
|
|
|
vm.openCreate()
|
|
|
|
|
vm.createForm.customerId = '10001'
|
|
|
|
|
vm.createForm.generationMode = 'BY_CUTOFF'
|
|
|
|
|
vm.createForm.periodStart = '2026-08-01'
|
|
|
|
|
vm.createForm.periodEnd = '2026-08-25'
|
|
|
|
|
vi.mocked(createReconciliationStatement).mockRejectedValueOnce(new ApiResponseError('没有符合结算条件的CNY应收费用来源', 'FIN_409'))
|
|
|
|
|
|
|
|
|
|
await vm.createStatement()
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(vm.createOpen).toBe(true)
|
|
|
|
|
expect(vm.createError?.message).toContain('没有可生成的应收费用')
|
|
|
|
|
expect(document.body.textContent).toContain('请调整账期或选择其他客户后重试')
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-17 19:52:04 +08:00
|
|
|
it('uses statement detail snapshots without a second fee-source request or technical trace fields', async () => {
|
2026-08-17 16:45:46 +08:00
|
|
|
const { wrapper } = await mountPage(['platform:finance:customer-statement:list'])
|
2026-08-13 12:22:41 +08:00
|
|
|
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(statement)
|
|
|
|
|
await flushPromises()
|
2026-08-13 14:55:59 +08:00
|
|
|
expect(document.body.textContent).toContain('CONT-31')
|
2026-08-17 19:52:04 +08:00
|
|
|
expect(document.body.textContent).toContain('订单 11')
|
2026-08-15 09:47:23 +08:00
|
|
|
expect(document.body.textContent).not.toContain('hash-81')
|
2026-08-13 12:22:41 +08:00
|
|
|
})
|
|
|
|
|
|
2026-09-09 15:26:43 +08:00
|
|
|
it('surfaces container-filter detail hydration failures instead of silently returning an empty filtered list', async () => {
|
2026-08-17 16:45:46 +08:00
|
|
|
const { wrapper } = await mountPage()
|
2026-08-13 11:57:44 +08:00
|
|
|
vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new Error('detail unavailable'))
|
2026-09-09 15:26:43 +08:00
|
|
|
await (wrapper.vm as unknown as { load(model: Record<string, unknown>): Promise<void> }).load({ status: 'ISSUED', containerNo: 'CONT-31' })
|
2026-08-13 11:57:44 +08:00
|
|
|
await flushPromises()
|
|
|
|
|
expect(document.body.textContent).toContain('筛选字段加载失败')
|
|
|
|
|
})
|
2026-08-13 12:22:41 +08:00
|
|
|
|
2026-08-17 16:45:46 +08:00
|
|
|
it('keeps the dedicated detail route visible and exposes a retry state', async () => {
|
2026-08-13 12:22:41 +08:00
|
|
|
vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new Error('detail unavailable'))
|
2026-08-17 16:45:46 +08:00
|
|
|
const { router } = await mountPage(['platform:finance:customer-statement:list'], '/finance/customer-statements/501')
|
2026-08-13 12:22:41 +08:00
|
|
|
await flushPromises()
|
2026-08-17 16:45:46 +08:00
|
|
|
expect(router.currentRoute.value.name).toBe('finance-customer-statement-detail')
|
2026-09-15 09:58:15 +08:00
|
|
|
expect(document.querySelector('[aria-label="客户对账单详情"]')).toBeTruthy()
|
2026-08-17 16:45:46 +08:00
|
|
|
expect(document.body.textContent).toContain('返回客户对账单')
|
2026-08-13 12:22:41 +08:00
|
|
|
expect(document.body.textContent).toContain('明细加载失败')
|
|
|
|
|
expect(document.body.textContent).toContain('重试')
|
|
|
|
|
})
|
2026-08-13 11:57:44 +08:00
|
|
|
})
|