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
+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": "建议已忽略"}