feat: 路线图R1决策建议一键落地+R2机会推送+R5预算闭环

R1(P0): AI建议一键应用到KPI/预算/行动方案
- 新表 ai_suggestions + AISuggestion 模型(init_db自动建)
- /api/cma/ai/suggestions CRUD + /{id}/apply(复用kpis/budget/action_plans) + dismiss
- 应用写 OperationLog(action=ai_suggestion_apply, detail含suggestion_id/before/after)
- 规则驱动建议生成 generate_rule_suggestions(低执行率/高执行率/预算超支/pending预警)
- 幂等: 同entity+type+target_id+title+unapplied不重复建; applied后拒绝重复应用
- 前端: Dashboard AI面板建议卡(应用到/忽略) + 建议中心页 /ai-suggestions

R2(P1): 数据找人扩大-机会类推送
- scripts/opportunity_detector.py: KPI向好(执行率>110%)/预算余量(<70%且actual>0)/预测上行
- scripts/daily_push.py: 异常+机会 每日9:15推企微(8800/send, --dry-run调试)
- crontab: 15 9 * * * (alert_generator 9:00之后)

R5(P0): 预算闭环加固
- auto-decompose批量幂等: 只取年度行(period=YYYY-00)+同KPI多版本取一行
- scripts/closed_loop_check.py: 预算执行率异常→检查现金流/行动同步→缺失提示+报告
- scripts/verify_decompose_idempotent.py: 幂等验证脚本

