feat: 网银流水导入模板+现金流联动(财务数据通道P1)
This commit is contained in:
@@ -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 }),
|
||||
// 页面看板(日历+预测+提醒)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user