feat: 多租户联动 — CMA切换企业注入tenant_id + a2a_dispatch分发
This commit is contained in:
@@ -216,7 +216,7 @@ def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(requi
|
|||||||
in_progress = query.filter(ActionPlan.status == "in_progress").count()
|
in_progress = query.filter(ActionPlan.status == "in_progress").count()
|
||||||
completed = query.filter(ActionPlan.status == "completed").count()
|
completed = query.filter(ActionPlan.status == "completed").count()
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
overdue = query.filter(ActionPlan.status.in_(["pending", "in_progress"]), ActionPlan.deadline < datetime.now()).count()
|
overdue = query.filter(ActionPlan.status.in_(["pending", "in_progress"]), ActionPlan.due_date < datetime.now()).count()
|
||||||
return {
|
return {
|
||||||
"total": total,
|
"total": total,
|
||||||
"pending": pending,
|
"pending": pending,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""租户状态API — CMA系统切换公司时记录当前tenant,供项目Bot分发A2A任务"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import SystemConfig, Entity
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/cma/tenant", tags=["多租户"])
|
||||||
|
|
||||||
|
TENANT_KEY = "current_tenant"
|
||||||
|
|
||||||
|
class TenantSwitchRequest(BaseModel):
|
||||||
|
entity_id: int
|
||||||
|
source: str = "cma-system"
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_name(entity_id: int) -> str:
|
||||||
|
"""entity_id → tenant_id"""
|
||||||
|
return "company_b" if entity_id == 2 else "company_a"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/current")
|
||||||
|
def get_current_tenant(db: Session = Depends(get_db)):
|
||||||
|
"""查询当前租户"""
|
||||||
|
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
|
||||||
|
if cfg and cfg.config_value:
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
return json.loads(cfg.config_value)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return {"tenant_id": "company_a", "entity_id": 1, "name": "陕西酣客文化传媒"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/switch")
|
||||||
|
def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db)):
|
||||||
|
"""切换当前租户(CMA前端企业切换器调用)"""
|
||||||
|
ent = db.query(Entity).filter(Entity.id == data.entity_id).first()
|
||||||
|
if not ent:
|
||||||
|
raise HTTPException(404, "企业不存在")
|
||||||
|
|
||||||
|
import json
|
||||||
|
state = {
|
||||||
|
"tenant_id": _tenant_name(data.entity_id),
|
||||||
|
"entity_id": data.entity_id,
|
||||||
|
"name": ent.name,
|
||||||
|
"short_name": ent.short_name,
|
||||||
|
"source": data.source,
|
||||||
|
"switched_at": __import__("datetime").datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
|
||||||
|
if cfg:
|
||||||
|
cfg.config_value = json.dumps(state, ensure_ascii=False)
|
||||||
|
else:
|
||||||
|
db.add(SystemConfig(
|
||||||
|
config_key=TENANT_KEY,
|
||||||
|
config_value=json.dumps(state, ensure_ascii=False),
|
||||||
|
description="当前租户状态(CMA企业切换联动)",
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"success": True, "current": state}
|
||||||
+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, bot_bridge_v2, lead, 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, bot_iron_law, analysis_results, expenses, cash, tax_compliance
|
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, 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, bot_iron_law, analysis_results, expenses, cash, tax_compliance
|
||||||
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
|
||||||
@@ -57,6 +57,7 @@ app.include_router(knowledge.router)
|
|||||||
app.include_router(bot_bridge.router)
|
app.include_router(bot_bridge.router)
|
||||||
app.include_router(bot_bridge_v2.router)
|
app.include_router(bot_bridge_v2.router)
|
||||||
app.include_router(lead.router)
|
app.include_router(lead.router)
|
||||||
|
app.include_router(tenant.router)
|
||||||
app.include_router(customer_dashboard.router)
|
app.include_router(customer_dashboard.router)
|
||||||
app.include_router(deviation_push.router)
|
app.include_router(deviation_push.router)
|
||||||
app.include_router(budget_generate.router)
|
app.include_router(budget_generate.router)
|
||||||
|
|||||||
+146
-71
@@ -22,7 +22,7 @@ from dotenv import load_dotenv
|
|||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
from app.database import get_engine, get_session_local
|
from app.database import get_engine, get_session_local
|
||||||
from app.models import KPIDefinition, KPIValue, OperationLog
|
from app.models import KPIDefinition, KPIValue, OperationLog, DataSourceConfig
|
||||||
|
|
||||||
logger = logging.getLogger("erp_sync")
|
logger = logging.getLogger("erp_sync")
|
||||||
|
|
||||||
@@ -30,6 +30,16 @@ logger = logging.getLogger("erp_sync")
|
|||||||
ERP_API_BASE = os.getenv("ERP_API_BASE", "http://127.0.0.1:8300/api/v1")
|
ERP_API_BASE = os.getenv("ERP_API_BASE", "http://127.0.0.1:8300/api/v1")
|
||||||
ERP_API_KEY = os.getenv("ERP_API_KEY", "erp-gateway-key-bhwl-2026")
|
ERP_API_KEY = os.getenv("ERP_API_KEY", "erp-gateway-key-bhwl-2026")
|
||||||
|
|
||||||
|
# 无DB会话时使用的静态映射(保底,使用真实KPI编码)
|
||||||
|
# 对应 data_source_config 表的 active 端点(id=1,2,3,4)
|
||||||
|
# {period_year} 和 {period_month} 在 _resolve_endpoint 中替换
|
||||||
|
STATIC_API_MAP = {
|
||||||
|
"F_REVENUE": f"{ERP_API_BASE}/stats/monthly?year={{{'period_year'}}}",
|
||||||
|
"F_GROSS_MARGIN": f"{ERP_API_BASE}/stats/gross-profit?year={{{'period_year'}}}&month={{{'period_month'}}}",
|
||||||
|
"F_NET_PROFIT": f"{ERP_API_BASE}/stats/monthly?year={{{'period_year'}}}",
|
||||||
|
"F_COST_RATIO": f"{ERP_API_BASE}/stats/monthly-cost?year={{{'period_year'}}}",
|
||||||
|
}
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 公式解析
|
# 公式解析
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -95,36 +105,147 @@ def parse_formula(formula: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# API 模式: 通过 ERP 接口查询
|
# API 模式: 通过 ERP 接口查询(表驱动)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
def fetch_via_api(kpi: KPIDefinition, parsed: dict, period: str) -> float:
|
# 端点类型 → 解析方式映射
|
||||||
"""通过 erp-api-gateway 查询ERP数据"""
|
# key: 端点路径片段, value: 解析类型
|
||||||
|
# 注意: 更具体的路径要放在前面(如 monthly-cost 在 monthly 之前)
|
||||||
|
ENDPOINT_PARSER = {
|
||||||
|
"/stats/monthly-cost": "monthly_cost", # data:[{period, sales, cost, orders}]
|
||||||
|
"/stats/monthly": "monthly", # data:[{period, orders, customers, amount}]
|
||||||
|
"/stats/gross-profit": "gross_profit", # {gross_profit_rate}
|
||||||
|
"/stats/customer-top": "customer_top", # data:[{name, orders, amount}]
|
||||||
|
"/stats/product-top": "product_top", # data:[{name, qty, amount, orders}]
|
||||||
|
"/sales/summary": "sales_summary", # {total_amount, customer_count, ...}
|
||||||
|
"/sales/trend": "trend", # data:[{period, amount}]
|
||||||
|
"/data/query": "raw_query", # {data:[...]}
|
||||||
|
"/crm/retention-rate": "crm_retention", # {retention_rate|value}
|
||||||
|
"/crm/new-customers": "crm_new", # {new_customers|value}
|
||||||
|
"/production/quality-rate": "quality", # {quality_rate|value}
|
||||||
|
"/production/rework-rate": "rework", # {rework_rate|value}
|
||||||
|
"/cashflow": "cashflow", # {value}
|
||||||
|
"/ar/aging": "ar_aging", # {value}
|
||||||
|
"/delivery/rate": "delivery_rate", # {value}
|
||||||
|
"/quality/defect": "quality_defect", # {value}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_endpoint(kpi: KPIDefinition, db_session, period: str) -> tuple:
|
||||||
|
"""从 data_source_config 表解析KPI的API端点。
|
||||||
|
返回 (url, parser_type) 或 (None, None)
|
||||||
|
"""
|
||||||
period_month = int(period[5:7])
|
period_month = int(period[5:7])
|
||||||
period_year = int(period[:4])
|
period_year = int(period[:4])
|
||||||
|
|
||||||
|
# 1. 优先从 kpi.data_source_config JSON (source_ids) 查 data_source_config 表
|
||||||
|
if db_session is not None:
|
||||||
|
cfg = getattr(kpi, "data_source_config", None)
|
||||||
|
if isinstance(cfg, dict) and cfg.get("source_ids"):
|
||||||
|
for sid in cfg["source_ids"]:
|
||||||
|
src = db_session.query(DataSourceConfig).filter(
|
||||||
|
DataSourceConfig.id == sid,
|
||||||
|
DataSourceConfig.status == "active",
|
||||||
|
).first()
|
||||||
|
if src and src.api_endpoint:
|
||||||
|
url = src.api_endpoint
|
||||||
|
# 兼容 {period_year}/{period_month} 模板
|
||||||
|
url = url.replace("{period_year}", str(period_year)) \
|
||||||
|
.replace("{period_month}", str(period_month))
|
||||||
|
parser = _detect_parser(url)
|
||||||
|
logger.info(f" [{kpi.kpi_code}] 表驱动: source_id={sid} → {url}")
|
||||||
|
return url, parser
|
||||||
|
|
||||||
|
# 2. 静态映射保底(旧版硬编码,含真实KPI编码)
|
||||||
|
url = STATIC_API_MAP.get(kpi.kpi_code)
|
||||||
|
if url:
|
||||||
|
url = url.replace("{period_year}", str(period_year)) \
|
||||||
|
.replace("{period_month}", str(period_month))
|
||||||
|
return url, _detect_parser(url)
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_parser(url: str) -> str:
|
||||||
|
"""根据URL路径推断解析类型"""
|
||||||
|
for fragment, parser in ENDPOINT_PARSER.items():
|
||||||
|
if fragment in url:
|
||||||
|
return parser
|
||||||
|
return "generic"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_response(kpi_code: str, parser: str, data: dict, period: str) -> float:
|
||||||
|
"""按端点类型解析响应,返回数值"""
|
||||||
|
# 通用兜底
|
||||||
|
if isinstance(data, (int, float)):
|
||||||
|
return float(data)
|
||||||
|
|
||||||
|
# data[] 数组类型
|
||||||
|
rows = data.get("data", []) if isinstance(data, dict) else []
|
||||||
|
|
||||||
|
if parser == "monthly":
|
||||||
|
for m in rows:
|
||||||
|
if m.get("period") == period:
|
||||||
|
return float(m.get("amount", 0))
|
||||||
|
return float(data.get("summary", {}).get("total_amount", 0)) if isinstance(data, dict) else 0
|
||||||
|
|
||||||
|
elif parser == "monthly_cost":
|
||||||
|
for m in rows:
|
||||||
|
if m.get("period") == period:
|
||||||
|
return float(m.get("cost", 0))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
elif parser == "customer_top":
|
||||||
|
return float(sum(c.get("amount", 0) for c in rows))
|
||||||
|
|
||||||
|
elif parser == "product_top":
|
||||||
|
return float(sum(c.get("amount", 0) for c in rows))
|
||||||
|
|
||||||
|
elif parser == "trend":
|
||||||
|
for m in rows:
|
||||||
|
if m.get("period") == period:
|
||||||
|
return float(m.get("amount", 0))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
elif parser == "raw_query":
|
||||||
|
# /data/query 返回 {data:[{...}]} 取第一行第一个数值
|
||||||
|
if rows:
|
||||||
|
first = rows[0]
|
||||||
|
for v in first.values():
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return float(v)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
elif parser == "gross_profit":
|
||||||
|
return float(data.get("gross_profit_rate", data.get("value", 0)))
|
||||||
|
|
||||||
|
elif parser == "sales_summary":
|
||||||
|
return float(data.get("total_amount", data.get("value", 0)))
|
||||||
|
|
||||||
|
elif parser in ("crm_retention", "crm_new", "quality", "rework",
|
||||||
|
"cashflow", "ar_aging", "delivery_rate", "quality_defect"):
|
||||||
|
# 所有财务/运营扩展端点统一返回 {value: xxx}
|
||||||
|
return float(data.get("value", data.get("retention_rate", data.get("new_customers",
|
||||||
|
data.get("quality_rate", data.get("rework_rate", 0))))))
|
||||||
|
|
||||||
|
# generic: 取常见字段
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for key in ("value", "amount", "total", "actual_value", "result"):
|
||||||
|
if key in data and data[key] is not None:
|
||||||
|
return float(data[key])
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_via_api(kpi: KPIDefinition, parsed: dict, period: str, db_session=None) -> float:
|
||||||
|
"""通过 erp-api-gateway 查询ERP数据(表驱动)"""
|
||||||
kpi_code = kpi.kpi_code
|
kpi_code = kpi.kpi_code
|
||||||
|
|
||||||
# 各KPI对应的API路径
|
url, parser = _resolve_endpoint(kpi, db_session, period)
|
||||||
API_MAP = {
|
if not url:
|
||||||
"SALES_TOTAL": f"{ERP_API_BASE}/stats/monthly?year={period_year}",
|
|
||||||
"CUSTOMER_COUNT": f"{ERP_API_BASE}/stats/monthly?year={period_year}",
|
|
||||||
"SALES_PROFIT_RATE": f"{ERP_API_BASE}/stats/gross-profit?year={period_year}&month={period_month}",
|
|
||||||
"TOP5_CUSTOMER_RATIO": f"{ERP_API_BASE}/stats/customer-top?year={period_year}&limit=5",
|
|
||||||
# P1: CRM模块
|
|
||||||
"C_RETENTION_RATE": f"{ERP_API_BASE}/crm/retention-rate?year={period_year}&month={period_month}",
|
|
||||||
"C_NEW_CLIENTS": f"{ERP_API_BASE}/crm/new-customers?year={period_year}&month={period_month}",
|
|
||||||
# P1: 生产模块
|
|
||||||
"F_QUALITY_RATE": f"{ERP_API_BASE}/production/quality-rate?year={period_year}&month={period_month}",
|
|
||||||
"F_REWORK_RATE": f"{ERP_API_BASE}/production/rework-rate?year={period_year}&month={period_month}",
|
|
||||||
}
|
|
||||||
|
|
||||||
headers = {"X-API-Key": ERP_API_KEY, "User-Agent": "CMA-ERP-SYNC/1.0"}
|
|
||||||
|
|
||||||
if kpi_code not in API_MAP:
|
|
||||||
raise ValueError(f"未配置API映射: {kpi_code}")
|
raise ValueError(f"未配置API映射: {kpi_code}")
|
||||||
|
|
||||||
url = API_MAP[kpi_code]
|
headers = {"X-API-Key": ERP_API_KEY, "User-Agent": "CMA-ERP-SYNC/1.0"}
|
||||||
logger.info(f" [{kpi_code}] API请求: {url}")
|
logger.info(f" [{kpi_code}] API请求: {url} (parser={parser})")
|
||||||
|
|
||||||
req = urllib.request.Request(url, headers=headers)
|
req = urllib.request.Request(url, headers=headers)
|
||||||
try:
|
try:
|
||||||
@@ -135,53 +256,7 @@ def fetch_via_api(kpi: KPIDefinition, parsed: dict, period: str) -> float:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ConnectionError(f"API请求失败: {e}")
|
raise ConnectionError(f"API请求失败: {e}")
|
||||||
|
|
||||||
if kpi_code == "SALES_TOTAL":
|
return _parse_response(kpi_code, parser, data, period)
|
||||||
# 从 monthly trend 中取对应月份
|
|
||||||
for m in data.get("data", []):
|
|
||||||
if m["period"] == period:
|
|
||||||
return float(m["amount"])
|
|
||||||
# fallback: 取汇总
|
|
||||||
return float(data.get("summary", {}).get("total_amount", 0))
|
|
||||||
|
|
||||||
elif kpi_code == "CUSTOMER_COUNT":
|
|
||||||
for m in data.get("data", []):
|
|
||||||
if m["period"] == period:
|
|
||||||
return float(m["customers"])
|
|
||||||
return 0
|
|
||||||
|
|
||||||
elif kpi_code == "SALES_PROFIT_RATE":
|
|
||||||
return float(data.get("gross_profit_rate", 0))
|
|
||||||
|
|
||||||
elif kpi_code == "TOP5_CUSTOMER_RATIO":
|
|
||||||
top5 = data.get("data", [])
|
|
||||||
top5_total = sum(c["amount"] for c in top5)
|
|
||||||
# 同时获取全年总额
|
|
||||||
total_url = f"{ERP_API_BASE}/stats/monthly?year={period_year}"
|
|
||||||
req2 = urllib.request.Request(total_url, headers=headers)
|
|
||||||
with urllib.request.urlopen(req2, timeout=15) as resp2:
|
|
||||||
total_data = json.loads(resp2.read().decode())
|
|
||||||
total_amount = sum(m["amount"] for m in total_data.get("data", []))
|
|
||||||
if total_amount > 0:
|
|
||||||
return round(top5_total / total_amount * 100, 2)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# P1: CRM模块 — 客户保留率
|
|
||||||
elif kpi_code == "C_RETENTION_RATE":
|
|
||||||
return float(data.get("retention_rate", data.get("value", 0)))
|
|
||||||
|
|
||||||
# P1: CRM模块 — 新客户数
|
|
||||||
elif kpi_code == "C_NEW_CLIENTS":
|
|
||||||
return float(data.get("new_customers", data.get("value", 0)))
|
|
||||||
|
|
||||||
# P1: 生产模块 — 产品合格率
|
|
||||||
elif kpi_code == "F_QUALITY_RATE":
|
|
||||||
return float(data.get("quality_rate", data.get("value", 0)))
|
|
||||||
|
|
||||||
# P1: 生产模块 — 返工率
|
|
||||||
elif kpi_code == "F_REWORK_RATE":
|
|
||||||
return float(data.get("rework_rate", data.get("value", 0)))
|
|
||||||
|
|
||||||
raise ValueError(f"未实现的API映射: {kpi_code}")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -254,7 +329,7 @@ def sync_kpi(kpi: KPIDefinition, db_session, dry_run: bool = False,
|
|||||||
api_ok = False
|
api_ok = False
|
||||||
if use_api:
|
if use_api:
|
||||||
try:
|
try:
|
||||||
value = fetch_via_api(kpi, parsed, current_period)
|
value = fetch_via_api(kpi, parsed, current_period, db_session=db_session)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
api_ok = True
|
api_ok = True
|
||||||
logger.info(f" [{kpi.kpi_code}] API结果: {current_period}={value}")
|
logger.info(f" [{kpi.kpi_code}] API结果: {current_period}={value}")
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ function onEntityChange(id: number) {
|
|||||||
currentEntityName.value = e?.short_name || e?.name || ''
|
currentEntityName.value = e?.short_name || e?.name || ''
|
||||||
localStorage.setItem('cma_entity_id', String(id))
|
localStorage.setItem('cma_entity_id', String(id))
|
||||||
localStorage.setItem('cma_entity_name', currentEntityName.value)
|
localStorage.setItem('cma_entity_name', currentEntityName.value)
|
||||||
|
// 多租户联动:通知后端记录当前tenant(供项目Bot A2A分发)
|
||||||
|
try {
|
||||||
|
api.post('/tenant/switch', { entity_id: id, source: 'cma-frontend' })
|
||||||
|
} catch (_) {}
|
||||||
location.reload()
|
location.reload()
|
||||||
}
|
}
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user