优化
This commit is contained in:
@@ -11,7 +11,7 @@ import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { adjustFinanceFeeSource, createReconciliationStatement, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance'
|
||||
import { adjustFinanceFeeSource, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance'
|
||||
import type { FinanceFeeSourceDetail, FinanceFeeSourceSummary } from '../types'
|
||||
import FinanceCustomerArapView from './FinanceCustomerArapView.vue'
|
||||
|
||||
@@ -23,7 +23,6 @@ vi.mock('../api/finance', () => ({
|
||||
getFinanceFeeSource: vi.fn(),
|
||||
reviewFinanceFeeSource: vi.fn(),
|
||||
editFinanceFeeSource: vi.fn(), submitFinanceFeeSource: vi.fn(), adjustFinanceFeeSource: vi.fn(), voidFinanceFeeSource: vi.fn(),
|
||||
createReconciliationStatement: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
@@ -62,6 +61,7 @@ async function mountView(permissions: string[], initialPath = '/finance/customer
|
||||
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 },
|
||||
{ path: '/finance/customer-statements', name: 'finance-customer-statements', component: { template: '<div>客户对账单</div>' } },
|
||||
] })
|
||||
await router.push(initialPath); await router.isReady()
|
||||
const wrapper = mount(FinanceCustomerArapView, {
|
||||
@@ -96,7 +96,6 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
||||
vi.mocked(submitFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'PENDING', operationVersion: 4 })
|
||||
vi.mocked(adjustFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'DRAFT', operationVersion: 4 })
|
||||
vi.mocked(voidFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, sourceStatus: 'VOIDED', status: 'VOIDED', operationVersion: 4 })
|
||||
vi.mocked(createReconciliationStatement).mockReset().mockResolvedValue({} as never)
|
||||
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ id: 'exp-3', taskNo: 'EXP-3', taskName: '客户应收应付费用来源', status: 'PENDING' } as never)
|
||||
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
|
||||
})
|
||||
@@ -254,18 +253,19 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
||||
expect(wrapper.text()).not.toContain(customerName)
|
||||
})
|
||||
|
||||
it('creates a reconciliation draft from selected eligible receivables', async () => {
|
||||
it('continues selected eligible receivables in the customer statement generation center', async () => {
|
||||
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:reconciliation:create'])
|
||||
await loadRows(wrapper)
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selectedRows: Array<Record<string, unknown>>
|
||||
generationForm: { mode: 'BY_CUTOFF' | 'SELECTED'; customerId: string; period: string[]; remark: string }
|
||||
submitGeneration(): Promise<void>
|
||||
continueSelectedGeneration(): Promise<void>
|
||||
}
|
||||
vm.selectedRows = [{ id: 'RECEIVABLE:42', feeSourceId: '42', customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', status: 'AVAILABLE', statementGenerated: false, amount: 100 }]
|
||||
Object.assign(vm.generationForm, { mode: 'SELECTED', customerId: '10001', period: ['2026-08-01', '2026-08-31'], remark: '八月费用' })
|
||||
await vm.submitGeneration()
|
||||
expect(createReconciliationStatement).toHaveBeenCalledWith({ customerId: '10001', currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: ['42'], periodStart: '2026-08-01', periodEnd: '2026-08-31', remark: '八月费用' })
|
||||
vm.selectedRows = [{ id: 'RECEIVABLE:42', feeSourceId: '42', customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', status: 'AVAILABLE', statementGenerated: false, amount: 100, businessDate: '2026-08-11' }]
|
||||
await vm.continueSelectedGeneration()
|
||||
expect(wrapper.router.currentRoute.value).toMatchObject({
|
||||
name: 'finance-customer-statements',
|
||||
query: { createMode: 'SELECTED', customerId: '10001', feeSourceIds: '42', periodStart: '2026-08-11', periodEnd: '2026-08-11' },
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a fee-source export task with the complete active filters and typed booleans', async () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
import { adjustFinanceFeeSource, createReconciliationStatement, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance'
|
||||
import { adjustFinanceFeeSource, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance'
|
||||
import { financeArapStatusOptions, financeDirectionLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
|
||||
import type { FinanceFeeSourceDetail } from '../types'
|
||||
|
||||
@@ -72,10 +72,6 @@ const mutationError = ref<ApiErrorDetails | null>(null)
|
||||
const mutationMode = ref<'EDIT' | 'ADJUST' | 'SUBMIT' | 'VOID'>('EDIT')
|
||||
const mutationForm = reactive({ amount: 0, reason: '' })
|
||||
const mutationTitle = computed(() => ({ EDIT: '编辑费用', ADJUST: '调整费用', SUBMIT: '提交审核', VOID: '作废费用' } as const)[mutationMode.value])
|
||||
const generationOpen = ref(false)
|
||||
const generationSaving = ref(false)
|
||||
const generationError = ref<ApiErrorDetails | null>(null)
|
||||
const generationForm = reactive<{ mode: 'BY_CUTOFF' | 'SELECTED'; customerId: string; period: string[]; cutoffDate: string; remark: string }>({ mode: 'SELECTED', customerId: '', period: [], cutoffDate: '', remark: '' })
|
||||
const selectedRows = ref<ArapRow[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
@@ -101,7 +97,7 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'keyword', type: 'input', label: '关键词', width: 240, placeholder: '业务单号/费用/客户/供应商', props: { 'aria-label': '关键词' } },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: (row: ArapRow) => row.direction === 'RECEIVABLE' && Boolean(row.feeSourceId) }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
|
||||
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
|
||||
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
|
||||
{ key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'supplierName', label: '供应商', minWidth: 170, slotName: 'supplierName' },
|
||||
{ key: 'status', label: '来源状态', width: 130, slotName: 'sourceStatus' },
|
||||
@@ -247,22 +243,31 @@ async function submitMutation() {
|
||||
function statusLabel(status: string) { return financeStatusLabel(status) }
|
||||
function money(value: unknown) { return Number(value || 0).toFixed(2) }
|
||||
function onSelectionChange(rows: ArapRow[]) { selectedRows.value = rows }
|
||||
function openGeneration(mode: 'BY_CUTOFF' | 'SELECTED') {
|
||||
generationForm.mode = mode
|
||||
generationForm.customerId = ''
|
||||
generationForm.period = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []
|
||||
generationForm.cutoffDate = generationForm.period[1] || ''
|
||||
generationForm.remark = ''
|
||||
generationError.value = null
|
||||
generationOpen.value = true
|
||||
function isSelectableReceivable(row: ArapRow) {
|
||||
return Boolean(row.feeSourceId)
|
||||
&& row.direction === 'RECEIVABLE'
|
||||
&& row.reviewStatus === 'APPROVED'
|
||||
&& row.status === 'AVAILABLE'
|
||||
&& row.amount > 0
|
||||
&& row.statementGenerated === false
|
||||
}
|
||||
async function submitGeneration() {
|
||||
const eligible = selectedRows.value.filter((row) => row.feeSourceId && row.direction === 'RECEIVABLE' && row.reviewStatus === 'APPROVED' && row.status === 'AVAILABLE' && row.amount > 0 && row.statementGenerated === false && row.customerId === generationForm.customerId)
|
||||
const period = generationForm.period
|
||||
if (!generationForm.customerId || !period[0] || !period[1]) { ElMessage.warning('请选择客户和业务期间'); return }
|
||||
if (generationForm.mode === 'SELECTED' && !eligible.length) { ElMessage.warning('请选择审核通过、有效且未出账的应收费用'); return }
|
||||
generationSaving.value = true; generationError.value = null
|
||||
try { await createReconciliationStatement({ customerId: generationForm.customerId, currency: 'CNY', generationMode: generationForm.mode, feeSourceIds: generationForm.mode === 'SELECTED' ? eligible.map((row) => row.feeSourceId as string) : undefined, periodStart: period[0], periodEnd: period[1], remark: generationForm.remark.trim() }); generationOpen.value = false; selectedRows.value = []; ElMessage.success('对账单草稿已生成'); await load() } catch (error) { generationError.value = getApiErrorDetails(error, '对账单生成失败,请重试') } finally { generationSaving.value = false }
|
||||
async function continueSelectedGeneration() {
|
||||
if (!selectedRows.value.length) return ElMessage.warning('请先勾选需要生成对账单的应收费用')
|
||||
if (selectedRows.value.some((row) => !isSelectableReceivable(row))) return ElMessage.warning('所选费用中存在不可生成对账单的记录,请刷新后重新选择')
|
||||
const customerIds = [...new Set(selectedRows.value.map((row) => row.customerId))]
|
||||
if (customerIds.length !== 1) return ElMessage.warning('一次只能选择同一客户的费用生成对账单')
|
||||
const businessDates = selectedRows.value.map((row) => row.businessDate).filter((value) => /^\d{4}-\d{2}-\d{2}$/.test(value)).sort()
|
||||
if (businessDates.length !== selectedRows.value.length) return ElMessage.warning('所选费用缺少有效业务日期,暂不能生成对账单')
|
||||
await router.push({
|
||||
name: 'finance-customer-statements',
|
||||
query: {
|
||||
createMode: 'SELECTED',
|
||||
customerId: customerIds[0],
|
||||
feeSourceIds: selectedRows.value.map((row) => row.feeSourceId as string).join(','),
|
||||
periodStart: businessDates[0],
|
||||
periodEnd: businessDates.at(-1),
|
||||
},
|
||||
})
|
||||
}
|
||||
async function exportFeeSources() {
|
||||
const range = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []
|
||||
@@ -315,14 +320,14 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="finance-arap-page" spacing="normal">
|
||||
<OperationErrorToast :details="reviewError || mutationError || generationError" />
|
||||
<OperationErrorToast :details="reviewError || mutationError" />
|
||||
<el-result v-if="!canView" icon="warning" title="无权访问客户应收应付" sub-title="请联系管理员开通账单查看权限" />
|
||||
<ListPageLayout v-else-if="!detailOpen" aria-label="客户应收应付">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="search" /></template>
|
||||
<template v-if="stateError" #state><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
|
||||
<section v-if="!customersError && !loadError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><div class="generation-actions"><el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportFeeSources"><Download :size="16" />导出Excel</el-button><template v-if="canCreateStatement"><el-button @click="openGeneration('BY_CUTOFF')"><FilePlus2 :size="16" />按截止日期生成</el-button><el-button type="primary" :disabled="!selectedRows.length" @click="openGeneration('SELECTED')"><FilePlus2 :size="16" />按勾选生成</el-button></template></div></template>
|
||||
<template #toolbar-actions><div class="generation-actions"><el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportFeeSources"><Download :size="16" />导出Excel</el-button><el-button v-if="canCreateStatement" type="primary" :disabled="!selectedRows.length" @click="continueSelectedGeneration"><FilePlus2 :size="16" />生成所选对账单</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canEditSource && row.sourceSystem !== 'FIRSTLEG' && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" plain @click="openMutation(row, 'EDIT')"><Pencil :size="15" />编辑</el-button><el-button v-if="canEditSource && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" plain type="primary" @click="openMutation(row, 'SUBMIT')"><Send :size="15" />提交审核</el-button><el-button v-if="canReviewSource && row.reviewStatus === 'PENDING' && row.status === 'AVAILABLE'" size="small" plain type="success" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canEditSource && row.sourceSystem !== 'FIRSTLEG' && row.reviewStatus === 'APPROVED' && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" plain @click="openMutation(row, 'ADJUST')"><Pencil :size="15" />调整</el-button><el-button v-if="canEditSource && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" plain type="danger" @click="openMutation(row, 'VOID')"><Ban :size="15" />作废</el-button></template><span v-else class="muted-action">手工成本</span></div></template>
|
||||
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
|
||||
<template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template>
|
||||
@@ -347,9 +352,6 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
<AppDialog v-model="mutationOpen" :title="mutationTitle" :confirm-text="mutationMode === 'VOID' ? '确认作废' : '确认'" :confirm-loading="mutationSaving" @confirm="submitMutation">
|
||||
<el-form label-position="top"><el-form-item v-if="mutationMode === 'EDIT' || mutationMode === 'ADJUST'" label="金额(CNY)"><el-input-number v-model="mutationForm.amount" :min="0.01" :precision="2" :controls="false" style="width:100%" /></el-form-item><el-form-item label="操作原因"><el-input v-model="mutationForm.reason" type="textarea" :rows="3" maxlength="500" placeholder="请填写操作原因" show-word-limit /></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="generationOpen" :title="generationForm.mode === 'BY_CUTOFF' ? '按截止日期生成对账单' : '按勾选生成对账单'" confirm-text="生成草稿" :confirm-loading="generationSaving" @confirm="submitGeneration">
|
||||
<el-form label-position="top"><el-form-item label="客户"><el-select v-model="generationForm.customerId" filterable placeholder="请选择客户" style="width:100%"><el-option v-for="customer in activeCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item><el-form-item label="业务期间"><el-date-picker v-model="generationForm.period" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="截止日期" style="width:100%" /></el-form-item><el-form-item v-if="generationForm.mode === 'SELECTED'" label="已选费用"><span>{{ selectedRows.length }} 笔,合计 {{ money(selectedAmount) }} CNY</span></el-form-item><el-form-item label="备注"><el-input v-model="generationForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import { useAuthStore } from '@/core/auth/auth'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
import { getReconciliationStatement, listFinanceStatements } from '../api/finance'
|
||||
import type { FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
|
||||
import { createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
|
||||
import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
|
||||
import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue'
|
||||
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
@@ -19,7 +19,7 @@ vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
||||
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
|
||||
vi.mock('../api/finance', () => ({
|
||||
actionReconciliationStatement: vi.fn(), createReconciliationStatement: vi.fn(),
|
||||
getReconciliationStatement: vi.fn(), listFinanceStatements: vi.fn(),
|
||||
getReconciliationStatement: vi.fn(), listFinanceFeeSources: vi.fn(), listFinanceStatements: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||
@@ -44,6 +44,13 @@ const detail: FinanceReconciliationDetail = {
|
||||
paymentHistory: [{ transactionId: '701', transactionType: 'RECEIPT', transactionDate: '2026-08-20', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', voucherFileIds: ['9001'], createdBy: '9', createdByName: '财务员', occurredAt: '2026-08-20 10:00:00' }],
|
||||
revisions: [{ revisionNo: 1, action: 'ADJUST', beforeAmount: 320, afterAmount: 300, reason: '优惠调整', operatorId: '10', operatorName: '调整人', occurredAt: '2026-08-20 09:00:00' }],
|
||||
}
|
||||
const eligibleFeeSource: FinanceFeeSourceSummary = {
|
||||
poolItemId: '42', feeSourceId: '42', sourceSystem: 'FIRSTLEG', sourceType: 'INBOUND_FEE', sourceBusinessId: 'IN-21',
|
||||
sourceId: '81', sourceVersion: 2, customerId: '10001', customerName: '客户A', direction: 'RECEIVABLE', supplierId: null,
|
||||
supplierName: '', orderId: '11', inboundId: '21', inboundCode: 'IN-21', containerId: null, containerNo: '', businessDate: '2026-08-15',
|
||||
feeItemName: '运费', quantity: 10, unitPrice: 30, amount: 320, currency: 'CNY', reviewStatus: 'APPROVED',
|
||||
sourceStatus: 'AVAILABLE', statementGenerated: false, status: 'AVAILABLE', createdAt: '2026-08-15',
|
||||
}
|
||||
|
||||
async function mountPage(permissions = ['platform:finance:customer-statement:list'], initialPath = '/finance/customer-statements') {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
@@ -73,6 +80,8 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: '10001', tenantName: '客户A', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listFinanceStatements).mockResolvedValue([statement])
|
||||
vi.mocked(getReconciliationStatement).mockResolvedValue(detail)
|
||||
vi.mocked(listFinanceFeeSources).mockReset().mockResolvedValue({ records: [eligibleFeeSource], total: 1, pages: 1 })
|
||||
vi.mocked(createReconciliationStatement).mockReset().mockResolvedValue({ ...detail, id: '502', billNo: 'REC-502', status: 'DRAFT' })
|
||||
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ taskName: '客户对账单', taskNo: 'EXP-STMT-1' } as never)
|
||||
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
|
||||
})
|
||||
@@ -255,6 +264,34 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
expect(listPlatformTenants).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts selected fee sources from customer arap and creates them in the statement page', async () => {
|
||||
const permissions = [
|
||||
'platform:finance:customer-statement:list',
|
||||
'platform:finance:customer-arap:list',
|
||||
'platform:finance:reconciliation:create',
|
||||
'platform:customer:list',
|
||||
]
|
||||
const { wrapper, router } = await mountPage(permissions, '/finance/customer-statements?createMode=SELECTED&customerId=10001&feeSourceIds=42&periodStart=2026-08-15&periodEnd=2026-08-15')
|
||||
const vm = wrapper.vm as unknown as {
|
||||
createOpen: boolean
|
||||
createForm: { generationMode: string; customerId: string; feeSourceIds: string[]; periodStart: string; periodEnd: string }
|
||||
createStatement(): Promise<void>
|
||||
}
|
||||
expect(vm.createOpen).toBe(true)
|
||||
expect(vm.createForm).toMatchObject({ generationMode: 'SELECTED', customerId: '10001', feeSourceIds: ['42'], periodStart: '2026-08-15', periodEnd: '2026-08-15' })
|
||||
expect(listFinanceFeeSources).toHaveBeenCalledWith(expect.objectContaining({
|
||||
customerId: '10001', direction: 'RECEIVABLE', status: 'AVAILABLE', reviewStatus: 'APPROVED', statementGenerated: false,
|
||||
businessDateFrom: '2026-08-15', businessDateTo: '2026-08-15',
|
||||
}))
|
||||
|
||||
await vm.createStatement()
|
||||
expect(createReconciliationStatement).toHaveBeenCalledWith({
|
||||
customerId: '10001', currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: ['42'],
|
||||
periodStart: '2026-08-15', periodEnd: '2026-08-15', remark: undefined,
|
||||
})
|
||||
expect(router.currentRoute.value).toMatchObject({ name: 'finance-customer-statement-detail', params: { statementId: '502' } })
|
||||
})
|
||||
|
||||
it('uses statement detail snapshots without a second fee-source request or technical trace fields', async () => {
|
||||
const { wrapper } = await mountPage(['platform:finance:customer-statement:list'])
|
||||
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(statement)
|
||||
|
||||
@@ -17,9 +17,9 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceStatements } from '../api/finance'
|
||||
import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
|
||||
import { financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
|
||||
import type { FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
|
||||
import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
|
||||
|
||||
interface AdjustmentRow {
|
||||
selected: boolean
|
||||
@@ -61,14 +61,20 @@ const activeDetailTab = ref<'lines' | 'payments'>('lines')
|
||||
const detailCache = new Map<string, FinanceReconciliationDetail>()
|
||||
const createOpen = ref(false)
|
||||
const createBusy = ref(false)
|
||||
const createSourcesLoading = ref(false)
|
||||
const createSourcesError = ref<ApiErrorDetails | null>(null)
|
||||
const createSourceOptions = ref<FinanceFeeSourceSummary[]>([])
|
||||
const exportBusy = ref(false)
|
||||
const actionBusy = ref(false)
|
||||
const createForm = ref({
|
||||
generationMode: 'BY_CUTOFF' as 'BY_CUTOFF' | 'SELECTED',
|
||||
customerId: '',
|
||||
periodStart: new Date().toISOString().slice(0, 8) + '01',
|
||||
periodEnd: new Date().toISOString().slice(0, 10),
|
||||
feeSourceIds: [] as string[],
|
||||
remark: '',
|
||||
})
|
||||
let createSourceLoadSequence = 0
|
||||
const adjustOpen = ref(false)
|
||||
const adjustBusy = ref(false)
|
||||
const adjustStatement = ref<FinanceStatementReadModel | null>(null)
|
||||
@@ -78,6 +84,7 @@ const adjustLines = ref<AdjustmentRow[]>([])
|
||||
const canView = computed(() => access.can('platform:finance:customer-statement:list'))
|
||||
const canListCustomers = computed(() => access.can('platform:customer:list'))
|
||||
const canViewFeeSource = computed(() => access.can('platform:finance:customer-statement:list'))
|
||||
const canSelectSources = computed(() => access.can('platform:finance:customer-arap:list'))
|
||||
const canCreate = computed(() => access.can('platform:finance:reconciliation:create'))
|
||||
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
|
||||
const canReview = computed(() => access.can('platform:finance:reconciliation:review'))
|
||||
@@ -86,6 +93,8 @@ const canAdjust = computed(() => access.can('platform:finance:reconciliation:adj
|
||||
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)
|
||||
const selectedCreateSources = computed(() => createSourceOptions.value.filter((item) => createForm.value.feeSourceIds.includes(item.feeSourceId)))
|
||||
const selectedCreateAmount = computed(() => selectedCreateSources.value.reduce((total, item) => total + Number(item.amount || 0), 0))
|
||||
const detailRouteId = computed(() => route.name === 'finance-customer-statement-detail' ? String(route.params.statementId || '') : '')
|
||||
const isDetailRoute = computed(() => Boolean(detailRouteId.value))
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
@@ -366,22 +375,120 @@ async function exportStatements() {
|
||||
}
|
||||
}
|
||||
|
||||
function defaultCreateForm() {
|
||||
return {
|
||||
generationMode: 'BY_CUTOFF' as 'BY_CUTOFF' | 'SELECTED',
|
||||
customerId: '',
|
||||
periodStart: new Date().toISOString().slice(0, 8) + '01',
|
||||
periodEnd: new Date().toISOString().slice(0, 10),
|
||||
feeSourceIds: [] as string[],
|
||||
remark: '',
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
if (!canListCustomers.value) return ElMessage.warning('缺少客户列表权限,无法选择客户生成对账单')
|
||||
createForm.value = { ...createForm.value, customerId: '' }
|
||||
createForm.value = defaultCreateForm()
|
||||
createSourceOptions.value = []
|
||||
createSourcesError.value = null
|
||||
createOpen.value = true
|
||||
}
|
||||
|
||||
async function loadEligibleCreateSources(preferredIds: string[] = createForm.value.feeSourceIds) {
|
||||
const sequence = ++createSourceLoadSequence
|
||||
createSourcesError.value = null
|
||||
createSourceOptions.value = []
|
||||
if (createForm.value.generationMode !== 'SELECTED' || !createForm.value.customerId
|
||||
|| !createForm.value.periodStart || !createForm.value.periodEnd || !canSelectSources.value) {
|
||||
createForm.value.feeSourceIds = []
|
||||
return
|
||||
}
|
||||
createSourcesLoading.value = true
|
||||
try {
|
||||
const records: FinanceFeeSourceSummary[] = []
|
||||
let currentPage = 1
|
||||
let pages = 1
|
||||
do {
|
||||
const result = await listFinanceFeeSources({
|
||||
page: currentPage,
|
||||
size: 200,
|
||||
customerId: createForm.value.customerId,
|
||||
direction: 'RECEIVABLE',
|
||||
status: 'AVAILABLE',
|
||||
reviewStatus: 'APPROVED',
|
||||
statementGenerated: false,
|
||||
businessDateFrom: createForm.value.periodStart,
|
||||
businessDateTo: createForm.value.periodEnd,
|
||||
})
|
||||
records.push(...result.records)
|
||||
pages = Math.max(1, result.pages)
|
||||
currentPage += 1
|
||||
} while (currentPage <= pages)
|
||||
if (sequence !== createSourceLoadSequence) return
|
||||
createSourceOptions.value = records.filter((item) => item.direction === 'RECEIVABLE'
|
||||
&& item.reviewStatus === 'APPROVED' && item.status === 'AVAILABLE'
|
||||
&& !item.statementGenerated && item.amount > 0)
|
||||
const availableIds = new Set(createSourceOptions.value.map((item) => item.feeSourceId))
|
||||
createForm.value.feeSourceIds = preferredIds.filter((id) => availableIds.has(id))
|
||||
if (preferredIds.length && createForm.value.feeSourceIds.length !== preferredIds.length) {
|
||||
ElMessage.warning('部分带入费用已被占用或不再符合生成条件,请重新核对')
|
||||
}
|
||||
} catch (cause) {
|
||||
if (sequence !== createSourceLoadSequence) return
|
||||
createSourcesError.value = getApiErrorDetails(cause, '可生成费用加载失败')
|
||||
createForm.value.feeSourceIds = []
|
||||
} finally {
|
||||
if (sequence === createSourceLoadSequence) createSourcesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function queryText(value: unknown) {
|
||||
return Array.isArray(value) ? String(value[0] || '') : String(value || '')
|
||||
}
|
||||
|
||||
async function openSelectedCreateFromRoute() {
|
||||
if (route.name !== 'finance-customer-statements' || queryText(route.query.createMode) !== 'SELECTED') return
|
||||
if (!canGenerate.value || !canSelectSources.value) {
|
||||
ElMessage.warning('缺少客户或费用来源权限,无法生成所选对账单')
|
||||
return
|
||||
}
|
||||
const feeSourceIds = queryText(route.query.feeSourceIds).split(',').map((value) => value.trim()).filter((value) => /^[1-9]\d*$/.test(value))
|
||||
const customerId = queryText(route.query.customerId)
|
||||
const periodStart = queryText(route.query.periodStart)
|
||||
const periodEnd = queryText(route.query.periodEnd)
|
||||
if (!customerId || !feeSourceIds.length || !/^\d{4}-\d{2}-\d{2}$/.test(periodStart) || !/^\d{4}-\d{2}-\d{2}$/.test(periodEnd)) {
|
||||
ElMessage.warning('带入的对账单生成参数不完整,请重新选择费用')
|
||||
return
|
||||
}
|
||||
if (!activeCustomers.value.some((item) => String(item.id) === customerId)) {
|
||||
ElMessage.warning('带入费用所属客户当前不可用,请重新选择费用')
|
||||
return
|
||||
}
|
||||
createForm.value = { generationMode: 'SELECTED', customerId, periodStart, periodEnd, feeSourceIds: [], remark: '' }
|
||||
await loadEligibleCreateSources(feeSourceIds)
|
||||
createOpen.value = true
|
||||
await router.replace({ name: 'finance-customer-statements' })
|
||||
}
|
||||
|
||||
async function createStatement() {
|
||||
if (!createForm.value.customerId) return ElMessage.warning('请先选择客户')
|
||||
if (!createForm.value.periodStart || !createForm.value.periodEnd) return ElMessage.warning('请选择完整账期')
|
||||
if (createForm.value.periodStart > createForm.value.periodEnd) return ElMessage.warning('账期开始日期不能晚于结束日期')
|
||||
if (createForm.value.generationMode === 'SELECTED' && !createForm.value.feeSourceIds.length) return ElMessage.warning('请选择至少一笔应收费用')
|
||||
createBusy.value = true
|
||||
try {
|
||||
await createReconciliationStatement({ customerId: String(createForm.value.customerId), currency: 'CNY', generationMode: 'BY_CUTOFF', periodStart: createForm.value.periodStart, periodEnd: createForm.value.periodEnd, remark: createForm.value.remark || undefined })
|
||||
const created = await createReconciliationStatement({
|
||||
customerId: String(createForm.value.customerId),
|
||||
currency: 'CNY',
|
||||
generationMode: createForm.value.generationMode,
|
||||
feeSourceIds: createForm.value.generationMode === 'SELECTED' ? createForm.value.feeSourceIds : undefined,
|
||||
periodStart: createForm.value.periodStart,
|
||||
periodEnd: createForm.value.periodEnd,
|
||||
remark: createForm.value.remark || undefined,
|
||||
})
|
||||
createOpen.value = false
|
||||
ElMessage.success('对账单已生成')
|
||||
await load(filters.value)
|
||||
await router.push({ name: 'finance-customer-statement-detail', params: { statementId: created.id } })
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '创建对账单失败') }
|
||||
finally { createBusy.value = false }
|
||||
}
|
||||
@@ -431,7 +538,17 @@ async function submitAdjustment() {
|
||||
finally { adjustBusy.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => { if (canListCustomers.value) void loadCustomers() })
|
||||
onMounted(async () => {
|
||||
if (canListCustomers.value) await loadCustomers()
|
||||
await openSelectedCreateFromRoute()
|
||||
})
|
||||
watch(
|
||||
() => [createOpen.value, createForm.value.generationMode, createForm.value.customerId, createForm.value.periodStart, createForm.value.periodEnd],
|
||||
([open, mode]) => {
|
||||
if (!open || mode !== 'SELECTED') return
|
||||
void loadEligibleCreateSources()
|
||||
},
|
||||
)
|
||||
watch(detailRouteId, (statementId) => {
|
||||
if (statementId) void loadDetail(statementId)
|
||||
else {
|
||||
@@ -568,7 +685,29 @@ watch(detailRouteId, (statementId) => {
|
||||
</template>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="createOpen" title="生成客户对账单" width="min(560px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement"><el-form label-position="top"><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><el-form-item label="账期开始"><el-date-picker v-model="createForm.periodStart" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="账期结束"><el-date-picker v-model="createForm.periodEnd" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" /></el-form-item></el-form></AppDialog>
|
||||
<AppDialog v-model="createOpen" title="生成客户对账单" width="min(680px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="生成方式">
|
||||
<el-radio-group v-model="createForm.generationMode">
|
||||
<el-radio-button value="BY_CUTOFF">按账期自动汇总</el-radio-button>
|
||||
<el-radio-button value="SELECTED" :disabled="!canSelectSources">指定费用生成</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<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>
|
||||
<div class="create-period-row"><el-form-item label="账期开始"><el-date-picker v-model="createForm.periodStart" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="账期结束"><el-date-picker v-model="createForm.periodEnd" type="date" value-format="YYYY-MM-DD" /></el-form-item></div>
|
||||
<template v-if="createForm.generationMode === 'SELECTED'">
|
||||
<el-alert v-if="!canSelectSources" type="warning" :closable="false" title="当前账号没有查看费用来源的权限,不能指定费用生成。" />
|
||||
<el-alert v-else-if="createSourcesError" type="error" :closable="false"><template #title>可生成费用加载失败</template><p>{{ createSourcesError.message }}</p><ApiErrorMeta :details="createSourcesError" /><el-button size="small" @click="loadEligibleCreateSources()">重试</el-button></el-alert>
|
||||
<el-form-item label="应收费用">
|
||||
<el-select v-model="createForm.feeSourceIds" multiple filterable collapse-tags :max-collapse-tags="3" :loading="createSourcesLoading" placeholder="请选择审核通过、已发布且未出账的费用">
|
||||
<el-option v-for="item in createSourceOptions" :key="item.feeSourceId" :label="`${item.feeItemName} · ${item.businessDate} · ${Number(item.amount).toFixed(2)} CNY`" :value="item.feeSourceId" />
|
||||
</el-select>
|
||||
<div class="selected-source-summary">已选 {{ createForm.feeSourceIds.length }} 笔,合计 {{ selectedCreateAmount.toFixed(2) }} CNY</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="adjustOpen" title="调整客户对账单" width="min(900px, 96vw)" :confirm-loading="adjustBusy" @confirm="submitAdjustment">
|
||||
<el-form label-position="top"><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit /></el-form-item></el-form>
|
||||
@@ -580,6 +719,8 @@ watch(detailRouteId, (statementId) => {
|
||||
<style scoped>
|
||||
.finance-statements-page { min-width: 0; }
|
||||
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
|
||||
.create-period-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.selected-source-summary { width: 100%; margin-top: 6px; color: var(--cc-text-secondary); font-size: 12px; text-align: right; }
|
||||
.statement-detail-page { min-width: 0; width: min(100%, 1920px); margin: 0 auto; overflow: hidden; }
|
||||
.statement-detail-page :deep(.detail-page-layout) { gap: 0; }
|
||||
.statement-detail-page__header { min-height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--cc-border-subtle); }
|
||||
|
||||
Reference in New Issue
Block a user