修复财务页面竞态并整理采购与运输展示

This commit is contained in:
2026-10-30 18:50:53 +08:00
parent 19a618c9d2
commit 7704532e8f
29 changed files with 591 additions and 217 deletions
@@ -60,7 +60,7 @@ function setFee(overrides: Partial<FinanceFeeSourceDetail> = {}) {
vi.mocked(api.listFinanceFeeSources).mockResolvedValue({ records: [current], total: 1, pages: 1 })
vi.mocked(api.getFinanceFeeSource).mockResolvedValue(current)
}
async function mountPage(kind: 'arap' | 'statements') {
async function mountPage(kind: 'arap' | 'statements', cachedWorkspace = false) {
const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/finance/customer-arap', name: 'finance-customer-arap', component: FinanceCustomerArapView },
{ path: '/finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: FinanceCustomerArapView },
@@ -69,7 +69,10 @@ async function mountPage(kind: 'arap' | 'statements') {
] })
await router.push(kind === 'arap' ? '/finance/customer-arap' : '/finance/customer-statements')
await router.isReady()
const wrapper = mount(kind === 'arap' ? FinanceCustomerArapView : FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
const component = cachedWorkspace
? { template: '<router-view v-slot="{ Component, route }"><keep-alive><component :is="Component" :key="route.name" /></keep-alive></router-view>' }
: kind === 'arap' ? FinanceCustomerArapView : FinanceCustomerStatementsView
const wrapper = mount(component, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
return { wrapper, router }
}
@@ -99,17 +102,6 @@ async function fillCreation(dialog: VueWrapper, selected = false) {
await flushPromises()
await dialog.get('textarea').setValue('测试备注')
}
async function openAdjustment() {
const { wrapper, router } = await mountPage('statements')
await rowClick(wrapper, '调整')
return { wrapper, router, dialog: activeDialog(wrapper) }
}
async function fillAdjustment(dialog: VueWrapper) {
await dialog.get('textarea[placeholder="请输入整体调整原因"]').setValue(' 整体调整 ')
await dialog.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true)
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 88.12)
await dialog.get('.el-table__body-wrapper input[placeholder="请输入明细说明"]').setValue(' 行说明 ')
}
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.resetAllMocks()
@@ -233,6 +225,30 @@ describe('fee source and statement native dialogs', () => {
await click(dialog, '确定')
expect(api.createReconciliationStatement).toHaveBeenCalledWith(expect.objectContaining({ generationMode: 'SELECTED', feeSourceIds: [fee.feeSourceId] }))
})
it.each([false, true])('opens selected statement generation directly from arap with a cached statement page=%s', async cached => {
setFee({ reviewStatus: 'APPROVED' })
const { wrapper, router } = await mountPage(cached ? 'statements' : 'arap', true)
const cachedPage = cached ? wrapper.getComponent(FinanceCustomerStatementsView).element : null
if (cached) {
await click(wrapper, '生成对账单')
await click(activeDialog(wrapper), '取消')
await router.push({ name: 'finance-customer-arap' })
await flushPromises()
}
await wrapper.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true)
await click(wrapper, '生成所选对账单')
const page = wrapper.getComponent(FinanceCustomerStatementsView)
if (cachedPage) expect(page.element).toBe(cachedPage)
const dialog = activeDialog(wrapper)
expect(dialog.props('title')).toBe('生成客户对账单')
expect(dialog.getComponent({ name: 'ElRadioGroup' }).props('modelValue')).toBe('SELECTED')
expect(dialog.findAllComponents({ name: 'ElSelect' })[0]!.props('modelValue')).toBe(String(customer.id))
expect(dialog.findAllComponents({ name: 'ElDatePicker' }).map(item => item.props('modelValue'))).toEqual(['2026-10-01', '2026-10-01'])
expect(dialog.get('.selected-source-summary').text()).toContain('已选 1 笔,合计 100.00 CNY')
expect(router.currentRoute.value.query).toEqual({})
expect(api.createReconciliationStatement).not.toHaveBeenCalled()
})
it('preserves customer and period validation before creating', async () => {
const { dialog } = await openCreate()
await click(dialog, '确定')
@@ -271,48 +287,14 @@ describe('fee source and statement native dialogs', () => {
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('测试备注')
expect(dialog.props('modelValue')).toBe(true)
})
it('keeps all adjustment rows in the main dialog flow with plain statement facts', async () => {
vi.mocked(api.getReconciliationStatement).mockResolvedValue({ ...detail, lines: Array.from({ length: 13 }, (_, index) => ({ ...detail.lines[0]!, statementLineId: 'line-' + index, feeItemName: '费用' + (index + 1) })) })
const { dialog } = await openAdjustment()
expect(dialog.props('width')).toContain('900px')
expect(dialog.props('title')).toContain(statement.billNo)
expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false)
expect(dialog.getComponent({ name: 'ElTable' }).props('maxHeight')).toBeUndefined()
expect(dialog.findAll('.el-table__body-wrapper tr')).toHaveLength(13)
expect(dialog.get('.el-table__body-wrapper').text()).toContain('费用13')
})
it('saves only checked lines with full IDs and the freshly loaded version', async () => {
const { dialog } = await openAdjustment()
await fillAdjustment(dialog)
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
await click(dialog, '确定')
expect(api.actionReconciliationStatement).toHaveBeenCalledWith(statement.id, { action: 'ADJUST', reason: '整体调整', expectedVersion: 8, adjustmentLines: [{ statementLineId: '9223372036854775011', adjustedAmount: 88.12, reason: '行说明' }] })
})
it('requires an overall reason, selection and each selected line reason', async () => {
const { dialog } = await openAdjustment()
await click(dialog, '确定')
it.each(['APPROVED', 'ISSUED', 'PAID'])('does not offer adjustment or void after statement approval: %s', async status => {
vi.mocked(api.listFinanceStatements).mockResolvedValue([{ ...statement, status }])
const { wrapper } = await mountPage('statements')
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('作废')
expect(api.actionReconciliationStatement).not.toHaveBeenCalled()
await dialog.get('textarea').setValue('整体调整')
await click(dialog, '确定')
expect(api.actionReconciliationStatement).not.toHaveBeenCalled()
await dialog.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true)
await click(dialog, '确定')
expect(api.actionReconciliationStatement).not.toHaveBeenCalled()
})
it('locks adjustment rows and retains their state with visible failure diagnostics', async () => {
const pending = deferred<FinanceReconciliationDetail>()
vi.mocked(api.actionReconciliationStatement).mockReturnValue(pending.promise)
const { dialog } = await openAdjustment()
await fillAdjustment(dialog)
await click(dialog, '确定')
expect(dialog.props('showClose')).toBe(false)
expect(dialog.get('.el-table__body-wrapper input[type="checkbox"]').attributes('disabled')).toBeDefined()
pending.reject(failure())
await flushPromises()
expect(dialog.getComponent(OperationErrorToast).props('details')).toMatchObject({ message: '测试拒绝操作', requestId: 'finance-dialog-test' })
expect((dialog.get('.el-table__body-wrapper input[type="checkbox"]').element as HTMLInputElement).checked).toBe(true)
expect((dialog.get('.el-table__body-wrapper input[placeholder="请输入明细说明"]').element as HTMLInputElement).value).toBe(' 行说明 ')
expect(dialog.props('modelValue')).toBe(true)
})
it.each([
['驳回', 'PENDING_REVIEW', 'REJECT'], ['作废', 'DRAFT', 'VOID'], ['重开', 'REJECTED', 'REOPEN'],
@@ -576,7 +576,7 @@ onBeforeUnmount(clearVoucherPreview)
<tr><th scope="row">原消费金额</th><td>{{ money(refundSource?.entry.amount) }}</td></tr>
</tbody></table></section>
<el-alert title="退款会生成独立流水并关联原消费;累计可退金额以服务端校验为准。" type="info" :closable="false" />
<div v-if="refundError"><el-alert :title="refundError.message" type="error" :closable="false" /><ApiErrorMeta :details="refundError" /></div>
<el-alert v-if="refundError" :title="refundError.message" type="error" :closable="false" />
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="退款登记"><el-form class="density-form" label-position="left" label-width="100px" :disabled="refundSaving"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="退款金额" required><el-input v-model="refundForm.amount" inputmode="decimal" placeholder="请输入退款金额" /></el-form-item></td></tr>
<tr><td><el-form-item label="退款说明" required><el-input v-model="refundForm.reason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请填写退款说明" /></el-form-item></td></tr>
@@ -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)
@@ -2,7 +2,7 @@
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import '../financeDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Download, Plus, SlidersHorizontal } from '@lucide/vue'
@@ -29,15 +29,6 @@ import { actionReconciliationStatement, createReconciliationStatement, getReconc
import { financeBillStatusOptions, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
interface AdjustmentRow {
selected: boolean
statementLineId: string
feeItemName: string
originalAmount: number
adjustedAmount: number
reason: string
}
type StatementRow = Omit<FinanceStatementReadModel, 'creditDays' | 'settled'> & {
periodStart: string
periodEnd: string
@@ -85,12 +76,9 @@ const createForm = ref({
remark: '',
})
let createSourceLoadSequence = 0
const adjustOpen = ref(false)
const adjustBusy = ref(false)
const adjustError = ref<ApiErrorDetails | null>(null)
const adjustStatement = ref<FinanceStatementReadModel | null>(null)
const adjustReason = ref('')
const adjustLines = ref<AdjustmentRow[]>([])
let selectedCreateGeneration = 0
let pageActive = true
let initialLoadCompleted = false
const canView = computed(() => access.can('platform:finance:customer-statement:list'))
const canListCustomers = computed(() => access.can('platform:customer:list'))
@@ -100,7 +88,6 @@ const canCreate = computed(() => access.can('platform:finance:reconciliation:cre
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
const canReview = computed(() => access.can('platform:finance:reconciliation:review'))
const canIssue = computed(() => access.can('platform:finance:reconciliation:issue'))
const canAdjust = computed(() => access.can('platform:finance:reconciliation:adjust'))
const canVoid = computed(() => access.can('platform:finance:reconciliation:void'))
const canExport = computed(() => access.can('platform:finance:customer-statement:export') && access.can('platform:download:list'))
const canGenerate = computed(() => canCreate.value && canListCustomers.value)
@@ -407,6 +394,7 @@ function defaultCreateForm() {
function openCreate() {
if (!canListCustomers.value) return ElMessage.warning('缺少客户列表权限,无法选择客户生成对账单')
invalidateCreateLoads()
createForm.value = defaultCreateForm()
createSourceOptions.value = []
createSourcesError.value = null
@@ -440,6 +428,7 @@ async function loadEligibleCreateSources(preferredIds: string[] = createForm.val
businessDateFrom: createForm.value.periodStart,
businessDateTo: createForm.value.periodEnd,
})
if (sequence !== createSourceLoadSequence) return
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
@@ -467,7 +456,9 @@ function queryText(value: unknown) {
}
async function openSelectedCreateFromRoute() {
if (route.name !== 'finance-customer-statements' || queryText(route.query.createMode) !== 'SELECTED') return
if (!pageActive || route.name !== 'finance-customer-statements' || queryText(route.query.createMode) !== 'SELECTED') return
const generation = ++selectedCreateGeneration
const sourcePath = route.fullPath
if (!canGenerate.value || !canSelectSources.value) {
ElMessage.warning('缺少客户或费用来源权限,无法生成所选对账单')
return
@@ -484,8 +475,12 @@ async function openSelectedCreateFromRoute() {
ElMessage.warning('带入费用所属客户当前不可用,请重新选择费用')
return
}
createError.value = null
createForm.value = { generationMode: 'SELECTED', customerId, periodStart, periodEnd, feeSourceIds: [], remark: '' }
await loadEligibleCreateSources(feeSourceIds)
// 带入候选只属于当前激活页面及本次路由,旧响应不能重新打开弹框或覆盖用户导航。
if (!pageActive || generation !== selectedCreateGeneration || route.fullPath !== sourcePath
|| !canGenerate.value || !canSelectSources.value) return
createOpen.value = true
await router.replace({ name: 'finance-customer-statements' })
}
@@ -535,18 +530,6 @@ async function performAction(row: FinanceStatementReadModel, actionName: Reconci
finally { actionBusy.value = false }
}
async function openAdjustment(row: FinanceStatementReadModel) {
adjustError.value = null
adjustBusy.value = true
try {
const current = await fetchDetail(row.id, true)
adjustStatement.value = { ...row, version: current.version }
adjustReason.value = ''
adjustLines.value = current.lines.flatMap((line) => line.statementLineId ? [{ selected: false, statementLineId: line.statementLineId, feeItemName: line.feeItemName, originalAmount: line.amount, adjustedAmount: line.amount, reason: '' }] : [])
adjustOpen.value = true
} catch (cause) { error.value = getApiErrorDetails(cause, '调整明细加载失败') }
finally { adjustBusy.value = false }
}
function statementActions(row: FinanceStatementReadModel): RowActionItem[] {
return [
{ key: 'detail', label: '详情', priority: 300, disabled: loading.value },
@@ -554,14 +537,13 @@ 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: 'adjust', label: '调整', priority: 200, visible: row.status === 'ISSUED' && canAdjust.value, loading: adjustBusy.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', 'APPROVED', 'ISSUED'].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 },
]
}
function handleStatementAction(command: string, row: FinanceStatementReadModel) {
if (!statementActions(row).some(item => item.key === command && item.visible !== false)) return
if (command === 'detail') void openDetail(row)
else if (command === 'adjust') void openAdjustment(row)
else {
const actions: Record<string, ReconciliationActionPayload['action']> = {
submit: 'SUBMIT', approve: 'APPROVE', reject: 'REJECT', issue: 'ISSUE', reopen: 'REOPEN', void: 'VOID',
@@ -571,35 +553,39 @@ function handleStatementAction(command: string, row: FinanceStatementReadModel)
}
}
async function submitAdjustment() {
if (adjustBusy.value) return
const statement = adjustStatement.value
const selected = adjustLines.value.filter((line) => line.selected)
if (!statement || !adjustReason.value.trim() || !selected.length) return ElMessage.warning('请填写调整原因并至少选择一条明细')
if (selected.some((line) => line.adjustedAmount <= 0 || !line.reason.trim())) return ElMessage.warning('请填写所选明细的新金额和调整说明')
adjustBusy.value = true
adjustError.value = null
try {
await actionReconciliationStatement(statement.id, {
action: 'ADJUST', reason: adjustReason.value.trim(), expectedVersion: statement.version,
adjustmentLines: selected.map((line) => ({ statementLineId: line.statementLineId, adjustedAmount: line.adjustedAmount, reason: line.reason.trim() })),
})
adjustOpen.value = false
ElMessage.success('对账单调整已保存')
await load(appliedFilters.value)
await openDetail(statement)
} catch (cause) { adjustError.value = getApiErrorDetails(cause, '对账单调整失败'); error.value = adjustError.value }
finally { adjustBusy.value = false }
function invalidateCreateLoads() {
selectedCreateGeneration += 1
createSourceLoadSequence += 1
createSourcesLoading.value = false
}
function deactivateCreateLoads() {
pageActive = false
invalidateCreateLoads()
}
onMounted(async () => {
if (canListCustomers.value) await loadCustomers()
await (canListCustomers.value ? loadCustomers() : undefined)
initialLoadCompleted = true
await openSelectedCreateFromRoute()
})
onActivated(() => {
pageActive = true
if (initialLoadCompleted) void openSelectedCreateFromRoute()
})
onDeactivated(deactivateCreateLoads)
onBeforeUnmount(deactivateCreateLoads)
watch(() => route.name, (name) => {
if (name !== 'finance-customer-statements') invalidateCreateLoads()
}, { flush: 'sync' })
watch(
() => [createOpen.value, createForm.value.generationMode, createForm.value.customerId, createForm.value.periodStart, createForm.value.periodEnd],
([open, mode]) => {
if (!open || mode !== 'SELECTED') return
([open, mode], [wasOpen]) => {
if (!open) {
if (wasOpen === true) invalidateCreateLoads()
return
}
if (mode !== 'SELECTED') return
void loadEligibleCreateSources()
},
)
@@ -744,16 +730,7 @@ watch(detailRouteId, (statementId) => {
</section>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="adjustOpen" :title="`调整客户对账单 · ${adjustStatement?.billNo || '—'}`" width="min(900px, 96vw)" fullscreenable :confirm-loading="adjustBusy" @confirm="submitAdjustment">
<OperationErrorToast :details="adjustError" />
<el-form class="density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="adjustBusy">
<section v-if="adjustStatement" class="density-dialog-section workbench-evidence" data-tone="summary" aria-label="当前对账单"><h3 class="density-dialog-section__title">当前对账单</h3><table class="density-table statement-dialog-facts"><tbody><tr><th scope="row">对账单号</th><td>{{ adjustStatement.billNo || '—' }}</td><th scope="row">客户</th><td>{{ customerDisplayName(adjustStatement.customerId, adjustStatement.customerName) }}</td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="work" aria-label="对账单调整登记"><h3 class="density-dialog-section__title">调整登记</h3><div class="statement-adjust-editor">
<div class="workbench-evidence"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit placeholder="请输入整体调整原因" /></el-form-item></td></tr></tbody></table></div>
<div class="workbench-table"><el-table :data="adjustLines" size="small" border stripe row-key="statementLineId"><el-table-column label="选择" width="64"><template #default="{ row }"><el-checkbox v-model="row.selected" :aria-label="`选择${row.feeItemName || '费用明细'}`" /></template></el-table-column><el-table-column prop="feeItemName" label="费用项目" min-width="150" show-overflow-tooltip /><el-table-column prop="originalAmount" label="原金额(CNY)" width="130" /><el-table-column label="新金额(CNY)" width="180"><template #default="{ row }"><el-input-number v-model="row.adjustedAmount" :min="0.01" :precision="2" placeholder="请输入新金额" /></template></el-table-column><el-table-column label="明细说明" min-width="220"><template #default="{ row }"><el-input v-model="row.reason" maxlength="500" placeholder="请输入明细说明" /></template></el-table-column></el-table></div>
</div></section>
</el-form>
</AppDialog>
</AdminPage>
</template>
@@ -761,10 +738,8 @@ watch(detailRouteId, (statementId) => {
.finance-statements-page { min-width: 0; }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
.selected-source-summary { width: 100%; margin-top: var(--admin-space-2); text-align: right; }
.statement-create-table, .statement-dialog-facts { table-layout: fixed; }
.statement-create-table { table-layout: fixed; }
.statement-create-table td:not([colspan]) { width: 50%; }
.statement-dialog-facts th { width: var(--admin-label-width); }
.statement-adjust-editor { display: grid; gap: var(--admin-space-5); }
.statement-detail-page { min-width: 0; width: 100%; margin: 0 auto; overflow: hidden; }
.statement-summary-section { padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); }
.statement-outstanding { color: var(--el-color-warning-dark-2); }
@@ -90,6 +90,7 @@ const receiptValidationError = ref<string | null>(null)
const receiptError = ref<ApiErrorDetails | null>(null)
const receiptForm = ref({ receivedDate: new Date().toISOString().slice(0, 10), amount: 0, paymentMethod: 'BANK_TRANSFER' as 'BANK_TRANSFER' | 'CASH' | 'OTHER', remark: '' })
const actionBusy = ref(false)
const actionError = ref<ApiErrorDetails | null>(null)
const exportSubmitting = ref(false)
const createPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:create' : 'platform:finance:settlement-ap:create')
@@ -459,12 +460,19 @@ async function performAction(row: SettlementRow, action: 'VOID' | 'REOPEN') {
}).then((result) => result.value.trim()).catch(() => null)
if (reason === null) return
actionBusy.value = true
actionError.value = null
try {
await actionFinanceSettlement(row.id, { action, reason, expectedVersion: row.version })
ElMessage.success(action === 'VOID' ? '结算单已作废' : '结算单已重开')
if (detailMode.value) await loadDetail(row.id)
else await load()
} catch (cause) { error.value = getApiErrorDetails(cause, `${action === 'VOID' ? '作废' : '重开'}结算单失败`) }
} catch (cause) {
const details = getApiErrorDetails(cause, `${action === 'VOID' ? '作废' : '重开'}结算单失败`)
if (props.kind === 'receivable' && action === 'REOPEN' && details.message === '原对账单已生成其他有效应收结算单') {
details.message = '已有新的应收单,无法重开'
}
actionError.value = details
}
finally { actionBusy.value = false }
}
@@ -524,6 +532,7 @@ onMounted(() => {
<OperationErrorToast v-if="createOpen || paymentOpen" :details="error" />
<OperationErrorToast :message="receiptValidationError" />
<OperationErrorToast :details="receiptError" />
<OperationErrorToast :details="actionError" />
<el-result v-if="!canView" icon="warning" :title="`无权访问${title}`" sub-title="请联系管理员开通结算单查看权限" />
<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel finance-density-detail workbench-panel is-flush is-page-surface" :aria-label="`${title}详情`">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" aria-label="返回列表" @click="backToList"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.settlementNo || '—' }}</h1><StatusBadge v-if="detail" :status="financeStatusLabel(detail.status)" /></div><div class="finance-detail-actions">
@@ -674,7 +683,7 @@ onMounted(() => {
</template>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="submitCreate">
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="结算单生成设置"><h3 class="density-dialog-section__title">生成设置</h3>
<section class="density-dialog-section workbench-evidence" aria-label="结算单生成设置"><h3 class="density-dialog-section__title">生成设置</h3>
<el-form class="density-form" label-position="left" label-width="120px" :disabled="createBusy"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="币种"><span>CNY</span></el-form-item></td></tr>
@@ -95,7 +95,7 @@ describe('finance G3/G4 page operations', () => {
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 () => {
it('keeps issued statement history readable and ignores stale adjustment or void commands', 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 },
@@ -105,26 +105,17 @@ describe('finance G3/G4 page operations', () => {
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 }>
openDetail(row: FinanceStatementReadModel): Promise<void>
handleStatementAction(command: string, row: FinanceStatementReadModel): void
}
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()
vm.handleStatementAction('adjust', statement)
vm.handleStatementAction('void', statement)
await flushPromises()
expect(actionReconciliationStatement).not.toHaveBeenCalled()
await vm.openDetail(statement)
await flushPromises()
expect(getReconciliationStatement).toHaveBeenCalledWith('501')
expect(actionReconciliationStatement).toHaveBeenCalledWith('501', {
action: 'ADJUST', reason: '账期复核调整', expectedVersion: 3,
adjustmentLines: [
{ statementLineId: '601', adjustedAmount: 90, reason: '运费更正' },
{ statementLineId: '602', adjustedAmount: 180, reason: '操作费更正' },
],
})
expect(wrapper.text()).toContain(statement.billNo)
}, 15_000)
it('loads AR linkage, uploads a voucher and allocates a real receipt to its statement', async () => {
@@ -101,16 +101,14 @@ describe('Finance G3/G4 views', () => {
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('keeps an issued statement read-only with legacy adjustment permission', async () => {
const wrapper = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust', 'platform:finance:reconciliation:void'])
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('作废')
expect(actionReconciliationStatement).not.toHaveBeenCalled()
})
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 } }
@@ -1,8 +1,51 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { listPlatformTenants } from '@/modules/iam'
import { getReceivableStatistics } from '../api/finance'
import type { ReceivableStatisticsResult } from '../types'
import FinanceReceivableStatisticsView from './FinanceReceivableStatisticsView.vue'
const can = vi.hoisted(() => vi.fn((_permission: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
vi.mock('../api/finance', () => ({ getReceivableStatistics: vi.fn() }))
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceReceivableStatisticsView.vue'), 'utf8')
const statistics: ReceivableStatisticsResult = {
statisticsMonth: '2026-10', currency: 'CNY', totalReceivable: '2600', totalReceived: '2550', totalOutstanding: '50',
totalCustomers: 2, page: 1, pageSize: 20,
records: [
{ customerId: '10001', customerName: '客户A', currency: 'CNY', totalReceivable: '2400', totalReceived: '2400', totalOutstanding: '0' },
{ customerId: '10002', customerName: '客户 10002', currency: 'CNY', totalReceivable: '200', totalReceived: '150', totalOutstanding: '50' },
],
}
async function mountPage() {
const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/finance/receivable-statistics', name: 'finance-receivable-statistics', component: FinanceReceivableStatisticsView },
{ path: '/other', name: 'other-page', component: { template: '<div>其他页面</div>' } },
] })
await router.push('/finance/receivable-statistics')
await router.isReady()
const wrapper = mount(FinanceReceivableStatisticsView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
return { wrapper, router }
}
enableAutoUnmount(afterEach)
beforeEach(() => {
can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: '10002', tenantName: '客户B', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getReceivableStatistics).mockResolvedValue(statistics)
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
describe('receivable statistics page', () => {
it('renders only the approved CNY derived metrics and current-filter actions', () => {
@@ -25,4 +68,47 @@ describe('receivable statistics page', () => {
expect(page).toContain('当前月份暂无应收数据')
expect(page).toContain(':loading="loading"')
})
it('prints an isolated report with all five columns, the loaded month and server totals', async () => {
const print = vi.spyOn(window, 'print').mockImplementation(() => {})
const { wrapper } = await mountPage()
const report = document.body.querySelector(':scope > .receivable-statistics-print')!
expect(report).not.toBeNull()
expect(report.querySelector('h1')?.textContent).toBe('应收款统计表')
expect(report.textContent).toContain('统计月份:2026-10')
expect([...report.querySelectorAll('thead th')].map(cell => cell.textContent)).toEqual(['应收单位', '币种', '应收总额(CNY)', '已收合计(CNY)', '余额未收(CNY)'])
expect([...report.querySelectorAll('tbody tr')].map(row => [...row.querySelectorAll('td')].map(cell => cell.textContent))).toEqual([
['客户A', 'CNY', '2,400.00', '2,400.00', '0.00'],
['客户B', 'CNY', '200.00', '150.00', '50.00'],
])
expect([...report.querySelectorAll('tfoot td')].map(cell => cell.textContent)).toEqual(['2,600.00', '2,550.00', '50.00'])
expect(report.querySelector('button, input, .finance-metric-grid, .data-table__pagination')).toBeNull()
expect(page).toContain(':global(body:has(> .receivable-statistics-print) > :not(.receivable-statistics-print)) { display: none !important; }')
expect(page).toContain('width: 100%; border-collapse: collapse; table-layout: fixed;')
await wrapper.findAll('button').find(button => button.text() === '打印')!.trigger('click')
expect(print).toHaveBeenCalledOnce()
})
it('removes the print report when a cached statistics page is no longer the active route', async () => {
const { router } = await mountPage()
expect(document.querySelector('.receivable-statistics-print')).not.toBeNull()
await router.push('/other')
await flushPromises()
expect(document.querySelector('.receivable-statistics-print')).toBeNull()
await router.push('/finance/receivable-statistics')
await flushPromises()
expect(document.querySelector('.receivable-statistics-print')).not.toBeNull()
})
it.each(['loading', 'error', 'no-permission'])('does not print unavailable statistics: %s', async state => {
if (state === 'loading') vi.mocked(getReceivableStatistics).mockReturnValueOnce(new Promise(() => {}))
if (state === 'error') vi.mocked(getReceivableStatistics).mockRejectedValueOnce(new Error('不可用'))
if (state === 'no-permission') can.mockReturnValue(false)
const print = vi.spyOn(window, 'print').mockImplementation(() => {})
const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as { printCurrent(): void }
expect(document.querySelector('.receivable-statistics-print')).toBeNull()
vm.printCurrent()
expect(print).not.toHaveBeenCalled()
})
})
@@ -5,7 +5,7 @@ import '../financeDensity.css'
import { computed, onMounted, ref } from 'vue'
import { CircleCheck, Clock3, Download, Printer, RotateCcw, Search, Wallet } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -21,6 +21,7 @@ import type { ReceivableStatisticsResult, ReceivableStatisticsRow } from '../typ
const now = new Date()
const defaultMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
const access = useAccessPreviewStore()
const route = useRoute()
const router = useRouter()
const month = ref(defaultMonth)
const customerId = ref('')
@@ -33,6 +34,7 @@ const error = ref<ApiErrorDetails | null>(null)
const result = ref<ReceivableStatisticsResult | null>(null)
const canView = computed(() => access.can('platform:finance:receivable-statistics:list'))
const canPrint = computed(() => route.name === 'finance-receivable-statistics' && canView.value && !loading.value && !error.value && Boolean(result.value))
const canExport = computed(() => access.can('platform:finance:receivable-statistics:export') && access.can('platform:download:list'))
const rows = computed<ReceivableStatisticsRow[]>(() => result.value?.records ?? [])
const customerNameById = computed(() => new Map(customers.value.map((item) => [String(item.id), item.tenantName])))
@@ -65,7 +67,7 @@ async function loadCustomers() {
function search() { page.value = 1; void load() }
function reset() { month.value = defaultMonth; customerId.value = ''; page.value = 1; void load() }
function printCurrent() { window.print() }
function printCurrent() { if (canPrint.value) window.print() }
function statisticsRowKey(row: ReceivableStatisticsRow) { return `${row.customerId}:${row.customerName}` }
function displayCustomerName(value: unknown) {
const row = value as ReceivableStatisticsRow
@@ -116,12 +118,30 @@ onMounted(() => { void loadCustomers(); void load() })
</section></template>
<template #notices><LoadErrorState v-if="error" title="应收款统计加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #toolbar-actions><div class="report-actions"><el-button :disabled="loading || !result" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div></template>
<template #toolbar-actions><div class="report-actions"><el-button :disabled="!canPrint" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div></template>
<template #customerName="{ row }">{{ displayCustomerName(row) }}</template><template #totalReceivable="{ row }">{{ money(row.totalReceivable) }}</template><template #totalReceived="{ row }">{{ money(row.totalReceived) }}</template><template #totalOutstanding="{ row }"><span class="outstanding-cell">{{ money(row.totalOutstanding) }}</span></template>
<template #pagination-left>统计月份:{{ result?.statisticsMonth }} · 币种:CNY</template>
</DataTable></div>
</template>
</AdminPage>
<Teleport to="body">
<section v-if="canPrint && result" class="receivable-statistics-print" aria-label="应收款统计打印报表">
<header>
<h1>应收款统计表</h1>
<p>统计月份:{{ result.statisticsMonth }} · 币种:CNY</p>
</header>
<table>
<colgroup><col class="print-customer-column" /><col class="print-currency-column" /><col /><col /><col /></colgroup>
<thead><tr><th scope="col">应收单位</th><th scope="col">币种</th><th scope="col">应收总额(CNY)</th><th scope="col">已收合计(CNY)</th><th scope="col">余额未收(CNY)</th></tr></thead>
<tbody>
<tr v-for="row in rows" :key="statisticsRowKey(row)"><td>{{ displayCustomerName(row) }}</td><td>{{ row.currency }}</td><td>{{ money(row.totalReceivable) }}</td><td>{{ money(row.totalReceived) }}</td><td>{{ money(row.totalOutstanding) }}</td></tr>
<tr v-if="!rows.length"><td colspan="5">当前月份暂无应收数据</td></tr>
</tbody>
<tfoot><tr><th scope="row" colspan="2">统计汇总</th><td>{{ money(result.totalReceivable) }}</td><td>{{ money(result.totalReceived) }}</td><td>{{ money(result.totalOutstanding) }}</td></tr></tfoot>
</table>
<p>数据第 {{ result.page }} 页 · 共 {{ result.totalCustomers }} 家应收单位</p>
</section>
</Teleport>
</template>
<style scoped>
@@ -133,10 +153,21 @@ onMounted(() => { void loadCustomers(); void load() })
.query-actions :deep(.el-button), .report-actions :deep(.el-button) { margin-left: 0; }
.outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; }
.statistics-table { overflow: hidden; }
.receivable-statistics-print { display: none; }
@media print {
.statistics-query, :deep(.data-table__toolbar), :deep(.data-table__pagination) { display: none !important; }
.receivable-statistics-page { gap: 10px; padding: 0; color: #000; }
.workbench-table > :deep(.data-table) { border-radius: var(--admin-radius); box-shadow: none; }
.finance-metric-grid > article, .statistics-table { border-color: #999; box-shadow: none; break-inside: avoid; }
:global(body:has(> .receivable-statistics-print) > :not(.receivable-statistics-print)) { display: none !important; }
:global(body:has(> .receivable-statistics-print)) { min-width: 0; height: auto; margin: 0; overflow: visible; background: #fff; }
.receivable-statistics-print { display: block; width: 100%; color: #000; font: 10pt/1.5 Arial, 'Microsoft YaHei', sans-serif; }
.receivable-statistics-print h1 { margin: 0 0 8pt; text-align: center; font-size: 18pt; }
.receivable-statistics-print p { margin: 8pt 0; }
.receivable-statistics-print table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.receivable-statistics-print col { width: 18%; }
.receivable-statistics-print .print-customer-column { width: 38%; }
.receivable-statistics-print .print-currency-column { width: 8%; }
.receivable-statistics-print th, .receivable-statistics-print td { padding: 5pt; border: 1px solid #999; text-align: left; overflow-wrap: anywhere; }
.receivable-statistics-print tbody td:nth-child(n+3), .receivable-statistics-print thead th:nth-child(n+3), .receivable-statistics-print tfoot td { text-align: right; font-variant-numeric: tabular-nums; }
.receivable-statistics-print thead { display: table-header-group; }
.receivable-statistics-print tfoot { display: table-row-group; font-weight: 700; }
.receivable-statistics-print tr { break-inside: avoid; }
}
</style>
@@ -2,7 +2,7 @@ import { createRequire } from 'node:module'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ElMessageBox } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
@@ -295,4 +295,46 @@ describe('formal settlement native dialogs', () => {
expect(prompt).toHaveBeenCalledWith('请输入操作原因', expect.stringContaining(current.settlementNo), expect.objectContaining({ customClass: 'admin-density-confirm admin-workbench', inputPattern: /\S+/ }))
expect(api.actionFinanceSettlement).toHaveBeenCalledWith(current.id, { action, reason: '测试原因', expectedVersion: current.version })
})
it('shows a concise reopen conflict message and retains both the voided and replacement receivables', async () => {
const voided = { ...ar, status: 'VOIDED' }
const replacement = { ...ar, id: '9223372036854775012', settlementNo: 'AR202610200002' }
vi.mocked(api.listFinanceReceivableSettlements).mockResolvedValue([voided, replacement])
vi.mocked(api.actionFinanceSettlement).mockRejectedValue(new ApiResponseError(
'原对账单已生成其他有效应收结算单', 'FIN_409', 409, null, 'reopen-test-request', 'reopen-test-trace',
))
vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue(Object.assign('confirm', { value: '测试重开', action: 'confirm' as const }))
const message = vi.spyOn(ElMessage, 'error')
const wrapper = await mountPage('receivable')
for (let attempt = 1; attempt <= 2; attempt += 1) {
await click(wrapper, '重开', '.el-table__body-wrapper button')
expect(message).toHaveBeenCalledTimes(attempt)
expect(message).toHaveBeenLastCalledWith({ message: '已有新的应收单,无法重开', showClose: true, duration: 4500 })
}
expect(api.actionFinanceSettlement).toHaveBeenLastCalledWith(ar.id, { action: 'REOPEN', reason: '测试重开', expectedVersion: ar.version })
expect(errorDetails(wrapper)).toMatchObject({ message: '已有新的应收单,无法重开', code: 'FIN_409', requestId: 'reopen-test-request', traceId: 'reopen-test-trace' })
const list = wrapper.get('.el-table__body-wrapper')
expect(list.text()).toContain(voided.settlementNo)
expect(list.text()).toContain(replacement.settlementNo)
expect(wrapper.text()).not.toContain('加载失败')
expect(wrapper.text()).not.toContain('reopen-test-request')
expect(wrapper.text()).not.toContain('reopen-test-trace')
})
it.each([
['receivable', 'VOID', 'ISSUED'], ['receivable', 'REOPEN', 'VOIDED'],
['payable', 'VOID', 'ISSUED'], ['payable', 'REOPEN', 'VOIDED'],
] as const)('retains the %s list and original diagnostics when %s fails', async (kind, action, status) => {
const current = kind === 'receivable' ? ar : ap
if (kind === 'receivable') vi.mocked(api.listFinanceReceivableSettlements).mockResolvedValue([{ ...ar, status }])
else vi.mocked(api.listFinancePayableSettlements).mockResolvedValue([{ ...ap, status }])
vi.mocked(api.actionFinanceSettlement).mockRejectedValue(failure())
vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue(Object.assign('confirm', { value: '测试原因', action: 'confirm' as const }))
const wrapper = await mountPage(kind)
await click(wrapper, action === 'VOID' ? '作废' : '重开', '.el-table__body-wrapper button')
expect(errorDetails(wrapper)).toMatchObject({ message: '测试操作失败', code: 'TEST_REJECTED', requestId: 'settlement-test-request' })
expect(wrapper.get('.el-table__body-wrapper').text()).toContain(current.settlementNo)
expect(wrapper.text()).not.toContain('加载失败')
})
})
+15
View File
@@ -52,6 +52,21 @@ describe('finance status presentation', () => {
expect(financeOperationActionLabel('NEW_ACTION')).toBe('未知操作')
})
it.each([
['OVERSEAS_INBOUND_FEE', '海外入仓费用'],
['OVERSEAS_PROCUREMENT_FEE', '海外采购费用'],
['OVERSEAS_TRUCK_FEE', '本土卡车费用'],
['OVERSEAS_LOOSEBOARD_FEE', '本土散板费用'],
['OVERSEAS_WAREHOUSE_FEE', '海外仓服务费'],
['OVERSEAS_CONTAINER_FEE', '海外柜子费用'],
['OVERSEAS_ORDER_FEE', '尾程订单费用'],
['OVERSEAS_DELIVERY_FEE', '尾程派送费用'],
['OVERSEAS_RELABEL_FEE', '换标费用'],
])('labels the current overseas fee source %s consistently', (code, label) => {
expect(financeSourceTypeLabel(code)).toBe(label)
expect(financeSourceTypeLabel(` ${code.toLowerCase()} `)).toBe(label)
})
it('uses review-status values for the customer AR/AP review filter', () => {
expect(financeArapStatusOptions).toEqual([
{ label: '草稿', value: 'DRAFT' },
+5
View File
@@ -60,6 +60,11 @@ export function financeStatusTone(status: unknown): FinanceStatusTone {
const financeSourceTypeLabels: Record<string, string> = {
INBOUND_FEE: '入库费用', ORDER_FEE: '订单费用', CONTAINER_FEE: '装柜费用', CONTAINER_COST: '整柜成本', MANUAL_COST: '人工成本', STORAGE_FEE: '仓储费用',
OVERSEAS_INBOUND_FEE: '海外入仓费用', OVERSEAS_PROCUREMENT_FEE: '海外采购费用',
OVERSEAS_TRUCK_FEE: '本土卡车费用', OVERSEAS_LOOSEBOARD_FEE: '本土散板费用',
OVERSEAS_WAREHOUSE_FEE: '海外仓服务费', OVERSEAS_CONTAINER_FEE: '海外柜子费用',
OVERSEAS_ORDER_FEE: '尾程订单费用', OVERSEAS_DELIVERY_FEE: '尾程派送费用',
OVERSEAS_RELABEL_FEE: '换标费用',
}
const financeBusinessTypeLabels: Record<string, string> = {
RECONCILIATION_CREATED: '生成对账单',