feat: 网银流水导入模板+现金流联动(财务数据通道P1)

This commit is contained in:
Hermes CI Fix
2026-08-28 09:59:07 +08:00
parent b9c624fd7e
commit 3bc68fa1c6
9 changed files with 1050 additions and 6 deletions
+7
View File
@@ -383,6 +383,13 @@ export const cashApi = {
receivables: (params?: any) => api.get('/cash/receivables', { params }),
registerPayment: (id: number, data: any) => api.post(`/cash/receivables/${id}/payment`, data),
importBohaiAR: () => api.post('/cash/import/bohai-ar', {}),
// 网银流水导入(P1方案② 2026-08-28
importVouchers: (file: File) => {
const form = new FormData()
form.append('file', file)
return api.post('/cash/import/vouchers', form, { timeout: 60000 })
},
getVoucherTemplate: () => api.get('/cash/import/template', { responseType: 'blob' }),
// 到期提醒
upcoming: (params?: any) => api.get('/cash/upcoming', { params }),
// 页面看板(日历+预测+提醒)
+174 -1
View File
@@ -2,6 +2,8 @@
<div>
<h3 style="margin-bottom: 12px;">收付款计划 · 资金缺口预测</h3>
<el-tabs v-model="activeTab" class="cash-plan-tabs">
<el-tab-pane label="资金看板" name="dashboard">
<!-- 顶部统计卡片 -->
<el-row :gutter="12" style="margin-bottom: 12px;">
<el-col :span="4">
@@ -197,7 +199,80 @@
</template>
</el-table-column>
</el-table>
</el-card>
</el-card>
</el-tab-pane>
<!-- 流水导入 tab网银流水标准导入 校验 入库 现金流联动 -->
<el-tab-pane label="流水导入" name="import">
<el-card shadow="never">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<span>📥 网银流水导入Excel 三校验 入库 现金流联动</span>
<button type="button" class="cash-btn cash-btn-primary" @click="downloadTemplate"> 下载导入模板</button>
</div>
</template>
<div class="import-toolbar">
<input ref="fileInput" type="file" accept=".xlsx,.xls" style="display:none;" @change="onFileChange" />
<button type="button" class="cash-btn" @click="fileInput?.click()">📂 选择Excel文件</button>
<span v-if="selectedFile" class="import-filename">{{ selectedFile.name }}</span>
<span v-else class="import-filename muted">未选择文件请先下载模板按模板列整理银行流水后导入</span>
<button type="button" class="cash-btn cash-btn-primary" :disabled="!selectedFile || importing" @click="doImportVouchers">
{{ importing ? '导入中' : '🚀 开始导入' }}
</button>
</div>
<div v-if="importError" class="import-result error">
<b> 导入失败</b>{{ importError }}
</div>
<div v-if="importResult && !importError" class="import-result">
<!-- 借贷平衡 -->
<div class="ir-row" :class="importResult.balance_check?.passed ? 'ok' : 'fail'">
<span class="ir-label">借贷平衡</span>
<b>{{ importResult.balance_check?.passed ? '✅ 通过' : '❌ 不平衡' }}</b>
<span class="ir-sub">借方合计 {{ fmtMoney(importResult.balance_check?.debit_total) }} / 贷方合计 {{ fmtMoney(importResult.balance_check?.credit_total) }} / 差额 {{ fmtMoney(importResult.balance_check?.diff) }}容差 {{ importResult.balance_check?.tolerance }}</span>
</div>
<!-- 期间合计 -->
<div class="ir-row">
<span class="ir-label">期间合计</span>
<span v-for="(pt, period) in importResult.period_totals || {}" :key="period" class="ir-tag">
{{ period }} {{ fmtMoney(pt.debit_total) }} / {{ fmtMoney(pt.credit_total) }}
</span>
<span v-if="!Object.keys(importResult.period_totals || {}).length" class="ir-sub muted">无有效行</span>
</div>
<!-- 行统计 -->
<div class="ir-row">
<span class="ir-label">导入统计</span>
<span class="ir-tag">总行数 {{ importResult.total }}</span>
<span class="ir-tag ok">成功 {{ importResult.success_rows }}</span>
<span class="ir-tag" :class="importResult.failed_rows > 0 ? 'fail' : 'ok'">失败 {{ importResult.failed_rows }}</span>
<span class="ir-tag">结转行 {{ importResult.carry_forward_count }}</span>
<span class="ir-tag">批次 {{ importResult.batch }}</span>
</div>
<!-- 现金流联动 -->
<div class="ir-row" v-if="importResult.cash_balance !== null && importResult.cash_balance !== undefined">
<span class="ir-label">现金余额联动</span>
<b class="ok">更新后货币资金余额 = {{ importResult.cash_balance }} 万元</b>
<span class="ir-sub">1001/1002 科目期末余额 ÷ 10000</span>
</div>
<div class="ir-row" v-if="(importResult.kpi_updates || []).length">
<span class="ir-label">KPI联动</span>
<span v-for="k in importResult.kpi_updates" :key="k.kpi_code + k.period" class="ir-tag">
{{ k.kpi_name }}{{ k.kpi_code }}{{ k.period }} = {{ k.value }}
</span>
</div>
<!-- 错误明细 -->
<div v-if="(importResult.errors || []).length" class="ir-errors">
<div class="ir-label"> 错误明细{{ importResult.errors.length }} </div>
<div v-for="(e, i) in importResult.errors" :key="i" class="ir-error-line">
{{ e.row === 0 ? '' : e.row }} [{{ e.field }}]{{ e.reason }}
</div>
</div>
</div>
</el-card>
</el-tab-pane>
</el-tabs>
<!-- 新建/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑收付款计划' : '新建收付款计划'" width="480px">
@@ -255,6 +330,14 @@ const alertChecking = ref(false)
const alertCheckMsg = ref('触发预警检查')
const saving = ref(false)
// ── 流水导入 ──
const activeTab = ref('dashboard')
const fileInput = ref<HTMLInputElement>()
const selectedFile = ref<File | null>(null)
const importing = ref(false)
const importResult = ref<any>(null)
const importError = ref('')
// ── 日历 ──
const now = new Date()
const curY = now.getFullYear()
@@ -517,6 +600,60 @@ async function runAlertCheck() {
}
}
// ── 网银流水导入 ──
function fmtMoney(v: any): string {
if (v === null || v === undefined || isNaN(Number(v))) return '—'
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function onFileChange(e: any) {
const f = e.target?.files?.[0]
selectedFile.value = f || null
importResult.value = null
importError.value = ''
}
async function downloadTemplate() {
try {
const blob: any = await cashApi.getVoucherTemplate()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '网银流水导入模板.xlsx'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success('模板已下载,请按模板列整理流水后导入')
} catch (e: any) {
ElMessage.error('模板下载失败: ' + (e?.response?.data?.detail || e.message))
}
}
async function doImportVouchers() {
if (!selectedFile.value) { ElMessage.warning('请先选择Excel文件'); return }
importing.value = true
importResult.value = null
importError.value = ''
try {
const r: any = await cashApi.importVouchers(selectedFile.value)
importResult.value = r
// 导入成功 → 刷新看板(余额/日历/计划)+ 现金余额卡片
await loadAll()
if (r.balance_check?.passed && r.success_rows > 0) {
ElMessage.success(`导入完成:成功 ${r.success_rows} 行 / 失败 ${r.failed_rows} 行,现金余额已联动为 ${r.cash_balance} 万元`)
} else {
ElMessage.warning(`导入完成但有异常:成功 ${r.success_rows} 行 / 失败 ${r.failed_rows} 行,详见下方结果`)
}
} catch (e: any) {
const msg = e?.response?.data?.detail || e.message
importError.value = msg
ElMessage.error('导入失败: ' + msg)
} finally {
importing.value = false
}
}
// ── 生命周期 ──
onMounted(() => { loadAll() })
onBeforeUnmount(() => { if (chart) { chart.dispose(); chart = null } })
@@ -554,4 +691,40 @@ onBeforeUnmount(() => { if (chart) { chart.dispose(); chart = null } })
.remind-amt { font-weight: 700; color: #303133; }
.remind-ct { color: #606266; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.remind-date { color: #909399; font-size: 12px; }
/* ── 流水导入 tab ── */
.cash-plan-tabs { margin-top: 4px; }
.import-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
.cash-btn {
display: inline-flex; align-items: center; gap: 4px;
padding: 7px 14px; font-size: 13px; line-height: 1.4;
border: 1px solid #dcdfe6; border-radius: 4px; background: #fff; color: #606266;
cursor: pointer; transition: all .2s;
}
.cash-btn:hover { border-color: #409eff; color: #409eff; }
.cash-btn-primary { background: #409eff; border-color: #409eff; color: #fff; }
.cash-btn-primary:hover { background: #66b1ff; border-color: #66b1ff; color: #fff; }
.cash-btn:disabled { opacity: .5; cursor: not-allowed; }
.import-filename { font-size: 13px; color: #303133; word-break: break-all; }
.import-filename.muted { color: #909399; }
.import-result {
border: 1px solid #e4e7ed; border-radius: 6px; padding: 12px 14px;
background: #fafafa; font-size: 13px;
}
.import-result.error { border-color: #f56c6c; background: #fef0f0; color: #f56c6c; }
.ir-row { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; padding: 6px 0; border-bottom: 1px dashed #ebeef5; }
.ir-row:last-child { border-bottom: none; }
.ir-row.ok { color: #67c23a; }
.ir-row.fail { color: #f56c6c; }
.ir-label { font-weight: 600; color: #606266; min-width: 90px; }
.ir-sub { color: #909399; font-size: 12px; }
.ir-tag {
display: inline-block; padding: 1px 8px; border-radius: 3px;
background: #ecf5ff; color: #409eff; font-size: 12px; margin-right: 4px;
}
.ir-tag.ok { background: #f0f9eb; color: #67c23a; }
.ir-tag.fail { background: #fef0f0; color: #f56c6c; }
.muted { color: #909399; }
.ir-errors { margin-top: 8px; }
.ir-error-line { padding: 3px 0 3px 10px; border-left: 3px solid #f56c6c; margin: 4px 0; background: #fef0f0; color: #f56c6c; font-size: 12px; }
</style>