Files
cma-management/frontend/src/views/CashPlan.vue
T

731 lines
34 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
<template>
<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">
<el-card shadow="never" class="stat-card">
<div class="stat-label">当前现金余额(万元)</div>
<div class="stat-value" style="display:flex;align-items:center;gap:6px;">
<el-input-number v-model="currentCash" :min="0" :precision="2" :controls="false" size="small" style="width:110px;" />
<el-button size="small" type="primary" :loading="balanceLoading" @click="saveBalance">保存</el-button>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="stat-card">
<div class="stat-label">{{ dashMonth }} 应收计划</div>
<div class="stat-value green">+{{ dash.month_receive?.toFixed(1) ?? '0.0' }} 万</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="stat-card">
<div class="stat-label">{{ dashMonth }} 应付计划</div>
<div class="stat-value red">-{{ dash.month_pay?.toFixed(1) ?? '0.0' }} 万</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="stat-card">
<div class="stat-label">{{ dashMonth }} 净流入</div>
<div class="stat-value" :class="(dash.month_net ?? 0) >= 0 ? 'green' : 'red'">{{ dash.month_net?.toFixed(1) ?? '0.0' }} 万</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="stat-card">
<div class="stat-label">未来30天最低余额</div>
<div class="stat-value" :class="minCashClass">{{ forecast.min_cash ?? '--' }} 万</div>
<div class="stat-sub">{{ forecast.min_cash_date || '' }}</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="stat-card" :class="{ 'warn-card': (forecast.gap_dates?.length || 0) > 0 }">
<div class="stat-label">资金缺口预警</div>
<div class="stat-value" :class="(forecast.gap_dates?.length || 0) > 0 ? 'red' : 'green'">
{{ forecast.gap_dates?.length || 0 }} 天
</div>
<el-button size="small" style="margin-top:6px;" @click="runAlertCheck" :loading="alertChecking">
{{ alertCheckMsg }}
</el-button>
</el-card>
</el-col>
</el-row>
<el-row :gutter="12">
<!-- ── 收付款日历视图 ── -->
<el-col :span="16">
<el-card shadow="never">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span>📅 收付款日历 — {{ calTitle }}</span>
<div>
<el-button size="small" @click="shiftMonth(-1)">‹ 上月</el-button>
<el-button size="small" @click="shiftMonth(1)">下月 ›</el-button>
<el-button size="small" type="primary" @click="openCreate">+ 新增计划</el-button>
</div>
</div>
</template>
<div class="cal-grid">
<div v-for="wd in ['一','二','三','四','五','六','日']" :key="wd" class="cal-wd">{{ wd }}</div>
<div v-for="(cell, i) in calCells" :key="i" class="cal-cell"
:class="{ 'cal-today': cell && cell.date === todayStr, 'cal-gap': cell && cell.gap, 'cal-empty': !cell }">
<template v-if="cell">
<div class="cal-day" :class="{ 'gap-day': cell.gap }">{{ cell.day }}</div>
<div class="cal-item in" v-for="(it, k) in cell.cell.items.filter((x: any) => x.plan_type === 'receive').slice(0, 3)" :key="'i' + k" :title="it.counterparty + ' ' + it.description">
+{{ it.amount }} <span class="cal-ct">{{ it.counterparty }}</span>
</div>
<div class="cal-item out" v-for="(it, k) in cell.cell.items.filter((x: any) => x.plan_type === 'pay').slice(0, 3)" :key="'o' + k" :title="it.counterparty + ' ' + it.description">
-{{ it.amount }} <span class="cal-ct">{{ it.counterparty }}</span>
</div>
<div class="cal-item more" v-if="cell.cell.items.length > 6">… 还有 {{ cell.cell.items.length - 6 }} 笔</div>
</template>
</div>
</div>
<div style="margin-top:8px;font-size:12px;color:#909399;display:flex;gap:16px;">
<span><i class="dot" style="background:#67c23a;"></i> 收款</span>
<span><i class="dot" style="background:#f56c6c;"></i> 付款</span>
<span><i class="dot" style="background:#e6a23c;"></i> 资金缺口日(余额&lt;警戒线)</span>
<span style="margin-left:auto;">警戒线 {{ forecast.warning_line ?? 20 }} 万 / 危急线 {{ forecast.critical_line ?? 10 }} 万</span>
</div>
</el-card>
</el-col>
<!-- ── 到期提醒 ── -->
<el-col :span="8">
<el-card shadow="never">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span>⏰ 未来7天到期提醒</span>
<el-tag v-if="(upcoming.upcoming?.length || 0) + (upcoming.overdue?.length || 0) > 0" type="danger" size="small">
{{ (upcoming.upcoming?.length || 0) + (upcoming.overdue?.length || 0) }} 条
</el-tag>
</div>
</template>
<div v-if="upcoming.overdue?.length" class="remind-block">
<div class="remind-title" style="color:#f56c6c;">🔴 已逾期</div>
<div v-for="p in upcoming.overdue" :key="'od' + p.id" class="remind-item">
<el-tag :type="p.plan_type === 'receive' ? 'success' : 'danger'" size="small">{{ p.plan_type_label }}</el-tag>
<span class="remind-amt">{{ p.amount }}万</span>
<span class="remind-ct">{{ p.counterparty || '—' }}</span>
<span class="remind-date">{{ p.plan_date }}</span>
<el-button size="small" text type="primary" @click="markComplete(p)">到账</el-button>
</div>
</div>
<div v-if="upcoming.upcoming?.length" class="remind-block">
<div class="remind-title">🟡 未来7天到期</div>
<div v-for="p in upcoming.upcoming" :key="'up' + p.id" class="remind-item">
<el-tag :type="p.plan_type === 'receive' ? 'success' : 'danger'" size="small">{{ p.plan_type_label }}</el-tag>
<span class="remind-amt">{{ p.amount }}万</span>
<span class="remind-ct">{{ p.counterparty || '—' }}</span>
<span class="remind-date">{{ p.plan_date }}</span>
<el-button v-if="p.plan_type === 'receive'" size="small" text type="primary" @click="markComplete(p)">到账</el-button>
</div>
</div>
<el-empty v-if="!(upcoming.overdue?.length) && !(upcoming.upcoming?.length)" description="未来7天无到期计划" :image-size="60" />
<div class="remind-title" style="margin-top:14px;">🚨 缺口前3天预警(预测)</div>
<div v-if="forecast.pre_alerts?.length">
<el-alert v-for="(a, i) in forecast.pre_alerts" :key="i" :type="a.level === 'red' ? 'error' : 'warning'"
:closable="false" style="margin-bottom:6px;">
<template #title>
<b>{{ a.alert_date }}</b> 前安排资金:{{ a.gap_date }} 余额预计 {{ a.gap_cash }}万(最低 {{ a.worst_cash }}万 / {{ a.worst_date }})
</template>
</el-alert>
</div>
<el-empty v-else description="近期无资金缺口预警" :image-size="60" />
</el-card>
</el-col>
</el-row>
<!-- ── 资金余额预测图 ── -->
<el-card shadow="never" style="margin-top:12px;">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span>📈 未来30天资金余额预测(趋势引擎 + 收付款计划)</span>
<el-radio-group v-model="forecastDays" size="small" @change="reloadForecast">
<el-radio-button :value="30">30天</el-radio-button>
<el-radio-button :value="60">60天</el-radio-button>
<el-radio-button :value="90">90天</el-radio-button>
</el-radio-group>
</div>
</template>
<div ref="chartRef" style="height:380px;width:100%;"></div>
</el-card>
<!-- ── 收付款计划列表 ── -->
<el-card shadow="never" style="margin-top:12px;">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span>📋 收付款计划明细</span>
<div>
<el-select v-model="planFilter.type" size="small" clearable placeholder="全部类型" style="width:110px;margin-right:6px;" @change="loadPlans">
<el-option label="收款" value="receive" />
<el-option label="付款" value="pay" />
</el-select>
<el-select v-model="planFilter.status" size="small" clearable placeholder="全部状态" style="width:110px;" @change="loadPlans">
<el-option label="待执行" value="pending" />
<el-option label="已完成" value="completed" />
<el-option label="已取消" value="cancelled" />
</el-select>
</div>
</div>
</template>
<el-table :data="plans" border stripe size="small">
<el-table-column prop="plan_date" label="计划日期" width="110" />
<el-table-column label="类型" width="80">
<template #default="{ row }">
<el-tag :type="row.plan_type === 'receive' ? 'success' : 'danger'" size="small">{{ row.plan_type_label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="amount" label="金额(万)" width="100" align="right">
<template #default="{ row }">
<span :class="row.plan_type === 'receive' ? 'green' : 'red'">{{ row.plan_type === 'receive' ? '+' : '-' }}{{ row.amount }}</span>
</template>
</el-table-column>
<el-table-column prop="counterparty" label="往来单位" min-width="130" />
<el-table-column prop="description" label="说明" min-width="160" show-overflow-tooltip />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'completed' ? 'info' : row.status === 'cancelled' ? 'warning' : 'primary'" size="small">{{ row.status_label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'pending'" size="small" text type="success" @click="markComplete(row)">完成</el-button>
<el-button size="small" text type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="small" text type="danger" @click="removePlan(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</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">
<el-form :model="form" label-width="90px">
<el-form-item label="类型" required>
<el-radio-group v-model="form.plan_type">
<el-radio-button value="receive">收款</el-radio-button>
<el-radio-button value="pay">付款</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="金额(万)" required>
<el-input-number v-model="form.amount" :min="0.01" :precision="2" style="width:100%;" />
</el-form-item>
<el-form-item label="计划日期" required>
<el-date-picker v-model="form.plan_date" type="date" value-format="YYYY-MM-DD" style="width:100%;" />
</el-form-item>
<el-form-item label="往来单位">
<el-input v-model="form.counterparty" placeholder="客户/供应商名称" />
</el-form-item>
<el-form-item label="说明">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="收款/付款事由" />
</el-form-item>
<el-form-item v-if="form.id" label="状态">
<el-select v-model="form.status" style="width:100%;">
<el-option label="待执行" value="pending" />
<el-option label="已完成" value="completed" />
<el-option label="已取消" value="cancelled" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="savePlan">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { cashApi } from '../api/index'
const entityId = Number(localStorage.getItem('cma_entity_id') || 1)
// ── 状态 ──
const currentCash = ref<number>(30)
const balanceLoading = ref(false)
const dash = ref<any>({})
const forecast = ref<any>({})
const upcoming = ref<any>({})
const plans = ref<any[]>([])
const forecastDays = ref(30)
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()
const curM = now.getMonth() + 1
const viewMonth = ref(`${curY}-${String(curM).padStart(2, '0')}`)
const todayStr = `${curY}-${String(curM).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const dashMonth = computed(() => dash.value.month || viewMonth.value.slice(0, 7))
const calTitle = computed(() => `${viewMonth.value.slice(0, 4)}年${Number(viewMonth.value.slice(5, 7))}月`)
const minCashClass = computed(() => {
const v = forecast.value.min_cash
if (v === undefined || v === null) return ''
return v < (forecast.value.critical_line ?? 10) ? 'red' : v < (forecast.value.warning_line ?? 20) ? 'orange' : 'green'
})
// 日历格子:周一开头
const calCells = computed(() => {
const [y, m] = viewMonth.value.split('-').map(Number)
const first = new Date(y, m - 1, 1)
const offset = (first.getDay() + 6) % 7
const daysInMonth = new Date(y, m, 0).getDate()
const cells: any[] = []
for (let i = 0; i < offset; i++) cells.push(null)
const gapSet = new Set((forecast.value.gap_dates || []).map((g: any) => g.date))
for (let d = 1; d <= daysInMonth; d++) {
const key = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
cells.push({
day: d,
date: key,
cell: (dash.value.calendar || {})[key] || { receive: 0, pay: 0, items: [] },
gap: gapSet.has(key),
})
}
while (cells.length % 7 !== 0) cells.push(null)
return cells
})
// ── 筛选 ──
const planFilter = reactive({ type: '', status: '' })
// ── 弹窗表单 ──
const dialogVisible = ref(false)
const form = reactive<any>({ id: null, plan_type: 'receive', amount: 10, plan_date: '', counterparty: '', description: '', status: 'pending' })
// ── 数据加载 ──
async function loadAll() {
await Promise.all([loadBalance(), loadDashboard(), loadPlans()])
}
async function loadBalance() {
try {
const r: any = await cashApi.getBalance({ entity_id: entityId })
if (r.current_cash !== null && r.current_cash !== undefined) currentCash.value = r.current_cash
} catch (_) {}
}
async function loadDashboard() {
try {
const params: any = { entity_id: entityId, days: forecastDays.value, month: viewMonth.value }
if (currentCash.value > 0) params.current_cash = currentCash.value
const r: any = await cashApi.dashboard(params)
dash.value = r
forecast.value = r.forecast || {}
upcoming.value = r.upcoming || {}
await nextTick()
renderChart()
} catch (e: any) {
ElMessage.error('加载看板失败: ' + (e?.response?.data?.detail || e.message))
}
}
async function reloadForecast() {
await loadDashboard()
}
async function loadPlans() {
try {
const params: any = { entity_id: entityId, page_size: 200 }
if (planFilter.type) params.plan_type = planFilter.type
if (planFilter.status) params.status = planFilter.status
const r: any = await cashApi.listPlans(params)
plans.value = r.data || []
} catch (_) {}
}
async function saveBalance() {
balanceLoading.value = true
try {
await cashApi.setBalance({ current_cash: currentCash.value })
ElMessage.success('当前现金余额已保存')
await loadDashboard()
} catch (e: any) {
ElMessage.error('保存失败: ' + (e?.response?.data?.detail || e.message))
} finally {
balanceLoading.value = false
}
}
// ── 日历翻月 ──
function shiftMonth(delta: number) {
const [y, m] = viewMonth.value.split('-').map(Number)
const d = new Date(y, m - 1 + delta, 1)
viewMonth.value = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
loadDashboard()
}
// ── 预测图 ──
const chartRef = ref<HTMLElement>()
let chart: any = null
function renderChart() {
if (!chartRef.value || !forecast.value.forecast?.length) return
import('echarts').then((echarts: any) => {
if (chart) chart.dispose()
chart = echarts.init(chartRef.value!)
const fc = forecast.value.forecast
const dates = fc.map((d: any) => d.date.slice(5))
const cash = fc.map((d: any) => d.predicted_cash)
const plannedIn = fc.map((d: any) => d.planned_in)
const plannedOut = fc.map((d: any) => -d.planned_out)
const lower = fc.map((d: any) => d.lower_bound)
const upper = fc.map((d: any) => d.upper_bound)
const warningLine = forecast.value.warning_line ?? 20
const criticalLine = forecast.value.critical_line ?? 10
chart.setOption({
tooltip: {
trigger: 'axis',
formatter: function (params: any) {
const idx = params[0]?.dataIndex
const d = fc[idx]
if (!d) return ''
const st = d.alert_status === 'red' ? '🔴 预警' : d.alert_status === 'yellow' ? '🟡 关注' : '🟢 安全'
const plansTxt = (d.plans || []).map((p: any) => `<br/> ${p.type === 'receive' ? '收' : '付'} ${p.counterparty || ''} ${p.amount}万`).join('')
return `<strong>${d.date}</strong><br/>余额: <strong>${d.predicted_cash.toFixed(2)}万</strong> (${st})<br/>当日计划收: ${d.planned_in.toFixed(2)}万 / 付: ${d.planned_out.toFixed(2)}万<br/>净变动: ${d.net_flow.toFixed(2)}万${plansTxt}`
}
},
legend: { bottom: 0, icon: 'circle', itemWidth: 8, itemHeight: 8 },
grid: { left: 50, right: 20, top: 30, bottom: 45 },
xAxis: { type: 'category', data: dates, axisLabel: { interval: Math.ceil(fc.length / 15) } },
yAxis: { type: 'value', name: '万元' },
series: [
{
name: '预测余额', type: 'line', data: cash, smooth: true, symbol: 'none',
lineStyle: { width: 2.5, color: '#409eff' },
markLine: {
silent: true, symbol: 'none',
label: { formatter: '警戒线 {c}万' },
data: [
{ yAxis: warningLine, lineStyle: { color: '#e6a23c', type: 'dashed', width: 1.5 }, label: { formatter: `警戒线 ${warningLine}万`, color: '#e6a23c', position: 'insideEndTop' } },
{ yAxis: criticalLine, lineStyle: { color: '#f56c6c', type: 'dashed', width: 1.5 }, label: { formatter: `危急线 ${criticalLine}万`, color: '#f56c6c' } },
],
},
markPoint: {
symbolSize: 42,
data: (forecast.value.gap_dates || []).map((g: any) => ({
coord: [g.date.slice(5), g.predicted_cash],
value: '缺口',
itemStyle: { color: g.level === 'red' ? '#f56c6c' : '#e6a23c' },
})),
},
},
{
name: '计划收款', type: 'bar', data: plannedIn, stack: 'plan', barWidth: '60%',
itemStyle: { color: '#67c23a', opacity: 0.55 },
},
{
name: '计划付款', type: 'bar', data: plannedOut, stack: 'plan', barWidth: '60%',
itemStyle: { color: '#f56c6c', opacity: 0.55 },
},
{
name: '置信上界', type: 'line', data: upper, smooth: true, symbol: 'none',
lineStyle: { width: 1, color: '#909399', type: 'dashed' },
},
{
name: '置信下界', type: 'line', data: lower, smooth: true, symbol: 'none',
lineStyle: { width: 1, color: '#909399', type: 'dashed' },
areaStyle: { color: 'rgba(144,147,153,0.05)' },
},
],
})
})
}
// ── CRUD ──
function openCreate() {
Object.assign(form, { id: null, plan_type: 'receive', amount: 10, plan_date: todayStr, counterparty: '', description: '', status: 'pending' })
dialogVisible.value = true
}
function openEdit(row: any) {
Object.assign(form, {
id: row.id, plan_type: row.plan_type, amount: row.amount, plan_date: row.plan_date,
counterparty: row.counterparty, description: row.description, status: row.status,
})
dialogVisible.value = true
}
async function savePlan() {
if (!form.plan_date) { ElMessage.warning('请选择计划日期'); return }
if (!form.amount || form.amount <= 0) { ElMessage.warning('请输入金额'); return }
saving.value = true
try {
const payload = { plan_type: form.plan_type, amount: form.amount, plan_date: form.plan_date, counterparty: form.counterparty, description: form.description, status: form.status }
if (form.id) {
await cashApi.updatePlan(form.id, payload)
ElMessage.success('计划已更新')
} else {
await cashApi.createPlan({ entity_id: entityId, ...payload })
ElMessage.success('计划已创建')
}
dialogVisible.value = false
await loadDashboard()
await loadPlans()
} catch (e: any) {
ElMessage.error('保存失败: ' + (e?.response?.data?.detail || e.message))
} finally {
saving.value = false
}
}
async function markComplete(row: any) {
try {
await cashApi.completePlan(row.id)
ElMessage.success('已标记完成')
await loadDashboard()
await loadPlans()
} catch (e: any) {
ElMessage.error('操作失败: ' + (e?.response?.data?.detail || e.message))
}
}
async function removePlan(row: any) {
try {
await ElMessageBox.confirm(`确认删除 ${row.plan_type_label} ${row.amount}万 (${row.counterparty || '—'})?`, '删除确认', { type: 'warning' })
await cashApi.deletePlan(row.id)
ElMessage.success('已删除')
await loadDashboard()
await loadPlans()
} catch (_) {}
}
// ── 预警检查 ──
async function runAlertCheck() {
alertChecking.value = true
alertCheckMsg.value = '检查中…'
try {
const r: any = await cashApi.checkAlerts({ entity_id: entityId })
if (r.new_alerts > 0) {
ElMessage.success(`已生成 ${r.new_alerts} 条资金预警`)
} else {
ElMessage.info('无新增预警(已检查)')
}
await loadDashboard()
} catch (e: any) {
ElMessage.error('预警检查失败: ' + (e?.response?.data?.detail || e.message))
} finally {
alertChecking.value = false
alertCheckMsg.value = '触发预警检查'
}
}
// ── 网银流水导入 ──
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 } })
</script>
<style scoped>
.stat-card { text-align: center; }
.stat-label { font-size: 12px; color: #909399; margin-bottom: 8px; }
.stat-value { font-size: 20px; font-weight: 700; color: #303133; }
.stat-sub { font-size: 12px; color: #909399; margin-top: 4px; }
.stat-value.green { color: #67c23a; }
.stat-value.red { color: #f56c6c; }
.stat-value.orange { color: #e6a23c; }
.warn-card { background: #fef0f0; }
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
.cal-wd { text-align: center; font-size: 12px; color: #909399; padding: 4px 0; }
.cal-cell { min-height: 74px; border: 1px solid #ebeef5; border-radius: 6px; padding: 4px 6px; background: #fff; cursor: default; }
.cal-cell.cal-today { border-color: #409eff; box-shadow: 0 0 0 1px #409eff inset; }
.cal-cell.cal-gap { background: #fdf6ec; border-color: #e6a23c; }
.cal-cell.cal-empty { background: #fafafa; }
.cal-day { font-size: 13px; font-weight: 600; color: #606266; margin-bottom: 2px; }
.cal-day.gap-day { color: #e6a23c; }
.cal-item { font-size: 11px; line-height: 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cal-item.in { color: #67c23a; }
.cal-item.out { color: #f56c6c; }
.cal-item.more { color: #909399; }
.cal-ct { color: #909399; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; }
.remind-block { margin-bottom: 6px; }
.remind-title { font-size: 13px; font-weight: 600; color: #606266; margin: 6px 0 4px; }
.remind-item { display: flex; align-items: center; gap: 6px; padding: 5px 0; border-bottom: 1px dashed #f0f0f0; font-size: 13px; }
.remind-item:last-child { border-bottom: none; }
.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>