"""资金管理API — 资金缺口预测 + 收付款计划 + 预警 + 应收催收闭环 (资金管理智能体)""" import json import logging from datetime import datetime, timedelta from fastapi import APIRouter, HTTPException, Depends, Query, Request from sqlalchemy.orm import Session from sqlalchemy import or_ from app.database import get_db from app.deps import get_entity_id, resolve_entity_for_request 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 _aging_bucket(days_late: int) -> str: """账龄区间(按逾期天数):未到期 / 0-30天 / 30-60天 / 60-90天 / 90天以上""" if days_late <= 0: return "未到期" if days_late <= 30: return "0-30天" if days_late <= 60: return "30-60天" if days_late <= 90: return "60-90天" return "90天以上" def _plan_dict(p: CashPlan) -> dict: today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) due = p.plan_date.replace(hour=0, minute=0, second=0, microsecond=0) if p.plan_date else today days_late = (today - due).days paid = round(p.paid_amount or 0, 2) balance = round((p.amount or 0) - paid, 2) if p.plan_type == "receive": overdue = p.status == "pending" and due < today if p.status == "pending": aging = _aging_bucket(days_late) elif p.status == "completed": aging = "已结清" else: aging = "已取消" else: overdue = False aging = "—" 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), "paid_amount": paid, "receivable_balance": max(balance, 0), "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), "owner": p.owner or "", "source": p.source or "manual", "overdue": overdue, "overdue_days": max(days_late, 0) if overdue else 0, "aging_bucket": aging, "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 = Depends(get_entity_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 = Depends(get_entity_id), 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 = Depends(get_entity_id), 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(request: Request, 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=resolve_entity_for_request(request, 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"), owner=(data.get("owner") or "").strip() or None, source=(data.get("source") or "manual").strip(), paid_amount=float(data.get("paid_amount") or 0), ) 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 "owner" in data: plan.owner = (data["owner"] or "").strip() or None if "source" in data: plan.source = (data["source"] or "manual").strip() if "paid_amount" in data: plan.paid_amount = float(data["paid_amount"] or 0) 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) # 完成收款时联动:消除到期未收款预警 + 催收行动 + F_AR_DAYS if plan.plan_type == "receive" and plan.status == "completed": try: _resolve_plan_alerts(db, plan.entity_id, plan.id, plan.paid_amount or plan.amount) _sync_collection_action_plan(db, plan.entity_id) _update_ar_days_kpi(db, plan.entity_id) db.commit() except Exception as e: logger.warning(f"完成收款联动失败: {e}") 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() if plan.plan_type == "receive" and not plan.paid_amount: plan.paid_amount = plan.amount db.commit() db.refresh(plan) if plan.plan_type == "receive": try: _resolve_plan_alerts(db, plan.entity_id, plan.id, plan.paid_amount or plan.amount) _sync_collection_action_plan(db, plan.entity_id) _update_ar_days_kpi(db, plan.entity_id) db.commit() except Exception as e: logger.warning(f"完成收款联动失败: {e}") return {"message": "已标记完成", "data": _plan_dict(plan)} # ══════════════════════════════════════════════════════════ # 3. 到期提醒 + 页面看板 # ══════════════════════════════════════════════════════════ @router.get("/upcoming") def api_upcoming( entity_id: int = Depends(get_entity_id), 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 = Depends(get_entity_id), 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 = Depends(get_entity_id), 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 = Depends(get_entity_id), 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"], } # ══════════════════════════════════════════════════════════ # 5. 应收催收闭环 — 催收视图 / 回款登记 / 博海数据录入 # 唯一应收载体:cash_plans(plan_type=receive) # ══════════════════════════════════════════════════════════ # 博海应收款汇总表(2026H1期末余额,按业务员)— 录入源数据 # 来源: 应收款汇总表(业务员+客户).xlsx / bohai_comprehensive_report.md BOHAI_AR_DATA = [ # (业务员, 期末余额元, 业务说明) ("董均国", 387676, "IT业务-重点催收"), ("蒋亚文", 529786, "IT业务-重点催收"), ("陈艳", 89949, "IT业务"), ("李亚玲", 92450, "IT业务"), ("李巧玲", 45006, "IT业务"), ("贾妮", 27820, "IT业务"), ("王平安", 31298, "IT业务"), ("任富海", 26978, "IT业务"), ("其他", 6559, "IT业务"), ("王婧", 1065000, "酣客酒类(独立核算)"), ] def _resolve_plan_alerts(db: Session, entity_id: int, plan_id: int, amount: float) -> int: """回款登记后自动消除该计划的【到期未收款】预警(kpi_alerts.alert_type=cash_plan)""" from app.models import KPIAlert alerts = db.query(KPIAlert).filter( KPIAlert.alert_type == "cash_plan", KPIAlert.status.in_(["pending", "processing"]), ).all() resolved = 0 for a in alerts: try: sug = json.loads(a.suggestion or "{}") except Exception: continue if sug.get("plan_id") == plan_id: a.status = "resolved" a.resolution = f"回款登记+{amount:.2f}万,系统自动消除" a.resolved_at = datetime.now() resolved += 1 if resolved: db.commit() return resolved def _sync_collection_action_plan(db: Session, entity_id: int): """逾期应收 → 『应收账款催收行动』联动:有逾期→in_progress,全部结清→completed""" from app.models import ActionPlan today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) plans = db.query(CashPlan).filter( CashPlan.entity_id == entity_id, CashPlan.plan_type == "receive", ).all() pending = [p for p in plans if p.status == "pending"] overdue = [p for p in pending if p.plan_date < today] total_amount = sum(p.amount or 0 for p in plans) paid_amount = sum(p.paid_amount or 0 for p in plans) progress = round(paid_amount / total_amount * 100) if total_amount > 0 else 0 if overdue: new_status = "in_progress" elif not pending: new_status = "completed" progress = 100 else: new_status = "in_progress" ap = db.query(ActionPlan).filter( ActionPlan.title.like("%催收%"), ActionPlan.status != "cancelled", ).order_by(ActionPlan.id.asc()).first() if not ap: return changed = False if ap.status != new_status: ap.status = new_status changed = True if ap.progress is None or progress > (ap.progress or 0): ap.progress = progress changed = True if changed: db.commit() logger.info(f"催收行动#{ap.id} 联动: status={new_status} progress={progress}%") def _update_ar_days_kpi(db: Session, entity_id: int): """联动F_AR_DAYS(应收周转天数KPI)— 按当前应收余额重算当期值 = 应收余额/月营收×30""" from app.models import KPIDefinition, KPIValue kpi = db.query(KPIDefinition).filter( KPIDefinition.entity_id == entity_id, KPIDefinition.kpi_code == "F_AR_DAYS", ).first() if not kpi: return plans = db.query(CashPlan).filter( CashPlan.entity_id == entity_id, CashPlan.plan_type == "receive", CashPlan.status == "pending", ).all() ar_balance_wan = round(sum((p.amount or 0) - (p.paid_amount or 0) for p in plans), 2) period = datetime.now().strftime("%Y-%m") monthly_rev = 100.0 # 缺省月营收(万元) rev_kpi = db.query(KPIDefinition).filter( KPIDefinition.entity_id == entity_id, KPIDefinition.kpi_code == "F_REVENUE", ).first() if rev_kpi: val = db.query(KPIValue).filter( KPIValue.kpi_id == rev_kpi.id, KPIValue.period == period, KPIValue.actual_value.isnot(None), ).order_by(KPIValue.id.desc()).first() if val and val.actual_value: monthly_rev = float(val.actual_value) ar_days = round(ar_balance_wan / monthly_rev * 30, 1) if monthly_rev > 0 else None if ar_days is None: return remark = f"应收催收闭环联动: 应收余额{ar_balance_wan:.2f}万/月营收{monthly_rev:.1f}万×30天" existing = db.query(KPIValue).filter( KPIValue.kpi_id == kpi.id, KPIValue.period == period, ).order_by(KPIValue.id.desc()).first() if existing: existing.actual_value = ar_days existing.source_type = "cash_plan" existing.remark = remark existing.calculated_at = datetime.now() else: db.add(KPIValue( kpi_id=kpi.id, period=period, actual_value=ar_days, source_type="cash_plan", data_status="calculated", remark=remark, )) db.commit() logger.info(f"F_AR_DAYS联动更新: {period} = {ar_days}天 (应收{ar_balance_wan}万)") @router.get("/receivables") def api_receivables( entity_id: int = Depends(get_entity_id), owner: str = Query(None, description="按负责人/业务员筛选"), status: str = Query(None, description="pending/completed/cancelled/overdue"), aging: str = Query(None, description="账龄: 未到期/0-30天/30-60天/60-90天/90天以上/已结清"), keyword: str = Query(None, description="客户/说明关键字"), db: Session = Depends(get_db), ): """应收催收视图 — 应收余额 + 账龄(30/60/90) + 逾期状态 + 负责人筛选""" query = db.query(CashPlan).filter( CashPlan.entity_id == entity_id, CashPlan.plan_type == "receive", ) if owner: query = query.filter(CashPlan.owner == owner) if keyword: kw = f"%{keyword}%" query = query.filter(or_(CashPlan.counterparty.like(kw), CashPlan.description.like(kw))) plans = query.order_by(CashPlan.plan_date.asc(), CashPlan.id.asc()).all() items = [] total_amount = total_paid = 0.0 pending_balance = 0.0 overdue_count = 0 overdue_amount = 0.0 aging_amount = {} completed_amount = 0.0 for p in plans: d = _plan_dict(p) if status: if status == "overdue": if not d["overdue"]: continue elif d["status"] != status: continue if aging and d["aging_bucket"] != aging: continue items.append(d) total_amount += d["amount"] total_paid += d["paid_amount"] if d["status"] == "pending": pending_balance += d["receivable_balance"] if d["overdue"]: overdue_count += 1 overdue_amount += d["receivable_balance"] if d["status"] == "pending": aging_amount[d["aging_bucket"]] = aging_amount.get(d["aging_bucket"], 0) + d["receivable_balance"] elif d["status"] == "completed": completed_amount += d["amount"] owners = [r[0] for r in db.query(CashPlan.owner).filter( CashPlan.entity_id == entity_id, CashPlan.plan_type == "receive", CashPlan.owner.isnot(None), CashPlan.owner != "", ).distinct().order_by(CashPlan.owner.asc()).all()] return { "entity_id": entity_id, "total": len(items), "owners": owners, "summary": { "total_amount": round(total_amount, 2), "total_paid": round(total_paid, 2), "total_balance": round(total_amount - total_paid, 2), "pending_balance": round(pending_balance, 2), "overdue_count": overdue_count, "overdue_amount": round(overdue_amount, 2), "completed_amount": round(completed_amount, 2), "aging": {k: round(v, 2) for k, v in sorted(aging_amount.items(), key=lambda x: x[0])}, }, "data": items, } @router.post("/receivables/{plan_id}/payment") def api_register_payment(plan_id: int, data: dict, db: Session = Depends(get_db)): """回款登记 — 更新计划状态 + 自动消除到期未收款预警 + 联动催收行动/F_AR_DAYS""" plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first() if not plan: raise HTTPException(404, "应收计划不存在") if plan.plan_type != "receive": raise HTTPException(400, "仅应收(receive)计划支持回款登记") if plan.status == "cancelled": raise HTTPException(400, "已取消的计划不能登记回款") amount = float(data.get("amount", 0)) if amount <= 0: raise HTTPException(400, "回款金额必须大于0") paid_date_str = str(data.get("paid_date") or "")[:10] if not paid_date_str: paid_date_str = datetime.now().strftime("%Y-%m-%d") try: paid_date = datetime.strptime(paid_date_str, "%Y-%m-%d") except Exception: raise HTTPException(400, "paid_date格式应为YYYY-MM-DD") balance = round((plan.amount or 0) - (plan.paid_amount or 0), 2) if amount > balance + 1e-9: raise HTTPException(400, f"回款金额{amount}万超过应收余额{balance}万") plan.paid_amount = round((plan.paid_amount or 0) + amount, 2) if plan.paid_amount >= (plan.amount or 0) - 1e-9: plan.paid_amount = plan.amount plan.status = "completed" plan.completed_at = paid_date else: plan.status = "pending" plan.completed_at = None db.commit() entity_id = plan.entity_id resolved = _resolve_plan_alerts(db, entity_id, plan.id, amount) _sync_collection_action_plan(db, entity_id) _update_ar_days_kpi(db, entity_id) db.commit() db.refresh(plan) logger.info(f"回款登记 #{plan.id} {plan.counterparty or ''} +{amount}万 → {plan.status}, 消除预警{resolved}条") return { "message": "回款登记成功", "resolved_alerts": resolved, "data": _plan_dict(plan), } @router.post("/import/bohai-ar") def api_import_bohai_ar(data: dict = None, entity_id: int = Depends(get_entity_id), db: Session = Depends(get_db)): """录入博海应收汇总表数据(¥2.3M, 按业务员)到cash_plans — 幂等,重复调用不重复导入""" if data is None: data = {} existing = db.query(CashPlan).filter( CashPlan.source == "bohai_ar", CashPlan.entity_id == entity_id, ).count() if existing and not data.get("force"): return {"message": "博海应收已录入,未重复导入", "existing": existing, "imported": 0} if existing: db.query(CashPlan).filter( CashPlan.source == "bohai_ar", CashPlan.entity_id == entity_id, ).delete() db.commit() plan_date = datetime(2026, 6, 30) # 2026H1期末余额基准日 imported = 0 total_wan = 0.0 for sp, yuan, note in BOHAI_AR_DATA: wan = round(yuan / 10000, 2) total_wan += wan db.add(CashPlan( entity_id=entity_id, plan_type="receive", amount=wan, plan_date=plan_date, counterparty=sp, description=f"博海应收款汇总表2026H1期末余额({note})", status="pending", owner=sp, source="bohai_ar", paid_amount=0, )) imported += 1 db.commit() # 触发预警检查生成到期未收款预警 + 联动催收行动/F_AR_DAYS try: check_cash_alerts(db, entity_id=entity_id) except Exception as e: logger.warning(f"导入后预警检查失败: {e}") _sync_collection_action_plan(db, entity_id) _update_ar_days_kpi(db, entity_id) db.commit() return { "message": "博海应收已录入", "imported": imported, "total_amount_wan": round(total_wan, 2), "entity_id": entity_id, }