2026-08-11 18:43:56 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-08-11 19:59:15 +08:00
|
|
|
|
import { computed, onMounted, ref } from 'vue'
|
2026-08-12 09:25:55 +08:00
|
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
|
|
|
import { Eye, MoreHorizontal, Plus, RotateCcw, Send, ShieldCheck, XCircle } from '@lucide/vue'
|
2026-08-11 18:43:56 +08:00
|
|
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
|
|
|
|
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
|
|
|
|
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|
|
|
|
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
|
|
|
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
2026-08-12 09:25:55 +08:00
|
|
|
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
2026-08-11 18:43:56 +08:00
|
|
|
|
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
|
|
|
|
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
|
|
|
|
|
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'
|
2026-08-12 09:25:55 +08:00
|
|
|
|
import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceStatements } from '../api/finance'
|
2026-08-11 19:35:28 +08:00
|
|
|
|
import { financeStatusLabel, financeStatusTone } from '../status'
|
2026-08-12 09:25:55 +08:00
|
|
|
|
import type { FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
|
|
|
|
|
|
|
|
|
|
|
|
interface AdjustmentRow {
|
|
|
|
|
|
selected: boolean
|
|
|
|
|
|
statementLineId: string
|
|
|
|
|
|
feeItemName: string
|
|
|
|
|
|
originalAmount: number
|
|
|
|
|
|
adjustedAmount: number
|
|
|
|
|
|
reason: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const access = useAccessPreviewStore()
|
|
|
|
|
|
const customers = ref<PlatformTenantResource[]>([])
|
|
|
|
|
|
const filters = ref<QueryModel>({ customerId: '' })
|
|
|
|
|
|
const rows = ref<FinanceStatementReadModel[]>([])
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const error = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
|
const customerError = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
|
const drawerOpen = ref(false)
|
|
|
|
|
|
const detail = ref<FinanceReconciliationDetail | null>(null)
|
|
|
|
|
|
const detailError = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
|
const createOpen = ref(false)
|
|
|
|
|
|
const createBusy = ref(false)
|
|
|
|
|
|
const actionBusy = ref(false)
|
|
|
|
|
|
const createForm = ref({ periodStart: new Date().toISOString().slice(0, 8) + '01', periodEnd: new Date().toISOString().slice(0, 10), remark: '' })
|
|
|
|
|
|
const adjustOpen = ref(false)
|
|
|
|
|
|
const adjustBusy = ref(false)
|
|
|
|
|
|
const adjustStatement = ref<FinanceStatementReadModel | null>(null)
|
|
|
|
|
|
const adjustReason = ref('')
|
|
|
|
|
|
const adjustLines = ref<AdjustmentRow[]>([])
|
|
|
|
|
|
|
|
|
|
|
|
const canView = computed(() => access.can('platform:finance:bill:view'))
|
|
|
|
|
|
const canCreate = computed(() => access.can('platform:finance:reconciliation:create'))
|
|
|
|
|
|
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 fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) }])
|
|
|
|
|
|
const columns: DataTableColumn[] = [
|
|
|
|
|
|
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
|
|
|
|
|
|
{ key: 'billNo', label: '对账单号', minWidth: 180 },
|
|
|
|
|
|
{ key: 'customerName', label: '客户', minWidth: 160 },
|
|
|
|
|
|
{ key: 'billDate', label: '账单日期', width: 130 },
|
|
|
|
|
|
{ key: 'dueDate', label: '到期日', width: 130 },
|
|
|
|
|
|
{ key: 'totalAmount', label: '应收(CNY)', width: 140 },
|
|
|
|
|
|
{ key: 'receivedAmount', label: '已收(CNY)', width: 140 },
|
|
|
|
|
|
{ key: 'outstandingAmount', label: '未收(CNY)', width: 140 },
|
|
|
|
|
|
{ key: 'status', label: '状态', width: 120, slotName: 'status' },
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
async function loadCustomers() {
|
|
|
|
|
|
customerError.value = null
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await listPlatformTenants({ page: 1, size: 200 })
|
|
|
|
|
|
customers.value = result.records
|
|
|
|
|
|
} catch (cause) {
|
|
|
|
|
|
customerError.value = getApiErrorDetails(cause, '客户列表加载失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function load(model: QueryModel = filters.value) {
|
|
|
|
|
|
filters.value = { ...model }
|
|
|
|
|
|
rows.value = []
|
|
|
|
|
|
error.value = null
|
|
|
|
|
|
if (!filters.value.customerId) return
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
try { rows.value = await listFinanceStatements(String(filters.value.customerId)) }
|
|
|
|
|
|
catch (cause) { error.value = getApiErrorDetails(cause, '客户对账单加载失败,请重试') }
|
|
|
|
|
|
finally { loading.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function fetchDetail(statementId: string) {
|
|
|
|
|
|
return getReconciliationStatement(statementId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function openDetail(row: FinanceStatementReadModel) {
|
|
|
|
|
|
drawerOpen.value = true
|
|
|
|
|
|
detail.value = null
|
|
|
|
|
|
detailError.value = null
|
|
|
|
|
|
try { detail.value = await fetchDetail(row.id) }
|
|
|
|
|
|
catch (cause) { detailError.value = getApiErrorDetails(cause, '对账单详情加载失败,请重试') }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function createStatement() {
|
|
|
|
|
|
if (!filters.value.customerId) return ElMessage.warning('请先选择客户')
|
|
|
|
|
|
createBusy.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await createReconciliationStatement({ customerId: String(filters.value.customerId), currency: 'CNY', generationMode: 'BY_CUTOFF', periodStart: createForm.value.periodStart, periodEnd: createForm.value.periodEnd, remark: createForm.value.remark || undefined })
|
|
|
|
|
|
createOpen.value = false
|
|
|
|
|
|
ElMessage.success('对账单已生成')
|
|
|
|
|
|
await load()
|
|
|
|
|
|
} catch (cause) { error.value = getApiErrorDetails(cause, '创建对账单失败') }
|
|
|
|
|
|
finally { createBusy.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function performAction(row: FinanceStatementReadModel, actionName: ReconciliationActionPayload['action']) {
|
|
|
|
|
|
const reason = ['REJECT', 'VOID', 'REOPEN'].includes(actionName)
|
|
|
|
|
|
? await ElMessageBox.prompt('请输入操作原因', '对账单操作', { inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消' }).then((result) => result.value).catch(() => null)
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
if (reason === null) return
|
|
|
|
|
|
actionBusy.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await actionReconciliationStatement(row.id, { action: actionName, expectedVersion: row.version, ...(reason ? { reason } : {}) })
|
|
|
|
|
|
ElMessage.success('对账单状态已更新')
|
|
|
|
|
|
await load()
|
|
|
|
|
|
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单操作失败') }
|
|
|
|
|
|
finally { actionBusy.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function openAdjustment(row: FinanceStatementReadModel) {
|
|
|
|
|
|
adjustBusy.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const current = await fetchDetail(row.id)
|
|
|
|
|
|
adjustStatement.value = { ...row, version: current.version }
|
|
|
|
|
|
adjustReason.value = ''
|
|
|
|
|
|
adjustLines.value = current.lines.flatMap((line) => {
|
|
|
|
|
|
const statementLineId = line.statementLineId
|
|
|
|
|
|
return statementLineId ? [{ selected: false, statementLineId, feeItemName: line.feeItemName, originalAmount: line.amount, adjustedAmount: line.amount, reason: '' }] : []
|
|
|
|
|
|
})
|
|
|
|
|
|
adjustOpen.value = true
|
|
|
|
|
|
} catch (cause) { error.value = getApiErrorDetails(cause, '调整明细加载失败') }
|
|
|
|
|
|
finally { adjustBusy.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function submitAdjustment() {
|
|
|
|
|
|
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
|
|
|
|
|
|
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()
|
|
|
|
|
|
await openDetail(statement)
|
|
|
|
|
|
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单调整失败') }
|
|
|
|
|
|
finally { adjustBusy.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 18:43:56 +08:00
|
|
|
|
onMounted(() => void loadCustomers())
|
|
|
|
|
|
</script>
|
2026-08-12 09:25:55 +08:00
|
|
|
|
|
2026-08-11 18:43:56 +08:00
|
|
|
|
<template>
|
2026-08-12 09:25:55 +08:00
|
|
|
|
<AdminPage as="div" class="finance-statements-page" spacing="normal">
|
|
|
|
|
|
<el-result v-if="!canView" icon="warning" title="无权访问客户对账单" sub-title="请联系管理员开通财务查看权限" />
|
|
|
|
|
|
<ListPageLayout v-else aria-label="客户对账单">
|
|
|
|
|
|
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" preference-key="finance-statements-query" @search="load" /></template>
|
|
|
|
|
|
<template v-if="customerError || error" #state><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template>
|
|
|
|
|
|
<section v-if="!customerError && !error" class="surface-panel table-panel">
|
|
|
|
|
|
<DataTable :data="rows" :columns="columns" :loading="loading" aria-label="客户对账单列表" row-key="id" empty-text="请选择客户查询对账单">
|
|
|
|
|
|
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="createOpen = true"><Plus :size="15" />生成对账单</el-button></template>
|
|
|
|
|
|
<template #actions="{ row }">
|
|
|
|
|
|
<el-button link type="primary" @click="openDetail(row as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button>
|
|
|
|
|
|
<el-button v-if="row.status === 'DRAFT' && canCreate" link :loading="actionBusy" @click="performAction(row as FinanceStatementReadModel, 'SUBMIT')"><Send :size="15" />提交</el-button>
|
|
|
|
|
|
<el-button v-if="row.status === 'PENDING_REVIEW' && canReview" link :loading="actionBusy" @click="performAction(row as FinanceStatementReadModel, 'APPROVE')"><ShieldCheck :size="15" />通过</el-button>
|
|
|
|
|
|
<el-dropdown v-if="row.status === 'PENDING_REVIEW' && (canReview || canVoid)" trigger="click" @command="(command: ReconciliationActionPayload['action']) => performAction(row as FinanceStatementReadModel, command)">
|
|
|
|
|
|
<el-button link aria-label="更多对账单操作" title="更多对账单操作"><MoreHorizontal :size="16" /></el-button>
|
|
|
|
|
|
<template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canReview" command="REJECT"><XCircle :size="15" />驳回</el-dropdown-item><el-dropdown-item v-if="canVoid" command="VOID" divided><XCircle :size="15" />作废</el-dropdown-item></el-dropdown-menu></template>
|
|
|
|
|
|
</el-dropdown>
|
|
|
|
|
|
<el-button v-if="row.status === 'APPROVED' && canIssue" link :loading="actionBusy" @click="performAction(row as FinanceStatementReadModel, 'ISSUE')"><Send :size="15" />出账</el-button>
|
|
|
|
|
|
<el-button v-if="row.status === 'ISSUED' && canAdjust" link :loading="adjustBusy" @click="openAdjustment(row as FinanceStatementReadModel)"><RotateCcw :size="15" />调整</el-button>
|
|
|
|
|
|
<el-button v-if="['REJECTED','VOIDED'].includes(String(row.status)) && canVoid" link :loading="actionBusy" @click="performAction(row as FinanceStatementReadModel, 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
|
|
|
|
|
|
<el-button v-if="['DRAFT','APPROVED','ISSUED'].includes(String(row.status)) && canVoid" link type="danger" :loading="actionBusy" @click="performAction(row as FinanceStatementReadModel, 'VOID')"><XCircle :size="15" />作废</el-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
|
|
|
|
|
|
<template #pagination-left>币种:CNY</template>
|
|
|
|
|
|
</DataTable>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</ListPageLayout>
|
|
|
|
|
|
|
|
|
|
|
|
<AppDrawer v-model="drawerOpen" title="客户对账单详情" size="min(820px, 94vw)">
|
|
|
|
|
|
<LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="false"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
|
|
|
|
|
<template v-else-if="detail">
|
|
|
|
|
|
<el-descriptions :column="2" border><el-descriptions-item label="对账单号">{{ detail.billNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(detail.status) }}</el-descriptions-item><el-descriptions-item label="金额">{{ detail.totalAmount }} CNY</el-descriptions-item></el-descriptions>
|
|
|
|
|
|
<el-table :data="detail.lines" size="small"><el-table-column prop="feeItemName" label="费用项目" /><el-table-column prop="amount" label="账单金额(CNY)" /></el-table>
|
|
|
|
|
|
<el-table v-if="detail.revisions.length" :data="detail.revisions" size="small"><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column prop="action" label="动作" width="130" /><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /></el-table>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</AppDrawer>
|
|
|
|
|
|
|
|
|
|
|
|
<AppDialog v-model="createOpen" title="生成客户对账单" width="min(520px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement"><el-form label-position="top"><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="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>
|
|
|
|
|
|
<el-table :data="adjustLines" size="small" max-height="420"><el-table-column label="选择" width="64"><template #default="{ row }"><el-checkbox v-model="row.selected" /></template></el-table-column><el-table-column prop="feeItemName" label="费用项目" min-width="150" /><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" /></template></el-table-column><el-table-column label="明细说明" min-width="220"><template #default="{ row }"><el-input v-model="row.reason" maxlength="500" /></template></el-table-column></el-table>
|
|
|
|
|
|
</AppDialog>
|
|
|
|
|
|
</AdminPage>
|
2026-08-11 18:43:56 +08:00
|
|
|
|
</template>
|
2026-08-12 09:25:55 +08:00
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.finance-statements-page { min-width: 0; }
|
|
|
|
|
|
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
|
|
|
|
|
|
</style>
|