From 8e277de3debd97a6b26230cb73f52f0fad74f575 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Tue, 4 Aug 2026 14:53:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B5=84=E9=87=91=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=BC=BA=E5=8C=96=E2=80=94=E7=BC=BA=E5=8F=A3=E9=A2=84=E6=B5=8B?= =?UTF-8?q?+=E6=94=B6=E4=BB=98=E6=AC=BE=E8=AE=A1=E5=88=92+=E9=A2=84?= =?UTF-8?q?=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/cash.py | 328 +++++++++++++ backend/app/main.py | 19 +- backend/app/models/__init__.py | 16 + backend/app/utils/cash_forecast_engine.py | 426 ++++++++++++++++- frontend/src/api/index.ts | 22 + frontend/src/permission.ts | 9 +- frontend/src/router/index.ts | 1 + frontend/src/views/CashPlan.vue | 557 ++++++++++++++++++++++ 8 files changed, 1351 insertions(+), 27 deletions(-) create mode 100644 backend/app/api/cash.py create mode 100644 frontend/src/views/CashPlan.vue diff --git a/backend/app/api/cash.py b/backend/app/api/cash.py new file mode 100644 index 00000000..6dab3ef4 --- /dev/null +++ b/backend/app/api/cash.py @@ -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"], + } diff --git a/backend/app/main.py b/backend/app/main.py index f048104b..1e44fb1e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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: diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index fb6f8ef9..c4afb267 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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" diff --git a/backend/app/utils/cash_forecast_engine.py b/backend/app/utils/cash_forecast_engine.py index 588f38d3..3ebf79c8 100644 --- a/backend/app/utils/cash_forecast_engine.py +++ b/backend/app/utils/cash_forecast_engine.py @@ -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"], + } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 981c0805..0de570d1 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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 diff --git a/frontend/src/permission.ts b/frontend/src/permission.ts index 6dabd05d..ce7236a2 100644 --- a/frontend/src/permission.ts +++ b/frontend/src/permission.ts @@ -9,10 +9,10 @@ interface MenuItem { // ── 角色路由映射 ── export const ROLE_ROUTES: Record = { - 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 = { @@ -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)── diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index bbd433a6..489ff91c 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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'] } }, ] }, ] diff --git a/frontend/src/views/CashPlan.vue b/frontend/src/views/CashPlan.vue new file mode 100644 index 00000000..04eb4a4d --- /dev/null +++ b/frontend/src/views/CashPlan.vue @@ -0,0 +1,557 @@ + + + + +