feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
"""
|
||||
CMA BOT API桥接层 — 供财务BOT/店研学BOT调用
|
||||
无需用户登录,使用 BOT API Key 认证
|
||||
"""
|
||||
import os, json, logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, desc
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.models import (
|
||||
User, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
|
||||
DataSourceConfig, KPIAlert, OperationLog, NotificationChannel,
|
||||
NotificationLog, RolePermission, ActionPlan, OrgNode,
|
||||
StrategicMapVersion, MapObjective,
|
||||
)
|
||||
from app.models.budget_plan import BudgetPlan
|
||||
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
||||
|
||||
logger = logging.getLogger("cma.bot_bridge")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/bot", tags=["BOT桥接"])
|
||||
|
||||
# ── BOT API Key 配置 ──
|
||||
_BOT_API_KEYS = {}
|
||||
|
||||
def _load_bot_keys():
|
||||
global _BOT_API_KEYS
|
||||
raw = os.getenv("CMA_BOT_API_KEYS", "")
|
||||
if not raw:
|
||||
_BOT_API_KEYS = {
|
||||
"cma-bot-finance-2026": {"role": "finance", "name": "财务BOT"},
|
||||
"cma-bot-shop-2026": {"role": "business", "name": "店研学BOT"},
|
||||
"cma-bot-admin-2026": {"role": "ceo", "name": "管理BOT"},
|
||||
}
|
||||
else:
|
||||
try:
|
||||
_BOT_API_KEYS = json.loads(raw)
|
||||
except:
|
||||
_BOT_API_KEYS = {}
|
||||
|
||||
_load_bot_keys()
|
||||
|
||||
def verify_bot_key(x_bot_key: str = Header(None, alias="X-BOT-KEY")):
|
||||
if not x_bot_key or x_bot_key not in _BOT_API_KEYS:
|
||||
raise HTTPException(401, "无效的BOT API Key")
|
||||
bot_info = _BOT_API_KEYS[x_bot_key]
|
||||
logger.info(f"BOT访问: {bot_info['name']} ({bot_info['role']})")
|
||||
return bot_info
|
||||
|
||||
|
||||
# ═══════════════ 通用工具 ═══════════════
|
||||
|
||||
def _float(v):
|
||||
if v is None: return None
|
||||
try: return float(v)
|
||||
except: return None
|
||||
|
||||
def _safe_iso(dt):
|
||||
if dt is None: return None
|
||||
try: return dt.isoformat() if hasattr(dt, 'isoformat') else str(dt)
|
||||
except: return None
|
||||
|
||||
def _model_dict(obj, fields: dict):
|
||||
"""安全地将模型字段转为dict"""
|
||||
result = {}
|
||||
for key, attr in fields.items():
|
||||
v = getattr(obj, attr, None)
|
||||
if isinstance(v, float):
|
||||
result[key] = _float(v)
|
||||
else:
|
||||
result[key] = v
|
||||
return result
|
||||
|
||||
|
||||
# ═══════════════ 端点 ═══════════════
|
||||
|
||||
@router.get("/ping")
|
||||
def ping():
|
||||
return {"status": "ok", "version": "1.0", "timestamp": datetime.now().isoformat()}
|
||||
|
||||
|
||||
# ── 总览 ──
|
||||
|
||||
@router.get("/overview")
|
||||
def bot_overview(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""系统总览 — BOT首选入口"""
|
||||
return {
|
||||
"bot": bot,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"stats": {
|
||||
"kpis_total": db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active").scalar() or 0,
|
||||
"alerts_open": db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar() or 0,
|
||||
"maps_total": db.query(func.count(StrategicMap.id)).scalar() or 0,
|
||||
"budget_plans": db.query(func.count(BudgetPlan.id)).scalar() or 0,
|
||||
"action_plans_pending": db.query(func.count(ActionPlan.id)).filter(ActionPlan.status.in_(["pending", "in_progress"])).scalar() or 0,
|
||||
"data_sources": db.query(func.count(DataSourceConfig.id)).scalar() or 0,
|
||||
"users": db.query(func.count(User.id)).scalar() or 0,
|
||||
"org_nodes": db.query(func.count(OrgNode.id)).scalar() or 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ── KPI ──
|
||||
|
||||
@router.get("/kpis")
|
||||
def bot_kpis(
|
||||
dimension: Optional[str] = Query(None),
|
||||
status: str = Query("active"),
|
||||
limit: int = Query(200, le=1000),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(KPIDefinition).filter(KPIDefinition.status == status)
|
||||
if dimension:
|
||||
query = query.filter(KPIDefinition.dimension == dimension)
|
||||
kpis = query.order_by(KPIDefinition.dimension, KPIDefinition.kpi_code).limit(limit).all()
|
||||
|
||||
results = []
|
||||
for k in kpis:
|
||||
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)\
|
||||
.order_by(KPIValue.period.desc()).first()
|
||||
results.append({
|
||||
"id": k.id, "name": k.kpi_name, "code": k.kpi_code,
|
||||
"dimension": k.dimension, "category": k.category,
|
||||
"unit": k.unit, "formula": k.formula,
|
||||
"frequency": k.frequency, "data_source_type": k.data_source_type,
|
||||
"target_value": _float(k.target_value),
|
||||
"threshold_green": k.threshold_green,
|
||||
"threshold_yellow": k.threshold_yellow,
|
||||
"threshold_red": k.threshold_red,
|
||||
"responsible_dept": k.responsible_dept, "owner": k.responsible_user,
|
||||
"objective": k.objective, "description": k.description,
|
||||
"latest_value": _float(latest.actual_value) if latest else None,
|
||||
"latest_period": latest.period if latest else None,
|
||||
"status": k.status,
|
||||
})
|
||||
return {"total": len(results), "items": results}
|
||||
|
||||
|
||||
@router.get("/kpis/{kpi_id}/history")
|
||||
def bot_kpi_history(
|
||||
kpi_id: int, limit: int = Query(12, le=60),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id)\
|
||||
.order_by(KPIValue.period.desc()).limit(limit).all()
|
||||
return {
|
||||
"kpi": {"id": kpi.id, "name": kpi.kpi_name, "code": kpi.kpi_code, "unit": kpi.unit},
|
||||
"values": [
|
||||
{
|
||||
"period": v.period,
|
||||
"actual": _float(v.actual_value),
|
||||
"source_type": v.source_type,
|
||||
"data_status": v.data_status,
|
||||
} for v in values
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 战略地图 ──
|
||||
|
||||
@router.get("/strategic-maps")
|
||||
def bot_maps(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
maps = db.query(StrategicMap).order_by(StrategicMap.id.desc()).all()
|
||||
result = []
|
||||
for m in maps:
|
||||
objectives = db.query(MapObjective).filter(MapObjective.map_id == m.id).all()
|
||||
dims = {}
|
||||
for obj in objectives:
|
||||
dk = obj.dimension_key
|
||||
if dk not in dims:
|
||||
dims[dk] = []
|
||||
dims[dk].append({"id": obj.id, "name": obj.name, "description": obj.description})
|
||||
result.append({
|
||||
"id": m.id, "title": m.title, "version": m.version,
|
||||
"status": m.status, "dimensions": m.dimensions,
|
||||
"objectives": dims,
|
||||
"created_at": _safe_iso(m.created_at),
|
||||
"updated_at": _safe_iso(m.updated_at),
|
||||
})
|
||||
return {"total": len(result), "items": result}
|
||||
|
||||
|
||||
# ── 预警 ──
|
||||
|
||||
@router.get("/alerts")
|
||||
def bot_alerts(
|
||||
status: str = Query("pending"),
|
||||
level: Optional[str] = Query(None),
|
||||
limit: int = Query(50, le=200),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(KPIAlert)
|
||||
query = query.filter(KPIAlert.status == status)
|
||||
if level:
|
||||
query = query.filter(KPIAlert.alert_level == level)
|
||||
alerts = query.order_by(KPIAlert.created_at.desc()).limit(limit).all()
|
||||
return {
|
||||
"total": len(alerts),
|
||||
"items": [
|
||||
{
|
||||
"id": a.id, "kpi_id": a.kpi_id,
|
||||
"level": a.alert_level, "message": a.alert_message,
|
||||
"status": a.status, "assignee": a.assignee,
|
||||
"resolution": a.resolution,
|
||||
"created_at": _safe_iso(a.created_at),
|
||||
"resolved_at": _safe_iso(a.resolved_at),
|
||||
} for a in alerts
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 预算 ──
|
||||
|
||||
@router.get("/budget/plans")
|
||||
def bot_budget_plans(
|
||||
year: Optional[int] = Query(None),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(BudgetPlan)
|
||||
if year:
|
||||
query = query.filter(BudgetPlan.budget_year == year)
|
||||
plans = query.order_by(BudgetPlan.period.desc()).limit(200).all()
|
||||
return {
|
||||
"total": len(plans),
|
||||
"items": [
|
||||
{
|
||||
"id": p.id, "kpi_id": p.kpi_id,
|
||||
"period": p.period,
|
||||
"budget_value": _float(p.budget_value),
|
||||
"year": p.budget_year, "month": p.budget_month,
|
||||
"version": p.version, "status": p.status,
|
||||
"remark": p.remark,
|
||||
} for p in plans
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 成本 ──
|
||||
|
||||
@router.get("/cost/standard")
|
||||
def bot_standard_costs(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
costs = db.query(StandardCost).filter(StandardCost.status == "active").limit(200).all()
|
||||
return {
|
||||
"total": len(costs),
|
||||
"items": [
|
||||
{
|
||||
"id": c.id, "product_code": c.product_code,
|
||||
"product_name": c.product_name, "cost_type": c.cost_type,
|
||||
"item_name": c.item_name,
|
||||
"standard_quantity": _float(c.standard_quantity),
|
||||
"unit": c.unit,
|
||||
"standard_price": _float(c.standard_price),
|
||||
"standard_cost": _float(c.standard_cost),
|
||||
"version": c.version, "remark": c.remark,
|
||||
} for c in costs
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/cost/actual")
|
||||
def bot_actual_costs(
|
||||
period: Optional[str] = Query(None),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(ActualCost)
|
||||
if period:
|
||||
query = query.filter(ActualCost.period == period)
|
||||
costs = query.order_by(ActualCost.period.desc()).limit(200).all()
|
||||
return {
|
||||
"total": len(costs),
|
||||
"items": [
|
||||
{
|
||||
"id": c.id, "period": c.period,
|
||||
"product_code": c.product_code,
|
||||
"product_name": c.product_name,
|
||||
"cost_type": c.cost_type, "item_name": c.item_name,
|
||||
"actual_quantity": _float(c.actual_quantity),
|
||||
"actual_price": _float(c.actual_price),
|
||||
"actual_cost": _float(c.actual_cost),
|
||||
} for c in costs
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 行动方案 ──
|
||||
|
||||
@router.get("/actions")
|
||||
def bot_actions(
|
||||
status: Optional[str] = Query(None),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(ActionPlan)
|
||||
if status:
|
||||
query = query.filter(ActionPlan.status == status)
|
||||
plans = query.order_by(ActionPlan.priority, ActionPlan.id.desc()).limit(100).all()
|
||||
return {
|
||||
"total": len(plans),
|
||||
"items": [
|
||||
{
|
||||
"id": p.id, "title": p.title,
|
||||
"description": p.description, "kpi_id": p.kpi_id,
|
||||
"assignee": p.assignee, "priority": p.priority,
|
||||
"status": p.status, "progress": p.progress,
|
||||
"target_value": p.target_value,
|
||||
"due_date": _safe_iso(p.due_date),
|
||||
"created_at": _safe_iso(p.created_at),
|
||||
} for p in plans
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 组织 ──
|
||||
|
||||
@router.get("/organization")
|
||||
def bot_org(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
nodes = db.query(OrgNode).order_by(OrgNode.level, OrgNode.sort_order).all()
|
||||
return {
|
||||
"total": len(nodes),
|
||||
"items": [
|
||||
{
|
||||
"id": n.id, "name": n.name,
|
||||
"parent_id": n.parent_id, "level": n.level,
|
||||
"code": n.code, "sort_order": n.sort_order,
|
||||
"enabled": n.enabled,
|
||||
} for n in nodes
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 数据源 ──
|
||||
|
||||
@router.get("/data-sources")
|
||||
def bot_data_sources(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
sources = db.query(DataSourceConfig).all()
|
||||
return {
|
||||
"total": len(sources),
|
||||
"items": [
|
||||
{
|
||||
"id": s.id, "name": s.name,
|
||||
"source_type": s.source_type,
|
||||
"api_endpoint": s.api_endpoint,
|
||||
"sync_type": s.sync_type,
|
||||
"status": s.status,
|
||||
"last_sync_at": _safe_iso(s.last_sync_at),
|
||||
} for s in sources
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 用户 ──
|
||||
|
||||
@router.get("/users")
|
||||
def bot_users(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
users = db.query(User).all()
|
||||
return {
|
||||
"total": len(users),
|
||||
"items": [
|
||||
{"id": u.id, "username": u.username, "name": u.name,
|
||||
"role": u.role, "phone": u.phone}
|
||||
for u in users
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 统一查询(BOT首选) ──
|
||||
|
||||
@router.get("/query")
|
||||
def bot_query(
|
||||
q: str = Query("overview", description="overview/kpis/alerts/maps/budget/cost/actions/all"),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""统一查询入口 — BOT用这个一次拿完需要的数据"""
|
||||
result = {"bot": bot["name"], "role": bot["role"], "timestamp": datetime.now().isoformat()}
|
||||
|
||||
if q in ("overview", "all"):
|
||||
result["overview"] = {
|
||||
"kpis": db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active").scalar() or 0,
|
||||
"alerts_open": db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar() or 0,
|
||||
"maps": db.query(func.count(StrategicMap.id)).scalar() or 0,
|
||||
"budget_plans": db.query(func.count(BudgetPlan.id)).scalar() or 0,
|
||||
}
|
||||
|
||||
if q in ("kpis", "all"):
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").limit(100).all()
|
||||
result["kpis"] = [
|
||||
{"id": k.id, "name": k.kpi_name, "code": k.kpi_code,
|
||||
"dimension": k.dimension, "target": _float(k.target_value), "unit": k.unit}
|
||||
for k in kpis
|
||||
]
|
||||
|
||||
if q in ("alerts", "all"):
|
||||
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending")\
|
||||
.order_by(KPIAlert.created_at.desc()).limit(20).all()
|
||||
result["alerts"] = [
|
||||
{"id": a.id, "level": a.alert_level, "message": a.alert_message,
|
||||
"kpi_id": a.kpi_id, "created_at": _safe_iso(a.created_at)}
|
||||
for a in alerts
|
||||
]
|
||||
|
||||
if q in ("maps", "all"):
|
||||
maps = db.query(StrategicMap).limit(10).all()
|
||||
result["maps"] = [
|
||||
{"id": m.id, "title": m.title, "status": m.status,
|
||||
"version": m.version, "created_at": _safe_iso(m.created_at)}
|
||||
for m in maps
|
||||
]
|
||||
|
||||
if q in ("budget", "all"):
|
||||
plans = db.query(BudgetPlan).limit(50).all()
|
||||
result["budget"] = [
|
||||
{"id": p.id, "period": p.period, "budget_value": _float(p.budget_value),
|
||||
"year": p.budget_year, "month": p.budget_month, "status": p.status,
|
||||
"kpi_id": p.kpi_id}
|
||||
for p in plans
|
||||
]
|
||||
|
||||
if q in ("cost", "all"):
|
||||
sc = db.query(StandardCost).limit(50).all()
|
||||
result["costs"] = [
|
||||
{"id": c.id, "product": c.product_name, "type": c.cost_type,
|
||||
"standard": _float(c.standard_cost), "unit": c.unit}
|
||||
for c in sc
|
||||
]
|
||||
|
||||
if q in ("actions", "all"):
|
||||
acts = db.query(ActionPlan).limit(30).all()
|
||||
result["actions"] = [
|
||||
{"id": a.id, "title": a.title, "status": a.status,
|
||||
"progress": a.progress, "assignee": a.assignee}
|
||||
for a in acts
|
||||
]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── 自然语言查询 ──
|
||||
|
||||
@router.get("/nlp")
|
||||
def bot_nlp(
|
||||
intent: str = Query("overview"),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
自然语言意图映射:
|
||||
overview/总览/finance/财务/alerts/预警/budget/预算/cost/成本/maps/战略/actions/行动
|
||||
"""
|
||||
m = {
|
||||
"总览": "overview", "驾驶舱": "overview",
|
||||
"财务": "finance", "财务状况": "finance",
|
||||
"预警": "alerts", "风险": "alerts",
|
||||
"预算": "budget", "预算执行": "budget",
|
||||
"成本": "cost", "成本分析": "cost",
|
||||
"战略": "maps", "战略地图": "maps",
|
||||
"行动": "actions", "改善": "actions",
|
||||
}
|
||||
resolved = m.get(intent, intent)
|
||||
return bot_query(q=resolved, bot=bot, db=db)
|
||||
Reference in New Issue
Block a user