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"],
|
||||
}
|
||||
Reference in New Issue
Block a user