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
337 lines
12 KiB
Python
337 lines
12 KiB
Python
"""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": "建议已忽略"}
|