Files
admin-web/src/modules/overseas-master-data/components/VehicleCareFinanceLedger.vue
T

207 lines
18 KiB
Vue
Raw Normal View History

<script setup lang="ts">
2026-10-20 23:34:11 +08:00
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>
2026-10-20 23:34:11 +08:00
<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>
2026-10-20 23:34:11 +08:00
</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>
2026-10-20 23:34:11 +08:00
</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>