Files
admin-web/src/modules/overseas-master-data/components/VehicleCareFinanceLedger.vue
T
2026-10-20 23:34:11 +08:00

207 lines
18 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 '@/styles/adminWorkbench.css'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { companyCostPermissions as permissions, getCompanyCost, commandCompanyCost, payCompanyCost,
listCompanyCostHistory, listCompanyCostPayments, uploadCompanyCostVoucher, downloadCompanyCostVoucher,
type CompanyCost, type CompanyCostCommand, type CompanyCostPayment, type CompanyCostHistory, type CompanyCostPaid, type CompanyCostVoucher } from '../api/companyVehicleCosts'
const props = withDefaults(defineProps<{ costNo: string; active?: boolean }>(), { active: true })
const emit = defineEmits<{ changed: []; busy: [value: boolean] }>()
const access = useAccessPreviewStore(), allowed = computed(() => access.can(permissions.list))
const cost = ref<CompanyCost | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const action = ref<'approve' | 'void' | 'pay' | null>(null), saving = ref(false), uploading = ref(false), writeError = ref<ApiErrorDetails | null>(null)
const reason = ref(''), vouchers = ref<CompanyCostVoucher[]>([]), pendingFiles = ref<File[]>([])
const form = reactive<{ amount: number | null; paidAt: string; method: CompanyCostPayment['method']; referenceNo: string; remark: string }>({ amount: null, paidAt: '', method: 'BANK_TRANSFER', referenceNo: '', remark: '' })
type Pending = { action: 'approve' | 'void'; key: string; request: CompanyCostCommand } | { action: 'pay'; key: string; request: CompanyCostPayment }
const pending = ref<Pending | null>(null)
const tab = ref<'current' | 'history' | 'payments'>('current'), historyPage = ref(1), paymentPage = ref(1)
const history = ref<CompanyCostHistory[]>([]), payments = ref<CompanyCostPaid[]>([]), historyTotal = ref(0), paymentTotal = ref(0)
const recordsLoading = ref(false), recordsError = ref<ApiErrorDetails | null>(null)
let epoch = 0, readRequest = 0, recordsRequest = 0, disposed = false
const previews = new Set<{ target: Window; url: string; timer: ReturnType<typeof setInterval> }>()
const busy = computed(() => saving.value || uploading.value || !!pending.value)
watch(busy, value => emit('busy', value))
const canApprove = computed(() => allowed.value && access.can(permissions.approve) && cost.value?.status === 'PENDING')
const canVoid = computed(() => allowed.value && access.can(permissions.void) && !!cost.value && ['PENDING', 'APPROVED'].includes(cost.value.status) && cost.value.paidAmount === 0)
const canPay = computed(() => allowed.value && access.can(permissions.pay) && !!cost.value && ['APPROVED', 'PARTIALLY_PAID'].includes(cost.value.status) && (cost.value.outstandingAmount ?? 0) > 0)
const statusLabels = { PENDING: '待财务入账', APPROVED: '已入账 · 未付款', PARTIALLY_PAID: '部分付款', PAID: '已付款', VOIDED: '已作废' }
const methods = { BANK_TRANSFER: '银行转账', ONLINE: '线上支付', CASH: '现金', OTHER: '其他' }
const money = (value: number | null) => value == null ? '未形成应付' : `CNY ${value.toFixed(2)}`
const time = (value: string) => new Date(value).toLocaleString('zh-CN', { hour12: false })
const historyColumns: DataTableColumn[] = [
{ key: 'action', label: '操作', width: 90, slotName: 'action' }, { key: 'occurredAt', label: '时间', minWidth: 170, slotName: 'time' },
{ key: 'operatorName', prop: 'operatorName', label: '操作人', minWidth: 100 }, { key: 'amount', label: '当时金额', minWidth: 130, slotName: 'amount' },
{ key: 'reason', prop: 'reason', label: '说明', minWidth: 160 },
]
const paymentColumns: DataTableColumn[] = [
{ key: 'paymentNo', prop: 'paymentNo', label: '付款编号', minWidth: 170 }, { key: 'amount', label: '已付金额', minWidth: 130, slotName: 'amount' },
{ key: 'paidAt', label: '实际付款时间', minWidth: 170, slotName: 'time' }, { key: 'method', label: '方式', width: 100, slotName: 'method' },
{ key: 'referenceNo', prop: 'referenceNo', label: '流水号', minWidth: 150 }, { key: 'files', label: '付款凭证', minWidth: 130, slotName: 'files' },
]
async function load() {
const request = ++readRequest, scope = epoch; error.value = null; cost.value = null
if (!allowed.value) { loading.value = false; return }
loading.value = true
try { const value = await getCompanyCost(props.costNo); if (!disposed && scope === epoch && request === readRequest) cost.value = value }
catch (cause) { if (!disposed && scope === epoch && request === readRequest) error.value = getApiErrorDetails(cause, '公司费用读取失败') }
finally { if (scope === epoch && request === readRequest) loading.value = false }
}
watch([() => props.costNo, allowed], () => {
epoch++; readRequest++; recordsRequest++; pending.value = null; action.value = null; saving.value = false; uploading.value = false
vouchers.value = []; pendingFiles.value = []; history.value = []; payments.value = []; tab.value = 'current'; historyPage.value = 1; paymentPage.value = 1
reason.value = ''; Object.assign(form, { amount: null, paidAt: '', method: 'BANK_TRANSFER', referenceNo: '', remark: '' })
writeError.value = null; recordsError.value = null; void load()
}, { immediate: true })
watch(() => props.active, value => { if (value && !busy.value) void load() })
function choose(next: 'approve' | 'void' | 'pay') {
if (busy.value || !(next === 'approve' ? canApprove.value : next === 'void' ? canVoid.value : canPay.value)) return
action.value = next; tab.value = 'current'; writeError.value = null; reason.value = ''
if (next === 'pay' && !form.paidAt) form.paidAt = new Date().toISOString()
}
function selectFiles(event: Event) {
if (busy.value || !access.can(permissions.upload)) return
const input = event.target as HTMLInputElement, files = Array.from(input.files ?? [])
if (vouchers.value.length + pendingFiles.value.length + files.length > 20 || files.some(file => !file.size || file.size > 10 * 1024 * 1024 || !['image/png', 'image/jpeg'].includes(file.type))) {
ElMessage.warning('最多20张非空JPG/PNG付款凭证,每张不超过10MiB'); input.value = ''; return
}
pendingFiles.value.push(...files); input.value = ''; void uploadFiles()
}
async function uploadFiles() {
if (uploading.value || saving.value || pending.value || !access.can(permissions.upload)) return
const scope = epoch, no = props.costNo; uploading.value = true; writeError.value = null
try {
while (pendingFiles.value.length) {
const file = await uploadCompanyCostVoucher(no, pendingFiles.value[0]!)
if (disposed || scope !== epoch) return
if (!vouchers.value.some(value => value.fileId === file.fileId)) vouchers.value.push(file)
pendingFiles.value.shift()
}
} catch (cause) { if (!disposed && scope === epoch) writeError.value = getApiErrorDetails(cause, '凭证上传未完成;已成功上传的凭证已保留,尚未登记付款') }
finally { if (scope === epoch) uploading.value = false }
}
async function submit() {
const chosen = pending.value?.action ?? action.value
if (!chosen || saving.value || uploading.value || !allowed.value || !access.can(permissions[chosen])) return
if (!pending.value) {
if (!cost.value || !(chosen === 'approve' ? canApprove.value : chosen === 'void' ? canVoid.value : canPay.value)) return
if (chosen === 'pay') {
if (form.amount == null || !Number.isFinite(form.amount) || form.amount <= 0 || form.amount > (cost.value.outstandingAmount ?? 0)
|| !form.paidAt || !Number.isFinite(Date.parse(form.paidAt)) || !vouchers.value.length || pendingFiles.value.length) {
ElMessage.warning('请填写不超过未付余额的正数金额、实际付款时间,并完成凭证上传'); return
}
pending.value = { action: chosen, key: crypto.randomUUID(), request: { expectedVersion: cost.value.recordVersion, amount: form.amount,
paidAt: form.paidAt, method: form.method, referenceNo: form.referenceNo.trim() || null, voucherFileIds: vouchers.value.map(v => v.fileId), remark: form.remark.trim() || null } }
} else pending.value = { action: chosen, key: crypto.randomUUID(), request: { expectedVersion: cost.value.recordVersion, reason: reason.value.trim() || null } }
}
const command = pending.value, scope = epoch, no = props.costNo
saving.value = true; writeError.value = null; readRequest++
try {
const result = command.action === 'pay' ? await payCompanyCost(no, command.key, command.request) : await commandCompanyCost(no, command.action, command.key, command.request)
if (disposed || scope !== epoch) return
cost.value = result; pending.value = null; action.value = null; vouchers.value = []; pendingFiles.value = []
Object.assign(form, { amount: null, paidAt: '', method: 'BANK_TRANSFER', referenceNo: '', remark: '' })
ElMessage.success(chosen === 'pay' ? '实际付款已登记' : chosen === 'void' ? '公司费用已作废' : '公司费用已入账')
emit('changed')
if (tab.value !== 'current') void loadRecords()
} catch (cause) {
if (disposed || scope !== epoch) return
if (cause instanceof ApiResponseError && (/^A04/.test(cause.code) || ['FIN_400', 'FIN_404', 'FIN_409'].includes(cause.code))) pending.value = null
writeError.value = getApiErrorDetails(cause, '财务操作未完成,请保留输入并重试')
} finally { if (scope === epoch) saving.value = false }
}
async function loadRecords() {
const request = ++recordsRequest, scope = epoch, current = tab.value
history.value = []; payments.value = []; historyTotal.value = 0; paymentTotal.value = 0; recordsError.value = null
if (!allowed.value || current === 'current') { recordsLoading.value = false; return }
recordsLoading.value = true
try {
if (current === 'history') {
const result = await listCompanyCostHistory(props.costNo, historyPage.value)
if (!disposed && scope === epoch && request === recordsRequest) { history.value = result.records; historyTotal.value = result.total }
} else {
const result = await listCompanyCostPayments(props.costNo, paymentPage.value)
if (!disposed && scope === epoch && request === recordsRequest) { payments.value = result.records; paymentTotal.value = result.total }
}
} catch (cause) { if (!disposed && scope === epoch && request === recordsRequest) recordsError.value = getApiErrorDetails(cause, '财务记录读取失败') }
finally { if (scope === epoch && request === recordsRequest) recordsLoading.value = false }
}
watch([tab, historyPage, paymentPage], loadRecords)
async function preview(fileId: string) {
if (!allowed.value) return
const target = window.open('about:blank', '_blank')
if (!target) { ElMessage.warning('请允许浏览器打开付款凭证'); return }
target.opener = null; const scope = epoch
try {
const blob = await downloadCompanyCostVoucher(props.costNo, fileId)
if (disposed || scope !== epoch || target.closed) { target.close(); return }
const url = URL.createObjectURL(blob); target.location.href = url
const item = { target, url, timer: setInterval(() => { if (target.closed) { clearInterval(item.timer); URL.revokeObjectURL(url); previews.delete(item) } }, 1000) }
previews.add(item)
} catch (cause) { target.close(); if (!disposed && scope === epoch) writeError.value = getApiErrorDetails(cause, '付款凭证读取失败') }
}
onBeforeUnmount(() => { disposed = true; epoch++; readRequest++; recordsRequest++; previews.forEach(item => { clearInterval(item.timer); URL.revokeObjectURL(item.url) }); emit('busy', false) })
</script>
<template>
<section aria-label="公司成本财务处理">
<p v-if="!allowed">暂无公司成本财务查询权限。</p>
<template v-else>
<div class="ledger-actions"><el-button :disabled="loading || saving || uploading" @click="load">刷新费用</el-button><el-button @click="tab = 'current'">费用详情</el-button><el-button @click="tab = 'history'">操作历史</el-button><el-button @click="tab = 'payments'">付款流水</el-button></div>
<LoadErrorState v-if="error" title="公司费用读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="writeError" title="财务操作未完成" :message="writeError.message" @retry="pendingFiles.length ? uploadFiles() : submit()"><template #details><ApiErrorMeta :details="writeError" /></template></LoadErrorState>
<p v-if="pending">上次操作结果尚未确认。重试使用原版本、原内容和原请求键。</p>
<template v-if="tab === 'current' && cost">
<p>{{ cost.costNo }} · {{ statusLabels[cost.status] }} · {{ cost.supplierName }}</p>
<p>{{ cost.source.feeName }} · {{ cost.source.businessDate }} · 原币 {{ cost.source.originalCurrency }} {{ cost.source.originalAmount.toFixed(2) }}</p>
<p>汇率月份 {{ cost.source.exchangeRateMonth }} · 汇率 {{ cost.source.exchangeRate }} · 折算 {{ money(cost.source.amount) }}</p>
<p>已付 {{ money(cost.paidAmount) }} · 未付 {{ money(cost.outstandingAmount) }}</p>
<div class="ledger-actions"><el-button v-if="canApprove" :disabled="busy" type="primary" @click="choose('approve')">确认入账</el-button><el-button v-if="canVoid" :disabled="busy" type="danger" plain @click="choose('void')">作废费用</el-button><el-button v-if="canPay" :disabled="busy" type="primary" @click="choose('pay')">登记付款</el-button></div>
<el-form v-if="action" label-position="top" :disabled="saving || uploading || !!pending" class="ledger-form">
<template v-if="action === 'pay'">
<el-form-item label="实际付款金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :max="cost.outstandingAmount ?? 0" :precision="2" placeholder="请输入本次实际付款金额" /></el-form-item>
<el-form-item label="实际付款时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="付款方式" required><el-select v-model="form.method"><el-option v-for="(label, key) in methods" :key="key" :label="label" :value="key" /></el-select></el-form-item>
<el-form-item label="付款流水号(选填)"><el-input v-model="form.referenceNo" maxlength="128" placeholder="填写银行流水号或支付凭据号" /></el-form-item>
<el-form-item label="付款凭证" required class="ledger-wide">
<input v-if="access.can(permissions.upload)" type="file" accept="image/png,image/jpeg" multiple :disabled="busy" aria-label="上传公司付款凭证" @change="selectFiles" />
<p v-else>登记新付款需公司付款凭证上传权限。</p>
<p v-for="file in vouchers" :key="file.fileId">已上传:{{ file.fileName }}</p>
<p v-if="pendingFiles.length">{{ pendingFiles.length }} 张凭证待完成上传。</p>
<el-button v-if="pendingFiles.length" :disabled="busy" @click="uploadFiles">重试剩余凭证</el-button>
</el-form-item>
<el-form-item label="付款说明(选填)" class="ledger-wide"><el-input v-model="form.remark" type="textarea" maxlength="1000" placeholder="补充本次付款用途或说明" /></el-form-item>
</template>
<el-form-item v-else label="说明(选填)" class="ledger-wide"><el-input v-model="reason" maxlength="1000" placeholder="补充本次入账或作废原因" /></el-form-item>
</el-form>
<template v-if="action">
<p>{{ action === 'pay' ? '确认实际付款及凭证后登记。此操作不会发起银行转账。' : action === 'void' ? '确认作废这笔未付款费用;历史记录保留,重新登记须重新入账。' : '确认将这笔公司费用入账为应付;不会自动付款。' }}</p>
<el-button :loading="saving" :disabled="uploading || !access.can(permissions[action])" :type="action === 'void' ? 'danger' : 'primary'" @click="submit">{{ pending ? '重试原操作' : '确认操作' }}</el-button>
<el-button :disabled="busy" @click="action = null">取消操作</el-button>
</template>
</template>
<LoadErrorState v-if="recordsError" title="财务记录读取失败" :message="recordsError.message" @retry="loadRecords"><template #details><ApiErrorMeta :details="recordsError" /></template></LoadErrorState>
<div class="workbench-table" v-if="tab === 'history'"><DataTable v-model:page="historyPage" :page-size="10" :data="history" :columns="historyColumns" :total="historyTotal" :loading="recordsLoading" row-key="recordVersion">
<template #action="{ row }">{{ ({ RECEIVE: '接收', APPROVE: '入账', VOID: '作废', PAY: '付款' } as Record<string, string>)[row.action] || '—' }}</template>
<template #time="{ row }">{{ time(row.occurredAt) }}</template><template #amount="{ row }">{{ money(row.snapshot.source.amount) }}</template>
</DataTable></div>
<div class="workbench-table" v-if="tab === 'payments'"><DataTable v-model:page="paymentPage" :page-size="10" :data="payments" :columns="paymentColumns" :total="paymentTotal" :loading="recordsLoading" row-key="paymentNo">
<template #amount="{ row }">{{ money(row.amount) }}</template><template #time="{ row }">{{ time(row.paidAt) }}</template>
<template #method="{ row }">{{ methods[row.method as keyof typeof methods] || '—' }}</template>
<template #files="{ row }"><el-button v-for="(fileId, index) in row.voucherFileIds" :key="fileId" link type="primary" @click="preview(fileId)">凭证 {{ Number(index) + 1 }}</el-button></template>
</DataTable></div>
</template>
</section>
</template>
<style scoped>
.ledger-actions { display:flex; flex-wrap:wrap; gap:var(--admin-space-2); margin-bottom:var(--admin-space-3); }
.ledger-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:0 var(--admin-space-4); }
.ledger-wide { grid-column:1/-1; }
.ledger-form :deep(.el-select), .ledger-form :deep(.el-input-number), .ledger-form :deep(.el-date-editor) { width:100%; }
</style>