feat: 资金管理强化—缺口预测+收付款计划+预警
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
"""资金管理API — 资金缺口预测 + 收付款计划 + 预警 (资金管理智能体)"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import CashPlan
|
||||
from app.utils.cash_forecast_engine import (
|
||||
forecast_cash_flow_with_plans,
|
||||
check_cash_alerts,
|
||||
DEFAULT_CASH_WARNING,
|
||||
DEFAULT_CASH_CRITICAL,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("cma.cash")
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/cash",
|
||||
tags=["资金管理"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
|
||||
def _plan_dict(p: CashPlan) -> dict:
|
||||
return {
|
||||
"id": p.id,
|
||||
"entity_id": p.entity_id,
|
||||
"plan_type": p.plan_type,
|
||||
"plan_type_label": "收款" if p.plan_type == "receive" else "付款",
|
||||
"amount": round(p.amount or 0, 2),
|
||||
"plan_date": p.plan_date.strftime("%Y-%m-%d") if p.plan_date else "",
|
||||
"counterparty": p.counterparty or "",
|
||||
"description": p.description or "",
|
||||
"status": p.status,
|
||||
"status_label": {"pending": "待执行", "completed": "已完成", "cancelled": "已取消"}.get(p.status, p.status),
|
||||
"completed_at": p.completed_at.strftime("%Y-%m-%d %H:%M") if p.completed_at else None,
|
||||
"created_at": p.created_at.strftime("%Y-%m-%d %H:%M") if p.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 1. 资金缺口预测
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/gap-forecast")
|
||||
def api_gap_forecast(
|
||||
entity_id: int = Query(1, description="企业ID"),
|
||||
days: int = Query(30, ge=1, le=90, description="预测天数"),
|
||||
current_cash: float = Query(None, description="当前现金余额(万元),不传则自动获取"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""资金缺口预测 — 趋势引擎 + 收付款计划,识别余额<警戒线的缺口日期"""
|
||||
try:
|
||||
return forecast_cash_flow_with_plans(entity_id, db, days=days, current_cash=current_cash)
|
||||
except Exception as e:
|
||||
logger.error(f"资金缺口预测失败: {e}", exc_info=True)
|
||||
raise HTTPException(400, f"资金缺口预测失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/balance")
|
||||
def api_get_balance(
|
||||
entity_id: int = Query(1),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取当前现金余额(预测基线)"""
|
||||
from app.utils.cash_forecast_engine import get_current_cash_balance
|
||||
value = get_current_cash_balance(db, entity_id)
|
||||
return {"entity_id": entity_id, "current_cash": value}
|
||||
|
||||
|
||||
@router.post("/balance")
|
||||
def api_set_balance(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""设置当前现金余额(万元),作为预测基线"""
|
||||
from app.utils.cash_forecast_engine import set_current_cash_balance
|
||||
value = float(data.get("current_cash", 0))
|
||||
if value < 0:
|
||||
raise HTTPException(400, "现金余额不能为负")
|
||||
set_current_cash_balance(db, value)
|
||||
return {"message": "当前现金余额已更新", "current_cash": value}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 2. 收付款计划 CRUD
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/plans")
|
||||
def api_list_plans(
|
||||
entity_id: int = Query(1),
|
||||
plan_type: str = Query(None, description="receive/pay"),
|
||||
status: str = Query(None, description="pending/completed/cancelled"),
|
||||
month: str = Query(None, description="YYYY-MM 按计划月份过滤"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""收付款计划列表"""
|
||||
query = db.query(CashPlan).filter(CashPlan.entity_id == entity_id)
|
||||
if plan_type:
|
||||
query = query.filter(CashPlan.plan_type == plan_type)
|
||||
if status:
|
||||
query = query.filter(CashPlan.status == status)
|
||||
if month:
|
||||
try:
|
||||
y, m = int(month[:4]), int(month[5:7])
|
||||
start = datetime(y, m, 1)
|
||||
end = (start + timedelta(days=32)).replace(day=1)
|
||||
query = query.filter(CashPlan.plan_date >= start, CashPlan.plan_date < end)
|
||||
except Exception:
|
||||
raise HTTPException(400, "month格式应为YYYY-MM")
|
||||
total = query.count()
|
||||
plans = query.order_by(CashPlan.plan_date.asc(), CashPlan.id.desc()) \
|
||||
.offset((page - 1) * page_size).limit(page_size).all()
|
||||
return {"total": total, "data": [_plan_dict(p) for p in plans]}
|
||||
|
||||
|
||||
@router.post("/plans")
|
||||
def api_create_plan(data: dict, db: Session = Depends(get_db)):
|
||||
"""新建收付款计划"""
|
||||
plan_type = data.get("plan_type")
|
||||
if plan_type not in ("receive", "pay"):
|
||||
raise HTTPException(400, "plan_type必须为receive(收)或pay(付)")
|
||||
amount = float(data.get("amount", 0))
|
||||
if amount <= 0:
|
||||
raise HTTPException(400, "金额必须大于0")
|
||||
date_str = data.get("plan_date")
|
||||
if not date_str:
|
||||
raise HTTPException(400, "缺少计划日期")
|
||||
try:
|
||||
plan_date = datetime.strptime(str(date_str)[:10], "%Y-%m-%d")
|
||||
except Exception:
|
||||
raise HTTPException(400, "plan_date格式应为YYYY-MM-DD")
|
||||
|
||||
plan = CashPlan(
|
||||
entity_id=int(data.get("entity_id", 1)),
|
||||
plan_type=plan_type,
|
||||
amount=amount,
|
||||
plan_date=plan_date,
|
||||
counterparty=(data.get("counterparty") or "").strip(),
|
||||
description=(data.get("description") or "").strip(),
|
||||
status=data.get("status", "pending"),
|
||||
)
|
||||
db.add(plan)
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
logger.info(f"新建收付款计划 #{plan.id} [{plan_type}] {amount}万 {date_str}")
|
||||
return {"message": "收付款计划已创建", "data": _plan_dict(plan)}
|
||||
|
||||
|
||||
@router.put("/plans/{plan_id}")
|
||||
def api_update_plan(plan_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""更新收付款计划"""
|
||||
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, "计划不存在")
|
||||
if "plan_type" in data:
|
||||
if data["plan_type"] not in ("receive", "pay"):
|
||||
raise HTTPException(400, "plan_type必须为receive或pay")
|
||||
plan.plan_type = data["plan_type"]
|
||||
if "amount" in data:
|
||||
amount = float(data["amount"])
|
||||
if amount <= 0:
|
||||
raise HTTPException(400, "金额必须大于0")
|
||||
plan.amount = amount
|
||||
if "plan_date" in data and data["plan_date"]:
|
||||
try:
|
||||
plan.plan_date = datetime.strptime(str(data["plan_date"])[:10], "%Y-%m-%d")
|
||||
except Exception:
|
||||
raise HTTPException(400, "plan_date格式应为YYYY-MM-DD")
|
||||
if "counterparty" in data:
|
||||
plan.counterparty = (data["counterparty"] or "").strip()
|
||||
if "description" in data:
|
||||
plan.description = (data["description"] or "").strip()
|
||||
if "status" in data:
|
||||
plan.status = data["status"]
|
||||
if data["status"] == "completed" and not plan.completed_at:
|
||||
plan.completed_at = datetime.now()
|
||||
elif data["status"] in ("pending", "cancelled"):
|
||||
plan.completed_at = None
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
return {"message": "计划已更新", "data": _plan_dict(plan)}
|
||||
|
||||
|
||||
@router.delete("/plans/{plan_id}")
|
||||
def api_delete_plan(plan_id: int, db: Session = Depends(get_db)):
|
||||
"""删除收付款计划"""
|
||||
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, "计划不存在")
|
||||
db.delete(plan)
|
||||
db.commit()
|
||||
return {"message": "计划已删除"}
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/complete")
|
||||
def api_complete_plan(plan_id: int, db: Session = Depends(get_db)):
|
||||
"""标记计划为已完成(收款到账/付款完成)"""
|
||||
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, "计划不存在")
|
||||
plan.status = "completed"
|
||||
plan.completed_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
return {"message": "已标记完成", "data": _plan_dict(plan)}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 3. 到期提醒 + 页面看板
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/upcoming")
|
||||
def api_upcoming(
|
||||
entity_id: int = Query(1),
|
||||
days: int = Query(7, ge=1, le=30),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""未来N天到期提醒 + 已逾期未收/未付"""
|
||||
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
upcoming = db.query(CashPlan).filter(
|
||||
CashPlan.entity_id == entity_id,
|
||||
CashPlan.status == "pending",
|
||||
CashPlan.plan_date >= today,
|
||||
CashPlan.plan_date <= today + timedelta(days=days),
|
||||
).order_by(CashPlan.plan_date.asc()).all()
|
||||
overdue = db.query(CashPlan).filter(
|
||||
CashPlan.entity_id == entity_id,
|
||||
CashPlan.status == "pending",
|
||||
CashPlan.plan_date < today,
|
||||
).order_by(CashPlan.plan_date.asc()).all()
|
||||
return {
|
||||
"days": days,
|
||||
"upcoming": [_plan_dict(p) for p in upcoming],
|
||||
"overdue": [_plan_dict(p) for p in overdue],
|
||||
"overdue_receive_amount": round(sum(p.amount for p in overdue if p.plan_type == "receive"), 2),
|
||||
"overdue_pay_amount": round(sum(p.amount for p in overdue if p.plan_type == "pay"), 2),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
def api_cash_dashboard(
|
||||
entity_id: int = Query(1),
|
||||
month: str = Query(None, description="YYYY-MM 默认本月"),
|
||||
days: int = Query(30),
|
||||
current_cash: float = Query(None, description="当前现金余额(万元)"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""收付款计划页面看板 — 日历汇总 + 预测 + 到期提醒"""
|
||||
today = datetime.now()
|
||||
if month:
|
||||
try:
|
||||
y, m = int(month[:4]), int(month[5:7])
|
||||
except Exception:
|
||||
raise HTTPException(400, "month格式应为YYYY-MM")
|
||||
else:
|
||||
y, m = today.year, today.month
|
||||
start = datetime(y, m, 1)
|
||||
end = (start + timedelta(days=32)).replace(day=1)
|
||||
|
||||
plans = db.query(CashPlan).filter(
|
||||
CashPlan.entity_id == entity_id,
|
||||
CashPlan.status == "pending",
|
||||
CashPlan.plan_date >= start,
|
||||
CashPlan.plan_date < end,
|
||||
).all()
|
||||
|
||||
# 日历:按天汇总 应收/应付
|
||||
calendar = {}
|
||||
for p in plans:
|
||||
dkey = p.plan_date.strftime("%Y-%m-%d")
|
||||
cell = calendar.setdefault(dkey, {"receive": 0.0, "pay": 0.0, "items": []})
|
||||
if p.plan_type == "receive":
|
||||
cell["receive"] += p.amount
|
||||
else:
|
||||
cell["pay"] += p.amount
|
||||
cell["items"].append(_plan_dict(p))
|
||||
|
||||
# 本月合计
|
||||
month_receive = round(sum(p.amount for p in plans if p.plan_type == "receive"), 2)
|
||||
month_pay = round(sum(p.amount for p in plans if p.plan_type == "pay"), 2)
|
||||
|
||||
forecast = forecast_cash_flow_with_plans(entity_id, db, days=days, current_cash=current_cash)
|
||||
upcoming = api_upcoming(entity_id, 7, db)
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"month": f"{y:04d}-{m:02d}",
|
||||
"month_receive": month_receive,
|
||||
"month_pay": month_pay,
|
||||
"month_net": round(month_receive - month_pay, 2),
|
||||
"calendar": calendar,
|
||||
"forecast": forecast,
|
||||
"upcoming": upcoming,
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 4. 资金预警 — 缺口前3天预警 + 到期未收款提醒
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
@router.post("/check-alerts")
|
||||
def api_check_cash_alerts(entity_id: int = 1, db: Session = Depends(get_db)):
|
||||
"""手动触发资金预警检查(写入预警中心kpi_alerts)"""
|
||||
try:
|
||||
return check_cash_alerts(db, entity_id=entity_id)
|
||||
except Exception as e:
|
||||
logger.error(f"资金预警检查失败: {e}", exc_info=True)
|
||||
raise HTTPException(400, f"资金预警检查失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/alerts/status")
|
||||
def api_cash_alert_status(entity_id: int = Query(1), db: Session = Depends(get_db)):
|
||||
"""资金预警状态概览 — 当前缺口/逾期情况(不写库,只读)"""
|
||||
result = forecast_cash_flow_with_plans(entity_id, db, days=30)
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"warning_line": result["warning_line"],
|
||||
"critical_line": result["critical_line"],
|
||||
"gap_dates": result["gap_dates"],
|
||||
"pre_alerts": result["pre_alerts"],
|
||||
"min_cash": result["min_cash"],
|
||||
"min_cash_date": result["min_cash_date"],
|
||||
"summary": result["summary"],
|
||||
}
|
||||
+16
-3
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from dotenv import load_dotenv
|
||||
from app.database import init_db
|
||||
from app.api import auth, kpis, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, bot_iron_law, analysis_results, expenses
|
||||
from app.api import auth, kpis, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, bot_iron_law, analysis_results, expenses, cash
|
||||
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
||||
from scripts.erp_sync import run_sync as run_erp_sync
|
||||
from app.auth_middleware import require_auth
|
||||
@@ -74,6 +74,7 @@ app.include_router(bot_kpis.router)
|
||||
app.include_router(bot_iron_law.router)
|
||||
app.include_router(analysis_results.router)
|
||||
app.include_router(expenses.router)
|
||||
app.include_router(cash.router)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
@@ -111,13 +112,25 @@ def admin_erp_sync_dry_run(user=Depends(require_auth), kpi_codes: str = None):
|
||||
|
||||
@app.post("/api/cma/admin/alerts/check")
|
||||
def admin_check_alerts():
|
||||
"""手动触发预警检查"""
|
||||
"""手动触发预警检查(含资金管理预警:缺口前3天 + 到期未收款)"""
|
||||
from app.database import get_session_local
|
||||
from scripts.alert_generator import generate_and_push
|
||||
from app.utils.cash_forecast_engine import check_cash_alerts
|
||||
from app.models import Entity
|
||||
db = get_session_local()()
|
||||
try:
|
||||
result = generate_and_push(db)
|
||||
return {"message": "预警检查完成", "result": result}
|
||||
# 资金管理预警 — 对每个激活企业执行
|
||||
cash_result = None
|
||||
try:
|
||||
entities = db.query(Entity).filter(Entity.status == "active").all()
|
||||
cash_list = []
|
||||
for e in entities:
|
||||
cash_list.append(check_cash_alerts(db, entity_id=e.id))
|
||||
cash_result = {"entities": len(entities), "details": cash_list}
|
||||
except Exception as e:
|
||||
logger.warning(f"资金预警检查失败: {e}")
|
||||
return {"message": "预警检查完成", "result": result, "cash": cash_result}
|
||||
except Exception as e:
|
||||
return JSONResponse(status_code=500, content={"detail": f"检查失败: {str(e)}"})
|
||||
finally:
|
||||
|
||||
@@ -409,6 +409,22 @@ class CashForecast(Base):
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class CashPlan(Base):
|
||||
"""收付款计划 — 资金管理智能体"""
|
||||
__tablename__ = "cash_plans"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, ForeignKey("entities.id"), default=1, comment="企业ID")
|
||||
plan_type = Column(String(10), nullable=False, comment="receive收/pay付")
|
||||
amount = Column(Float, nullable=False, comment="金额(万元)")
|
||||
plan_date = Column(DateTime, nullable=False, comment="计划日期")
|
||||
counterparty = Column(String(200), nullable=True, comment="关联客户/供应商")
|
||||
description = Column(String(500), nullable=True, comment="说明")
|
||||
status = Column(String(20), default="pending", comment="pending/completed/cancelled")
|
||||
completed_at = Column(DateTime, nullable=True, comment="完成时间")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class SystemConfig(Base):
|
||||
"""系统配置 — key-value存储"""
|
||||
__tablename__ = "system_configs"
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
"""现金流预测引擎 — 根据历史KPI数据推算未来30天现金流"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from sqlalchemy.orm import Session
|
||||
import math
|
||||
import random
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models import KPIDefinition
|
||||
|
||||
logger = logging.getLogger("cma.cash_forecast")
|
||||
|
||||
# 默认现金阈值(万元)
|
||||
DEFAULT_CASH_WARNING = 20.0 # 黄灯 — 低于20万
|
||||
DEFAULT_CASH_CRITICAL = 10.0 # 红灯 — 低于10万
|
||||
|
||||
# 历史KPI编码映射
|
||||
# 历史KPI编码映射(含候选编码,兼容F_*标准编码)
|
||||
KPI_CODES = {
|
||||
"operating_cash_flow": "CASH_FLOW_001", # 经营现金流
|
||||
"receivables": "AR_001", # 应收账款
|
||||
@@ -20,14 +23,32 @@ KPI_CODES = {
|
||||
"cash_balance": "CASH_001", # 现金余额
|
||||
}
|
||||
|
||||
# 候选编码列表 — 依次尝试,找不到则回退
|
||||
KPI_CODE_CANDIDATES = {
|
||||
"operating_cash_flow": ["CASH_FLOW_001", "F_OP_CFLOW", "F_REVENUE"],
|
||||
"receivables": ["AR_001", "F_AR_DAYS", "C_AR_BALANCE"],
|
||||
"payables": ["AP_001", "F_AP_DAYS"],
|
||||
"cash_balance": ["CASH_001", "CASH_BALANCE", "F_CASH"],
|
||||
}
|
||||
|
||||
|
||||
def find_kpi(db: Session, entity_id: int, codes: list) -> Optional["KPIDefinition"]:
|
||||
"""按候选编码列表查找KPI,返回第一个命中的"""
|
||||
from app.models import KPIDefinition
|
||||
for code in codes:
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == code,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if kpi:
|
||||
return kpi
|
||||
return None
|
||||
|
||||
|
||||
def get_entity_kpi_history(entity_id: int, kpi_code: str, db: Session, limit_months: int = 6) -> list:
|
||||
"""获取实体某个KPI的历史值"""
|
||||
from app.models import KPIDefinition, KPIValue
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == kpi_code,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
kpi = find_kpi(db, entity_id, [kpi_code])
|
||||
if not kpi:
|
||||
return []
|
||||
values = db.query(KPIValue).filter(
|
||||
@@ -51,6 +72,47 @@ def calc_trend(values: list) -> float:
|
||||
return slope / max(abs(avg_y), 1.0) * 100 # 趋势百分比
|
||||
|
||||
|
||||
def get_current_cash_balance(db: Session, entity_id: int) -> Optional[float]:
|
||||
"""获取当前现金余额(万元)— 优先级:KPI实际值 > SystemConfig > None"""
|
||||
from app.models import SystemConfig, KPIValue
|
||||
kpi = find_kpi(db, entity_id, KPI_CODE_CANDIDATES["cash_balance"])
|
||||
if kpi:
|
||||
latest_cash = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
).order_by(KPIValue.period.desc()).first()
|
||||
if latest_cash and latest_cash.actual_value is not None:
|
||||
return float(latest_cash.actual_value)
|
||||
cfg = db.query(SystemConfig).filter(
|
||||
SystemConfig.config_key == "cash.current_balance"
|
||||
).first()
|
||||
if cfg and cfg.config_value:
|
||||
try:
|
||||
return float(cfg.config_value)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def set_current_cash_balance(db: Session, value: float) -> float:
|
||||
"""设置当前现金余额(写入SystemConfig,供预测引擎使用)"""
|
||||
from app.models import SystemConfig
|
||||
cfg = db.query(SystemConfig).filter(
|
||||
SystemConfig.config_key == "cash.current_balance"
|
||||
).first()
|
||||
if cfg:
|
||||
cfg.config_value = str(value)
|
||||
else:
|
||||
cfg = SystemConfig(
|
||||
config_key="cash.current_balance",
|
||||
config_value=str(value),
|
||||
description="当前现金余额(万元),资金预测基线",
|
||||
)
|
||||
db.add(cfg)
|
||||
db.commit()
|
||||
return value
|
||||
|
||||
|
||||
def forecast_cash_flow(
|
||||
entity_id: int,
|
||||
db: Session,
|
||||
@@ -69,31 +131,26 @@ def forecast_cash_flow(
|
||||
|
||||
# 获取当前现金余额
|
||||
if current_cash is None:
|
||||
cash_kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == KPI_CODES["cash_balance"],
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if cash_kpi:
|
||||
latest_cash = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == cash_kpi.id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
).order_by(KPIValue.period.desc()).first()
|
||||
base_cash = latest_cash.actual_value if latest_cash else 30.0
|
||||
else:
|
||||
base_cash = get_current_cash_balance(db, entity_id)
|
||||
if base_cash is None:
|
||||
base_cash = 30.0 # 默认假设30万
|
||||
else:
|
||||
base_cash = current_cash
|
||||
|
||||
# 获取经营现金流历史
|
||||
ocf_history = get_entity_kpi_history(entity_id, KPI_CODES["operating_cash_flow"], db)
|
||||
ocf_history = get_entity_kpi_history(entity_id, KPI_CODE_CANDIDATES["operating_cash_flow"][0], db) or \
|
||||
get_entity_kpi_history(entity_id, KPI_CODE_CANDIDATES["operating_cash_flow"][1], db) or \
|
||||
get_entity_kpi_history(entity_id, KPI_CODE_CANDIDATES["operating_cash_flow"][2], db)
|
||||
ocf_trend = calc_trend(ocf_history)
|
||||
|
||||
# 获取应收历史
|
||||
ar_history = get_entity_kpi_history(entity_id, KPI_CODES["receivables"], db)
|
||||
ar_history = get_entity_kpi_history(entity_id, KPI_CODE_CANDIDATES["receivables"][0], db) or \
|
||||
get_entity_kpi_history(entity_id, KPI_CODE_CANDIDATES["receivables"][1], db)
|
||||
ar_trend = calc_trend(ar_history)
|
||||
|
||||
# 获取应付历史
|
||||
ap_history = get_entity_kpi_history(entity_id, KPI_CODES["payables"], db)
|
||||
ap_history = get_entity_kpi_history(entity_id, KPI_CODE_CANDIDATES["payables"][0], db) or \
|
||||
get_entity_kpi_history(entity_id, KPI_CODE_CANDIDATES["payables"][1], db)
|
||||
ap_trend = calc_trend(ap_history)
|
||||
|
||||
# 计算日均现金变化
|
||||
@@ -320,3 +377,332 @@ def generate_scenario_suggestion(alert_type: str, kpi_name: str, extra: dict = N
|
||||
if extra:
|
||||
sug["extra"] = extra
|
||||
return sug
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 资金缺口预测 — 趋势引擎 + 收付款计划叠加 (资金管理智能体)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def get_cash_plans(db: Session, entity_id: int, start_date: datetime, end_date: datetime) -> list:
|
||||
"""获取指定日期范围内的待执行收付款计划"""
|
||||
from app.models import CashPlan
|
||||
return db.query(CashPlan).filter(
|
||||
CashPlan.entity_id == entity_id,
|
||||
CashPlan.status == "pending",
|
||||
CashPlan.plan_date >= start_date,
|
||||
CashPlan.plan_date <= end_date,
|
||||
).order_by(CashPlan.plan_date.asc()).all()
|
||||
|
||||
|
||||
def _budget_monthly_ocf(db: Session, entity_id: int) -> Optional[float]:
|
||||
"""获取本月经营现金流预算(万元/月),用于校准预测基线"""
|
||||
from app.models import BudgetPlan
|
||||
kpi = find_kpi(db, entity_id, KPI_CODE_CANDIDATES["operating_cash_flow"])
|
||||
if not kpi:
|
||||
return None
|
||||
month_key = datetime.now().strftime("%Y-%m")
|
||||
bp = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.kpi_id == kpi.id,
|
||||
BudgetPlan.period == month_key,
|
||||
BudgetPlan.status == "active",
|
||||
).order_by(BudgetPlan.version.desc()).first()
|
||||
if bp and bp.budget_value is not None:
|
||||
return float(bp.budget_value)
|
||||
return None
|
||||
|
||||
|
||||
def forecast_cash_flow_with_plans(
|
||||
entity_id: int,
|
||||
db: Session,
|
||||
days: int = 30,
|
||||
current_cash: Optional[float] = None,
|
||||
warning_line: float = DEFAULT_CASH_WARNING,
|
||||
critical_line: float = DEFAULT_CASH_CRITICAL,
|
||||
include_plans: bool = True,
|
||||
) -> dict:
|
||||
"""
|
||||
资金缺口预测 — 在趋势预测基础上叠加收付款计划:
|
||||
1. 趋势引擎生成基线预测
|
||||
2. 叠加 cash_plans 的应收(收) / 应付(付)
|
||||
3. 识别资金缺口日期(余额 < 警戒线)
|
||||
4. 生成缺口前3天预警点
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from app.models import CashPlan
|
||||
|
||||
base = forecast_cash_flow(entity_id, db, days, current_cash)
|
||||
base_cash = base["base_cash"]
|
||||
|
||||
# 预算校准:本月经营现金流预算优先作为基线(万元/月)
|
||||
budget_ocf = _budget_monthly_ocf(db, entity_id)
|
||||
if budget_ocf is not None:
|
||||
base["trends"]["budget_monthly_ocf"] = round(budget_ocf, 2)
|
||||
|
||||
# ── 收付款计划加载 ──
|
||||
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today + timedelta(days=days)
|
||||
plan_map = defaultdict(lambda: {"in": 0.0, "out": 0.0, "items": []})
|
||||
plans = []
|
||||
if include_plans:
|
||||
plans = get_cash_plans(db, entity_id, today, end + timedelta(days=1))
|
||||
for p in plans:
|
||||
dkey = p.plan_date.strftime("%Y-%m-%d")
|
||||
item = {
|
||||
"id": p.id,
|
||||
"amount": round(p.amount, 2),
|
||||
"counterparty": p.counterparty or "",
|
||||
"description": p.description or "",
|
||||
}
|
||||
if p.plan_type == "receive":
|
||||
plan_map[dkey]["in"] += p.amount
|
||||
plan_map[dkey]["items"].append({"type": "receive", **item})
|
||||
else:
|
||||
plan_map[dkey]["out"] += p.amount
|
||||
plan_map[dkey]["items"].append({"type": "pay", **item})
|
||||
|
||||
# ── 逐日重算余额 ──
|
||||
forecast = []
|
||||
cash = base_cash
|
||||
prev_predicted = base_cash
|
||||
for i, f in enumerate(base["forecast"]):
|
||||
dkey = f["date"]
|
||||
# 趋势日净变化(与上一天预测值的差)
|
||||
trend_delta = f["predicted_cash"] - prev_predicted
|
||||
prev_predicted = f["predicted_cash"]
|
||||
|
||||
pin = round(plan_map[dkey]["in"], 2)
|
||||
pout = round(plan_map[dkey]["out"], 2)
|
||||
# 预算校准:有月度预算时用预算日均替代纯趋势增量
|
||||
if budget_ocf is not None:
|
||||
trend_delta = budget_ocf / 30.0
|
||||
if f["day_offset"] % 7 in (5, 6):
|
||||
trend_delta *= 0.5 # 周末减半
|
||||
if dkey[-2:] >= "25":
|
||||
trend_delta *= 1.3 # 月底回款高峰
|
||||
|
||||
cash = round(cash + trend_delta + pin - pout, 2)
|
||||
net_flow = round(trend_delta + pin - pout, 2)
|
||||
|
||||
if cash < critical_line:
|
||||
status = "red"
|
||||
elif cash < warning_line:
|
||||
status = "yellow"
|
||||
else:
|
||||
status = "green"
|
||||
|
||||
forecast.append({
|
||||
"date": dkey,
|
||||
"day_offset": f["day_offset"],
|
||||
"predicted_cash": cash,
|
||||
"planned_in": pin,
|
||||
"planned_out": pout,
|
||||
"trend_delta": round(trend_delta, 2),
|
||||
"net_flow": net_flow,
|
||||
"lower_bound": round(max(cash - abs(net_flow) * 0.5 - 0.5, 0), 2),
|
||||
"upper_bound": round(cash + abs(net_flow) * 0.5 + 0.5, 2),
|
||||
"alert_status": status,
|
||||
"gap": cash < warning_line,
|
||||
"plans": plan_map[dkey]["items"],
|
||||
})
|
||||
|
||||
# ── 资金缺口日期 ──
|
||||
gap_dates = [
|
||||
{"date": f["date"], "predicted_cash": f["predicted_cash"],
|
||||
"gap_amount": round(warning_line - f["predicted_cash"], 2),
|
||||
"level": "red" if f["predicted_cash"] < critical_line else "yellow"}
|
||||
for f in forecast if f["gap"]
|
||||
]
|
||||
|
||||
# ── 缺口前3天预警(每个连续缺口区间只预警一次,取区间首日) ──
|
||||
pre_alerts = []
|
||||
prev_was_gap = False
|
||||
for idx, f in enumerate(forecast):
|
||||
is_gap = f["gap"]
|
||||
gap_run_start = is_gap and not prev_was_gap
|
||||
prev_was_gap = is_gap
|
||||
if not gap_run_start:
|
||||
continue
|
||||
# 找到该连续缺口区间的最后一天及区间内最低余额(最严重时点)
|
||||
run_end = forecast[idx]
|
||||
run_min = forecast[idx]["predicted_cash"]
|
||||
run_min_date = forecast[idx]["date"]
|
||||
for j in range(idx + 1, len(forecast)):
|
||||
if forecast[j]["gap"]:
|
||||
run_end = forecast[j]
|
||||
if forecast[j]["predicted_cash"] < run_min:
|
||||
run_min = forecast[j]["predicted_cash"]
|
||||
run_min_date = forecast[j]["date"]
|
||||
else:
|
||||
break
|
||||
for lead in (3, 1): # 缺口前3天(主要)、前1天(紧急)
|
||||
pre_idx = idx - lead
|
||||
if pre_idx < 0:
|
||||
continue
|
||||
pf = forecast[pre_idx]
|
||||
if pf["alert_status"] == "green" or lead == 3:
|
||||
pre_alerts.append({
|
||||
"alert_date": pf["date"],
|
||||
"alert_offset": pf["day_offset"],
|
||||
"gap_date": f["date"],
|
||||
"gap_cash": f["predicted_cash"],
|
||||
"gap_amount": round(warning_line - f["predicted_cash"], 2),
|
||||
"worst_cash": round(run_min, 2),
|
||||
"worst_date": run_min_date,
|
||||
"lead_days": lead,
|
||||
"level": "red" if run_min < critical_line else "yellow",
|
||||
})
|
||||
break
|
||||
|
||||
# 到期未收款(逾期)
|
||||
overdue_receives = db.query(CashPlan).filter(
|
||||
CashPlan.entity_id == entity_id,
|
||||
CashPlan.plan_type == "receive",
|
||||
CashPlan.status == "pending",
|
||||
CashPlan.plan_date < today,
|
||||
).order_by(CashPlan.plan_date.asc()).all()
|
||||
|
||||
# 未来7天到期
|
||||
upcoming_7d = db.query(CashPlan).filter(
|
||||
CashPlan.entity_id == entity_id,
|
||||
CashPlan.status == "pending",
|
||||
CashPlan.plan_date >= today,
|
||||
CashPlan.plan_date <= today + timedelta(days=7),
|
||||
).order_by(CashPlan.plan_date.asc()).all()
|
||||
|
||||
# 整体结论
|
||||
min_cash = min(f["predicted_cash"] for f in forecast) if forecast else base_cash
|
||||
min_date = next((f["date"] for f in forecast if f["predicted_cash"] == min_cash), "")
|
||||
|
||||
suggestions = list(base.get("suggestions", []))
|
||||
if gap_dates:
|
||||
first_gap = gap_dates[0]
|
||||
if first_gap["level"] == "red":
|
||||
suggestions.insert(0, {
|
||||
"type": "critical",
|
||||
"message": f"预计{first_gap['date']}现金余额降至{first_gap['predicted_cash']:.1f}万,低于警戒线{warning_line:.0f}万,存在资金断流风险",
|
||||
"actions": ["立即催收大额应收账款", "暂停非必要支出", "准备短期融资安排"],
|
||||
})
|
||||
else:
|
||||
suggestions.insert(0, {
|
||||
"type": "warning",
|
||||
"message": f"预计{first_gap['date']}现金余额降至{first_gap['predicted_cash']:.1f}万,低于警戒线{warning_line:.0f}万",
|
||||
"actions": ["加快应收账款回款", "控制采购付款节奏", "评估短期现金流压力"],
|
||||
})
|
||||
if overdue_receives:
|
||||
total_overdue = sum(p.amount for p in overdue_receives)
|
||||
suggestions.append({
|
||||
"type": "warning",
|
||||
"message": f"有{len(overdue_receives)}笔应收款到期未收,合计{total_overdue:.1f}万",
|
||||
"actions": ["逐笔催收到期应收账款", "评估客户信用风险"],
|
||||
})
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"base_cash": round(base_cash, 2),
|
||||
"days": days,
|
||||
"warning_line": warning_line,
|
||||
"critical_line": critical_line,
|
||||
"budget_monthly_ocf": budget_ocf,
|
||||
"forecast": forecast,
|
||||
"gap_dates": gap_dates,
|
||||
"pre_alerts": pre_alerts,
|
||||
"min_cash": round(min_cash, 2),
|
||||
"min_cash_date": min_date,
|
||||
"trends": base.get("trends", {}),
|
||||
"suggestions": suggestions,
|
||||
"summary": {
|
||||
"total_planned_in": round(sum(p.amount for p in plans if p.plan_type == "receive"), 2),
|
||||
"total_planned_out": round(sum(p.amount for p in plans if p.plan_type == "pay"), 2),
|
||||
"plan_count": len(plans),
|
||||
"overdue_receive_count": len(overdue_receives),
|
||||
"overdue_receive_amount": round(sum(p.amount for p in overdue_receives), 2),
|
||||
"upcoming_7d_count": len(upcoming_7d),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def check_cash_alerts(db: Session, entity_id: int = 1) -> dict:
|
||||
"""资金预警 — 缺口前3天预警 + 到期未收款提醒,写入预警中心(kpi_alerts)"""
|
||||
import json as _json
|
||||
from app.models import KPIAlert, CashPlan
|
||||
|
||||
result = forecast_cash_flow_with_plans(entity_id, db, days=30)
|
||||
new_alerts = []
|
||||
|
||||
# 兜底KPI:现金KPI → 经营现金流KPI → 该实体任意KPI
|
||||
kpi = find_kpi(db, entity_id, KPI_CODE_CANDIDATES["cash_balance"]) or \
|
||||
find_kpi(db, entity_id, KPI_CODE_CANDIDATES["operating_cash_flow"]) or \
|
||||
db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id).first()
|
||||
ar_kpi = find_kpi(db, entity_id, KPI_CODE_CANDIDATES["receivables"]) or kpi
|
||||
|
||||
def _exists(msg: str) -> bool:
|
||||
return db.query(KPIAlert).filter(
|
||||
KPIAlert.alert_message == msg,
|
||||
KPIAlert.status.in_(["pending", "processing"]),
|
||||
).first() is not None
|
||||
|
||||
# ── 1. 缺口前3天预警 ──
|
||||
if kpi:
|
||||
for pre in result["pre_alerts"]:
|
||||
level = pre["level"]
|
||||
msg = (f"【资金缺口预警】预计{pre['gap_date']}现金余额降至{pre['gap_cash']:.1f}万"
|
||||
f"(低于警戒线{result['warning_line']:.0f}万,缺口{pre['gap_amount']:.1f}万),"
|
||||
f"请于{pre['alert_date']}前(提前{pre['lead_days']}天)安排资金")
|
||||
if _exists(msg):
|
||||
continue
|
||||
alert = KPIAlert(
|
||||
kpi_id=kpi.id,
|
||||
alert_level=level,
|
||||
alert_message=msg[:500],
|
||||
alert_type="forecast",
|
||||
status="pending",
|
||||
suggestion=_json.dumps({
|
||||
"actions": ["加快应收账款回款", "控制付款节奏", "评估短期融资"],
|
||||
"gap_date": pre["gap_date"],
|
||||
"gap_amount": pre["gap_amount"],
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
db.add(alert)
|
||||
new_alerts.append({"type": "gap_forecast", "level": level, "message": msg})
|
||||
logger.info(f"资金缺口预警: {msg}")
|
||||
|
||||
# ── 2. 到期未收款提醒 ──
|
||||
if ar_kpi:
|
||||
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
overdue = db.query(CashPlan).filter(
|
||||
CashPlan.entity_id == entity_id,
|
||||
CashPlan.plan_type == "receive",
|
||||
CashPlan.status == "pending",
|
||||
CashPlan.plan_date < today,
|
||||
).order_by(CashPlan.plan_date.asc()).all()
|
||||
for p in overdue:
|
||||
days_late = (today - p.plan_date).days
|
||||
msg = (f"【到期未收款】应收款{p.counterparty or '客户'} {p.amount:.1f}万 "
|
||||
f"原计划{p.plan_date.strftime('%Y-%m-%d')}到期,已逾期{days_late}天未收回")
|
||||
if _exists(msg):
|
||||
continue
|
||||
alert = KPIAlert(
|
||||
kpi_id=ar_kpi.id,
|
||||
alert_level="red" if days_late >= 7 else "yellow",
|
||||
alert_message=msg[:500],
|
||||
alert_type="cash_plan",
|
||||
status="pending",
|
||||
suggestion=_json.dumps({
|
||||
"actions": ["联系客户催收", "评估坏账风险", "调整信用政策"],
|
||||
"plan_id": p.id,
|
||||
"days_late": days_late,
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
db.add(alert)
|
||||
new_alerts.append({"type": "overdue_receive", "level": alert.alert_level, "message": msg})
|
||||
logger.info(f"到期未收款提醒: {msg}")
|
||||
|
||||
db.commit()
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"new_alerts": len(new_alerts),
|
||||
"alerts": new_alerts,
|
||||
"gap_dates": result["gap_dates"],
|
||||
"pre_alerts": result["pre_alerts"],
|
||||
"summary": result["summary"],
|
||||
}
|
||||
|
||||
@@ -324,4 +324,26 @@ export const expenseApi = {
|
||||
stats: (params?: any) => api.get('/expenses/stats', { params }),
|
||||
}
|
||||
|
||||
// ── 资金管理智能体:资金缺口预测 + 收付款计划 + 预警 ──
|
||||
export const cashApi = {
|
||||
// 资金缺口预测
|
||||
gapForecast: (params?: any) => api.get('/cash/gap-forecast', { params }),
|
||||
// 当前现金余额(预测基线)
|
||||
getBalance: (params?: any) => api.get('/cash/balance', { params }),
|
||||
setBalance: (data: any) => api.post('/cash/balance', data),
|
||||
// 收付款计划 CRUD
|
||||
listPlans: (params?: any) => api.get('/cash/plans', { params }),
|
||||
createPlan: (data: any) => api.post('/cash/plans', data),
|
||||
updatePlan: (id: number, data: any) => api.put(`/cash/plans/${id}`, data),
|
||||
deletePlan: (id: number) => api.delete(`/cash/plans/${id}`),
|
||||
completePlan: (id: number) => api.post(`/cash/plans/${id}/complete`),
|
||||
// 到期提醒
|
||||
upcoming: (params?: any) => api.get('/cash/upcoming', { params }),
|
||||
// 页面看板(日历+预测+提醒)
|
||||
dashboard: (params?: any) => api.get('/cash/dashboard', { params }),
|
||||
// 资金预警
|
||||
checkAlerts: (params?: any) => api.post('/cash/check-alerts', null, { params }),
|
||||
alertStatus: (params?: any) => api.get('/cash/alerts/status', { params }),
|
||||
}
|
||||
|
||||
export default api
|
||||
|
||||
@@ -9,10 +9,10 @@ interface MenuItem {
|
||||
|
||||
// ── 角色路由映射 ──
|
||||
export const ROLE_ROUTES: Record<string, string[]> = {
|
||||
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses'],
|
||||
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses'],
|
||||
business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses'],
|
||||
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses'],
|
||||
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan'],
|
||||
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan'],
|
||||
business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan'],
|
||||
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan'],
|
||||
}
|
||||
|
||||
export const ROLE_ACTIONS: Record<string, string[]> = {
|
||||
@@ -33,6 +33,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||
// ── GROUP 2: 执行与控制(Do)──
|
||||
{ path: '/cost', label: '成本分析', icon: 'Money', roles: ['ceo', 'finance', 'it'], group: '🟢 D 执行与控制' },
|
||||
{ path: '/expenses', label: '费用审核', icon: 'Money', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
|
||||
{ path: '/cash-plan', label: '收付款计划', icon: 'Money', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
|
||||
{ path: '/deviations', label: '差异分析', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
|
||||
|
||||
// ── GROUP 3: 监控与评价(Check)──
|
||||
|
||||
@@ -42,6 +42,7 @@ const routes = [
|
||||
{ path: 'bot-kpis', name: 'BotKpis', component: () => import('@/views/BotKpiDashboard.vue'), meta: { title: 'Bot KPI看板', roles: ['ceo', 'finance', 'it'] } },
|
||||
{ path: 'analysis-confidence', name: 'AnalysisConfidence', component: () => import('@/views/AnalysisConfidence.vue'), meta: { title: '分析置信度', roles: ['ceo', 'finance', 'it'] } },
|
||||
{ path: 'expenses', name: 'ExpenseManage', component: () => import('@/views/ExpenseManage.vue'), meta: { title: '费用审核', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||
{ path: 'cash-plan', name: 'CashPlan', component: () => import('@/views/CashPlan.vue'), meta: { title: '收付款计划', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 style="margin-bottom: 12px;">收付款计划 · 资金缺口预测</h3>
|
||||
|
||||
<!-- ── 顶部统计卡片 ── -->
|
||||
<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-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 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 = '触发预警检查'
|
||||
}
|
||||
}
|
||||
|
||||
// ── 生命周期 ──
|
||||
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; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user