This commit is contained in:
2026-11-03 11:43:50 +08:00
parent f8408b8076
commit 1c2dd8797a
34 changed files with 354 additions and 84 deletions
@@ -12,7 +12,7 @@ import { ApiResponseError } from '@/shared/api/http'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants } from '@/modules/iam'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue'
@@ -76,6 +76,29 @@ enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('FinanceCustomerStatementsView', () => {
it('omits reopening voided and rejected statements and ignores stale reopen commands', async () => {
vi.mocked(listFinanceStatements).mockResolvedValue([{ ...statement, status: 'VOIDED' }, { ...statement, id: '502', status: 'REJECTED' }])
const { wrapper } = await mountPage(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:void'])
const page = wrapper.vm as unknown as { handleStatementAction(command: string, row: FinanceStatementReadModel): void }
expect(wrapper.text()).not.toContain('重开')
page.handleStatementAction('reopen', { ...statement, status: 'VOIDED' })
await flushPromises()
expect(actionReconciliationStatement).not.toHaveBeenCalled()
})
it('keeps statement rows visible and shows only the business error after a failed action', async () => {
const draft = { ...statement, status: 'DRAFT' }
vi.mocked(listFinanceStatements).mockResolvedValue([draft])
vi.mocked(actionReconciliationStatement).mockRejectedValueOnce(new ApiResponseError('费用已生成新的对账单', 'FIN_409', 409, null, 'REQUEST-FAIL', 'TRACE-FAIL'))
const { wrapper } = await mountPage(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:create'])
const page = wrapper.vm as unknown as { handleStatementAction(command: string, row: FinanceStatementReadModel): void }
page.handleStatementAction('submit', draft)
await flushPromises()
expect(wrapper.text()).toContain('费用已生成新的对账单')
expect(wrapper.text()).toContain('REC-501')
expect(wrapper.text()).not.toMatch(/REQUEST-FAIL|TRACE-FAIL|客户对账单加载失败/)
})
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
@@ -52,6 +52,7 @@ const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const actionError = ref('')
const customerError = ref<ApiErrorDetails | null>(null)
const selectedId = ref('')
const detail = ref<FinanceReconciliationDetail | null>(null)
@@ -263,6 +264,7 @@ async function load(model?: QueryModel) {
detail.value = null
detailCache.clear()
error.value = null
actionError.value = ''
loading.value = true
try {
const statements = await listFinanceStatements(String(appliedFilters.value.customerId || '').trim() || undefined)
@@ -516,17 +518,18 @@ async function createStatement() {
}
async function performAction(row: FinanceStatementReadModel, actionName: ReconciliationActionPayload['action']) {
const title = ({ REJECT: '驳回对账单', VOID: '作废对账单', REOPEN: '重开对账单' } as Partial<Record<ReconciliationActionPayload['action'], string>>)[actionName] || '对账单操作'
const reason = ['REJECT', 'VOID', 'REOPEN'].includes(actionName)
const title = ({ REJECT: '驳回对账单', VOID: '作废对账单' } as Partial<Record<ReconciliationActionPayload['action'], string>>)[actionName] || '对账单操作'
const reason = ['REJECT', 'VOID'].includes(actionName)
? await ElMessageBox.prompt('请输入操作原因', `${title} · ${row.billNo || '—'}`, { customClass: 'admin-density-confirm admin-workbench', inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消' }).then((result) => result.value).catch(() => null)
: undefined
if (reason === null) return
actionError.value = ''
actionBusy.value = true
try {
await actionReconciliationStatement(row.id, { action: actionName, expectedVersion: row.version, ...(reason ? { reason } : {}) })
ElMessage.success('对账单状态已更新')
await load(appliedFilters.value)
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单操作失败') }
} catch (cause) { actionError.value = getApiErrorMessage(cause, '对账单操作失败') }
finally { actionBusy.value = false }
}
@@ -537,7 +540,6 @@ function statementActions(row: FinanceStatementReadModel): RowActionItem[] {
{ key: 'approve', label: '通过', priority: 250, tone: 'warning', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value },
{ key: 'reject', label: '驳回', priority: 200, tone: 'danger', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value },
{ key: 'issue', label: '出账', priority: 200, visible: row.status === 'APPROVED' && canIssue.value, loading: actionBusy.value },
{ key: 'reopen', label: '重开', priority: 150, visible: ['REJECTED', 'VOIDED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
{ key: 'void', label: '作废', tone: 'danger', divided: true, visible: ['DRAFT', 'PENDING_REVIEW'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
]
}
@@ -546,7 +548,7 @@ function handleStatementAction(command: string, row: FinanceStatementReadModel)
if (command === 'detail') void openDetail(row)
else {
const actions: Record<string, ReconciliationActionPayload['action']> = {
submit: 'SUBMIT', approve: 'APPROVE', reject: 'REJECT', issue: 'ISSUE', reopen: 'REOPEN', void: 'VOID',
submit: 'SUBMIT', approve: 'APPROVE', reject: 'REJECT', issue: 'ISSUE', void: 'VOID',
}
const action = actions[command]
if (action) void performAction(row, action)
@@ -686,7 +688,7 @@ watch(detailRouteId, (statementId) => {
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail">
<template #toolbar-start><h1>客户对账单</h1></template>
<template #filters><QueryToolbar id="statement-query-filters" v-model="filters" ariaLabel="客户对账单筛选" :fields="visibleFields" :available-fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" :search-on-reset="false" @search="load" @reset="resetFilters"><template #actions><el-button :aria-expanded="showAdvancedFilters" aria-controls="statement-query-filters" @click="showAdvancedFilters = !showAdvancedFilters"><SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}</el-button></template></QueryToolbar></template>
<template #notices><template v-if="customerError || error"><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template></template>
<template #notices><el-alert v-if="actionError" :title="actionError" type="error" :closable="false" role="alert" /><template v-if="customerError || error"><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template></template>
<template #toolbar-actions>
@@ -345,6 +345,7 @@ watch(() => createForm.value.supplierId, () => {
if (createForm.value.customerId) void refreshCreateDependencies(createForm.value.customerId).catch(reportCreateSourceError)
})
// 待客户确认的对账单生成应收前,提醒操作员确认后再继续。
async function submitCreate() {
if (createBusy.value) return
const customerId = String(createForm.value.customerId || '')
@@ -354,6 +355,14 @@ async function submitCreate() {
createBusy.value = true
error.value = null
try {
const statement = statementOptions.value.find((item) => item.id === createForm.value.reconciliationStatementId)
if (props.kind === 'receivable' && statement?.customerConfirmationStatus === 'PENDING_CONFIRMATION') {
const confirmed = await ElMessageBox.confirm('客户暂未确认!', '生成应收结算单', {
customClass: 'admin-density-confirm admin-workbench', type: 'warning',
confirmButtonText: '继续生成', cancelButtonText: '取消',
}).then(() => true).catch(() => false)
if (!confirmed) return
}
if (props.kind === 'receivable') await createReceivableSettlement({ reconciliationStatementId: createForm.value.reconciliationStatementId, currency: 'CNY' })
else await createPayableSettlement({ customerId, supplierId: createForm.value.supplierId, feeSourceIds: createForm.value.feeSourceIds, dueDate: createForm.value.dueDate, currency: 'CNY' })
createOpen.value = false
@@ -157,6 +157,21 @@ describe('formal settlement native dialogs', () => {
await click(dialog, '确定')
expect(api.createReceivableSettlement).toHaveBeenCalledWith({ reconciliationStatementId: statement.id, currency: 'CNY' })
})
it.each([true, false])('warns before creating receivables from an unconfirmed statement (continue=%s)', async (proceed) => {
vi.mocked(api.listFinanceStatements).mockResolvedValue([{ ...statement, customerConfirmationStatus: 'PENDING_CONFIRMATION' }])
const confirmation = vi.spyOn(ElMessageBox, 'confirm')
if (proceed) confirmation.mockResolvedValue('confirm')
else confirmation.mockRejectedValue('cancel')
const { dialog } = await openCreate('receivable')
const selects = dialog.findAllComponents({ name: 'ElSelect' })
selects[0]!.vm.$emit('update:modelValue', String(customer.id))
await flushPromises()
selects[1]!.vm.$emit('update:modelValue', statement.id)
await click(dialog, '确定')
expect(confirmation).toHaveBeenCalledWith('客户暂未确认!', '生成应收结算单', expect.objectContaining({ type: 'warning' }))
expect(api.createReceivableSettlement).toHaveBeenCalledTimes(proceed ? 1 : 0)
if (!proceed) expect(dialog.props('modelValue')).toBe(true)
})
it('keeps active suppliers and excludes directly paid or locked payable sources', async () => {
const { dialog } = await openCreate('payable')
const selects = dialog.findAllComponents({ name: 'ElSelect' })