731 lines
34 KiB
Vue
731 lines
34 KiB
Vue
<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> 资金缺口日(余额<警戒线)</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>
|