This commit is contained in:
2026-08-20 16:13:55 +08:00
parent 4df6a5f571
commit c93eea89c8
4 changed files with 226 additions and 46 deletions
@@ -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); }