Files
admin-web/src/modules/finance/pages/FinanceFormalSettlementPage.vue
T
2026-08-12 10:59:49 +08:00

265 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, type UploadFile } from 'element-plus'
import { CreditCard, Eye, HandCoins, Plus } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
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'
import {
createFinanceSettlementPayment,
createFinanceReceipt,
createPayableSettlement,
createReceivableSettlement,
getFinanceSettlement,
listFinanceFeePool,
listFinancePayableSettlements,
listFinanceReceivableSettlements,
listFinanceStatements,
uploadFinanceVoucher,
} from '../api/finance'
import { financeStatusLabel, financeStatusTone } from '../status'
import type {
FinanceFeePoolItem,
FinancePayableSettlement,
FinanceReceivableSettlement,
FinanceSettlementDetail,
FinanceStatementReadModel,
} from '../types'
const props = defineProps<{ kind: 'receivable' | 'payable' }>()
const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([])
const filters = ref<QueryModel>({ customerId: '' })
const rows = ref<Array<FinanceReceivableSettlement | FinancePayableSettlement>>([])
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const customerError = ref<ApiErrorDetails | null>(null)
const detailOpen = ref(false)
const detail = ref<FinanceSettlementDetail | null>(null)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const createOpen = ref(false)
const createBusy = ref(false)
const statementOptions = ref<FinanceStatementReadModel[]>([])
const feeOptions = ref<FinanceFeePoolItem[]>([])
const supplierOptions = ref<PlatformPartnerResource[]>([])
const createForm = ref({ reconciliationStatementId: '', supplierId: '', feeSourceIds: [] as string[], dueDate: '', })
const paymentOpen = ref(false)
const paymentBusy = ref(false)
const paymentFile = ref<File | null>(null)
const paymentForm = ref({ transactionDate: new Date().toISOString().slice(0, 10), amount: 0, method: 'BANK_TRANSFER' as 'BANK_TRANSFER' | 'CASH' | 'OTHER', referenceNo: '', remark: '' })
const receiptOpen = ref(false)
const receiptBusy = ref(false)
const receiptFile = ref<File | null>(null)
const receiptForm = ref({ receivedDate: new Date().toISOString().slice(0, 10), amount: 0, paymentMethod: 'BANK_TRANSFER' as 'BANK_TRANSFER' | 'CASH' | 'OTHER', remark: '' })
const viewPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:view' : 'platform:finance:settlement-ap:view')
const createPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:create' : 'platform:finance:settlement-ap:create')
const canView = computed(() => access.can(viewPermission.value))
const canCreate = computed(() => access.can(createPermission.value))
const canPay = computed(() => props.kind === 'payable' && access.can('platform:finance:payment:create'))
const canReceive = computed(() => props.kind === 'receivable' && access.can('platform:finance:receipt:create'))
const title = computed(() => props.kind === 'receivable' ? '结算单应收' : '结算单应付')
const createLabel = computed(() => props.kind === 'receivable' ? '生成应收结算单' : '生成应付结算单')
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true, loading: !customers.value.length && !customerError.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }])
const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
? [{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' }, { key: 'customerId', label: '客户', minWidth: 160, slotName: 'customer' }, { key: 'settlementNo', label: '应收结算单号', minWidth: 180 }, { key: 'receivableAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }]
: [{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' }, { key: 'customerId', label: '客户', minWidth: 160, slotName: 'customer' }, { key: 'settlementNo', label: '应付结算单号', minWidth: 180 }, { key: 'feeItemName', label: '费用项目', minWidth: 160 }, { key: 'payableAmount', label: '应付(CNY)', width: 140 }, { key: 'paidAmount', label: '已付(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未付(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }])
async function loadCustomers() {
customerError.value = null
try { customers.value = (await listPlatformTenants({ page: 1, size: 200 })).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 = props.kind === 'receivable'
? await listFinanceReceivableSettlements(String(filters.value.customerId))
: await listFinancePayableSettlements(String(filters.value.customerId))
} catch (cause) { error.value = getApiErrorDetails(cause, `${title.value}加载失败,请重试`) }
finally { loading.value = false }
}
async function openDetail(row: unknown) {
const settlement = row as FinanceReceivableSettlement | FinancePayableSettlement
detailOpen.value = true
detail.value = null
detailError.value = null
detailLoading.value = true
try { detail.value = await getFinanceSettlement(settlement.id) }
catch (cause) { detailError.value = getApiErrorDetails(cause, `${title.value}详情加载失败`) }
finally { detailLoading.value = false }
}
async function openCreate() {
const customerId = String(filters.value.customerId || '')
if (!customerId) {
ElMessage.warning('请先选择客户')
return
}
createForm.value = { reconciliationStatementId: '', supplierId: '', feeSourceIds: [], dueDate: '' }
createOpen.value = true
error.value = null
try {
if (props.kind === 'receivable') {
statementOptions.value = (await listFinanceStatements(customerId)).filter((item) => item.status === 'ISSUED')
} else {
const [fees, suppliers] = await Promise.all([
listFinanceFeePool(customerId, 'PAYABLE'),
listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' }),
])
feeOptions.value = fees.filter((item) => item.feeSourceId && item.status === 'AVAILABLE')
supplierOptions.value = suppliers.records
}
} catch (cause) { error.value = getApiErrorDetails(cause, '结算来源加载失败') }
}
async function submitCreate() {
const customerId = String(filters.value.customerId || '')
if (props.kind === 'receivable' && !createForm.value.reconciliationStatementId) return ElMessage.warning('请选择已出账对账单')
if (props.kind === 'payable' && (!createForm.value.supplierId || !createForm.value.dueDate || !createForm.value.feeSourceIds.length)) return ElMessage.warning('请完整填写供应商、到期日和费用来源')
createBusy.value = true
try {
if (props.kind === 'receivable') {
await createReceivableSettlement({ reconciliationStatementId: createForm.value.reconciliationStatementId, currency: 'CNY' })
} else {
await createPayableSettlement({ customerId, supplierId: createForm.value.supplierId, feeSourceIds: createForm.value.feeSourceIds, dueDate: createForm.value.dueDate, currency: 'CNY' })
}
createOpen.value = false
ElMessage.success(`${title.value}已生成`)
await load()
} catch (cause) { error.value = getApiErrorDetails(cause, `${title.value}生成失败`) }
finally { createBusy.value = false }
}
function openPayment(row: unknown) {
const settlement = row as FinancePayableSettlement
detail.value = { id: settlement.id, settlementNo: settlement.settlementNo, customerId: settlement.customerId, direction: 'PAYABLE', amount: settlement.payableAmount, paidAmount: settlement.paidAmount, outstandingAmount: settlement.outstandingAmount, currency: 'CNY', status: settlement.status, version: settlement.version, lines: [], transactions: [], revisions: [] }
paymentFile.value = null
paymentForm.value = { transactionDate: new Date().toISOString().slice(0, 10), amount: settlement.outstandingAmount, method: 'BANK_TRANSFER', referenceNo: '', remark: '' }
paymentOpen.value = true
}
function selectPaymentFile(file: UploadFile) { paymentFile.value = file.raw ?? null }
async function openReceipt(row: unknown) {
const settlement = row as FinanceReceivableSettlement
receiptBusy.value = true
try {
const current = await getFinanceSettlement(settlement.id)
if (!current.reconciliationStatementId) throw new Error('应收结算单缺少对账单关联')
detail.value = current
receiptFile.value = null
receiptForm.value = { receivedDate: new Date().toISOString().slice(0, 10), amount: current.outstandingAmount, paymentMethod: 'BANK_TRANSFER', remark: '' }
receiptOpen.value = true
} catch (cause) { error.value = getApiErrorDetails(cause, '应收结算单加载失败,无法登记收款') }
finally { receiptBusy.value = false }
}
function selectReceiptFile(file: UploadFile) { receiptFile.value = file.raw ?? null }
async function submitReceipt() {
const current = detail.value
if (!current?.reconciliationStatementId || !receiptFile.value || receiptForm.value.amount <= 0) return ElMessage.warning('请填写收款金额并选择收款凭证')
receiptBusy.value = true
try {
const voucher = await uploadFinanceVoucher(current.customerId, receiptFile.value)
await createFinanceReceipt({
customerId: current.customerId,
receivedDate: receiptForm.value.receivedDate,
amount: receiptForm.value.amount,
paymentMethod: receiptForm.value.paymentMethod,
remark: receiptForm.value.remark || undefined,
allocations: [{ billId: current.reconciliationStatementId, amount: receiptForm.value.amount }],
}, [voucher.fileId])
receiptOpen.value = false
ElMessage.success('收款已登记')
await load()
} catch (cause) { error.value = getApiErrorDetails(cause, '收款登记失败') }
finally { receiptBusy.value = false }
}
async function submitPayment() {
if (!detail.value || !paymentFile.value || paymentForm.value.amount <= 0) return ElMessage.warning('请填写付款金额并选择付款凭证')
paymentBusy.value = true
try {
const voucher = await uploadFinanceVoucher(detail.value.customerId, paymentFile.value)
await createFinanceSettlementPayment(detail.value.id, { ...paymentForm.value, currency: 'CNY', voucherFileIds: [voucher.fileId], expectedVersion: detail.value.version })
paymentOpen.value = false
ElMessage.success('付款已登记')
await load()
} catch (cause) { error.value = getApiErrorDetails(cause, '付款登记失败') }
finally { paymentBusy.value = false }
}
function customerName(customerId: string) { return customers.value.find((item) => String(item.id) === String(customerId))?.tenantName || '客户信息暂不可用' }
function totalAmount(row: FinanceReceivableSettlement | FinancePayableSettlement) { return props.kind === 'receivable' ? (row as FinanceReceivableSettlement).receivableAmount : (row as FinancePayableSettlement).payableAmount }
onMounted(() => void loadCustomers())
</script>
<template>
<AdminPage as="div" class="finance-settlement-page" spacing="normal">
<el-result v-if="!canView" icon="warning" :title="`无权访问${title}`" sub-title="请联系管理员开通结算单查看权限" />
<ListPageLayout v-else :aria-label="title">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" preference-key="finance-settlement-query" @search="load" />
</template>
<template v-if="customerError || error" #state>
<LoadErrorState :title="`${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="`${title}列表`" row-key="id" empty-text="请选择客户查询结算单">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="15" />{{ createLabel }}</el-button></template>
<template #actions="{ row }"><el-button link type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canReceive && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" link :loading="receiptBusy" @click="openReceipt(row)"><HandCoins :size="15" />收款</el-button><el-button v-if="canPay && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" link @click="openPayment(row)"><CreditCard :size="15" />付款</el-button></template>
<template #customer="{ row }">{{ customerName(String(row.customerId)) }}</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="detailOpen" :title="`${title}详情`" size="min(760px, 94vw)">
<LoadErrorState v-if="detailError" :title="`${title}详情加载失败`" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" :rows="6" animated />
<template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerName(detail.customerId) }}</el-descriptions-item><el-descriptions-item label="金额">{{ detail.amount }} CNY</el-descriptions-item><el-descriptions-item label="已收付">{{ detail.paidAmount }} CNY</el-descriptions-item><el-descriptions-item label="未收付">{{ detail.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(detail.status) }}</el-descriptions-item></el-descriptions></template>
</AppDrawer>
<AppDialog v-model="createOpen" :title="`生成${title}`" width="min(560px, 94vw)" :confirm-loading="createBusy" @confirm="submitCreate">
<el-form label-position="top">
<el-form-item v-if="props.kind === 'receivable'" label="已出账对账单"><el-select v-model="createForm.reconciliationStatementId" filterable><el-option v-for="item in statementOptions" :key="item.id" :label="`${item.billNo} · ${item.outstandingAmount} CNY`" :value="item.id" /></el-select></el-form-item>
<template v-else><el-form-item label="供应商"><el-select v-model="createForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="`${item.partnerName} · ${item.partnerCode}`" :value="String(item.id)" /></el-select></el-form-item><el-form-item label="到期日"><el-date-picker v-model="createForm.dueDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="应付费用来源"><el-select v-model="createForm.feeSourceIds" multiple filterable><el-option v-for="item in feeOptions" :key="item.feeSourceId!" :label="`${item.feeItemName} · ${item.amount} CNY`" :value="item.feeSourceId!" /></el-select></el-form-item></template>
</el-form>
</AppDialog>
<AppDialog v-model="paymentOpen" title="登记付款" width="min(560px, 94vw)" :confirm-loading="paymentBusy" @confirm="submitPayment">
<el-form label-position="top"><el-form-item label="付款日期"><el-date-picker v-model="paymentForm.transactionDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="付款金额(CNY)"><el-input-number v-model="paymentForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" /></el-form-item><el-form-item label="付款方式"><el-select v-model="paymentForm.method"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="凭证号"><el-input v-model="paymentForm.referenceNo" /></el-form-item><el-form-item label="付款凭证"><el-upload :auto-upload="false" :limit="1" accept="image/*" :on-change="selectPaymentFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="paymentForm.remark" type="textarea" /></el-form-item></el-form>
</AppDialog>
<AppDialog v-model="receiptOpen" title="登记收款" width="min(560px, 94vw)" :confirm-loading="receiptBusy" @confirm="submitReceipt">
<el-form label-position="top"><el-form-item label="到账日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" /></el-form-item><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="收款凭证"><el-upload :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="receiptForm.remark" type="textarea" /></el-form-item></el-form>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.finance-settlement-page { min-width: 0; }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
@media (max-width: 720px) { :deep(.el-drawer__footer .el-button) { min-height: 44px; } }
</style>