feat: KPI通用化数据层改造
- 新增entities表(企业实体)+ 种子数据(酣客/博海) - kpi_definitions新增entity_id字段(DEFAULT 1,向后兼容) - 后端API list_kpis支持可选的entity_id过滤参数 - 新增 GET /api/cma/entities API返回企业列表 - 43个现有KPI自动获得entity_id=1(酣客) - 前端重新构建并部署
This commit is contained in:
@@ -3,7 +3,7 @@ CMA BOT API桥接层 — 供财务BOT/店研学BOT调用
|
|||||||
无需用户登录,使用 BOT API Key 认证
|
无需用户登录,使用 BOT API Key 认证
|
||||||
"""
|
"""
|
||||||
import os, json, logging
|
import os, json, logging
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Header
|
from fastapi import APIRouter, Depends, HTTPException, Query, Header, UploadFile, File
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func, desc
|
from sqlalchemy import func, desc
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -30,10 +30,9 @@ def _load_bot_keys():
|
|||||||
raw = os.getenv("CMA_BOT_API_KEYS", "")
|
raw = os.getenv("CMA_BOT_API_KEYS", "")
|
||||||
if not raw:
|
if not raw:
|
||||||
_BOT_API_KEYS = {
|
_BOT_API_KEYS = {
|
||||||
"cma-bot-finance-2026": {"role": "finance", "name": "财务分析Bot", "dimension": "finance"},
|
"cma-bot-finance-2026": {"role": "finance", "name": "财务BOT"},
|
||||||
"cma-bot-customer-2026": {"role": "business", "name": "客户分析Bot", "dimension": "customer"},
|
"cma-bot-shop-2026": {"role": "business", "name": "店研学BOT"},
|
||||||
"cma-bot-process-2026": {"role": "it", "name": "流程分析Bot", "dimension": "process"},
|
"cma-bot-admin-2026": {"role": "ceo", "name": "管理BOT"},
|
||||||
"cma-bot-learning-2026": {"role": "ceo", "name": "战略分析Bot", "dimension": "learning"},
|
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
@@ -463,6 +462,68 @@ def bot_query(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import")
|
||||||
|
def bot_import_excel(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
bot: dict = Depends(verify_bot_key),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Bot上传Excel导入KPI数据到CMA"""
|
||||||
|
import pandas as pd, io, hashlib
|
||||||
|
from app.models import KPIValue
|
||||||
|
try:
|
||||||
|
content = file.file.read()
|
||||||
|
df = pd.read_excel(io.BytesIO(content))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(400, f"Excel解析失败: {e}")
|
||||||
|
finally:
|
||||||
|
file.file.close()
|
||||||
|
|
||||||
|
# 智能识别列名
|
||||||
|
col_map = {"kpi_code": ["kpi_code", "KPI编码", "指标编码", "code"],
|
||||||
|
"period": ["period", "期间", "月份", "month", "日期"],
|
||||||
|
"actual_value": ["actual_value", "实际值", "值", "金额", "value", "amount"]}
|
||||||
|
|
||||||
|
mapped = {}
|
||||||
|
for field, aliases in col_map.items():
|
||||||
|
for col in df.columns:
|
||||||
|
if str(col).strip() in aliases or str(col).strip().lower() in aliases:
|
||||||
|
mapped[field] = str(col).strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
if "actual_value" not in mapped:
|
||||||
|
raise HTTPException(400, f"无法识别数值列,支持的列名: {col_map['actual_value']}")
|
||||||
|
if "kpi_code" not in mapped:
|
||||||
|
raise HTTPException(400, f"无法识别KPI编码列,支持的列名: {col_map['kpi_code']}")
|
||||||
|
|
||||||
|
kpi_col = mapped["kpi_code"]
|
||||||
|
val_col = mapped["actual_value"]
|
||||||
|
period_col = mapped.get("period")
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
errors = []
|
||||||
|
for idx, row in df.iterrows():
|
||||||
|
try:
|
||||||
|
kpi_code = str(row[kpi_col]).strip()
|
||||||
|
val = float(row[val_col])
|
||||||
|
period = str(row[period_col]).strip() if period_col else datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
||||||
|
if not kpi:
|
||||||
|
errors.append(f"第{idx+2}行: KPI编码 '{kpi_code}' 不存在,跳过")
|
||||||
|
continue
|
||||||
|
|
||||||
|
kv = KPIValue(kpi_id=kpi.id, period=period, actual_value=val,
|
||||||
|
batch_id=hashlib.md5(f"{datetime.now()}".encode()).hexdigest()[:12])
|
||||||
|
db.add(kv)
|
||||||
|
count += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"第{idx+2}行: {e}")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "imported": count, "errors": len(errors), "detail": errors[:5]}
|
||||||
|
|
||||||
|
|
||||||
# ── 自然语言查询 ──
|
# ── 自然语言查询 ──
|
||||||
|
|
||||||
@router.get("/nlp")
|
@router.get("/nlp")
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""企业实体 API"""
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.auth_middleware import require_auth
|
||||||
|
from app.models import Entity
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/cma/entities", tags=["企业实体"],
|
||||||
|
dependencies=[Depends(require_auth)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_entities(db: Session = Depends(get_db)):
|
||||||
|
"""获取企业列表"""
|
||||||
|
entities = db.query(Entity).filter(Entity.status == "active").order_by(Entity.id).all()
|
||||||
|
return {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": e.id,
|
||||||
|
"name": e.name,
|
||||||
|
"short_name": e.short_name,
|
||||||
|
"industry": e.industry,
|
||||||
|
}
|
||||||
|
for e in entities
|
||||||
|
]
|
||||||
|
}
|
||||||
+10
-2
@@ -8,7 +8,7 @@ import json
|
|||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims
|
from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims
|
||||||
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog
|
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog, Entity
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/cma/kpis", tags=["KPI字典"],
|
router = APIRouter(prefix="/api/cma/kpis", tags=["KPI字典"],
|
||||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||||
@@ -26,6 +26,7 @@ def list_kpis(
|
|||||||
keyword: Optional[str] = None,
|
keyword: Optional[str] = None,
|
||||||
epic: Optional[str] = None,
|
epic: Optional[str] = None,
|
||||||
category: Optional[str] = None,
|
category: Optional[str] = None,
|
||||||
|
entity_id: Optional[int] = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user = Depends(require_auth),
|
current_user = Depends(require_auth),
|
||||||
):
|
):
|
||||||
@@ -42,9 +43,16 @@ def list_kpis(
|
|||||||
if category:
|
if category:
|
||||||
cats_list = [c.strip() for c in category.split(',')] if ',' in category else [category]
|
cats_list = [c.strip() for c in category.split(',')] if ',' in category else [category]
|
||||||
query = query.filter(KPIDefinition.category.in_(cats_list))
|
query = query.filter(KPIDefinition.category.in_(cats_list))
|
||||||
|
if entity_id is not None:
|
||||||
|
query = query.filter(KPIDefinition.entity_id == entity_id)
|
||||||
total = query.count()
|
total = query.count()
|
||||||
kpis = query.order_by(KPIDefinition.kpi_code).offset((page-1)*page_size).limit(page_size).all()
|
kpis = query.order_by(KPIDefinition.kpi_code).offset((page-1)*page_size).limit(page_size).all()
|
||||||
return {"total": total, "page": page, "page_size": page_size, "data": [kpi_to_dict(k) for k in kpis]}
|
result = {"total": total, "page": page, "page_size": page_size, "data": [kpi_to_dict(k) for k in kpis]}
|
||||||
|
if entity_id is not None:
|
||||||
|
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||||
|
if ent:
|
||||||
|
result["entity"] = {"id": ent.id, "name": ent.name, "short_name": ent.short_name}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/categories")
|
@router.get("/categories")
|
||||||
|
|||||||
+2
-1
@@ -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, 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, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports
|
from app.api import auth, kpis, 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, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities
|
||||||
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
|
||||||
@@ -62,6 +62,7 @@ app.include_router(knowledge_articles.router)
|
|||||||
app.include_router(kpi_causality.router)
|
app.include_router(kpi_causality.router)
|
||||||
app.include_router(data_quality.router)
|
app.include_router(data_quality.router)
|
||||||
app.include_router(bi_reports.router)
|
app.include_router(bi_reports.router)
|
||||||
|
app.include_router(entities.router)
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def global_exception_handler(request: Request, exc: Exception):
|
async def global_exception_handler(request: Request, exc: Exception):
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllo
|
|||||||
from app.models.knowledge import KnowledgeEvent, KnowledgeSummary
|
from app.models.knowledge import KnowledgeEvent, KnowledgeSummary
|
||||||
|
|
||||||
|
|
||||||
|
class Entity(Base):
|
||||||
|
"""企业实体"""
|
||||||
|
__tablename__ = "entities"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(100), nullable=False, comment="企业全称")
|
||||||
|
short_name = Column(String(50), comment="企业简称")
|
||||||
|
industry = Column(String(50), comment="行业")
|
||||||
|
status = Column(String(20), default="active", comment="active/inactive/demo")
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
"""用户"""
|
"""用户"""
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
@@ -37,6 +49,7 @@ class KPIDefinition(Base):
|
|||||||
"""KPI字典"""
|
"""KPI字典"""
|
||||||
__tablename__ = "kpi_definitions"
|
__tablename__ = "kpi_definitions"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=1, comment="企业ID")
|
||||||
map_id = Column(Integer, ForeignKey("strategic_maps.id"), nullable=True, comment="关联战略地图")
|
map_id = Column(Integer, ForeignKey("strategic_maps.id"), nullable=True, comment="关联战略地图")
|
||||||
kpi_code = Column(String(50), unique=True, nullable=False, comment="KPI编码")
|
kpi_code = Column(String(50), unique=True, nullable=False, comment="KPI编码")
|
||||||
kpi_name = Column(String(200), nullable=False, comment="KPI名称")
|
kpi_name = Column(String(200), nullable=False, comment="KPI名称")
|
||||||
|
|||||||
Reference in New Issue
Block a user