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 import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session 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.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role 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 from app.utils.cache import get as cache_get, set as cache_set
import json, hashlib, httpx, os import json, hashlib, httpx, os
from datetime import datetime from datetime import datetime, date
router = APIRouter(prefix="/api/cma/ai", tags=["AI分析"], router = APIRouter(prefix="/api/cma/ai", tags=["AI分析"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))], 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: async def _call_deepseek(prompt: str) -> str:
"""调用DeepSeek API""" """调用DeepSeek API"""
api_key = os.getenv("DEEPSEEK_API_KEY", "sk-8e24e6eb87f2475e96ea0980002dc2e8") 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", "") return data.get("choices", [{}])[0].get("message", {}).get("content", "")
@router.get("/dashboard-analysis") @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分析驾驶舱数据""" """AI分析驾驶舱数据"""
# 尝试缓存 # 尝试缓存
cache_key = f"dashboard_analysis:{role}" cache_key = f"dashboard_analysis:{role}:{entity_id}"
cached = cache_get("ai", cache_key) cached = cache_get("ai", cache_key)
if cached: 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 return cached
# 获取当前KPI数据 # 获取当前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 = [] kpi_summary = []
for k in kpis: for k in kpis:
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first() 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: except Exception as e:
analysis = f"AI分析暂时不可用: {str(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分钟 # 缓存10分钟
cache_set("ai", cache_key, result, ttl_seconds=600) cache_set("ai", cache_key, result, ttl_seconds=600)
return result return result
@router.get("/kpi-analysis/{kpi_id}") @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""" """AI分析单个KPI"""
# 尝试缓存 # 尝试缓存
cache_key = f"kpi_analysis:{kpi_id}" cache_key = f"kpi_analysis:{kpi_id}:{entity_id}"
cached = cache_get("ai", cache_key) cached = cache_get("ai", cache_key)
if cached: if cached:
return cached return cached
@@ -129,7 +313,14 @@ KPI名称:{kpi.kpi_name}
except Exception as e: except Exception as e:
analysis = f"分析暂时不可用: {str(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) cache_set("ai", cache_key, result, ttl_seconds=600)
return result 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 fastapi.responses import JSONResponse
from dotenv import load_dotenv from dotenv import load_dotenv
from app.database import init_db 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 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 scripts.erp_sync import run_sync as run_erp_sync
from app.auth_middleware import require_auth from app.auth_middleware import require_auth
@@ -43,6 +43,7 @@ app.include_router(dashboard.router)
app.include_router(data.router) app.include_router(data.router)
app.include_router(alerts.router) app.include_router(alerts.router)
app.include_router(ai_analysis.router) app.include_router(ai_analysis.router)
app.include_router(ai_suggestions.router)
app.include_router(alert_rules.router) app.include_router(alert_rules.router)
app.include_router(users.router) app.include_router(users.router)
app.include_router(thresholds.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") status = Column(String(20), default="pending", comment="pending/classified/ignored")
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
resolved_at = Column(DateTime, nullable=True) 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) @pytest.fixture(autouse=True)
def setup_db(): 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) Base.metadata.create_all(bind=TEST_ENGINE)
yield yield
Base.metadata.drop_all(bind=TEST_ENGINE) Base.metadata.drop_all(bind=TEST_ENGINE)
cache_util.delete("ai")
@pytest.fixture @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) token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="CONTRACT_DECOMP") 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), 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), 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
+84
View File
@@ -0,0 +1,84 @@
# CMA 产品愿景与实施路线图 v1.02026-08-30 固化)
> 提出:任富海 | 整理:项目Bot | 状态:✅ 已确认(北极星)+ 战略全景(8方向)
## 一、产品愿景(北极星)
**一句话**:CMA = 管理会计操作系统——数据接入(接口/手工) → 多Bot互动数据 → 数据找人(主动) → 决策建议 → 决策修改+检查(闭环可审计)。
**四层北极星**
```
① 数据接入(财务软件接口/手工录入)
② 多Bot互动(14Bot协作处理数据)
③ 数据找人(异常+机会主动推送)
④ 决策建议 → 决策修改+检查(闭环)
```
**价值主张**:让管理团队从"被数据淹没"到"数据找人、人做决策、决策留痕"——每项决策可追溯(谁/何时/依据什么/结果如何)。
## 二、战略全景(8 补充方向)
| 方向 | 定位 | 优先级 |
|:--|:--|:--:|
| A 产品化/商业化 | CMA→可交付产品(SaaS/私有/实施) | 🔴 |
| B 行业纵深 | 白酒经销→贸易→制造(行业包) | 🟠 |
| C 数据资产化 | 博海+客户数据→数据产品(DAMA治理) | 🟠 |
| D 决策智能 | 提建议→预测决策(敏感性/因果/复盘) | 🔴 |
| E AI原生组织方法论 | **护城河**:14Bot/铁律/闭环体系产品化 | 🟡 |
| F 生态联盟 | 财务软件对接(用友/金蝶)+渠道 | 🟡 |
| G 信任合规 | PIPL/等保/AI可信/审计链 | 🟠 |
| H 技术前瞻 | 数字员工/AI同事/Agent自动执行 | 🟡 |
## 三、实施路线图(按优先级)
### 🔴 近期(1-3个月)——北极星核心闭环
| # | 方向 | 目标 | 关键动作 | 验收 |
|:--|:--|:--|:--|:--|
| R1 | ④决策智能 | AI建议→一键落地 | ai_analysis 建议可"应用到KPI/预算/行动"(写库+留痕) | 建议生成→点击落地→操作日志可查 |
| R2 | ③数据找人 | 主动推送扩大 | 机会/趋势推送(不止异常):KPI向好/预算余量/滚动机会 | 每日推送含异常+机会两类 |
| R3 | ①数据接入调研 | 财务软件接口方案 | 调研用友/金蝶/管家婆开放API+实施成本 | 接口可行性报告 |
| R4 | A产品化准备 | 酣客试点成案例 | 试点数据闭环+试点报告(作首个客户案例) | 案例文档+官网可引用 |
| R5 | 预算bug修复链 | 系统稳定 | 年度分解幂等+预算/现金流/行动闭环加固 | pytest全绿 |
### 🟠 中期(3-6个月)——产品化+合规
| # | 方向 | 目标 | 关键动作 | 验收 |
|:--|:--|:--|:--|:--|
| M1 | A产品化 | CMA可交付形态 | SaaS多租户完善/私有部署包/实施文档 | 第2-3个客户可用 |
| M2 | B行业复制 | 白酒经销行业包 | 行业KPI库/OKR模板/科目模板校准(酣客数据) | 行业包v1 |
| M3 | G合规 | 信任背书 | 数据安全分级/PIPL清单/审计链完善 | 合规清单 |
| M4 | ①数据接入落地 | 财务软件接口 | 按R3方案接入1个财务软件 | 接口联调通过 |
### 🟡 远期(6-12个月)——方法论+生态
| # | 方向 | 目标 | 关键动作 | 验收 |
|:--|:--|:--|:--|:--|
| F1 | E方法论 | AI原生组织产品 | 评估+实施+运营三件套方法论文档化 | 方法论v1可售 |
| F2 | F生态 | 渠道伙伴 | 代账/咨询/本地IT渠道首批 | 3家伙伴 |
| F3 | H前瞻 | 数字员工试点 | AI同事(自动执行例行决策)试点 | 试点报告 |
| F4 | D完整版 | 预测性成本智能完整 | 宏观数据回归校准+预测偏差告警完善 | IMA对标 |
## 四、依赖与飞轮
```
R4酣客案例 → M1产品化 → F1方法论 → F2生态
↑____________↑___________________↓
数据/案例反哺(B行业包)
```
**飞轮起点**:近期 R1-R5(决策闭环+稳定+案例)——先让内部系统达到"决策可落地可追溯",再谈产品化。
## 五、北极星四层 → 落地项(映射)
| 层 | 近期 | 中期 | 远期 |
|:--|:--|:--|:--|
| ①数据接入 | R3调研 | M4接口落地 | 多软件适配 |
| ②多Bot互动 | 保持 | Bot联合决策 | Agent自动执行(F3) |
| ③数据找人 | R2推送扩大 | 推送策略化 | AI同事(F3) |
| ④决策闭环 | R1建议落地+R5稳定 | 决策复盘闭环 | 预测决策(F4) |
## 六、节奏建议
- **月度检查点**:每月对照路线图验收(R/M/F 项完成度)
- **北极星校验**:每季度问"数据找人了吗?决策落地了吗?可追溯吗?"
- **资源配置**:近期全栈Bot集中 R1/R2/R5(代码);项目Bot R3调研+R4案例(方案)
@@ -0,0 +1,26 @@
# CMA 路线图近期任务清单(R1-R52026-08-30 派单)
> 依据:docs/cma-product-vision-roadmap-v1.md 近期🔴项
## 已派全栈Bot(代码执行)
| 任务 | 内容 | 优先级 |
|:--|:--|:--:|
| **cma-roadmap-r1r2r5-20260830.md** | R1 AI建议→一键落地(应用KPI/预算/行动+留痕)<br>R2 数据找人扩大(机会类推送:KPI向好/预算余量)<br>R5 预算闭环加固(年度分解幂等确认+闭环自检) | P0/P1 |
| **data-quality-api-plus-v72-20260830.md** | ⓪预算年度分解累加bugP0<br>①数据质量API 7规则<br>②扫描脚本v7.2补做<br>③执行人中文名兼容 | P1 |
## 项目Bot负责(方案/调研/案例)
### R3 财务软件接口调研(✅ 已完成初步结论)
- **结论:可行**——用友/金蝶云有开放平台API(标准连接器),管家婆有API(erp.btype.list等),金蝶云星空支持表单查询/保存/提交/审核
- 2026 ERP API 开放性评估:用友/金蝶表现突出
- **待确认**:酣客实际使用哪套财务软件(用友/金蝶/管家婆/其他)→ 决定先接哪个适配器
### R4 酣客试点成案例(⏳ 待数据确认)
- 方案已出:docs/hanke-pilot-plan-20260829.md3天节奏)
- 待你确认:KPI目标值 / 真实实际值来源 / 节奏
- 完成后产出:试点报告 = 首个客户案例(产品化飞轮起点)
## 执行顺序建议
1. 全栈:先修 ⓪ 预算分解bug(P0,用户已遇到)→ R1(决策闭环核心)→ R2/R5/数据质量
2. 项目Bot:R3 等你告知酣客财务软件 → R4 试点数据确认
+79
View File
@@ -0,0 +1,79 @@
# CMA 战略愿景全景规划(2026-08-30)
> 提出:任富海 | 整理:项目Bot | 北极星:**数据接入→多Bot互动→数据找人→决策建议→决策修改检查**(四层愿景已确认)
## 一、北极星愿景(已确认,四层)
```
① 数据接入(财务软件接口/手工录入)
② 多Bot互动(14Bot协作处理数据)
③ 数据找人(异常+机会主动推送)
④ 决策建议 → 决策修改+检查(闭环可审计)
```
## 二、补充战略方向(8个,超越功能层面)
### A. 产品化与商业化(🔴 最高优先级——让能力变现)
- 现状:CMA 是内部系统,但官网已有 6 服务模块(含AI智能体/数据治理)可承接
- 方向:CMA → 可交付产品(SaaS/私有部署/实施服务三模式)
- 抓手:酣客试点成功 = 首个可复制案例;行业包 4 类(trading/it_service/manufacturing/general)已具雏形
### B. 行业纵深复制
- 白酒经销(酣客) → 贸易/流通 → 制造业/服务业
- 每个行业:KPI库/OKR模板/科目模板/预警规则 行业包(已有基础)
- 案例链:酣客成功 → 同行业客户 → 跨行业
### C. 数据资产化
- 博海自身资产:知识库/文章/系统数据 → 数据产品
- 客户侧:DAMA 数据治理服务(官网已上线)→ 治理→增值
- 企业数据资产盘点方法论 = 可售服务
### D. 决策智能升级(北极星④的深化)
- 从"提建议"→"预测决策":敏感性/情景模拟(已雏形)→ 完整预测性成本智能(IMA)
- 因果链验证(已建)→ 决策优化建议
- AI 复盘 → 组织学习闭环
### E. AI 原生组织方法论(差异化护城河)
- 博海自身 = AI 原生组织样板(14Bot/铁律/闭环)
- 对外输出"AI 原生组织落地方法论"(评估+实施+运营)
- 这是竞品(传统财务软件商)无法快速复制的能力
### F. 生态与联盟
- 财务软件生态:对接用友/金蝶/管家婆(当前**未接**,北极星①的关键缺口)
- 渠道伙伴:代账公司/咨询公司/本地IT服务商
- 区域深耕:陕西本地企业数字化
### G. 信任与合规(销售必要条件)
- 数据安全:PIPL/等保/数据分类分级
- AI 可信:RAG幻觉治理(Recall 100%已证)+ 验证铁律 = 可信AI叙事
- 审计链:操作日志/决策留痕(已有)
### H. 技术前瞻
- 数字员工:AI 同事(分域扫描已发现 CopilotKit/OpenBot 趋势)
- Agent 自动执行:决策→自动调度 Bot 执行(北极星②④融合)
- 预测智能:KPI趋势/宏观敏感性(已上线 MVP)→ 完整版
## 三、战略优先级与时间线
| 阶段 | 方向 | 关键动作 |
|:--|:--|:--|
| **近期(1-3月)** | A产品化 + D决策智能 + 北极星① | 财务软件接口调研;AI建议→行动一键落地;酣客试点成案例 |
| **中期(3-6月)** | B行业复制 + G合规 | 白酒经销行业包完善;数据安全/等保认证;第2-3个客户 |
| **远期(6-12月)** | E AI原生组织 + F生态 + H前瞻 | AI原生组织方法论产品化;渠道伙伴;数字员工/AI同事产品 |
## 四、北极星落地路线(四层 → 实施项)
| 层 | 当前状态 | 下一步 |
|:--|:--|:--|
| ① 数据接入 | 手工/Excel/Bot | **财务软件接口**(用友/金蝶/管家婆适配器) |
| ② 多Bot互动 | A2A/bot_bridge已有 | Bot联合决策(同数据多Bot出结论) |
| ③ 数据找人 | 预警/偏差/预测告警 | 主动推送扩大(机会+趋势,不止异常) |
| ④ 决策闭环 | 复盘持久化刚补 | **AI建议→一键落地**(建议落到KPI/预算/行动)+审计 |
## 五、关键洞察
1. **护城河不是 CMA 功能,是"AI原生组织方法论"**(E)——我们自己在用的整套体系(Bot协作/铁律/闭环/验证)就是最强差异化产品
2. **产品化(A)是其他一切的前提**——案例→行业包→方法论→生态
3. **北极星①(财务软件接口)是技术最大缺口**——直接影响"数据接入"自动化
+32
View File
@@ -0,0 +1,32 @@
# R3 财务软件接口调研(智享通·友加畅捷)2026-08-30 结论
> 酣客实际财务软件:**智享通**(成都友加畅捷,youjiasoft.com
## 一、产品画像
- 公司:成都友加畅捷科技有限公司
- 产品线:U+通用财务 / T1飞跃(进销存+财务)/ U+分销 / U+云商(SaaS) / U+移动
- **架构:本地 C/S 部署**(T1飞跃明确 C/S 架构)——非云 SaaS
- **开放 API:官网无开发者平台/公开API文档**(对比用友/金蝶有开放平台)
## 二、对接路径评估(3选1
| 路径 | 说明 | 可行性 | 工作量 |
|:--|:--|:--|:--|
| **A. Excel导出→CMA导入** ✅推荐 | 智享通导出科目余额/凭证Excel → CMA现有导入(data import已建) | 🟢 **零开发可用** | 0.5天(模板映射) |
| B. 数据库直读 | 本地部署 DB(SQL Server等)直接读财务数据 | 🟡 需账套密码+技术配合 | 2-3天 |
| C. 官方API | 联系友加畅捷确认是否有接口 | 🟠 官网未见,需商务确认 | 不确定 |
## 三、推荐方案(北极星①落地路径)
**阶段一(立即)**:方案 A——财务每月导出科目余额/凭证 Excel → CMA 导入 → KPI 实际值自动更新
- CMA 已有 import-excel 能力(data.py/bot_bridge),只需**模板映射**(智享通导出列 → CMA 科目/KPI)
- 实现:建"智享通导出模板"映射表(科目编码→KPI/科目)+ 导入验证
**阶段二(可选)**:方案 B/C——财务数据量大了再评估直读/官方接口
## 四、影响
北极星①"财务软件接口"对智享通的现实路径 = **导出导入(半自动)** 而非 API 直连(软件无开放API)。这是中小财务软件的现实——CMA 以"导入适配器"覆盖,不依赖厂商 API。
## 五、待办
- [ ] 向酣客财务要一份**智享通导出样例**(科目余额/凭证 Excel 各一)
- [ ] 建映射模板(样例列→CMA 字段)
- [ ] 全栈实现"智享通导入适配器"(模板映射+校验)
+9
View File
@@ -465,4 +465,13 @@ export const taxApi = {
seedDemo: (params?: any) => api.post('/tax/demo-data', null, { params }), seedDemo: (params?: any) => api.post('/tax/demo-data', null, { params }),
} }
// ── AI决策建议(路线图R1 2026-08-30):建议→一键应用到KPI/预算/行动方案 ──
export const aiSuggestionApi = {
list: (params?: any) => api.get('/ai/suggestions', { params }),
get: (id: number) => api.get(`/ai/suggestions/${id}`),
create: (data: any) => api.post('/ai/suggestions', data),
apply: (id: number, data: any) => api.post(`/ai/suggestions/${id}/apply`, data),
dismiss: (id: number) => api.post(`/ai/suggestions/${id}/dismiss`),
}
export default api export default api
+5 -4
View File
@@ -9,10 +9,10 @@ interface MenuItem {
// ── 角色路由映射 ── // ── 角色路由映射 ──
export const ROLE_ROUTES: Record<string, string[]> = { export const ROLE_ROUTES: Record<string, string[]> = {
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'], ceo: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'], finance: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'], business: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification'], it: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification'],
} }
export const ROLE_ACTIONS: Record<string, string[]> = { export const ROLE_ACTIONS: Record<string, string[]> = {
@@ -42,6 +42,7 @@ export const MENU_ITEMS: MenuItem[] = [
// ── GROUP 3: 监控与评价(Check)── // ── GROUP 3: 监控与评价(Check)──
{ path: '/dashboard', label: '经营看板', icon: 'DataBoard', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' }, { path: '/dashboard', label: '经营看板', icon: 'DataBoard', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
{ path: '/ai-suggestions', label: 'AI建议中心', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
{ path: '/reports', label: '管理报表', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business'], group: '🟡 C 监控与评价' }, { path: '/reports', label: '管理报表', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business'], group: '🟡 C 监控与评价' },
{ path: '/dupont-analysis', label: '杜邦分析', icon: 'TrendCharts', roles: ['ceo', 'finance', 'it'], group: '🟡 C 监控与评价' }, { path: '/dupont-analysis', label: '杜邦分析', icon: 'TrendCharts', roles: ['ceo', 'finance', 'it'], group: '🟡 C 监控与评价' },
{ path: '/customer', label: '客户维度', icon: 'User', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' }, { path: '/customer', label: '客户维度', icon: 'User', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
+1
View File
@@ -6,6 +6,7 @@ const routes = [
{ path: '/', component: () => import('@/layouts/MainLayout.vue'), redirect: '/my-dashboard', { path: '/', component: () => import('@/layouts/MainLayout.vue'), redirect: '/my-dashboard',
children: [ children: [
{ path: 'dashboard', name: 'Dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '经营看板', roles: ['ceo', 'finance', 'business', 'it'] } }, { path: 'dashboard', name: 'Dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '经营看板', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'ai-suggestions', name: 'SuggestionCenter', component: () => import('@/views/SuggestionCenter.vue'), meta: { title: 'AI建议中心', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'kpis', name: 'KPIs', component: () => import('@/views/KPIList.vue'), meta: { title: 'KPI字典', roles: ['ceo', 'finance', 'business', 'it'], editable: true } }, { path: 'kpis', name: 'KPIs', component: () => import('@/views/KPIList.vue'), meta: { title: 'KPI字典', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
{ path: 'kpis/:id', name: 'KPIDetail', component: () => import('@/views/KPIDetail.vue'), meta: { title: 'KPI详情', roles: ['ceo', 'finance', 'business', 'it'], editable: true } }, { path: 'kpis/:id', name: 'KPIDetail', component: () => import('@/views/KPIDetail.vue'), meta: { title: 'KPI详情', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
{ path: 'maps', name: 'Maps', component: () => import('@/views/MapList.vue'), meta: { title: '战略地图', roles: ['ceo', 'finance'], editable: true } }, { path: 'maps', name: 'Maps', component: () => import('@/views/MapList.vue'), meta: { title: '战略地图', roles: ['ceo', 'finance'], editable: true } },
+170 -8
View File
@@ -205,17 +205,87 @@
</el-card> </el-card>
</template> </template>
<!-- AI分析浮窗 --> <!-- AI分析浮窗R1AI建议一键落地 -->
<div v-if="showSidebar" class="ai-panel"> <div v-if="showSidebar" class="ai-panel">
<div class="ai-head"><span>🤖 AI 分析</span><el-button text size="small" @click="showSidebar = false"></el-button></div> <div class="ai-head">
<span>🤖 AI 分析</span>
<el-button text size="small" @click="router.push('/ai-suggestions')">建议中心</el-button>
<el-button text size="small" @click="showSidebar = false"></el-button>
</div>
<div class="ai-body"> <div class="ai-body">
<div class="ai-section-title">📋 决策建议</div>
<div v-if="suggestionsLoading" class="ai-loading"><el-icon class="is-loading" :size="16"><Loading /></el-icon><p>加载建议...</p></div>
<div v-else-if="suggestions.length === 0" class="ai-empty">暂无待应用建议</div>
<div v-else class="sug-list">
<div v-for="s in suggestions" :key="s.id" class="sug-card" :class="'type-' + s.suggestion_type">
<div class="sug-top">
<el-tag size="small" :type="sugTagType(s.suggestion_type)">{{ sugTypeLabel(s.suggestion_type) }}</el-tag>
<el-tag v-if="s.status === 'applied'" size="small" type="success">已应用</el-tag>
<el-tag v-else size="small" type="info">未应用</el-tag>
</div>
<div class="sug-title">{{ s.title }}</div>
<div class="sug-content">{{ s.content }}</div>
<div class="sug-foot">
<el-button v-if="s.status === 'unapplied'" size="small" type="primary" @click="openApplyDialog(s)">应用到</el-button>
<el-button v-if="s.status === 'unapplied'" size="small" @click="dismissSuggestion(s)">忽略</el-button>
<span v-if="s.status === 'applied'" class="sug-applied-by"> {{ s.applied_by }} {{ (s.applied_at || '').slice(0, 16) }} 应用</span>
</div>
</div>
</div>
<el-divider />
<div class="ai-section-title">💡 AI分析文本</div>
<div v-if="aiLoading" class="ai-loading"><el-icon class="is-loading" :size="20"><Loading /></el-icon><p>分析中...</p></div> <div v-if="aiLoading" class="ai-loading"><el-icon class="is-loading" :size="20"><Loading /></el-icon><p>分析中...</p></div>
<div v-else-if="aiAnalysis" class="ai-content" v-html="renderMd(aiAnalysis)"></div> <div v-else-if="aiAnalysis" class="ai-content" v-html="renderMd(aiAnalysis)"></div>
<el-empty v-else description="暂无分析" /> <el-empty v-else description="暂无分析" />
</div> </div>
<div class="ai-foot"><el-button size="small" type="primary" @click="refreshAnalysis" :loading="aiLoading">刷新</el-button></div> <div class="ai-foot">
<el-button size="small" type="primary" @click="refreshAnalysis" :loading="aiLoading">刷新分析</el-button>
</div>
</div> </div>
<!-- 应用建议弹窗 -->
<el-dialog v-model="showApplyDialog" :title="'应用到:' + (applySug?.title || '')" width="520px">
<el-form label-width="100px">
<template v-if="applySug?.suggestion_type === 'kpi_target'">
<el-form-item label="KPI"><el-input :model-value="applyKpiName" disabled /></el-form-item>
<el-form-item label="新目标值" required>
<el-input-number v-model="applyForm.target_value" :precision="2" style="width:220px" />
</el-form-item>
<div class="apply-tip">应用后将修改KPI目标值并写入操作日志留痕</div>
</template>
<template v-else-if="applySug?.suggestion_type === 'budget_adjust'">
<el-form-item label="KPI"><el-input :model-value="applyKpiName" disabled /></el-form-item>
<el-form-item label="期间" required>
<el-input v-model="applyForm.period" placeholder="2026-09" style="width:220px" />
</el-form-item>
<el-form-item label="预算值" required>
<el-input-number v-model="applyForm.budget_value" :precision="2" style="width:220px" />
</el-form-item>
<div class="apply-tip">应用后将新增/更新该KPI对应期间的预算并写入操作日志留痕</div>
</template>
<template v-else>
<el-form-item label="关联KPI"><el-input :model-value="applyKpiName" disabled /></el-form-item>
<el-form-item label="计划标题" required><el-input v-model="applyForm.title" /></el-form-item>
<el-form-item label="负责人"><el-input v-model="applyForm.assignee" /></el-form-item>
<el-form-item label="优先级">
<el-select v-model="applyForm.priority" style="width:220px">
<el-option label="高" value="high" />
<el-option label="中" value="medium" />
<el-option label="低" value="low" />
</el-select>
</el-form-item>
<el-form-item label="截止日期">
<el-date-picker v-model="applyForm.due_date" type="date" value-format="YYYY-MM-DD" style="width:220px" />
</el-form-item>
<div class="apply-tip">应用后将创建行动方案并写入操作日志留痕</div>
</template>
</el-form>
<template #footer>
<el-button @click="showApplyDialog = false">取消</el-button>
<el-button type="primary" :loading="applying" @click="submitApply">确认应用</el-button>
</template>
</el-dialog>
<!-- KPI详情弹窗 --> <!-- KPI详情弹窗 -->
<el-dialog v-model="showDetail" :title="selectedKPI?.kpi_name" width="500px"> <el-dialog v-model="showDetail" :title="selectedKPI?.kpi_name" width="500px">
<div v-if="selectedKPI"> <div v-if="selectedKPI">
@@ -241,7 +311,7 @@ import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Loading, ArrowRight } from '@element-plus/icons-vue' import { Loading, ArrowRight } from '@element-plus/icons-vue'
import { dashboardApi, alertApi } from '../api/index' import { dashboardApi, alertApi, aiSuggestionApi } from '../api/index'
import axios from 'axios' import axios from 'axios'
import CountUp from '../components/CountUp.vue' import CountUp from '../components/CountUp.vue'
import GrowthQuality from '../components/GrowthQuality.vue' import GrowthQuality from '../components/GrowthQuality.vue'
@@ -299,6 +369,81 @@ const enabledChannels = ref(0)
const aiAnalysis = ref('') const aiAnalysis = ref('')
const aiLoading = ref(false) const aiLoading = ref(false)
// R1: AI +
const suggestions = ref<any[]>([])
const suggestionsLoading = ref(false)
const showApplyDialog = ref(false)
const applySug = ref<any>(null)
const applyForm = ref<any>({})
const applying = ref(false)
const applyKpiName = ref('')
const sugTypeLabel = (t: string) => ({ kpi_target: '改KPI目标', budget_adjust: '调预算', action_plan: '建行动方案' }[t] || t)
const sugTagType = (t: string) => ({ kpi_target: 'warning', budget_adjust: 'danger', action_plan: 'primary' }[t] || 'info')
async function loadSuggestions() {
suggestionsLoading.value = true
try {
const r: any = await aiSuggestionApi.list({ status: 'unapplied' })
suggestions.value = (r as any)?.data || []
} catch { suggestions.value = [] }
suggestionsLoading.value = false
}
function openApplyDialog(s: any) {
applySug.value = s
const sd = s.suggestion_data || {}
applyForm.value = {
target_value: sd.target_value ?? null,
period: sd.period || '',
budget_value: sd.budget_value ?? null,
title: sd.title || '',
assignee: sd.assignee || '',
priority: sd.priority || 'medium',
due_date: sd.due_date || '',
}
applyKpiName.value = s.target_name || ''
showApplyDialog.value = true
}
async function submitApply() {
if (!applySug.value) return
const action = applySug.value.suggestion_type
const body: any = { action, kpi_id: applySug.value.target_id || applySug.value.suggestion_data?.kpi_id }
if (action === 'kpi_target') {
if (applyForm.value.target_value == null) { ElMessage.warning('请输入新目标值'); return }
body.target_value = applyForm.value.target_value
} else if (action === 'budget_adjust') {
if (!applyForm.value.period || applyForm.value.budget_value == null) { ElMessage.warning('请填写期间和预算值'); return }
body.period = applyForm.value.period
body.budget_value = applyForm.value.budget_value
} else {
if (!applyForm.value.title) { ElMessage.warning('请输入计划标题'); return }
body.title = applyForm.value.title
body.assignee = applyForm.value.assignee
body.priority = applyForm.value.priority
body.due_date = applyForm.value.due_date
}
applying.value = true
try {
await aiSuggestionApi.apply(applySug.value.id, body)
ElMessage.success('建议已应用并留痕')
showApplyDialog.value = false
await loadSuggestions()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '应用失败')
}
applying.value = false
}
async function dismissSuggestion(s: any) {
try {
await aiSuggestionApi.dismiss(s.id)
ElMessage.success('建议已忽略')
await loadSuggestions()
} catch { ElMessage.error('忽略失败') }
}
const aiApi = axios.create({ baseURL: '/api/cma', timeout: 30000 }) const aiApi = axios.create({ baseURL: '/api/cma', timeout: 30000 })
aiApi.interceptors.request.use((config: any) => { aiApi.interceptors.request.use((config: any) => {
const token = localStorage.getItem('cma_token') const token = localStorage.getItem('cma_token')
@@ -376,8 +521,8 @@ async function loadKPIAnalysis() {
if (!selectedKPI.value?.id) return if (!selectedKPI.value?.id) return
loadingDetail.value = true loadingDetail.value = true
try { try {
const r: any = await aiApi.post('/ai/kpi-analysis', { kpi_id: selectedKPI.value.id, period: periodType.value }) const r: any = await aiApi.get(`/ai/kpi-analysis/${selectedKPI.value.id}`)
selectedKPIDetail.value = r.data || r.analysis || '暂无分析结果' selectedKPIDetail.value = r.data?.analysis || r.analysis || '暂无分析结果'
} catch { selectedKPIDetail.value = 'AI分析请求失败' } } catch { selectedKPIDetail.value = 'AI分析请求失败' }
loadingDetail.value = false loadingDetail.value = false
} }
@@ -385,8 +530,10 @@ async function loadKPIAnalysis() {
async function refreshAnalysis() { async function refreshAnalysis() {
aiLoading.value = true aiLoading.value = true
try { try {
const r: any = await aiApi.post('/ai/dashboard-analysis', { role: userRole.value, period: periodType.value }) const r: any = await aiApi.get('/ai/dashboard-analysis', { params: { role: userRole.value } })
aiAnalysis.value = r.data || r.analysis || '暂无分析' aiAnalysis.value = r.data?.analysis || r.analysis || '暂无分析'
// dashboard
await loadSuggestions()
} catch { aiAnalysis.value = 'AI分析暂时不可用' } } catch { aiAnalysis.value = 'AI分析暂时不可用' }
aiLoading.value = false aiLoading.value = false
} }
@@ -555,6 +702,21 @@ onMounted(() => { loadData() })
.ai-foot { padding:10px 16px; border-top:1px solid #f0f0f0; } .ai-foot { padding:10px 16px; border-top:1px solid #f0f0f0; }
.ai-loading { text-align:center; padding:30px 0; color:#999; } .ai-loading { text-align:center; padding:30px 0; color:#999; }
/* R1: 建议卡 */
.ai-section-title { font-size:13px; font-weight:600; color:#333; margin-bottom:10px; }
.ai-empty { text-align:center; color:#bbb; padding:16px 0; font-size:12px; }
.sug-list { display:flex; flex-direction:column; gap:10px; }
.sug-card { border:1px solid #eee; border-radius:8px; padding:10px 12px; background:#fafafa; border-left:3px solid #909399; }
.sug-card.type-kpi_target { border-left-color:#e6a23c; }
.sug-card.type-budget_adjust { border-left-color:#f56c6c; }
.sug-card.type-action_plan { border-left-color:#409eff; }
.sug-top { display:flex; gap:6px; margin-bottom:6px; }
.sug-title { font-size:13px; font-weight:600; color:#333; margin-bottom:4px; }
.sug-content { font-size:12px; color:#666; line-height:1.5; margin-bottom:8px; }
.sug-foot { display:flex; gap:6px; align-items:center; }
.sug-applied-by { font-size:11px; color:#999; }
.apply-tip { font-size:12px; color:#999; padding:0 0 10px 100px; }
/* KPI详情弹窗 */ /* KPI详情弹窗 */
.dlg-summary { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; } .dlg-summary { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
.kpi-bar.large { height:10px; background:#f0f0f0; border-radius:5px; overflow:hidden; } .kpi-bar.large { height:10px; background:#f0f0f0; border-radius:5px; overflow:hidden; }
+204
View File
@@ -0,0 +1,204 @@
<template>
<div class="sug-center-page">
<div class="page-head">
<h3>🤖 AI决策建议中心</h3>
<div class="head-right">
<el-radio-group v-model="statusFilter" size="small" @change="loadList">
<el-radio-button value="">全部</el-radio-button>
<el-radio-button value="unapplied">未应用</el-radio-button>
<el-radio-button value="applied">已应用</el-radio-button>
<el-radio-button value="dismissed">已忽略</el-radio-button>
</el-radio-group>
<el-button size="small" type="primary" @click="loadList" :loading="loading">刷新</el-button>
</div>
</div>
<div class="sug-grid">
<el-empty v-if="!loading && items.length === 0" description="暂无建议" />
<div v-for="s in items" :key="s.id" class="sug-card" :class="['type-' + s.suggestion_type, s.status]">
<div class="sug-top">
<el-tag size="small" :type="sugTagType(s.suggestion_type)">{{ sugTypeLabel(s.suggestion_type) }}</el-tag>
<el-tag size="small" :type="statusTagType(s.status)">{{ statusLabel(s.status) }}</el-tag>
<span class="sug-source">来源: {{ sourceLabel(s.source) }}</span>
</div>
<div class="sug-title">{{ s.title }}</div>
<div class="sug-content">{{ s.content }}</div>
<div class="sug-meta">
<span>创建: {{ (s.created_at || '').slice(0, 16) }}</span>
<span v-if="s.status === 'applied'" class="applied-info"> {{ s.applied_by }} {{ (s.applied_at || '').slice(0, 16) }} 应用</span>
</div>
<div v-if="s.status === 'applied' && s.apply_detail?.length" class="apply-detail">
<div v-for="(d, i) in s.apply_detail" :key="i" class="apply-detail-item">
<el-tag size="mini" type="info">{{ applyTargetLabel(d.target_type) }}</el-tag>
<span>{{ d.target_name }}</span>
<span class="before-after">改前: {{ fmtVal(d.before) }} 改后: {{ fmtVal(d.after) }}</span>
</div>
</div>
<div class="sug-foot">
<el-button v-if="s.status === 'unapplied'" size="small" type="primary" @click="openApplyDialog(s)">应用到</el-button>
<el-button v-if="s.status === 'unapplied'" size="small" @click="dismissSuggestion(s)">忽略</el-button>
<el-button size="small" @click="toggleDetail(s)">{{ s.showDetail ? '收起' : '参数详情' }}</el-button>
</div>
<pre v-if="s.showDetail" class="sug-json">{{ JSON.stringify(s.suggestion_data, null, 2) }}</pre>
</div>
</div>
<!-- 应用建议弹窗 -->
<el-dialog v-model="showApplyDialog" :title="'应用到:' + (applySug?.title || '')" width="520px">
<el-form label-width="100px">
<template v-if="applySug?.suggestion_type === 'kpi_target'">
<el-form-item label="KPI ID"><el-input :model-value="applySug?.target_id" disabled /></el-form-item>
<el-form-item label="新目标值" required>
<el-input-number v-model="applyForm.target_value" :precision="2" style="width:220px" />
</el-form-item>
</template>
<template v-else-if="applySug?.suggestion_type === 'budget_adjust'">
<el-form-item label="KPI ID"><el-input :model-value="applySug?.target_id" disabled /></el-form-item>
<el-form-item label="期间" required>
<el-input v-model="applyForm.period" placeholder="2026-09" style="width:220px" />
</el-form-item>
<el-form-item label="预算值" required>
<el-input-number v-model="applyForm.budget_value" :precision="2" style="width:220px" />
</el-form-item>
</template>
<template v-else>
<el-form-item label="KPI ID"><el-input :model-value="applySug?.target_id" disabled /></el-form-item>
<el-form-item label="计划标题" required><el-input v-model="applyForm.title" /></el-form-item>
<el-form-item label="负责人"><el-input v-model="applyForm.assignee" /></el-form-item>
<el-form-item label="优先级">
<el-select v-model="applyForm.priority" style="width:220px">
<el-option label="高" value="high" />
<el-option label="中" value="medium" />
<el-option label="低" value="low" />
</el-select>
</el-form-item>
<el-form-item label="截止日期">
<el-date-picker v-model="applyForm.due_date" type="date" value-format="YYYY-MM-DD" style="width:220px" />
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="showApplyDialog = false">取消</el-button>
<el-button type="primary" :loading="applying" @click="submitApply">确认应用</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { aiSuggestionApi } from '../api/index'
const statusFilter = ref('')
const items = ref<any[]>([])
const loading = ref(false)
const showApplyDialog = ref(false)
const applySug = ref<any>(null)
const applyForm = ref<any>({})
const applying = ref(false)
const sugTypeLabel = (t: string) => ({ kpi_target: '改KPI目标', budget_adjust: '调预算', action_plan: '建行动方案' }[t] || t)
const sugTagType = (t: string) => ({ kpi_target: 'warning', budget_adjust: 'danger', action_plan: 'primary' }[t] || 'info')
const statusLabel = (s: string) => ({ unapplied: '未应用', applied: '已应用', dismissed: '已忽略' }[s] || s)
const statusTagType = (s: string) => ({ unapplied: 'info', applied: 'success', dismissed: 'info' }[s] || 'info')
const sourceLabel = (s: string) => ({ dashboard: '看板分析', kpi: 'KPI分析', budget: '预算分析', manual: '手动', rule: '规则' }[s] || s)
const applyTargetLabel = (t: string) => ({ kpi: 'KPI', budget: '预算', action_plan: '行动方案', alert: '预警' }[t] || t)
function fmtVal(v: any): string {
if (v == null) return '-'
return Number.isInteger(v) ? String(v) : Number(v).toFixed(2)
}
async function loadList() {
loading.value = true
try {
const params: any = {}
if (statusFilter.value) params.status = statusFilter.value
const r: any = await aiSuggestionApi.list(params)
items.value = ((r as any)?.data || []).map((s: any) => ({ ...s, showDetail: false }))
} catch { items.value = [] }
loading.value = false
}
function toggleDetail(s: any) { s.showDetail = !s.showDetail }
function openApplyDialog(s: any) {
applySug.value = s
const sd = s.suggestion_data || {}
applyForm.value = {
target_value: sd.target_value ?? null,
period: sd.period || '',
budget_value: sd.budget_value ?? null,
title: sd.title || '',
assignee: sd.assignee || '',
priority: sd.priority || 'medium',
due_date: sd.due_date || '',
}
showApplyDialog.value = true
}
async function submitApply() {
if (!applySug.value) return
const action = applySug.value.suggestion_type
const body: any = { action, kpi_id: applySug.value.target_id || applySug.value.suggestion_data?.kpi_id }
if (action === 'kpi_target') {
if (applyForm.value.target_value == null) { ElMessage.warning('请输入新目标值'); return }
body.target_value = applyForm.value.target_value
} else if (action === 'budget_adjust') {
if (!applyForm.value.period || applyForm.value.budget_value == null) { ElMessage.warning('请填写期间和预算值'); return }
body.period = applyForm.value.period
body.budget_value = applyForm.value.budget_value
} else {
if (!applyForm.value.title) { ElMessage.warning('请输入计划标题'); return }
body.title = applyForm.value.title
body.assignee = applyForm.value.assignee
body.priority = applyForm.value.priority
body.due_date = applyForm.value.due_date
}
applying.value = true
try {
await aiSuggestionApi.apply(applySug.value.id, body)
ElMessage.success('建议已应用并留痕')
showApplyDialog.value = false
await loadList()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '应用失败')
}
applying.value = false
}
async function dismissSuggestion(s: any) {
try {
await aiSuggestionApi.dismiss(s.id)
ElMessage.success('建议已忽略')
await loadList()
} catch { ElMessage.error('忽略失败') }
}
onMounted(loadList)
</script>
<style scoped>
.sug-center-page { max-width: 1200px; margin: 0 auto; padding: 20px; }
.page-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.head-right { display: flex; gap: 12px; align-items: center; }
.sug-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 14px; }
.sug-card { background: #fff; border-radius: 10px; padding: 14px 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); border: 1px solid #f0f0f0; border-left: 4px solid #909399; }
.sug-card.type-kpi_target { border-left-color: #e6a23c; }
.sug-card.type-budget_adjust { border-left-color: #f56c6c; }
.sug-card.type-action_plan { border-left-color: #409eff; }
.sug-card.applied { background: #f8fbf8; }
.sug-card.dismissed { opacity: .6; }
.sug-top { display: flex; gap: 6px; align-items: center; margin-bottom: 8px; }
.sug-source { font-size: 11px; color: #aaa; margin-left: auto; }
.sug-title { font-size: 14px; font-weight: 600; color: #333; margin-bottom: 6px; }
.sug-content { font-size: 13px; color: #666; line-height: 1.6; margin-bottom: 10px; }
.sug-meta { font-size: 11px; color: #aaa; margin-bottom: 8px; display: flex; gap: 14px; }
.applied-info { color: #67c23a; }
.apply-detail { background: #f5f7fa; border-radius: 6px; padding: 8px 10px; margin-bottom: 8px; font-size: 12px; color: #555; display: flex; flex-direction: column; gap: 4px; }
.apply-detail-item { display: flex; gap: 8px; align-items: center; }
.before-after { color: #888; }
.sug-foot { display: flex; gap: 6px; }
.sug-json { background: #f8f8f8; border-radius: 6px; padding: 8px; font-size: 11px; color: #666; margin-top: 8px; max-height: 160px; overflow: auto; }
</style>