修复财务页面竞态并整理采购与运输展示
This commit is contained in:
@@ -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('加载失败')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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: '生成对账单',
|
||||
|
||||
Reference in New Issue
Block a user