修复财务页面竞态并整理采购与运输展示
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { defineComponent, h, KeepAlive, type VNode } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { createMemoryHistory, createRouter, RouterView } 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'
|
||||
@@ -53,7 +54,7 @@ const eligibleFeeSource: FinanceFeeSourceSummary = {
|
||||
sourceStatus: 'AVAILABLE', statementGenerated: false, status: 'AVAILABLE', createdAt: '2026-08-15',
|
||||
}
|
||||
|
||||
async function mountPage(permissions = ['platform:finance:customer-statement:list'], initialPath = '/finance/customer-statements') {
|
||||
async function mountPage(permissions = ['platform:finance:customer-statement:list'], initialPath = '/finance/customer-statements', cached = false) {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
@@ -63,7 +64,10 @@ async function mountPage(permissions = ['platform:finance:customer-statement:lis
|
||||
{ path: '/downloads', name: 'download-center', component: { template: '<div>下载中心</div>' } },
|
||||
] })
|
||||
await router.push(initialPath); await router.isReady()
|
||||
const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } })
|
||||
const cachedPage = defineComponent({ setup: () => () => h(RouterView, {}, {
|
||||
default: ({ Component }: { Component: VNode }) => h(KeepAlive, {}, { default: () => Component }),
|
||||
}) })
|
||||
const wrapper = mount(cached ? cachedPage : FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
@@ -121,6 +125,25 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
expect(wrapper.find('[aria-label="支付历史"]').isVisible()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows overseas procurement as the source type in statement detail fee lines', async () => {
|
||||
vi.mocked(getReconciliationStatement).mockResolvedValue({
|
||||
...detail,
|
||||
lines: ['开箱费', '提柜-额外费用'].map((feeItemName, index) => ({
|
||||
...detail.lines[0]!, statementLineId: `procurement-line-${index}`, sourceType: 'OVERSEAS_PROCUREMENT_FEE',
|
||||
sourceNo: 'CG-TEST-001', feeItemName,
|
||||
})),
|
||||
})
|
||||
const { wrapper } = await mountPage(['platform:finance:customer-statement:list'], '/finance/customer-statements/501')
|
||||
const table = wrapper.get('[aria-label="客户对账单账单明细"]')
|
||||
const rows = table.findAll('.el-table__body tbody tr')
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows.map(row => row.findAll('td')[1]!.text())).toEqual(['海外采购费用', '海外采购费用'])
|
||||
expect(table.text()).toContain('CG-TEST-001')
|
||||
expect(table.text()).toContain('开箱费')
|
||||
expect(table.text()).toContain('提柜-额外费用')
|
||||
expect(table.text()).not.toMatch(/未知来源类型|OVERSEAS_PROCUREMENT_FEE/)
|
||||
})
|
||||
|
||||
it('collapses advanced criteria without losing values and resets hidden filters', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; appliedFilters: Record<string, unknown> }
|
||||
@@ -160,31 +183,33 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
expect(source).toContain('<RowActions text-actions :actions="statementActions(row as FinanceStatementReadModel)"')
|
||||
})
|
||||
|
||||
it('keeps three available row actions visible with primary and danger semantics', async () => {
|
||||
it.each(['APPROVED', 'ISSUED', 'PAID'])('hides adjustment and void for reviewed statement %s even with write permissions', async status => {
|
||||
vi.mocked(listFinanceStatements).mockResolvedValue([{ ...statement, status, customerConfirmationStatus: 'PENDING_CONFIRM' }])
|
||||
const { wrapper } = await mountPage([
|
||||
'platform:finance:customer-statement:list',
|
||||
'platform:finance:reconciliation:adjust',
|
||||
'platform:finance:reconciliation:void',
|
||||
'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')
|
||||
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')
|
||||
|
||||
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)
|
||||
const labels = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
|
||||
expect(labels).toContain('详情')
|
||||
expect(labels).not.toContain('调整')
|
||||
expect(labels).not.toContain('作废')
|
||||
})
|
||||
|
||||
it.each(['DRAFT', 'PENDING_REVIEW'])('keeps void before approval with the independent permission: %s', async status => {
|
||||
vi.mocked(listFinanceStatements).mockResolvedValue([{ ...statement, status }])
|
||||
const { wrapper } = await mountPage(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:void'])
|
||||
const button = wrapper.findAll('.el-table__body-wrapper button').find(item => item.text().trim() === '作废')!
|
||||
expect(button.attributes('data-tone')).toBe('danger')
|
||||
const vm = wrapper.vm as unknown as { actionBusy: boolean }
|
||||
vm.actionBusy = true
|
||||
await flushPromises()
|
||||
expect(button.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not offer void without permission even before approval', async () => {
|
||||
vi.mocked(listFinanceStatements).mockResolvedValue([{ ...statement, status: 'DRAFT' }])
|
||||
const { wrapper } = await mountPage()
|
||||
expect(wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())).not.toContain('作废')
|
||||
})
|
||||
it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }
|
||||
@@ -224,10 +249,6 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
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('运费')
|
||||
})
|
||||
|
||||
it('filters hydrated statements by status, bill number, container, period, settlement and payment date', async () => {
|
||||
@@ -373,6 +394,67 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
expect(router.currentRoute.value).toMatchObject({ name: 'finance-customer-statement-detail', params: { statementId: '502' } })
|
||||
})
|
||||
|
||||
const selectedCreatePermissions = ['platform:finance:customer-statement:list', 'platform:finance:customer-arap:list', 'platform:finance:reconciliation:create', 'platform:customer:list']
|
||||
const selectedCreatePath = '/finance/customer-statements?createMode=SELECTED&customerId=10001&feeSourceIds=42&periodStart=2026-08-15&periodEnd=2026-08-15'
|
||||
type SourcePage = Awaited<ReturnType<typeof listFinanceFeeSources>>
|
||||
|
||||
it.each(['success', 'failure'] as const)('ignores a late %s after leaving a reactivated statement page', async (outcome) => {
|
||||
const { wrapper, router } = await mountPage(selectedCreatePermissions, '/finance/customer-statements', true)
|
||||
const pageVm = wrapper.getComponent(FinanceCustomerStatementsView).vm as unknown as {
|
||||
createOpen: boolean; createSourcesError: unknown; createSourcesLoading: boolean
|
||||
}
|
||||
await router.push('/downloads'); await flushPromises()
|
||||
let resolve!: (value: SourcePage) => void, reject!: (reason: Error) => void
|
||||
vi.mocked(listFinanceFeeSources).mockImplementationOnce(() => new Promise((yes, no) => { resolve = yes; reject = no }))
|
||||
await router.push(selectedCreatePath); await flushPromises()
|
||||
expect(listFinanceFeeSources).toHaveBeenCalledTimes(1)
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
await router.push('/downloads'); await flushPromises()
|
||||
if (outcome === 'success') resolve({ records: [eligibleFeeSource], total: 2, pages: 2 })
|
||||
else reject(new Error('旧候选查询失败'))
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.name).toBe('download-center')
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
expect(pageVm.createOpen).toBe(false)
|
||||
expect(pageVm.createSourcesError).toBeNull()
|
||||
expect(pageVm.createSourcesLoading).toBe(false)
|
||||
expect(listFinanceFeeSources).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('opens the current selection after reactivation without letting an older response reopen it', async () => {
|
||||
const { wrapper, router } = await mountPage(selectedCreatePermissions, '/finance/customer-statements', true)
|
||||
const pageVm = wrapper.getComponent(FinanceCustomerStatementsView).vm as unknown as {
|
||||
createOpen: boolean; createForm: { feeSourceIds: string[] }; createSourceOptions: FinanceFeeSourceSummary[]
|
||||
}
|
||||
await router.push('/downloads'); await flushPromises()
|
||||
let resolve!: (value: SourcePage) => void
|
||||
vi.mocked(listFinanceFeeSources).mockImplementationOnce(() => new Promise(yes => { resolve = yes }))
|
||||
await router.push(selectedCreatePath); await flushPromises()
|
||||
await router.push('/downloads'); await flushPromises()
|
||||
vi.mocked(listFinanceFeeSources).mockResolvedValue({ records: [{ ...eligibleFeeSource, feeSourceId: '43' }], total: 1, pages: 1 })
|
||||
await router.push(selectedCreatePath.replace('feeSourceIds=42', 'feeSourceIds=43')); await flushPromises()
|
||||
expect(pageVm.createOpen).toBe(true)
|
||||
expect(pageVm.createForm.feeSourceIds).toEqual(['43'])
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
resolve({ records: [eligibleFeeSource], total: 1, pages: 1 }); await flushPromises()
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
expect(pageVm.createForm.feeSourceIds).toEqual(['43'])
|
||||
expect(pageVm.createSourceOptions.map(item => item.feeSourceId)).toEqual(['43'])
|
||||
})
|
||||
|
||||
it('does not open or navigate after the statement page is unmounted during candidate loading', async () => {
|
||||
const { wrapper, router } = await mountPage(selectedCreatePermissions, '/finance/customer-statements', true)
|
||||
await router.push('/downloads'); await flushPromises()
|
||||
let resolve!: (value: SourcePage) => void
|
||||
vi.mocked(listFinanceFeeSources).mockImplementationOnce(() => new Promise(yes => { resolve = yes }))
|
||||
await router.push(selectedCreatePath); await flushPromises()
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
wrapper.unmount()
|
||||
resolve({ records: [eligibleFeeSource], total: 1, pages: 1 }); await flushPromises()
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
expect(document.body.textContent).not.toContain('对账单生成设置')
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user