519 lines
41 KiB
Vue
519 lines
41 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||
import { ArrowLeft, CreditCard, Download, Eye, HandCoins, Plus, RotateCcw, XCircle } 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 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 {
|
||
actionFinanceSettlement,
|
||
createFinanceReceipt,
|
||
createFinanceSettlementPayment,
|
||
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'
|
||
|
||
type SettlementRow = FinanceReceivableSettlement | FinancePayableSettlement
|
||
|
||
const props = defineProps<{ kind: 'receivable' | 'payable'; settlementId?: string }>()
|
||
const emit = defineEmits<{ openDetail: [settlementId: string]; back: [] }>()
|
||
const access = useAccessPreviewStore()
|
||
const customers = ref<PlatformTenantResource[]>([])
|
||
const filters = ref<QueryModel>({ customerId: '', supplierName: '', settlementNo: '', status: '' })
|
||
const rows = ref<SettlementRow[]>([])
|
||
const loading = ref(false)
|
||
const error = ref<ApiErrorDetails | null>(null)
|
||
const customerError = ref<ApiErrorDetails | null>(null)
|
||
const detail = ref<FinanceSettlementDetail | null>(null)
|
||
const selectedRow = ref<SettlementRow | 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 actionBusy = ref(false)
|
||
|
||
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 listPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:list' : 'platform:finance:settlement-ap:list')
|
||
const canView = computed(() => access.can(listPermission.value) || access.can(viewPermission.value))
|
||
const canListCustomers = computed(() => access.can('platform:customer:list'))
|
||
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 canVoid = computed(() => access.can('platform:finance:settlement:void'))
|
||
const title = computed(() => props.kind === 'receivable' ? '结算单应收' : '结算单应付')
|
||
const detailMode = computed(() => Boolean(props.settlementId))
|
||
const createLabel = computed(() => props.kind === 'receivable' ? '生成应收结算单' : '生成应付结算单')
|
||
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
|
||
const supplierNames = computed(() => [...new Set(rows.value.map((item) => 'supplierName' in item ? item.supplierName : '').filter(Boolean))])
|
||
const statusOptions = computed(() => [...new Set(rows.value.map((item) => item.status).filter(Boolean))])
|
||
const fields = computed<QueryField[]>(() => [
|
||
{ key: 'status', type: 'select', label: props.kind === 'receivable' ? '收款状态' : '付款状态', width: 180, placeholder: '全部状态', props: { clearable: true }, options: statusOptions.value.map((status) => ({ label: financeStatusLabel(status), value: status })) },
|
||
...(props.kind === 'receivable'
|
||
? [{ key: 'customerId', type: 'select' as const, label: '客户', width: 240, placeholder: '全部客户', props: { filterable: true, clearable: true, loading: !customers.value.length && !customerError.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }]
|
||
: [
|
||
{ key: 'supplierName', type: 'select' as const, label: '供应商', width: 240, placeholder: '全部供应商', props: { filterable: true, clearable: true }, options: supplierNames.value.map((name) => ({ label: name, value: name })) },
|
||
{ key: 'customerId', type: 'select' as const, label: '客户', width: 220, placeholder: '全部客户', props: { filterable: true, clearable: true, loading: !customers.value.length && !customerError.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
||
]),
|
||
{ key: 'settlementNo', type: 'input', label: '结算单号', width: 220, placeholder: '请输入结算单号' },
|
||
])
|
||
const filteredRows = computed(() => {
|
||
const status = String(filters.value.status || '')
|
||
const settlementNo = String(filters.value.settlementNo || '').trim().toLowerCase()
|
||
const supplierName = String(filters.value.supplierName || '')
|
||
return rows.value.filter((item) => (!status || item.status === status)
|
||
&& (!settlementNo || item.settlementNo.toLowerCase().includes(settlementNo))
|
||
&& (!supplierName || ('supplierName' in item && item.supplierName === supplierName)))
|
||
})
|
||
const totals = computed(() => filteredRows.value.reduce((result, row) => ({
|
||
amount: result.amount + totalAmount(row),
|
||
paid: result.paid + paidAmount(row),
|
||
outstanding: result.outstanding + row.outstandingAmount,
|
||
}), { amount: 0, paid: 0, outstanding: 0 }))
|
||
const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
|
||
? [
|
||
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
|
||
{ key: 'settlementNo', label: '结算单号', minWidth: 180 },
|
||
{ key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' },
|
||
{ key: 'currency', label: '币种', width: 80 },
|
||
{ key: 'receivableAmount', label: '应收金额', width: 130, slotName: 'money' },
|
||
{ key: 'receivedAmount', label: '已收', width: 120, slotName: 'paidMoney' },
|
||
{ key: 'outstandingAmount', label: '余额', width: 120, slotName: 'outstandingMoney' },
|
||
{ key: 'billDate', label: '生成日期', width: 130, slotName: 'billDate' },
|
||
{ key: 'dueDate', label: '到期日', width: 130, slotName: 'dueDate' },
|
||
{ key: 'status', label: '收款状态', width: 120, slotName: 'status' },
|
||
]
|
||
: [
|
||
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
|
||
{ key: 'settlementNo', label: '结算单号', minWidth: 180 },
|
||
{ key: 'supplierName', label: '供应商', minWidth: 180, slotName: 'supplier' },
|
||
{ key: 'feeItemName', label: '费用类型', minWidth: 150, slotName: 'feeType' },
|
||
{ key: 'currency', label: '币种', width: 80 },
|
||
{ key: 'payableAmount', label: '应付金额', width: 130, slotName: 'money' },
|
||
{ key: 'paidAmount', label: '已付', width: 120, slotName: 'paidMoney' },
|
||
{ key: 'outstandingAmount', label: '余额', width: 120, slotName: 'outstandingMoney' },
|
||
{ key: 'createdAt', label: '生成日期', width: 130, slotName: 'createdAt' },
|
||
{ key: 'status', label: '付款状态', width: 120, slotName: 'status' },
|
||
])
|
||
|
||
function money(value: number) { return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` }
|
||
function displayDate(value?: string) { return value || '—' }
|
||
function rowTotal(value: unknown) { return totalAmount(value as SettlementRow) }
|
||
function rowPaid(value: unknown) { return paidAmount(value as SettlementRow) }
|
||
function rowOutstanding(value: unknown) { return Number((value as SettlementRow).outstandingAmount || 0) }
|
||
function customerName(customerId: string, snapshot?: string) { return snapshot || customers.value.find((item) => String(item.id) === String(customerId))?.tenantName || `客户 ${customerId}` }
|
||
function totalAmount(row: SettlementRow) { return props.kind === 'receivable' ? (row as FinanceReceivableSettlement).receivableAmount : (row as FinancePayableSettlement).payableAmount }
|
||
function paidAmount(row: SettlementRow) { return props.kind === 'receivable' ? (row as FinanceReceivableSettlement).receivedAmount : (row as FinancePayableSettlement).paidAmount }
|
||
function selectedSupplierName() { return selectedRow.value && 'supplierName' in selectedRow.value ? selectedRow.value.supplierName || '—' : '—' }
|
||
function detailPartyName() { return !detail.value ? '—' : props.kind === 'receivable' ? customerName(detail.value.customerId, detail.value.customerName) : detail.value.supplierName || selectedSupplierName() }
|
||
function detailActionRow(): SettlementRow {
|
||
const current = detail.value!
|
||
return props.kind === 'receivable'
|
||
? { id: current.id, customerId: current.customerId, customerName: current.customerName || '', settlementNo: current.settlementNo, billDate: current.generatedDate || '', dueDate: current.dueDate || '', receivableAmount: current.amount, receivedAmount: current.paidAmount, outstandingAmount: current.outstandingAmount, currency: 'CNY', status: current.status as FinanceReceivableSettlement['status'], version: current.version }
|
||
: { id: current.id, customerId: current.customerId, settlementNo: current.settlementNo, costType: 'SINGLE_ORDER', orderId: null, inboundId: null, containerId: null, supplierName: current.supplierName || '', feeItemName: '', payableAmount: current.amount, paidAmount: current.paidAmount, outstandingAmount: current.outstandingAmount, currency: 'CNY', status: current.status, createdAt: current.generatedDate || '', version: current.version }
|
||
}
|
||
function remainingAfter(amount: number) { return money(Math.max(0, Number(detail.value?.outstandingAmount || 0) - Number(amount || 0))) }
|
||
|
||
async function loadCustomers() {
|
||
if (!canListCustomers.value) return
|
||
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
|
||
loading.value = true
|
||
try {
|
||
const customerId = String(filters.value.customerId || '').trim() || undefined
|
||
rows.value = props.kind === 'receivable'
|
||
? await listFinanceReceivableSettlements(customerId)
|
||
: await listFinancePayableSettlements(customerId)
|
||
} catch (cause) { error.value = getApiErrorDetails(cause, `${title.value}加载失败,请重试`) }
|
||
finally { loading.value = false }
|
||
}
|
||
|
||
async function openDetail(row: unknown) {
|
||
selectedRow.value = row as SettlementRow
|
||
emit('openDetail', selectedRow.value.id)
|
||
await loadDetail(selectedRow.value.id)
|
||
}
|
||
|
||
async function loadDetail(settlementId: string) {
|
||
detail.value = null
|
||
detailError.value = null
|
||
detailLoading.value = true
|
||
try { detail.value = await getFinanceSettlement(settlementId) }
|
||
catch (cause) { detailError.value = getApiErrorDetails(cause, `${title.value}详情加载失败`) }
|
||
finally { detailLoading.value = false }
|
||
}
|
||
|
||
function backToList() { emit('back') }
|
||
|
||
async function openCreate() {
|
||
const customerId = String(filters.value.customerId || '')
|
||
if (!customerId) return ElMessage.warning('请先选择客户')
|
||
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
|
||
selectedRow.value = settlement
|
||
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 openPaymentFromDetail() {
|
||
if (!detail.value) return
|
||
openPayment({ id: detail.value.id, settlementNo: detail.value.settlementNo, customerId: detail.value.customerId, supplierName: detail.value.supplierName || '', feeItemName: '', payableAmount: detail.value.amount, paidAmount: detail.value.paidAmount, outstandingAmount: detail.value.outstandingAmount, currency: 'CNY', status: detail.value.status, version: detail.value.version })
|
||
}
|
||
|
||
function selectPaymentFile(file: UploadFile) { paymentFile.value = file.raw ?? null }
|
||
|
||
async function openReceipt(row: unknown) {
|
||
const settlement = row as FinanceReceivableSettlement
|
||
selectedRow.value = settlement
|
||
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 }
|
||
}
|
||
|
||
async function openReceiptFromDetail() {
|
||
if (!detail.value) return
|
||
await openReceipt({ id: detail.value.id, customerId: detail.value.customerId, settlementNo: detail.value.settlementNo, receivableAmount: detail.value.amount, receivedAmount: detail.value.paidAmount, outstandingAmount: detail.value.outstandingAmount, currency: 'CNY', status: detail.value.status, version: detail.value.version })
|
||
}
|
||
|
||
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('收款已登记')
|
||
if (detailMode.value) await loadDetail(current.id)
|
||
else 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('付款已登记')
|
||
if (detailMode.value) await loadDetail(detail.value.id)
|
||
else await load()
|
||
} catch (cause) { error.value = getApiErrorDetails(cause, '付款登记失败') }
|
||
finally { paymentBusy.value = false }
|
||
}
|
||
|
||
async function performAction(row: SettlementRow, action: 'VOID' | 'REOPEN') {
|
||
const reason = await ElMessageBox.prompt('请输入操作原因', action === 'VOID' ? `作废${title.value}` : `重开${title.value}`, {
|
||
inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消',
|
||
}).then((result) => result.value.trim()).catch(() => null)
|
||
if (reason === null) return
|
||
actionBusy.value = true
|
||
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' ? '作废' : '重开'}结算单失败`) }
|
||
finally { actionBusy.value = false }
|
||
}
|
||
|
||
function csvCell(value: unknown) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
|
||
function exportRows() {
|
||
if (!filteredRows.value.length) return ElMessage.warning('当前没有可导出的结算单')
|
||
const header = props.kind === 'receivable'
|
||
? ['结算单号', '客户', '币种', '应收金额', '已收', '余额', '生成日期', '到期日', '收款状态']
|
||
: ['结算单号', '供应商', '费用类型', '币种', '应付金额', '已付', '余额', '生成日期', '付款状态']
|
||
const records = filteredRows.value.map((row) => props.kind === 'receivable'
|
||
? [row.settlementNo, customerName(row.customerId, (row as FinanceReceivableSettlement).customerName), row.currency, totalAmount(row), paidAmount(row), row.outstandingAmount, (row as FinanceReceivableSettlement).billDate, (row as FinanceReceivableSettlement).dueDate, financeStatusLabel(row.status)]
|
||
: [row.settlementNo, (row as FinancePayableSettlement).supplierName, (row as FinancePayableSettlement).feeItemName, row.currency, totalAmount(row), paidAmount(row), row.outstandingAmount, (row as FinancePayableSettlement).createdAt, financeStatusLabel(row.status)])
|
||
const blob = new Blob([`\ufeff${[header, ...records].map((record) => record.map(csvCell).join(',')).join('\r\n')}`], { type: 'text/csv;charset=utf-8' })
|
||
const url = URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = `${title.value}-${new Date().toISOString().slice(0, 10)}.csv`
|
||
link.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
function exportDetail() {
|
||
if (!detail.value) return
|
||
const current = detail.value
|
||
const header = ['结算单号', props.kind === 'receivable' ? '客户' : '供应商', '币种', props.kind === 'receivable' ? '应收金额' : '应付金额', props.kind === 'receivable' ? '已收' : '已付', '余额', '业务期间', '生成日期', '到期日', '状态']
|
||
const record = [current.settlementNo, props.kind === 'receivable' ? customerName(current.customerId, current.customerName) : current.supplierName, current.currency, current.amount, current.paidAmount, current.outstandingAmount, current.businessPeriod, current.generatedDate, current.dueDate, financeStatusLabel(current.status)]
|
||
const blob = new Blob([`\ufeff${[header, record].map((line) => line.map(csvCell).join(',')).join('\r\n')}`], { type: 'text/csv;charset=utf-8' })
|
||
const url = URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = `${current.settlementNo}.csv`
|
||
link.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
onMounted(() => {
|
||
if (canListCustomers.value) void loadCustomers()
|
||
const settlementId = String(props.settlementId || '')
|
||
if (settlementId) void loadDetail(settlementId)
|
||
else void load()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<AdminPage as="div" class="finance-settlement-page" spacing="normal">
|
||
<el-result v-if="!canView" icon="warning" :title="`无权访问${title}`" sub-title="请联系管理员开通结算单查看权限" />
|
||
<section v-else-if="detailMode" class="settlement-detail-page" :aria-label="`${title}详情`">
|
||
<header class="settlement-detail-header">
|
||
<div class="settlement-detail-heading">
|
||
<el-button aria-label="返回列表" title="返回列表" @click="backToList"><ArrowLeft :size="17" /></el-button>
|
||
<div><div class="settlement-detail-breadcrumb">财务管理 / {{ title }} / {{ detail?.settlementNo || settlementId }}</div><h2>{{ title }}详情</h2></div>
|
||
<el-tag v-if="detail" :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag>
|
||
</div>
|
||
<div class="settlement-detail-actions">
|
||
<el-button v-if="detail" @click="exportDetail"><Download :size="15" />导出</el-button>
|
||
<el-button v-if="detail && canReceive && ['ISSUED','PARTIALLY_PAID'].includes(detail.status)" type="primary" :loading="receiptBusy" @click="openReceiptFromDetail"><HandCoins :size="15" />部分收款</el-button>
|
||
<el-button v-if="detail && canPay && ['ISSUED','PARTIALLY_PAID'].includes(detail.status)" type="primary" @click="openPaymentFromDetail"><CreditCard :size="15" />部分付款</el-button>
|
||
<el-button v-if="detail && canVoid && detail.status === 'ISSUED'" type="danger" :loading="actionBusy" @click="performAction(detailActionRow(), 'VOID')"><XCircle :size="15" />作废</el-button>
|
||
<el-button v-if="detail && canVoid && detail.status === 'VOIDED'" :loading="actionBusy" @click="performAction(detailActionRow(), 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
|
||
</div>
|
||
</header>
|
||
<LoadErrorState v-if="detailError" :title="`${title}详情加载失败`" :message="detailError.message" :loading="detailLoading" @retry="loadDetail(String(settlementId || ''))"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||
<el-skeleton v-else-if="detailLoading" :rows="10" animated />
|
||
<template v-else-if="detail">
|
||
<section class="settlement-detail-summary" aria-label="结算金额汇总">
|
||
<div class="summary-item"><span>{{ kind === 'receivable' ? '应收金额' : '应付金额' }}</span><strong>{{ money(detail.amount) }}</strong></div>
|
||
<div class="summary-item"><span>{{ kind === 'receivable' ? '已收金额' : '已付金额' }}</span><strong class="is-success">{{ money(detail.paidAmount) }}</strong></div>
|
||
<div class="summary-item"><span>未结余额</span><strong class="is-danger">{{ money(detail.outstandingAmount) }}</strong></div>
|
||
<div class="summary-item"><span>到期日</span><strong class="is-date">{{ displayDate(detail.dueDate) }}</strong></div>
|
||
</section>
|
||
<section class="settlement-detail-info surface-panel">
|
||
<h3>基本信息</h3>
|
||
<el-descriptions :column="2" border>
|
||
<el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item>
|
||
<el-descriptions-item :label="kind === 'receivable' ? '客户' : '供应商'">{{ detailPartyName() }}</el-descriptions-item>
|
||
<el-descriptions-item label="币种">CNY</el-descriptions-item>
|
||
<el-descriptions-item label="收付款状态"><el-tag :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag></el-descriptions-item>
|
||
<el-descriptions-item label="关联账单">{{ detail.reconciliationStatementId || '—' }}</el-descriptions-item>
|
||
<el-descriptions-item label="业务期间">{{ detail.businessPeriod || '—' }}</el-descriptions-item>
|
||
<el-descriptions-item label="生成日期">{{ displayDate(detail.generatedDate) }}</el-descriptions-item>
|
||
<el-descriptions-item label="到期日">{{ displayDate(detail.dueDate) }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
</section>
|
||
<section class="detail-section surface-panel">
|
||
<h3>费用明细</h3>
|
||
<el-table :data="detail.lines" size="small" border stripe empty-text="暂无费用明细">
|
||
<el-table-column prop="feeItemName" label="费用类型" min-width="150" />
|
||
<el-table-column prop="sourceType" label="来源类型" min-width="140" />
|
||
<el-table-column prop="inboundCode" label="入仓码" min-width="130"><template #default="scope">{{ scope.row.inboundCode || '—' }}</template></el-table-column>
|
||
<el-table-column prop="containerNo" label="柜次号" min-width="130"><template #default="scope">{{ scope.row.containerNo || '—' }}</template></el-table-column>
|
||
<el-table-column prop="businessDate" label="业务发生日" width="120"><template #default="scope">{{ displayDate(scope.row.businessDate) }}</template></el-table-column>
|
||
<el-table-column prop="sourceVersion" label="来源版本" width="100" />
|
||
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
|
||
</el-table>
|
||
</section>
|
||
<section class="detail-section surface-panel">
|
||
<h3>{{ kind === 'receivable' ? '收款记录' : '付款记录' }}</h3>
|
||
<el-table :data="detail.transactions" size="small" border stripe :empty-text="kind === 'receivable' ? '暂无收款记录' : '暂无付款记录'">
|
||
<el-table-column prop="transactionDate" :label="kind === 'receivable' ? '收款日期' : '付款日期'" width="130" />
|
||
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
|
||
<el-table-column prop="method" :label="kind === 'receivable' ? '收款方式' : '付款方式'" min-width="130" />
|
||
<el-table-column label="凭证" width="100"><template #default="scope">{{ scope.row.voucherFileIds.length ? `${scope.row.voucherFileIds.length} 个` : '—' }}</template></el-table-column>
|
||
<el-table-column prop="remark" label="备注" min-width="180"><template #default="scope">{{ scope.row.remark || '—' }}</template></el-table-column>
|
||
</el-table>
|
||
</section>
|
||
<section class="detail-section surface-panel">
|
||
<h3>修订记录</h3>
|
||
<el-table :data="detail.revisions" size="small" border stripe empty-text="暂无修订记录">
|
||
<el-table-column prop="revisionNo" label="版本" width="90" />
|
||
<el-table-column prop="action" label="动作" min-width="120" />
|
||
<el-table-column prop="beforeAmount" label="调整前(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.beforeAmount) }}</template></el-table-column>
|
||
<el-table-column prop="afterAmount" label="调整后(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.afterAmount) }}</template></el-table-column>
|
||
<el-table-column prop="reason" label="原因" min-width="180"><template #default="scope">{{ scope.row.reason || '—' }}</template></el-table-column>
|
||
<el-table-column prop="occurredAt" label="时间" min-width="160" />
|
||
</el-table>
|
||
</section>
|
||
</template>
|
||
</section>
|
||
<ListPageLayout v-else :aria-label="title">
|
||
<template #toolbar>
|
||
<QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" :preference-key="`finance-settlement-${kind}-query`" @search="load" @reset="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>
|
||
<template v-if="!customerError && !error">
|
||
<section class="settlement-summary" aria-label="结算汇总">
|
||
<div class="summary-item"><span>{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</span><strong>{{ money(totals.amount) }}</strong></div>
|
||
<div class="summary-item"><span>{{ kind === 'receivable' ? '已收' : '已付' }}</span><strong class="is-success">{{ money(totals.paid) }}</strong></div>
|
||
<div class="summary-item"><span>{{ kind === 'receivable' ? '待收' : '待付' }}</span><strong class="is-danger">{{ money(totals.outstanding) }}</strong></div>
|
||
</section>
|
||
<section class="surface-panel table-panel">
|
||
<DataTable :data="filteredRows" :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>
|
||
<el-button @click="exportRows"><Download :size="15" />导出</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>
|
||
<el-button v-if="canVoid && row.status === 'ISSUED'" link type="danger" :loading="actionBusy" @click="performAction(row as SettlementRow, 'VOID')"><XCircle :size="15" />作废</el-button>
|
||
<el-button v-if="canVoid && row.status === 'VOIDED'" link :loading="actionBusy" @click="performAction(row as SettlementRow, 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
|
||
</template>
|
||
<template #customer="{ row }">{{ customerName(String(row.customerId), row.customerName) }}</template>
|
||
<template #supplier="{ row }">{{ row.supplierName || '—' }}</template>
|
||
<template #feeType="{ row }">{{ row.feeItemName || '详见费用明细' }}</template>
|
||
<template #money="{ row }">{{ money(rowTotal(row)) }}</template>
|
||
<template #paidMoney="{ row }">{{ money(rowPaid(row)) }}</template>
|
||
<template #outstandingMoney="{ row }">{{ money(rowOutstanding(row)) }}</template>
|
||
<template #billDate="{ row }">{{ displayDate((row as FinanceReceivableSettlement).billDate) }}</template>
|
||
<template #dueDate="{ row }">{{ displayDate((row as FinanceReceivableSettlement).dueDate) }}</template>
|
||
<template #createdAt="{ row }">{{ displayDate((row as FinancePayableSettlement).createdAt) }}</template>
|
||
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
|
||
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
|
||
</DataTable>
|
||
</section>
|
||
</template>
|
||
</ListPageLayout>
|
||
|
||
<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="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">
|
||
<div v-if="detail" class="partial-summary"><div><span>结算单号</span><strong>{{ detail.settlementNo }}</strong></div><div><span>供应商</span><strong>{{ detailPartyName() }}</strong></div><div><span>应付金额</span><strong>{{ money(detail.amount) }}</strong></div><div><span>已付金额</span><strong class="is-success">{{ money(detail.paidAmount) }}</strong></div><div><span>当前余额</span><strong class="is-danger">{{ money(detail.outstandingAmount) }}</strong></div></div>
|
||
<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>
|
||
<div v-if="detail" class="remaining-preview"><span>登记后剩余余额</span><strong>{{ remainingAfter(paymentForm.amount) }}</strong></div>
|
||
</AppDialog>
|
||
|
||
<AppDialog v-model="receiptOpen" title="登记收款" width="min(560px, 94vw)" :confirm-loading="receiptBusy" @confirm="submitReceipt">
|
||
<div v-if="detail" class="partial-summary"><div><span>结算单号</span><strong>{{ detail.settlementNo }}</strong></div><div><span>客户公司</span><strong>{{ detailPartyName() }}</strong></div><div><span>应收金额</span><strong>{{ money(detail.amount) }}</strong></div><div><span>已收金额</span><strong class="is-success">{{ money(detail.paidAmount) }}</strong></div><div><span>当前余额</span><strong class="is-danger">{{ money(detail.outstandingAmount) }}</strong></div></div>
|
||
<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>
|
||
<div v-if="detail" class="remaining-preview"><span>登记后剩余余额</span><strong>{{ remainingAfter(receiptForm.amount) }}</strong></div>
|
||
</AppDialog>
|
||
</AdminPage>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.finance-settlement-page { min-width: 0; }
|
||
.settlement-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||
.summary-item { min-width: 0; padding: 16px 18px; border: 1px solid var(--cc-border, #e5e7eb); border-radius: 8px; background: var(--cc-surface, #fff); }
|
||
.summary-item span { display: block; color: var(--cc-text-secondary, #64748b); font-size: 13px; }
|
||
.summary-item strong { display: block; margin-top: 8px; color: var(--cc-text-primary, #1f2937); font-size: 24px; line-height: 1.2; }
|
||
.summary-item strong.is-success { color: #16803c; }
|
||
.summary-item strong.is-danger { color: #c2413a; }
|
||
.settlement-detail-page { display: grid; min-width: 0; gap: 14px; }
|
||
.settlement-detail-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-width: 0; }
|
||
.settlement-detail-heading, .settlement-detail-actions { display: flex; align-items: center; gap: 10px; min-width: 0; flex-wrap: wrap; }
|
||
.settlement-detail-heading h2 { margin: 2px 0 0; font-size: 18px; letter-spacing: 0; }
|
||
.settlement-detail-breadcrumb { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
|
||
.settlement-detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
||
.summary-item strong.is-date { font-size: 20px; }
|
||
.settlement-detail-info, .detail-section { min-width: 0; padding: 16px; }
|
||
.settlement-detail-info h3, .detail-section h3 { margin: 0 0 12px; color: var(--cc-text-primary, #1f2937); font-size: 15px; font-weight: 600; }
|
||
.partial-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 18px; padding: 14px; border: 1px solid var(--el-color-primary-light-7); border-radius: 8px; background: var(--el-color-primary-light-9); }
|
||
.partial-summary div { min-width: 0; }
|
||
.partial-summary span, .partial-summary strong { display: block; overflow-wrap: anywhere; }
|
||
.partial-summary span { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
|
||
.partial-summary strong { margin-top: 4px; color: var(--cc-text-primary, #1f2937); font-size: 15px; }
|
||
.partial-summary strong.is-success { color: #16803c; }
|
||
.partial-summary strong.is-danger { color: #c2413a; }
|
||
.remaining-preview { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-radius: 8px; color: var(--cc-text-secondary, #64748b); background: var(--el-fill-color-light); }
|
||
.remaining-preview strong { color: #b45309; font-size: 16px; }
|
||
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
|
||
@media (max-width: 720px) {
|
||
.settlement-summary, .settlement-detail-summary, .partial-summary { grid-template-columns: 1fr; }
|
||
.summary-item strong { font-size: 21px; }
|
||
.settlement-detail-header { align-items: flex-start; flex-direction: column; }
|
||
.settlement-detail-actions { width: 100%; }
|
||
.settlement-detail-actions :deep(.el-button) { min-height: 40px; }
|
||
}
|
||
</style>
|