测试: test_ai_suggestions(10例)+test_roadmap_r2r5(14例); 修test_budget幂等契约适配年度行
全量: 673 passed
This commit is contained in:
Hermes CI Fix
2026-08-30 12:05:36 +08:00
parent 5b920df8a0
commit ad68471b29
22 changed files with 2170 additions and 25 deletions
+201 -10
View File
@@ -2,17 +2,185 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import func, text as sa_text
from sqlalchemy import func, text as sa_text, or_
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, StrategicMap, User, ActionPlan
from app.models import KPIDefinition, KPIValue, KPIAlert, StrategicMap, User, ActionPlan, BudgetPlan, AISuggestion
from app.utils.cache import get as cache_get, set as cache_set
import json, hashlib, httpx, os
from datetime import datetime
from datetime import datetime, date
router = APIRouter(prefix="/api/cma/ai", tags=["AI分析"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
# ============================================================
# R1 决策建议生成(规则驱动,稳定可复现,落库 ai_suggestions
# ============================================================
def _sug_dict(s: AISuggestion) -> dict:
return {
"id": s.id,
"entity_id": s.entity_id,
"source": s.source,
"suggestion_type": s.suggestion_type,
"target_type": s.target_type,
"target_id": s.target_id,
"title": s.title,
"content": s.content,
"suggestion_data": s.suggestion_data or {},
"status": s.status,
"applied_by": s.applied_by,
"applied_at": s.applied_at.isoformat() if s.applied_at else None,
"apply_detail": s.apply_detail or [],
"created_at": s.created_at.isoformat() if s.created_at else None,
}
def _existing_unapplied(db: Session, entity_id: int, suggestion_type: str,
target_id: int, title: str) -> bool:
"""幂等:同entity+类型+目标+标题的未应用建议存在则跳过"""
return db.query(AISuggestion).filter(
AISuggestion.entity_id == entity_id,
AISuggestion.suggestion_type == suggestion_type,
AISuggestion.target_id == target_id,
AISuggestion.title == title,
AISuggestion.status == "unapplied",
).first() is not None
def generate_rule_suggestions(db: Session, entity_id: int,
source: str = "dashboard", user_id: int = None,
kpi_id: int = None) -> list:
"""从数据规则生成决策建议并落库(R1,路线图2026-08-30
规则:
1. KPI执行率<70% → 建议建行动方案(异常类)
2. KPI执行率>110% → 建议上调KPI目标(机会类)
3. 预算执行率>110% → 建议调预算(预算类)
4. 有pending预警 → 建议建行动方案处理预警
幂等:同 entity+type+target_id+title+status=unapplied 不重复建。
"""
now = datetime.now()
period = now.strftime("%Y-%m")
created = []
def _add(suggestion_type: str, target_type: str, tid: int,
title: str, content: str, suggestion_data: dict):
nonlocal created
if _existing_unapplied(db, entity_id, suggestion_type, tid, title):
return
sug = AISuggestion(
entity_id=entity_id,
user_id=user_id,
source=source,
suggestion_type=suggestion_type,
target_type=target_type,
target_id=tid,
title=title,
content=content,
suggestion_data=suggestion_data,
status="unapplied",
)
db.add(sug)
created.append(sug)
# 查询KPI(可按kpi_id过滤)
q = db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active")
if kpi_id:
q = q.filter(KPIDefinition.id == kpi_id)
kpis = q.all()
for k in kpis:
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
or_(
KPIValue.entity_id == entity_id,
KPIValue.entity_id.is_(None),
),
).order_by(KPIValue.period.desc()).first()
if not latest or latest.actual_value is None:
continue
actual = latest.actual_value
target = k.target_value
ratio = (actual / target) if target else None
# 1. 异常:执行率<70% → 建行动方案
if ratio is not None and ratio < 0.7:
title = f"提升 {k.kpi_name}:达成率仅{ratio*100:.0f}%"
content = (f"KPI[{k.kpi_name}] 最新期间{latest.period}实际值{actual:g}"
f"目标{target:g},达成率{ratio*100:.1f}%,低于70%预警线。"
f"建议制定专项改善行动方案。")
_add("action_plan", "kpi", k.id, title, content, {
"kpi_id": k.id, "priority": "high",
"title": f"改善: {k.kpi_name}达成率提升",
})
# 2. 机会:执行率>110% → 上调KPI目标
elif ratio is not None and ratio > 1.1:
new_target = round(actual * 1.05, 2)
title = f"上调 {k.kpi_name} 目标:达成率{ratio*100:.0f}%超预期"
content = (f"KPI[{k.kpi_name}] 达成率{ratio*100:.1f}%超过110%"
f"建议将目标从{target:g}上调至{new_target:g},保持牵引力。")
_add("kpi_target", "kpi", k.id, title, content, {
"kpi_id": k.id, "target_value": new_target,
})
# 3. 预算执行率>110% → 调预算
budget_rows = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.status == "active",
BudgetPlan.period == period,
).all()
for b in budget_rows:
actual = db.query(func.max(KPIValue.actual_value)).filter(
KPIValue.kpi_id == b.kpi_id,
KPIValue.period == b.period,
).scalar()
if actual is None or b.budget_value is None or b.budget_value <= 0:
continue
exec_ratio = actual / b.budget_value
if exec_ratio > 1.1:
kpi_name = "KPI"
k = db.query(KPIDefinition).filter(KPIDefinition.id == b.kpi_id).first()
if k:
kpi_name = k.kpi_name
title = f"调整 {kpi_name} 预算:执行率{exec_ratio*100:.0f}%超预算"
content = (f"预算[{kpi_name}] {period}预算值{b.budget_value:g}"
f"实际{actual:g},执行率{exec_ratio*100:.1f}%超过110%。"
f"建议同步调整预算/现金流/行动方案。")
_add("budget_adjust", "budget", b.kpi_id, title, content, {
"kpi_id": b.kpi_id, "period": period, "budget_value": round(actual, 2),
})
# 4. pending预警 → 建行动方案
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending").all()
for a in alerts:
k = db.query(KPIDefinition).filter(KPIDefinition.id == a.kpi_id).first()
kpi_name = k.kpi_name if k else f"KPI#{a.kpi_id}"
title = f"处理预警:{kpi_name} {a.alert_message[:30]}"
content = f"存在待处理预警({a.alert_level}级):{a.alert_message}。建议建立行动方案跟进。"
_add("action_plan", "alert", a.id, title, content, {
"kpi_id": a.kpi_id, "priority": "high" if a.alert_level == "red" else "medium",
"alert_id": a.id,
"title": f"处理预警: {kpi_name}",
})
if created:
db.commit()
for s in created:
db.refresh(s)
return created
def _unapplied_suggestions(db: Session, entity_id: int, limit: int = 20) -> list:
items = db.query(AISuggestion).filter(
AISuggestion.entity_id == entity_id,
AISuggestion.status == "unapplied",
).order_by(AISuggestion.created_at.desc()).limit(limit).all()
return [_sug_dict(s) for s in items]
async def _call_deepseek(prompt: str) -> str:
"""调用DeepSeek API"""
api_key = os.getenv("DEEPSEEK_API_KEY", "sk-8e24e6eb87f2475e96ea0980002dc2e8")
@@ -34,15 +202,23 @@ async def _call_deepseek(prompt: str) -> str:
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
@router.get("/dashboard-analysis")
async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get_db)):
async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""AI分析驾驶舱数据"""
# 尝试缓存
cache_key = f"dashboard_analysis:{role}"
cache_key = f"dashboard_analysis:{role}:{entity_id}"
cached = cache_get("ai", cache_key)
if cached:
# 缓存命中(LLM文本10分钟内不重复调用),但轻量规则建议仍执行(幂等)
try:
generate_rule_suggestions(db, entity_id, source="dashboard")
except Exception:
pass
cached["suggestions"] = _unapplied_suggestions(db, entity_id)
return cached
# 获取当前KPI数据
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
kpis = db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active").all()
kpi_summary = []
for k in kpis:
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first()
@@ -81,17 +257,25 @@ async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get
except Exception as e:
analysis = f"AI分析暂时不可用: {str(e)}"
result = {"analysis": analysis, "kpi_count": len(kpi_summary), "alert_count": alerts}
# R1: 规则驱动生成可落地决策建议(幂等落库)
try:
generate_rule_suggestions(db, entity_id, source="dashboard")
except Exception as e:
pass
result = {"analysis": analysis, "kpi_count": len(kpi_summary), "alert_count": alerts,
"suggestions": _unapplied_suggestions(db, entity_id)}
# 缓存10分钟
cache_set("ai", cache_key, result, ttl_seconds=600)
return result
@router.get("/kpi-analysis/{kpi_id}")
async def kpi_analysis(kpi_id: int, db: Session = Depends(get_db)):
async def kpi_analysis(kpi_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""AI分析单个KPI"""
# 尝试缓存
cache_key = f"kpi_analysis:{kpi_id}"
cache_key = f"kpi_analysis:{kpi_id}:{entity_id}"
cached = cache_get("ai", cache_key)
if cached:
return cached
@@ -129,7 +313,14 @@ KPI名称:{kpi.kpi_name}
except Exception as e:
analysis = f"分析暂时不可用: {str(e)}"
result = {"kpi_name": kpi.kpi_name, "analysis": analysis}
# R1: 生成该KPI的可落地建议
try:
generate_rule_suggestions(db, entity_id, source="kpi", kpi_id=kpi_id)
except Exception as e:
pass
result = {"kpi_name": kpi.kpi_name, "analysis": analysis,
"suggestions": _unapplied_suggestions(db, entity_id)}
cache_set("ai", cache_key, result, ttl_seconds=600)
return result
+336
View File
@@ -0,0 +1,336 @@
"""AI决策建议 — 一键应用到KPI/预算/行动方案 (路线图R1 2026-08-30)
北极星④决策闭环:AI建议 → 点击应用 → 写库变更 → OperationLog留痕 → 前端可查已应用/未应用。
应用动作复用现有 kpis/budget/action_plans 数据模型,不新建业务接口。
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import Optional
from datetime import datetime
from app.database import get_db
from app.deps import get_entity_id, resolve_entity_for_request
from app.auth_middleware import require_auth, require_role
from app.models import AISuggestion, KPIDefinition, KPIValue, OperationLog, BudgetPlan, ActionPlan
router = APIRouter(prefix="/api/cma/ai/suggestions", tags=["AI建议"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
def _sug_dict(s: AISuggestion) -> dict:
return {
"id": s.id,
"entity_id": s.entity_id,
"user_id": s.user_id,
"source": s.source,
"suggestion_type": s.suggestion_type,
"target_type": s.target_type,
"target_id": s.target_id,
"title": s.title,
"content": s.content,
"suggestion_data": s.suggestion_data or {},
"status": s.status,
"applied_by": s.applied_by,
"applied_at": s.applied_at.isoformat() if s.applied_at else None,
"apply_detail": s.apply_detail or [],
"created_at": s.created_at.isoformat() if s.created_at else None,
}
@router.post("")
def create_suggestion(
request: Request,
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""创建AI建议(前端AI分析/手动保存建议)"""
suggestion_type = data.get("suggestion_type") or data.get("type")
title = (data.get("title") or "").strip()
if not suggestion_type:
raise HTTPException(400, "缺少 suggestion_type (kpi_target/budget_adjust/action_plan)")
if not title:
raise HTTPException(400, "缺少 title")
entity_id = resolve_entity_for_request(request, data.get("entity_id") or 1)
sug = AISuggestion(
entity_id=entity_id,
user_id=getattr(current_user, "id", None),
source=data.get("source", "manual"),
suggestion_type=suggestion_type,
target_type=data.get("target_type", "kpi"),
target_id=data.get("target_id"),
title=title,
content=data.get("content"),
suggestion_data=data.get("suggestion_data") or {},
status="unapplied",
)
db.add(sug)
db.commit()
db.refresh(sug)
return {"success": True, "message": "建议已保存", "data": _sug_dict(sug)}
@router.get("")
def list_suggestions(
status: Optional[str] = Query(None, description="unapplied/applied/dismissed"),
suggestion_type: Optional[str] = Query(None),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""建议列表(前端建议中心/详情页查看已应用/未应用状态)"""
query = db.query(AISuggestion).filter(AISuggestion.entity_id == entity_id)
if status:
query = query.filter(AISuggestion.status == status)
if suggestion_type:
query = query.filter(AISuggestion.suggestion_type == suggestion_type)
items = query.order_by(AISuggestion.created_at.desc()).limit(200).all()
return {"data": [_sug_dict(s) for s in items], "total": len(items)}
@router.get("/{suggestion_id}")
def get_suggestion(suggestion_id: int, db: Session = Depends(get_db)):
"""建议详情"""
s = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
if not s:
raise HTTPException(404, "建议不存在")
return {"data": _sug_dict(s)}
def _apply_kpi_target(db: Session, sug: AISuggestion, params: dict, current_user) -> dict:
"""改KPI目标"""
target_value = params.get("target_value")
if target_value is None:
raise HTTPException(400, "应用kpi_target需要 target_value")
kpi_id = params.get("kpi_id") or sug.target_id
if not kpi_id:
raise HTTPException(400, "缺少 kpi_id")
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, f"KPI {kpi_id} 不存在")
before = kpi.target_value
kpi.target_value = float(target_value)
db.flush()
detail_item = {
"target_type": "kpi",
"target_id": kpi.id,
"target_name": kpi.kpi_name,
"action": "update_target_value",
"before": before,
"after": float(target_value),
}
db.add(OperationLog(
user_id=getattr(current_user, "id", None),
action="ai_suggestion_apply",
target_type="kpi",
target_id=kpi.id,
detail={
"suggestion_id": sug.id,
"suggestion_title": sug.title,
"apply_action": "kpi_target",
"before": before,
"after": float(target_value),
},
))
return detail_item
def _apply_budget_adjust(db: Session, sug: AISuggestion, params: dict, current_user) -> dict:
"""调预算(BudgetPlan upsert,按 kpi_id+period"""
period = params.get("period")
budget_value = params.get("budget_value")
if not period or budget_value is None:
raise HTTPException(400, "应用budget_adjust需要 period + budget_value")
kpi_id = params.get("kpi_id") or sug.target_id
if not kpi_id:
raise HTTPException(400, "缺少 kpi_id")
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, f"KPI {kpi_id} 不存在")
# 解析期间 2026-09 → year=2026 month=9
try:
parts = period.split("-")
year = int(parts[0])
month = int(parts[1])
except Exception:
raise HTTPException(400, f"period格式错误: {period} (需要 YYYY-MM)")
plan = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == sug.entity_id,
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.period == period,
BudgetPlan.budget_year == year,
BudgetPlan.budget_month == month,
BudgetPlan.status == "active",
).first()
before = None
if plan:
before = plan.budget_value
plan.budget_value = float(budget_value)
else:
plan = BudgetPlan(
entity_id=sug.entity_id,
kpi_id=kpi_id,
period=period,
budget_value=float(budget_value),
budget_year=year,
budget_month=month,
version="v1.0",
status="active",
source_type="ai_suggestion",
calc_logic=f"AI建议应用 #{sug.id}: {sug.title}",
created_by=getattr(current_user, "name", "") or "",
)
db.add(plan)
db.flush()
detail_item = {
"target_type": "budget",
"target_id": plan.id,
"target_name": f"{kpi.kpi_name}[{period}]",
"action": "update_budget" if before is not None else "create_budget",
"before": before,
"after": float(budget_value),
}
db.add(OperationLog(
user_id=getattr(current_user, "id", None),
action="ai_suggestion_apply",
target_type="budget",
target_id=plan.id,
detail={
"suggestion_id": sug.id,
"suggestion_title": sug.title,
"apply_action": "budget_adjust",
"kpi_id": kpi_id,
"period": period,
"before": before,
"after": float(budget_value),
},
))
return detail_item
def _apply_action_plan(db: Session, sug: AISuggestion, params: dict, current_user) -> dict:
"""建行动方案"""
title = (params.get("title") or "").strip() or sug.title
kpi_id = params.get("kpi_id") or sug.target_id
if not kpi_id:
raise HTTPException(400, "缺少 kpi_id")
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, f"KPI {kpi_id} 不存在")
due_date = None
if params.get("due_date"):
try:
due_date = datetime.strptime(str(params["due_date"])[:10], "%Y-%m-%d")
except Exception:
due_date = None
plan = ActionPlan(
kpi_id=kpi_id,
title=title,
description=params.get("description") or sug.content or f"由AI建议 #{sug.id} 生成: {sug.title}",
assignee=params.get("assignee") or "",
priority=params.get("priority") or "medium",
due_date=due_date,
status="pending",
progress=0,
created_by=getattr(current_user, "name", "") or "ai_suggestion",
)
db.add(plan)
db.flush()
detail_item = {
"target_type": "action_plan",
"target_id": plan.id,
"target_name": title,
"action": "create_action_plan",
"before": None,
"after": plan.id,
}
db.add(OperationLog(
user_id=getattr(current_user, "id", None),
action="ai_suggestion_apply",
target_type="action_plan",
target_id=plan.id,
detail={
"suggestion_id": sug.id,
"suggestion_title": sug.title,
"apply_action": "action_plan",
"kpi_id": kpi_id,
"plan_title": title,
},
))
return detail_item
_APPLYERS = {
"kpi_target": _apply_kpi_target,
"budget_adjust": _apply_budget_adjust,
"action_plan": _apply_action_plan,
}
@router.post("/{suggestion_id}/apply")
def apply_suggestion(
suggestion_id: int,
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""应用建议:改KPI目标 / 调预算 / 建行动方案(写库+操作日志留痕)
Body 示例:
{"action": "kpi_target", "target_value": 2000000}
{"action": "budget_adjust", "period": "2026-09", "budget_value": 100000}
{"action": "action_plan", "title": "...", "assignee": "...", "priority": "high", "due_date": "2026-09-30"}
"""
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
if not sug:
raise HTTPException(404, "建议不存在")
if sug.status == "applied":
raise HTTPException(400, "该建议已应用,不能重复应用")
if sug.status == "dismissed":
raise HTTPException(400, "该建议已忽略,如需应用请重新创建")
action = data.get("action") or sug.suggestion_type
applier = _APPLYERS.get(action)
if not applier:
raise HTTPException(400, f"不支持的应用动作: {action} (支持 kpi_target/budget_adjust/action_plan)")
# 应用参数 = 请求体参数 覆盖 建议默认参数
params = dict(sug.suggestion_data or {})
params.update({k: v for k, v in data.items() if k != "action" and v is not None})
detail_item = applier(db, sug, params, current_user)
sug.status = "applied"
sug.applied_by = getattr(current_user, "name", "") or ""
sug.applied_user_id = getattr(current_user, "id", None)
sug.applied_at = datetime.now()
sug.apply_detail = [detail_item]
db.commit()
db.refresh(sug)
return {
"success": True,
"message": "建议已应用并留痕",
"data": _sug_dict(sug),
}
@router.post("/{suggestion_id}/dismiss")
def dismiss_suggestion(
suggestion_id: int,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""忽略建议"""
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
if not sug:
raise HTTPException(404, "建议不存在")
sug.status = "dismissed"
db.commit()
return {"success": True, "message": "建议已忽略"}
+2 -1
View File
@@ -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, kpi_governance, 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, tenant, 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, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, 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, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify
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
@@ -43,6 +43,7 @@ app.include_router(dashboard.router)
app.include_router(data.router)
app.include_router(alerts.router)
app.include_router(ai_analysis.router)
app.include_router(ai_suggestions.router)
app.include_router(alert_rules.router)
app.include_router(users.router)
app.include_router(thresholds.router)
+22
View File
@@ -911,3 +911,25 @@ class CashPlanUnclassified(Base):
status = Column(String(20), default="pending", comment="pending/classified/ignored")
created_at = Column(DateTime, server_default=func.now())
resolved_at = Column(DateTime, nullable=True)
class AISuggestion(Base):
"""AI决策建议 — 一键应用到KPI/预算/行动方案 (路线图R1 2026-08-30)"""
__tablename__ = "ai_suggestions"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID")
user_id = Column(Integer, nullable=True, comment="建议创建人ID")
source = Column(String(30), default="dashboard", comment="来源: dashboard/kpi/budget/manual/rule")
suggestion_type = Column(String(30), nullable=False, comment="kpi_target/budget_adjust/action_plan")
target_type = Column(String(30), nullable=False, comment="kpi/budget/action_plan")
target_id = Column(Integer, nullable=True, comment="目标ID (KPI ID/预算KPI ID等)")
title = Column(String(300), nullable=False, comment="建议标题")
content = Column(Text, nullable=True, comment="建议内容/理由")
suggestion_data = Column(JSON, nullable=True, comment="应用参数: {target_value, period, budget_value, plan_title, ...}")
status = Column(String(20), default="unapplied", comment="unapplied/applied/dismissed")
applied_by = Column(String(100), nullable=True, comment="应用人姓名")
applied_user_id = Column(Integer, nullable=True, comment="应用人ID")
applied_at = Column(DateTime, nullable=True, comment="应用时间")
apply_detail = Column(JSON, nullable=True, comment="应用结果明细: [{target_type,target_id,action,before,after}]")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
@@ -0,0 +1,27 @@
# 预算↔现金流↔行动 闭环自检报告
**检查时间**: 2026-08-30 11:43:16
## 账套 #1 · 期间 2026-08
- 🔴 营业收入(2026-08
预算 75 / 实际 150000 = 执行率 200000.0%(超预算)
现金流计划: 0 条 | 行动方案: 11 条
⚠️ 缺失: 现金流(本期间有其他计划但未关联本KPI)
💡 预算执行率200000%异常,请同步现金流情况核对(营业收入 2026-08)
- 🟡 净利润(2026-08
预算 16.67 / 实际 0 = 执行率 0.0%(低执行)
现金流计划: 0 条 | 行动方案: 3 条
⚠️ 缺失: 现金流(本期间有其他计划但未关联本KPI)
💡 预算执行率0%异常,请同步现金流情况核对(净利润 2026-08)
- 🔴 渠补率(2026-08
预算 12.78 / 实际 75 = 执行率 586.9%(超预算)
现金流计划: 0 条 | 行动方案: 4 条
⚠️ 缺失: 现金流(本期间有其他计划但未关联本KPI)
💡 预算执行率587%异常,请同步现金流情况核对(渠补率 2026-08)
- 🟡 经营性现金流(2026-08
预算 16.67 / 实际 -93000 = 执行率 -557888.4%(低执行)
现金流计划: 0 条 | 行动方案: 0 条
⚠️ 缺失: 现金流(本期间有其他计划但未关联本KPI)、行动方案
💡 预算执行率-557888%异常,请同步现金流情况核对、行动方案(经营性现金流 2026-08)
---
共发现异常 4 项
+188
View File
@@ -0,0 +1,188 @@
"""预算↔现金流↔行动 三闭环异常自检 — 路线图R5 (2026-08-30)
预算闭环加固预算执行率异常<70% >110%的KPI
检查是否同步了 现金流计划(CashPlan) 行动方案(ActionPlan)
缺失则输出提示防止"预算改了,现金流/行动没跟上"
输出控制台 + reports/closed_loop_check_YYYYMMDD.md
用法: /root/cma-management/backend/venv/bin/python3 scripts/closed_loop_check.py [--period 2026-08] [--push]
"""
import sys
import os
import json
import argparse
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import get_session_local
from app.models import KPIDefinition, KPIValue, BudgetPlan, CashPlan, ActionPlan
LOW_RATIO = 0.7
HIGH_RATIO = 1.1
REPORTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "reports")
def check_entity(db, entity_id: int, period: str) -> dict:
"""检测一个账套的闭环状态"""
issues = []
rows = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.status == "active",
BudgetPlan.period == period,
BudgetPlan.budget_value > 0,
).all()
seen = set()
for b in rows:
key = (b.kpi_id, b.period)
if key in seen:
continue
seen.add(key)
k = db.query(KPIDefinition).filter(KPIDefinition.id == b.kpi_id).first()
kpi_name = k.kpi_name if k else f"KPI#{b.kpi_id}"
actual = db.query(KPIValue).filter(
KPIValue.kpi_id == b.kpi_id,
KPIValue.period == b.period,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.calculated_at.desc()).first()
actual_val = actual.actual_value if actual else None
if actual_val is None:
continue
ratio = actual_val / b.budget_value
abnormal = ratio < LOW_RATIO or ratio > HIGH_RATIO
if not abnormal:
continue
# 现金流检查:该KPI该期间是否有收付款计划(related_kpi_id 或 budget_plan_id 关联)
period_start = datetime.strptime(period + "-01", "%Y-%m-%d")
if period.endswith("-12"):
period_end = datetime(period_start.year + 1, 1, 1)
else:
period_end = datetime(period_start.year, period_start.month + 1, 1)
cash_plans = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.status.in_(["pending", "completed"]),
CashPlan.plan_date >= period_start,
CashPlan.plan_date < period_end,
).filter(
(CashPlan.related_kpi_id == b.kpi_id) | (CashPlan.budget_plan_id == b.id)
).count()
# 兜底:无关联但期间内有任意现金流计划也算基本闭环
any_cash = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.status.in_(["pending", "completed"]),
CashPlan.plan_date >= period_start,
CashPlan.plan_date < period_end,
).count()
# 行动检查:该KPI是否有非完成的行动方案
actions = db.query(ActionPlan).filter(
ActionPlan.kpi_id == b.kpi_id,
ActionPlan.status.in_(["pending", "in_progress"]),
).count()
missing = []
if cash_plans == 0:
if any_cash > 0:
missing.append("现金流(本期间有其他计划但未关联本KPI)")
else:
missing.append("现金流")
if actions == 0:
missing.append("行动方案")
level = "critical" if ratio > HIGH_RATIO else "warning"
issues.append({
"kpi_id": b.kpi_id,
"kpi_name": kpi_name,
"period": period,
"budget_value": b.budget_value,
"actual_value": actual_val,
"exec_ratio": round(ratio * 100, 1),
"abnormal_type": "超预算" if ratio > HIGH_RATIO else "低执行",
"level": level,
"cash_plan_count": cash_plans,
"action_plan_count": actions,
"missing": missing,
"suggestion": (
f"预算执行率{ratio*100:.0f}%异常,请同步"
+ ("现金流计划" if "现金流" in missing else "现金流情况核对")
+ ("、行动方案" if "行动方案" in missing else "")
+ f"{kpi_name} {period}"
),
})
return {"entity_id": entity_id, "period": period, "issues": issues}
def build_report(results: list, checked_at: str) -> str:
lines = [f"# 预算↔现金流↔行动 闭环自检报告", f"**检查时间**: {checked_at}", ""]
total_issues = 0
for r in results:
lines.append(f"## 账套 #{r['entity_id']} · 期间 {r['period']}")
if not r["issues"]:
lines.append("- ✅ 无预算执行率异常")
for it in r["issues"]:
total_issues += 1
icon = "🔴" if it["level"] == "critical" else "🟡"
lines.append(f"- {icon} {it['kpi_name']}{it['period']}")
lines.append(f" 预算 {it['budget_value']:g} / 实际 {it['actual_value']:g} = 执行率 {it['exec_ratio']}%{it['abnormal_type']}")
lines.append(f" 现金流计划: {it['cash_plan_count']} 条 | 行动方案: {it['action_plan_count']}")
if it["missing"]:
lines.append(f" ⚠️ 缺失: {''.join(it['missing'])}")
lines.append(f" 💡 {it['suggestion']}")
else:
lines.append(f" ✅ 三闭环已同步")
lines.append("")
lines.append(f"---")
lines.append(f"共发现异常 {total_issues}")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--period", default=datetime.now().strftime("%Y-%m"))
parser.add_argument("--entity-id", type=int, default=1)
parser.add_argument("--push", action="store_true", help="异常时推送企微(8800/send)")
args = parser.parse_args()
os.makedirs(REPORTS_DIR, exist_ok=True)
checked_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
db = get_session_local()()
try:
result = check_entity(db, args.entity_id, args.period)
report = build_report([result], checked_at)
print(report)
# 写报告文件
fname = f"closed_loop_check_{datetime.now().strftime('%Y%m%d')}.md"
fpath = os.path.join(REPORTS_DIR, fname)
with open(fpath, "w", encoding="utf-8") as f:
f.write(report)
print(f"\n📄 报告已写入: {fpath}")
# 异常推送
if args.push and result["issues"]:
try:
import urllib.request
import urllib.parse
content = f"## 🔄 预算闭环自检({args.period})\n"
for it in result["issues"][:10]:
content += f"- {it['kpi_name']} 执行率{it['exec_ratio']}% 缺{'/'.join(it['missing']) or ''}\n"
content += f"\n{len(result['issues'])}项异常,详见系统报告"
data = urllib.parse.urlencode({"msg": content, "source": "管理会计OS"}).encode("utf-8")
req = urllib.request.Request("http://127.0.0.1:8800/send", data=data)
with urllib.request.urlopen(req, timeout=15) as resp:
print("推送:", resp.read().decode()[:200])
except Exception as e:
print(f"推送失败: {e}")
finally:
db.close()
if __name__ == "__main__":
main()
+125
View File
@@ -0,0 +1,125 @@
"""每日数据找人推送 — 路线图R2 (2026-08-30)
北极星③主动推送扩大 异常 + 机会两类
- 异常类待处理预警kpi_alerts pending
- 机会类KPI向好 / 预算余量 / 预测上行opportunity_detector
复用企微通道 8800/send公司群中继服务
用法: /root/cma-management/backend/venv/bin/python3 scripts/daily_push.py [--dry-run]
cron: 15 9 * * * (alert_generator 9:00 之后)
"""
import sys
import os
import json
import logging
import argparse
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import get_session_local
from app.models import KPIDefinition, KPIAlert
from scripts.opportunity_detector import detect_all, flatten
logger = logging.getLogger("cma.daily_push")
RELAY_URL = "http://127.0.0.1:8800/send"
SOURCE = "管理会计OS"
def collect_exceptions(db, limit: int = 10) -> list:
"""异常类:待处理预警(red/yellow)"""
out = []
alerts = db.query(KPIAlert).filter(
KPIAlert.status == "pending",
KPIAlert.alert_level.in_(["red", "yellow"]),
).order_by(KPIAlert.created_at.desc()).limit(limit).all()
for a in alerts:
k = db.query(KPIDefinition).filter(KPIDefinition.id == a.kpi_id).first()
kpi_name = k.kpi_name if k else f"KPI#{a.kpi_id}"
icon = "🔴" if a.alert_level == "red" else "🟡"
out.append({
"type": "exception",
"title": f"{icon} {kpi_name} 预警",
"detail": f"({a.alert_level}) {a.alert_message}",
"kpi_id": a.kpi_id,
"kpi_name": kpi_name,
"period": "",
})
return out
def build_message(exceptions: list, opportunities: list) -> str:
"""组装 markdown 推送内容"""
now = datetime.now().strftime("%Y-%m-%d %H:%M")
lines = [f"## 📊 管理会计OS · 每日经营播报", f"**{now}**", ""]
lines.append("### ⚠️ 异常关注")
if exceptions:
for e in exceptions:
lines.append(f"- {e['title']}")
lines.append(f" {e['detail']}")
else:
lines.append("- 今日无待处理预警 ✅")
lines.append("")
lines.append("### 🎯 机会发现")
if opportunities:
for o in opportunities:
lines.append(f"- {o['title']}")
lines.append(f" {o['detail']}")
else:
lines.append("- 今日暂无显著机会")
lines.append("")
lines.append("---")
lines.append("💡 数据找人:异常要处理,机会要把握。详情见 CMA 系统。")
return "\n".join(lines)
def push_wecom(msg: str) -> dict:
"""通过8800中继推送企微"""
import urllib.request
import urllib.parse
data = urllib.parse.urlencode({
"msg": msg,
"source": SOURCE,
"msgtype": "markdown",
}).encode("utf-8")
req = urllib.request.Request(RELAY_URL, data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
result = json.loads(resp.read().decode("utf-8"))
return result
except Exception as e:
return {"ok": False, "error": f"推送异常: {e}"}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true", help="只打印不推送")
parser.add_argument("--entity-id", type=int, default=1)
args = parser.parse_args()
db = get_session_local()()
try:
exceptions = collect_exceptions(db)
opportunities = flatten(detect_all(db, args.entity_id))
msg = build_message(exceptions, opportunities)
if args.dry_run:
print(msg)
print(f"\n[DRY-RUN] 异常{len(exceptions)}条 / 机会{len(opportunities)}")
return
result = push_wecom(msg)
print(f"推送结果: {json.dumps(result, ensure_ascii=False)}")
print(f"统计: 异常{len(exceptions)}条 / 机会{len(opportunities)}")
finally:
db.close()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
main()
+169
View File
@@ -0,0 +1,169 @@
"""机会检测器 — 路线图R2 数据找人扩大 (2026-08-30)
北极星③主动推送扩大 异常 + 机会两类
本脚本检测三类机会复用 budget/kpi 数据不新建表
1. KPI向好 (kpi_improving) : 最近3期执行率>110% 且最新期呈上升趋势
2. 预算余量 (budget_headroom): 可用预算>30%预算执行率<70%
3. 滚动机会 (rolling_up) : 预测值上升kpi_forecast_log 最新>上期
输出机会列表 [{type, title, detail, kpi_id, kpi_name, period}]
"""
import sys
import os
import json
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import get_session_local
from app.models import KPIDefinition, KPIValue, BudgetPlan, KpiForecastLog
HIGH_RATIO = 1.1 # 执行率>110% = 超预期
LOW_EXEC_RATIO = 0.7 # 执行率<70% = 预算余量大(可用>30%
def _exec_ratio(actual, target):
if target is None or target == 0:
return None
return actual / target
def detect_kpi_improving(db, entity_id: int, min_ratio: float = HIGH_RATIO) -> list:
"""KPI向好:最近3期执行率均>110%,且最新期>上期(上升中)"""
out = []
kpis = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active",
).all()
now = datetime.now()
for k in kpis:
if not k.target_value or k.target_value <= 0:
continue
vals = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).limit(3).all()
if len(vals) < 3:
continue
ratios = [_exec_ratio(v.actual_value, k.target_value) for v in vals]
if any(r is None or r < min_ratio for r in ratios):
continue
# 最新期 > 上期(上升趋势);若最新期低于上期但整体仍>110%,也算(持续向好)
latest, prev = vals[0], vals[1]
trend = "上升" if latest.actual_value > prev.actual_value else "高位"
out.append({
"type": "kpi_improving",
"title": f"📈 {k.kpi_name} 持续向好",
"detail": (f"{latest.period}实际{latest.actual_value:g}/目标{k.target_value:g}"
f" 达成率{ratios[0]*100:.0f}%({trend}),近3期均超110%"),
"kpi_id": k.id,
"kpi_name": k.kpi_name,
"period": latest.period,
})
return out
def detect_budget_headroom(db, entity_id: int, max_ratio: float = LOW_EXEC_RATIO) -> list:
"""预算余量:当月预算执行率<70%(可用预算>30%
注意跳过实际值为负的行现金流/利润为负是异常不是余量
KPI 同期间多版本预算只取一条去重
"""
out = []
period = datetime.now().strftime("%Y-%m")
rows = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.status == "active",
BudgetPlan.period == period,
BudgetPlan.budget_value > 0,
).all()
seen = set()
for b in rows:
key = (b.kpi_id, b.period)
if key in seen:
continue
seen.add(key)
actual = db.query(KPIValue).filter(
KPIValue.kpi_id == b.kpi_id,
KPIValue.period == b.period,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.calculated_at.desc()).first()
if not actual or actual.actual_value is None or actual.actual_value <= 0:
continue
ratio = actual.actual_value / b.budget_value
if ratio < max_ratio:
k = db.query(KPIDefinition).filter(KPIDefinition.id == b.kpi_id).first()
kpi_name = k.kpi_name if k else f"KPI#{b.kpi_id}"
headroom = (1 - ratio) * 100
out.append({
"type": "budget_headroom",
"title": f"💼 {kpi_name} 预算余量充足",
"detail": (f"{period}预算{b.budget_value:g}/实际{actual.actual_value:g}"
f" 执行率{ratio*100:.0f}%,可用预算余量约{headroom:.0f}%"),
"kpi_id": b.kpi_id,
"kpi_name": kpi_name,
"period": period,
})
return out
def detect_rolling_up(db, entity_id: int) -> list:
"""滚动机会:预测值上升(最新预测 > 上期预测)"""
out = []
# 每个KPI取最近两条预测记录
kpi_ids = [r[0] for r in db.query(KpiForecastLog.kpi_id).filter(
KpiForecastLog.entity_id == entity_id).distinct().limit(50).all()]
for kid in kpi_ids:
rows = db.query(KpiForecastLog).filter(
KpiForecastLog.entity_id == entity_id,
KpiForecastLog.kpi_id == kid,
KpiForecastLog.forecast_value.isnot(None),
).order_by(KpiForecastLog.created_at.desc(), KpiForecastLog.id.desc()).limit(2).all()
if len(rows) < 2:
continue
latest, prev = rows[0], rows[1]
if latest.forecast_value > prev.forecast_value:
k = db.query(KPIDefinition).filter(KPIDefinition.id == kid).first()
kpi_name = k.kpi_name if k else f"KPI#{kid}"
pct = (latest.forecast_value / prev.forecast_value - 1) * 100 if prev.forecast_value else 0
out.append({
"type": "rolling_up",
"title": f"🔮 {kpi_name} 预测上行",
"detail": (f"预测值 {prev.forecast_value:g}{latest.forecast_value:g}"
f" (+{pct:.1f}%){latest.period}期间"),
"kpi_id": kid,
"kpi_name": kpi_name,
"period": latest.period,
})
return out
def detect_all(db, entity_id: int = 1) -> dict:
"""检测全部机会,按类型分组"""
return {
"kpi_improving": detect_kpi_improving(db, entity_id),
"budget_headroom": detect_budget_headroom(db, entity_id),
"rolling_up": detect_rolling_up(db, entity_id),
}
def flatten(detected: dict) -> list:
out = []
for cat in ("kpi_improving", "budget_headroom", "rolling_up"):
out.extend(detected.get(cat, []))
return out
def main():
db = get_session_local()()
try:
detected = detect_all(db)
total = sum(len(v) for v in detected.values())
print(json.dumps(detected, ensure_ascii=False, indent=2))
print(f"\n机会总数: {total}")
finally:
db.close()
if __name__ == "__main__":
main()
@@ -0,0 +1,56 @@
"""验证年度预算分解幂等 — R5 (2026-08-30)
调用 /api/cma/budget/auto-decompose 3 对比月度预算值是否不变
用法: cd /root/cma-management/backend && ./venv/bin/python3 scripts/verify_decompose_idempotent.py
"""
import sys
import os
import json
import urllib.request
BASE = os.getenv("CMA_BASE", "http://127.0.0.1:8010")
def post(path, body, token=None):
req = urllib.request.Request(
BASE + path,
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {token}" if token else ""},
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
def main():
# 登录(账套模式必须 entity_id)
login = post("/api/cma/auth/login", {"username": "admin", "password": "admin123", "entity_id": 1})
token = login.get("token") or login.get("access_token")
if not token:
print("❌ 登录失败:", login)
sys.exit(1)
print("✅ 登录成功")
runs = []
for i in range(3):
r = post("/api/cma/budget/auto-decompose", {"year": 2026, "method": "equal", "version": "v1.0"}, token)
print(f"{i+1}次: {r.get('message', '')} created={r.get('created', 0)}")
# 提取 (kpi_id -> monthly tuple)
snap = {}
for res in r.get("results", []):
snap[res["kpi_id"]] = tuple(res.get("monthly") or [])
runs.append(snap)
# 对比三次结果
same = runs[0] == runs[1] == runs[2]
print(f"\n三次结果一致: {'✅ 是(幂等)' if same else '❌ 否(不幂等)'}")
if not same:
for i in range(1, 3):
for kid in runs[0]:
if runs[0].get(kid) != runs[i].get(kid):
print(f" KPI {kid} 第1次={runs[0].get(kid)}{i+1}次={runs[i].get(kid)}")
sys.exit(0 if same else 1)
if __name__ == "__main__":
main()
+3
View File
@@ -82,9 +82,12 @@ import hashlib
@pytest.fixture(autouse=True)
def setup_db():
"""每个测试函数自动初始化和清理数据库"""
from app.utils import cache as cache_util
cache_util.delete("ai") # 清AI分析缓存,防测试间Redis污染(dashboard-analysis缓存全局共享)
Base.metadata.create_all(bind=TEST_ENGINE)
yield
Base.metadata.drop_all(bind=TEST_ENGINE)
cache_util.delete("ai")
@pytest.fixture
+242
View File
@@ -0,0 +1,242 @@
"""
路线图R1AI建议一键落地 测试
建议CRUD + 应用到KPI/预算/行动方案 + OperationLog留痕 + 已应用/未应用状态
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from datetime import datetime
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
from app.models import AISuggestion, KPIDefinition, BudgetPlan, ActionPlan, OperationLog, KPIValue
def _create_suggestion(client, token, kpi_id, **kw):
body = {
"suggestion_type": "kpi_target",
"target_type": "kpi",
"target_id": kpi_id,
"title": "上调测试KPI目标",
"content": "达成率超预期",
"suggestion_data": {"kpi_id": kpi_id, "target_value": 150.0},
}
body.update(kw)
return client.post("/api/cma/ai/suggestions", json=body, headers=auth_header(token))
class TestSuggestionCRUD:
def test_create_and_list(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
r = _create_suggestion(client, token, kpi.id)
assert r.status_code == 200, r.text
data = r.json()["data"]
assert data["status"] == "unapplied"
assert data["suggestion_type"] == "kpi_target"
# 列表含未应用
lst = client.get("/api/cma/ai/suggestions", headers=auth_header(token)).json()
assert lst["total"] == 1
assert lst["data"][0]["id"] == data["id"]
# 详情
det = client.get(f"/api/cma/ai/suggestions/{data['id']}", headers=auth_header(token)).json()
assert det["data"]["title"] == "上调测试KPI目标"
def test_create_missing_fields(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
r = client.post("/api/cma/ai/suggestions", json={"title": "无类型"}, headers=auth_header(token))
assert r.status_code == 400
r2 = client.post("/api/cma/ai/suggestions", json={"suggestion_type": "kpi_target"}, headers=auth_header(token))
assert r2.status_code == 400
def test_apply_kpi_target(self, client, db):
"""应用建议→改KPI目标→操作日志可查"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, target_value=100.0)
r = _create_suggestion(client, token, kpi.id)
sug_id = r.json()["data"]["id"]
# 应用:改KPI目标为150
app = client.post(
f"/api/cma/ai/suggestions/{sug_id}/apply",
json={"action": "kpi_target", "target_value": 150.0},
headers=auth_header(token),
)
assert app.status_code == 200, app.text
app_data = app.json()["data"]
assert app_data["status"] == "applied"
assert app_data["applied_by"] == "测试管理员"
assert app_data["apply_detail"][0]["before"] == 100.0
assert app_data["apply_detail"][0]["after"] == 150.0
# KPI目标已变更
db.refresh(kpi)
assert kpi.target_value == 150.0
# OperationLog留痕
logs = db.query(OperationLog).filter(OperationLog.action == "ai_suggestion_apply").all()
assert len(logs) == 1
assert logs[0].target_type == "kpi"
assert logs[0].target_id == kpi.id
assert logs[0].detail["suggestion_id"] == sug_id
assert logs[0].detail["before"] == 100.0
assert logs[0].detail["after"] == 150.0
# 重复应用被拒绝
app2 = client.post(
f"/api/cma/ai/suggestions/{sug_id}/apply",
json={"action": "kpi_target", "target_value": 200.0},
headers=auth_header(token),
)
assert app2.status_code == 400
def test_apply_budget_adjust(self, client, db):
"""应用建议→调预算(新建/更新BudgetPlan)→操作日志"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
r = _create_suggestion(client, token, kpi.id, suggestion_type="budget_adjust",
title="调整预算", suggestion_data={"kpi_id": kpi.id})
sug_id = r.json()["data"]["id"]
app = client.post(
f"/api/cma/ai/suggestions/{sug_id}/apply",
json={"action": "budget_adjust", "period": "2026-09", "budget_value": 8888.0},
headers=auth_header(token),
)
assert app.status_code == 200, app.text
plan = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == kpi.id, BudgetPlan.period == "2026-09").first()
assert plan is not None
assert plan.budget_value == 8888.0
assert plan.source_type == "ai_suggestion"
# 同期间再应用→更新而非新增
app2 = client.post(
f"/api/cma/ai/suggestions/{sug_id}/apply",
json={"action": "budget_adjust", "period": "2026-09", "budget_value": 9999.0},
headers=auth_header(token),
)
# 已applied被拒;用新建议验证upsert
r2 = _create_suggestion(client, token, kpi.id, suggestion_type="budget_adjust",
title="调整预算2", suggestion_data={"kpi_id": kpi.id})
sug_id2 = r2.json()["data"]["id"]
app3 = client.post(
f"/api/cma/ai/suggestions/{sug_id2}/apply",
json={"action": "budget_adjust", "period": "2026-09", "budget_value": 9999.0},
headers=auth_header(token),
)
assert app3.status_code == 200
plans = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == kpi.id, BudgetPlan.period == "2026-09").all()
assert len(plans) == 1
assert plans[0].budget_value == 9999.0
assert app3.json()["data"]["apply_detail"][0]["before"] == 8888.0
def test_apply_action_plan(self, client, db):
"""应用建议→建行动方案→操作日志"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
r = _create_suggestion(client, token, kpi.id, suggestion_type="action_plan",
title="建行动方案", suggestion_data={"kpi_id": kpi.id})
sug_id = r.json()["data"]["id"]
app = client.post(
f"/api/cma/ai/suggestions/{sug_id}/apply",
json={"action": "action_plan", "title": "营收提升专项", "assignee": "张三",
"priority": "high", "due_date": "2026-09-30"},
headers=auth_header(token),
)
assert app.status_code == 200, app.text
plan = db.query(ActionPlan).filter(ActionPlan.kpi_id == kpi.id, ActionPlan.title == "营收提升专项").first()
assert plan is not None
assert plan.assignee == "张三"
assert plan.priority == "high"
assert plan.created_by == "测试管理员"
logs = db.query(OperationLog).filter(OperationLog.action == "ai_suggestion_apply",
OperationLog.target_type == "action_plan").all()
assert len(logs) == 1
assert logs[0].target_id == plan.id
def test_dismiss(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
r = _create_suggestion(client, token, kpi.id)
sug_id = r.json()["data"]["id"]
d = client.post(f"/api/cma/ai/suggestions/{sug_id}/dismiss", headers=auth_header(token))
assert d.status_code == 200
det = client.get(f"/api/cma/ai/suggestions/{sug_id}", headers=auth_header(token)).json()
assert det["data"]["status"] == "dismissed"
# 忽略后应用被拒
app = client.post(f"/api/cma/ai/suggestions/{sug_id}/apply",
json={"action": "kpi_target", "target_value": 1}, headers=auth_header(token))
assert app.status_code == 400
def test_apply_not_found(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
app = client.post("/api/cma/ai/suggestions/9999/apply", json={}, headers=auth_header(token))
assert app.status_code == 404
class TestRuleSuggestions:
"""dashboard-analysis 自动生成建议(规则驱动)"""
def test_generate_low_ratio_action(self, client, db):
"""执行率<70% → 生成建行动方案建议"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, target_value=100.0)
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=50.0))
db.commit()
# 直接调规则生成
resp = client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
assert resp.status_code == 200
s = db.query(AISuggestion).filter(AISuggestion.target_id == kpi.id).all()
assert len(s) >= 1
assert any(x.suggestion_type == "action_plan" for x in s)
# 幂等:再调一次不重复建
resp2 = client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
s2 = db.query(AISuggestion).filter(AISuggestion.target_id == kpi.id).all()
assert len(s2) == len(s)
def test_generate_high_ratio_target(self, client, db):
"""执行率>110% → 生成上调目标建议"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, target_value=100.0)
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=150.0))
db.commit()
resp = client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
assert resp.status_code == 200
s = db.query(AISuggestion).filter(AISuggestion.target_id == kpi.id).all()
assert any(x.suggestion_type == "kpi_target" for x in s)
assert "suggestions" in resp.json()
def test_generate_budget_overrun(self, client, db):
"""预算执行率>110% → 生成调预算建议"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, target_value=100.0)
db.add(KPIValue(kpi_id=kpi.id, period="2026-08", actual_value=200.0))
db.add(BudgetPlan(entity_id=1, kpi_id=kpi.id, period="2026-08", budget_value=100.0,
budget_year=2026, budget_month=8, status="active"))
db.commit()
resp = client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
assert resp.status_code == 200
s = db.query(AISuggestion).filter(AISuggestion.suggestion_type == "budget_adjust").all()
assert len(s) >= 1
+2 -2
View File
@@ -663,9 +663,9 @@ class TestBudgetContract20260825:
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="CONTRACT_DECOMP")
# 先创建年度预算(period=2026-00 或任意月份记录,让批量分解能聚合到
# 先创建年度预算(period=YYYY-00 年度行,批量分解只取年度行 — 幂等契约
client.post(f"{self.BASE}/plans", headers=auth_header(token),
json={"kpi_id": kpi.id, "period": "2026-01", "budget_value": 12000.0, "budget_year": 2026, "budget_month": 1})
json={"kpi_id": kpi.id, "period": "2026-00", "budget_value": 12000.0, "budget_year": 2026, "budget_month": 0})
# 第一次批量分解
resp1 = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token),
+187
View File
@@ -0,0 +1,187 @@
"""
路线图R2/R5 测试2026-08-30
R2: 机会检测KPI向好/预算余量/预测上行
R5: 预算现金流行动 闭环自检
"""
import pytest
from datetime import datetime
from sqlalchemy.orm import Session
from tests.conftest import create_test_kpi
from app.models import KPIDefinition, KPIValue, BudgetPlan, CashPlan, ActionPlan, KpiForecastLog
from scripts.opportunity_detector import (
detect_kpi_improving, detect_budget_headroom, detect_rolling_up, detect_all, flatten,
)
from scripts.closed_loop_check import check_entity, build_report
def _kpi(db, code, target=100.0, **kw):
return create_test_kpi(db, kpi_code=code, target_value=target, **kw)
def _value(db, kpi_id, period, actual, entity_id=1):
v = KPIValue(kpi_id=kpi_id, period=period, actual_value=actual, entity_id=entity_id)
db.add(v)
return v
def _budget(db, kpi_id, period, value, year=None, month=None, entity_id=1):
if year is None:
year = int(period.split("-")[0])
month = int(period.split("-")[1])
b = BudgetPlan(entity_id=entity_id, kpi_id=kpi_id, period=period, budget_value=value,
budget_year=year, budget_month=month, version="v1.0", status="active")
db.add(b)
return b
class TestOpportunityR2:
def test_kpi_improving(self, db):
"""连续3期执行率>110% → KPI向好机会"""
kpi = _kpi(db, "OPP_01", target=100.0)
_value(db, kpi.id, "2026-04", 120.0)
_value(db, kpi.id, "2026-05", 130.0)
_value(db, kpi.id, "2026-06", 140.0)
db.commit()
out = detect_kpi_improving(db, 1)
assert len(out) == 1
assert out[0]["type"] == "kpi_improving"
assert out[0]["kpi_id"] == kpi.id
def test_kpi_improving_not_enough_data(self, db):
"""不足3期不判定"""
kpi = _kpi(db, "OPP_02", target=100.0)
_value(db, kpi.id, "2026-05", 130.0)
_value(db, kpi.id, "2026-06", 140.0)
db.commit()
assert detect_kpi_improving(db, 1) == []
def test_kpi_improving_low_ratio_skip(self, db):
"""执行率未超110%不判定"""
kpi = _kpi(db, "OPP_03", target=100.0)
_value(db, kpi.id, "2026-04", 90.0)
_value(db, kpi.id, "2026-05", 95.0)
_value(db, kpi.id, "2026-06", 100.0)
db.commit()
assert detect_kpi_improving(db, 1) == []
def test_budget_headroom(self, db):
"""当月预算执行率<70% → 预算余量机会"""
kpi = _kpi(db, "OPP_04", target=1000.0)
_value(db, kpi.id, "2026-08", 300.0)
_budget(db, kpi.id, "2026-08", 1000.0)
db.commit()
out = detect_budget_headroom(db, 1)
assert len(out) == 1
assert out[0]["type"] == "budget_headroom"
def test_budget_headroom_negative_skip(self, db):
"""实际值为负(现金流异常)不误判为余量"""
kpi = _kpi(db, "OPP_05", target=1000.0)
_value(db, kpi.id, "2026-08", -500.0)
_budget(db, kpi.id, "2026-08", 1000.0)
db.commit()
assert detect_budget_headroom(db, 1) == []
def test_budget_headroom_dedup(self, db):
"""同KPI同期间多版本预算只取一条"""
kpi = _kpi(db, "OPP_06", target=1000.0)
_value(db, kpi.id, "2026-08", 300.0)
_budget(db, kpi.id, "2026-08", 1000.0)
b2 = _budget(db, kpi.id, "2026-08", 2000.0)
b2.version = "v2.0"
db.commit()
assert len(detect_budget_headroom(db, 1)) == 1
def test_rolling_up(self, db):
"""预测值上升 → 滚动机会"""
kpi = _kpi(db, "OPP_07", target=100.0)
now = datetime.now()
db.add(KpiForecastLog(entity_id=1, kpi_id=kpi.id, kpi_code=kpi.kpi_code,
period="2026-07", forecast_value=100.0, model="linear",
created_at=now))
db.add(KpiForecastLog(entity_id=1, kpi_id=kpi.id, kpi_code=kpi.kpi_code,
period="2026-08", forecast_value=130.0, model="linear",
created_at=now))
db.commit()
out = detect_rolling_up(db, 1)
assert len(out) == 1
assert out[0]["type"] == "rolling_up"
def test_rolling_down_skip(self, db):
"""预测下降不判定为机会"""
kpi = _kpi(db, "OPP_08", target=100.0)
now = datetime.now()
db.add(KpiForecastLog(entity_id=1, kpi_id=kpi.id, kpi_code=kpi.kpi_code,
period="2026-07", forecast_value=130.0, model="linear",
created_at=now))
db.add(KpiForecastLog(entity_id=1, kpi_id=kpi.id, kpi_code=kpi.kpi_code,
period="2026-08", forecast_value=100.0, model="linear",
created_at=now))
db.commit()
assert detect_rolling_up(db, 1) == []
def test_flatten(self):
d = {"kpi_improving": [1], "budget_headroom": [2, 3], "rolling_up": []}
assert flatten(d) == [1, 2, 3]
class TestClosedLoopR5:
def test_overrun_missing_both(self, db):
"""超预算且缺现金流/行动 → 提示同步"""
kpi = _kpi(db, "CL_01", target=100.0)
_value(db, kpi.id, "2026-08", 200.0)
_budget(db, kpi.id, "2026-08", 100.0)
db.commit()
r = check_entity(db, 1, "2026-08")
assert len(r["issues"]) == 1
it = r["issues"][0]
assert it["abnormal_type"] == "超预算"
assert "现金流" in it["missing"]
assert "行动方案" in it["missing"]
def test_overrun_has_cash_and_action(self, db):
"""超预算但有现金流+行动 → 三闭环同步"""
kpi = _kpi(db, "CL_02", target=100.0)
_value(db, kpi.id, "2026-08", 200.0)
b = _budget(db, kpi.id, "2026-08", 100.0)
db.add(CashPlan(entity_id=1, plan_type="receive", related_kpi_id=kpi.id, budget_plan_id=b.id,
amount=200.0, plan_date=datetime(2026, 8, 15), status="pending"))
db.add(ActionPlan(kpi_id=kpi.id, title="改善计划", status="in_progress"))
db.commit()
r = check_entity(db, 1, "2026-08")
assert len(r["issues"]) == 1
assert r["issues"][0]["missing"] == []
def test_normal_no_issue(self, db):
"""执行率正常 → 无异常"""
kpi = _kpi(db, "CL_03", target=100.0)
_value(db, kpi.id, "2026-08", 100.0)
_budget(db, kpi.id, "2026-08", 100.0)
db.commit()
r = check_entity(db, 1, "2026-08")
assert r["issues"] == []
def test_low_execution(self, db):
"""低执行率 → 异常(warning"""
kpi = _kpi(db, "CL_04", target=100.0)
_value(db, kpi.id, "2026-08", 50.0)
_budget(db, kpi.id, "2026-08", 100.0)
db.commit()
r = check_entity(db, 1, "2026-08")
assert len(r["issues"]) == 1
assert r["issues"][0]["abnormal_type"] == "低执行"
assert r["issues"][0]["level"] == "warning"
def test_build_report(self):
result = {"entity_id": 1, "period": "2026-08", "issues": [
{"kpi_id": 1, "kpi_name": "营收", "period": "2026-08", "budget_value": 100.0,
"actual_value": 200.0, "exec_ratio": 200.0, "abnormal_type": "超预算",
"level": "critical", "cash_plan_count": 0, "action_plan_count": 0,
"missing": ["现金流", "行动方案"], "suggestion": "请同步现金流、行动方案"}
]}
report = build_report([result], "2026-08-30 12:00:00")
assert "闭环自检" in report
assert "营收" in report
assert "共发现异常 1 项" in report