From 2d0abe341175a2bfb83ff9fab3a110802335403e Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Wed, 22 Jul 2026 17:43:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20bot-bridge+verify=E5=90=88=E5=B9=B6=20?= =?UTF-8?q?=E2=80=94=20API+=E6=95=B0=E6=8D=AE=E5=BA=93+=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E5=BC=95=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/bot_bridge_v2.py | 501 ++++++++++++++++++++++ backend/app/main.py | 3 +- backend/app/models/__init__.py | 24 ++ backend/scripts/migrate_bot_bridge_v2.sql | 23 + frontend/src/permission.ts | 12 +- 5 files changed, 561 insertions(+), 2 deletions(-) create mode 100644 backend/app/api/bot_bridge_v2.py create mode 100644 backend/scripts/migrate_bot_bridge_v2.sql diff --git a/backend/app/api/bot_bridge_v2.py b/backend/app/api/bot_bridge_v2.py new file mode 100644 index 00000000..6347a05f --- /dev/null +++ b/backend/app/api/bot_bridge_v2.py @@ -0,0 +1,501 @@ +""" +Bot-Bridge V2 — 合并改造:Bot桥接 + 自动验证引擎 + +数据流: + bot-bridge(财务Bot→CMA)→ 写入KPI + verify(CMA→校验)→ 读取KPI → 通过/失败 + +API: + POST /api/cma/bot-bridge/mpm-result — 接收MPM分析结果 + POST /api/cma/bot-bridge/verify/{action_plan_id} — 验证ActionPlan执行结果 +""" +import json, logging, re +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Header +from sqlalchemy.orm import Session +from sqlalchemy import func + +from app.database import get_db +from app.models import ( + MpmResult, BotBridgeConfig, + KPIDefinition, KPIValue, KPIAlert, + ActionPlan, Entity, +) + +logger = logging.getLogger("cma.bot_bridge_v2") + +router = APIRouter(prefix="/api/cma/bot-bridge", tags=["Bot桥接V2"]) + +# ═══════════════════════════════════════════════ +# 鉴权 +# ═══════════════════════════════════════════════ + +def verify_bridge_token( + x_bridge_token: str = Header(None, alias="X-BRIDGE-TOKEN"), + db: Session = Depends(get_db), +): + """从bot_bridge_config表校验Token""" + if not x_bridge_token: + raise HTTPException(401, "缺少X-BRIDGE-TOKEN请求头") + config = db.query(BotBridgeConfig).filter( + BotBridgeConfig.token == x_bridge_token, + BotBridgeConfig.is_active == True, + ).first() + if not config: + raise HTTPException(401, "Token无效或已停用") + return config.bot_name + + +# ═══════════════════════════════════════════════ +# 验证引擎 +# ═══════════════════════════════════════════════ + +def _get_kpi_current_value(db: Session, kpi_id: int, entity_id: int = None) -> Optional[dict]: + """获取KPI当前最新值及目标值""" + kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() + if not kpi: + return None + latest = db.query(KPIValue).filter( + KPIValue.kpi_id == kpi_id, + KPIValue.data_status == "verified", + ).order_by(KPIValue.period.desc()).first() + return { + "kpi_id": kpi.id, + "kpi_code": kpi.kpi_code, + "kpi_name": kpi.kpi_name, + "value": latest.actual_value if latest else None, + "target": kpi.target_value, + "period": latest.period if latest else None, + "unit": kpi.unit, + } + + +def _parse_condition(condition: str) -> list: + """ + 解析condition表达式为可执行结构 + + 支持格式: + "value > target" — 当前值 > 目标值 + "value >= 80" — 当前值 >= 80 (绝对值) + "value / target * 100 > 80" — 完成率 > 80% + "value < baseline" — 当前值 < 基线值(目标值=基线) + "value == 0" — 精确等于 + "value > target AND value < 100" — 多条件AND + "value > 50 OR value == -1" — 多条件OR + """ + if not condition: + return [] + + # 分割AND/OR + parts = re.split(r'\s+(AND|OR)\s+', condition, flags=re.IGNORECASE) + clauses = [] + for i in range(0, len(parts), 2): + expr = parts[i].strip() + # 检查是否有运算符连接 + match = re.match( + r'(value|target|baseline)\s*([><=!]+)\s*(value|target|baseline|-?\d+\.?\d*)(?:\s*([+\-*/])\s*(\d+\.?\d*))?', + expr + ) + if not match: + # 尝试 value/target*100 > 80 这种复合表达式 + match = re.match( + r'(value)\s*(/|\\*)\s*(target|baseline)\s*(\*?\s*\d+)?\s*([><=!]+)\s*(-?\d+\.?\d*)', + expr + ) + if not match: + logger.warning(f"无法解析condition表达式: {expr}") + continue + + logical_op = parts[i + 1].upper() if i + 1 < len(parts) else "AND" + clauses.append({"expr": expr, "logical_op": logical_op, "match": match.groups() if match else ()}) + + return clauses + + +def _evaluate_condition(condition: str, kpi_data: dict) -> bool: + """ + 评估condition表达式 + + Args: + condition: 条件表达式 e.g. "value > target" + kpi_data: {value, target, ...} + + Returns: + True=通过, False=不通过 + """ + if not condition: + return False + + value = kpi_data.get("value") + target = kpi_data.get("target") + baseline = kpi_data.get("baseline", target) + + if value is None: + logger.warning(f"KPI {kpi_data.get('kpi_code')} 当前值为空,无法验证") + return False + + result = True + current_logical = "AND" + + # 按AND/OR分割 + parts = re.split(r'\s+(AND|OR)\s+', condition, flags=re.IGNORECASE) + + for i in range(0, len(parts), 2): + expr = parts[i].strip() + if i + 1 < len(parts): + current_logical = parts[i + 1].upper() + + clause_passed = _eval_single_expr(expr, value, target, baseline) + + if current_logical == "AND": + result = result and clause_passed + elif current_logical == "OR": + result = result or clause_passed + + # 短路优化 + if current_logical == "AND" and not result: + break + if current_logical == "OR" and result: + break + + return result + + +def _eval_single_expr(expr: str, value: float, target: Optional[float], baseline: Optional[float]) -> bool: + """评估单条条件表达式""" + # 模式1: value target/baseline/number (如 value > target, value >= 80) + m = re.match( + r'(value|target|baseline)\s*([><=!]+)\s*(value|target|baseline|-?\d+\.?\d*)', + expr + ) + if m: + left = m.group(1) + op = m.group(2) + right_raw = m.group(3) + + left_val = _resolve_var(left, value, target, baseline) + right_val = _resolve_var(right_raw, value, target, baseline) + + if left_val is None or right_val is None: + return False + + return _apply_op(left_val, op, right_val) + + # 模式2: value [*/] target/baseline [* number] number (如 value/target*100 > 80) + m = re.match( + r'(value)\s*([/*])\s*(target|baseline)(?:\s*\*\s*(\d+))?\s*([><=!]+)\s*(-?\d+\.?\d*)', + expr + ) + if m: + left = m.group(1) + op1 = m.group(2) # / or * + var2 = m.group(3) # target or baseline + multiplier = float(m.group(4)) if m.group(4) else 100 + op2 = m.group(5) # > < >= <= == != + right_num = float(m.group(6)) + + left_val = _resolve_var(left, value, target, baseline) + right_var = _resolve_var(var2, value, target, baseline) + + if left_val is None or right_var is None or right_var == 0: + return False + + if op1 == '/': + computed = (left_val / right_var) * multiplier + else: # * + computed = left_val * right_var + + return _apply_op(computed, op2, right_num) + + # 模式3: 纯数字比较 (提供兼容) + logger.warning(f"无法解析表达式: {expr}") + return False + + +def _resolve_var(token: str, value: float, target: Optional[float], baseline: Optional[float]) -> Optional[float]: + """将变量名解析为数值""" + token = token.strip() + if token == "value": + return value + elif token == "target": + return target + elif token == "baseline": + return baseline + else: + try: + return float(token) + except (ValueError, TypeError): + return None + + +def _apply_op(left: float, op: str, right: float) -> bool: + """应用比较运算符""" + try: + if op == ">": + return left > right + elif op == ">=": + return left >= right + elif op == "<": + return left < right + elif op == "<=": + return left <= right + elif op in ("==", "="): + return abs(left - right) < 0.0001 + elif op == "!=": + return abs(left - right) >= 0.0001 + else: + logger.warning(f"未知运算符: {op}") + return False + except (TypeError, ValueError): + return False + + +# ═══════════════════════════════════════════════ +# KPI映射表:MPM结果字段 → KPI编码 +# ═══════════════════════════════════════════════ + +MPM_TO_KPI_MAP = { + "revenue": None, # 不做KPI映射,保留在raw_data + "cost": None, + "gross_margin_standard": None, + "gross_margin_adjusted": "F_GROSS_MARGIN", + "net_profit_standard": None, + "net_profit_adjusted": "F_NET_PROFIT", + "channel_rebate_rate": "C_REBATE_RATE", + "mgmt_expense_ratio": "F_COST_RATIO", +} + +# 预警阈值配置 +ALERT_THRESHOLDS = { +"channel_rebate_rate": {"threshold": 75, "operator": ">", "kpi_code": "C_REBATE_RATE", "kpi_name": "渠补率"}, +"mgmt_expense_ratio": {"threshold": 50, "operator": ">", "kpi_code": "F_COST_RATIO", "kpi_name": "管理费/净收入"}, +} + + +# ═══════════════════════════════════════════════ +# API端点 +# ═══════════════════════════════════════════════ + +@router.post("/mpm-result") +def receive_mpm_result( + data: dict, + bridge_bot: str = Depends(verify_bridge_token), + db: Session = Depends(get_db), +): + """ + 接收财务Bot/其他Bot的MPM分析结果 + + - 鉴权(校验X-BRIDGE-TOKEN) + - 写入mpm_results表 + - 更新KPI当前值(kpi_values表) + - 触发预警(如渠补率超75%) + """ + source = data.get("source", bridge_bot) + entity_id = data.get("entity_id") + period = data.get("period") + results = data.get("results", {}) + + if not entity_id or not period: + raise HTTPException(400, "缺少必填字段: entity_id, period") + + # 验证entity存在 + entity = db.query(Entity).filter(Entity.id == entity_id).first() + if not entity: + raise HTTPException(404, f"实体entity_id={entity_id}不存在") + + # ── 1. 写入MPM结果 ── + record = MpmResult( + entity_id=entity_id, + period=period, + source=source, + raw_data=results, + ) + db.add(record) + db.flush() + + # ── 2. 更新KPI当前值 ── + kpi_updates = [] + for field, kpi_code in MPM_TO_KPI_MAP.items(): + if kpi_code is None: + continue + field_value = results.get(field) + if field_value is None: + continue + + kpi = db.query(KPIDefinition).filter( + KPIDefinition.kpi_code == kpi_code, + KPIDefinition.entity_id == entity_id, + ).first() + if not kpi: + logger.warning(f"KPI编码 {kpi_code} 未找到 (entity_id={entity_id})") + continue + + # 写入最新值 (upsert: 存在同period则更新,否则插入) + existing = db.query(KPIValue).filter( + KPIValue.kpi_id == kpi.id, + KPIValue.period == period, + ).first() + if existing: + existing.actual_value = field_value + existing.source_type = "bot-bridge-v2" + existing.data_status = "verified" + else: + kpi_val = KPIValue( + kpi_id=kpi.id, + period=period, + actual_value=field_value, + source_type="bot-bridge-v2", + data_status="verified", + ) + db.add(kpi_val) + kpi_updates.append(kpi_code) + + # ── 3. 触发预警 ── + alerts = [] + for field, config in ALERT_THRESHOLDS.items(): + field_value = results.get(field) + if field_value is None: + continue + threshold = config["threshold"] + operator = config["operator"] + kpi_code = config.get("kpi_code") + kpi_name = config["kpi_name"] + + if (operator == ">" and field_value > threshold) or \ + (operator == ">=" and field_value >= threshold): + # 查找关联KPI + kpi_id = None + if kpi_code: + kpi_def = db.query(KPIDefinition).filter( + KPIDefinition.kpi_code == kpi_code, + KPIDefinition.entity_id == entity_id, + ).first() + if kpi_def: + kpi_id = kpi_def.id + + # 创建预警 + alert_msg = f"⚠️ {kpi_name}异常: {field_value:.1f}% (阈值: {threshold}%) [entity={entity.short_name or entity.name}, period={period}]" + alert = KPIAlert( + kpi_id=kpi_id or 1, # fallback to first KPI if not found + alert_level="red", + alert_message=alert_msg, + alert_type="actual", + status="pending", + ) + db.add(alert) + alerts.append(alert_msg) + logger.warning(alert_msg) + + db.commit() + db.refresh(record) + + return { + "success": True, + "kpi_updated": len(kpi_updates), + "alerts_triggered": len(alerts), + "mpm_record_id": record.id, + "details": { + "kpi_codes": kpi_updates, + "alerts": alerts, + }, + } + + +@router.post("/verify/{action_plan_id}") +def verify_action_plan( + action_plan_id: int, + bridge_bot: str = Depends(verify_bridge_token), + db: Session = Depends(get_db), +): + """ + 验证ActionPlan的执行结果 + + 1. 读取ActionPlan的auto_verify_rule + 2. 读取关联KPI的当前值 + 3. 按condition校验 + 4. 返回通过/失败 + 详细数据 + """ + plan = db.query(ActionPlan).filter(ActionPlan.id == action_plan_id).first() + if not plan: + raise HTTPException(404, f"ActionPlan {action_plan_id} 不存在") + + if not plan.auto_verify_rule: + raise HTTPException(400, "该ActionPlan未配置auto_verify_rule验证规则") + + # 解析验证规则 + if isinstance(plan.auto_verify_rule, str): + rule = json.loads(plan.auto_verify_rule) + else: + rule = plan.auto_verify_rule + + condition = rule.get("condition", "") + description = rule.get("description", "") + + # 获取KPI当前值 + kpi_data = _get_kpi_current_value(db, plan.kpi_id) + if not kpi_data: + return { + "success": False, + "action_plan_id": action_plan_id, + "title": plan.title, + "verify_result": "fail", + "reason": "关联KPI不存在", + } + + # 执行验证 + passed = _evaluate_condition(condition, kpi_data) + + # 记录验证日志 + verify_log_entry = { + "timestamp": datetime.now().isoformat(), + "condition": condition, + "kpi_data": kpi_data, + "passed": passed, + } + existing_logs = plan.verify_log or [] + if isinstance(existing_logs, list): + existing_logs.append(verify_log_entry) + else: + existing_logs = [verify_log_entry] + + plan.verify_result = "pass" if passed else "fail" + plan.verify_log = existing_logs + db.commit() + + return { + "success": True, + "action_plan_id": action_plan_id, + "title": plan.title, + "verify_result": plan.verify_result, + "condition": condition, + "condition_description": description, + "kpi_data": kpi_data, + "verification_detail": { + "value": kpi_data.get("value"), + "target": kpi_data.get("target"), + "condition": condition, + "passed": passed, + }, + } + + +@router.get("/verify/{action_plan_id}/history") +def verify_history( + action_plan_id: int, + bridge_bot: str = Depends(verify_bridge_token), + db: Session = Depends(get_db), +): + """获取ActionPlan的验证历史""" + plan = db.query(ActionPlan).filter(ActionPlan.id == action_plan_id).first() + if not plan: + raise HTTPException(404, f"ActionPlan {action_plan_id} 不存在") + + return { + "action_plan_id": action_plan_id, + "title": plan.title, + "verify_result": plan.verify_result, + "verify_log": plan.verify_log or [], + "auto_verify_rule": plan.auto_verify_rule, + } diff --git a/backend/app/main.py b/backend/app/main.py index 4fc60cff..0faff560 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from dotenv import load_dotenv 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, entities, bsc_layers, okr, okr_templates, subjects, driver_budget +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, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget 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 app.auth_middleware import require_auth @@ -55,6 +55,7 @@ app.include_router(reports.router) app.include_router(security.router) app.include_router(knowledge.router) app.include_router(bot_bridge.router) +app.include_router(bot_bridge_v2.router) app.include_router(customer_dashboard.router) app.include_router(deviation_push.router) app.include_router(budget_generate.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 19e251ea..868b7e6a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -204,6 +204,9 @@ class ActionPlan(Base): status = Column(String(20), default="pending", comment="pending/in_progress/completed/cancelled") progress = Column(Integer, default=0, comment="完成进度 0-100") result = Column(Text, nullable=True, comment="改善结果") + auto_verify_rule = Column(JSON, nullable=True, comment="自动验证规则: {\"condition\": \"value > target\"}") + verify_result = Column(String(20), nullable=True, comment="验证结果: pass/fail/pending") + verify_log = Column(JSON, nullable=True, comment="验证历史日志") created_by = Column(String(100), nullable=True, comment="创建人") created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) @@ -266,6 +269,27 @@ class KPICausality(Base): updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) +class MpmResult(Base): + """MPM财务Bot分析结果记录""" + __tablename__ = "mpm_results" + id = Column(Integer, primary_key=True, index=True) + entity_id = Column(Integer, ForeignKey("entities.id"), nullable=False, comment="企业实体ID") + period = Column(String(20), nullable=False, comment="期间 YYYY-MM") + source = Column(String(50), default="finance-bot", comment="来源Bot标识") + raw_data = Column(JSON, nullable=False, comment="完整的MPM计算结果") + created_at = Column(DateTime, server_default=func.now()) + + +class BotBridgeConfig(Base): + """Bot桥接鉴权配置""" + __tablename__ = "bot_bridge_config" + id = Column(Integer, primary_key=True, index=True) + bot_name = Column(String(50), unique=True, nullable=False, comment="Bot名称") + token = Column(String(64), nullable=False, comment="鉴权Token") + is_active = Column(Boolean, default=True, comment="是否激活") + created_at = Column(DateTime, server_default=func.now()) + + class KpiDataQualityLog(Base): """数据质量监控日志""" __tablename__ = "kpi_data_quality_log" diff --git a/backend/scripts/migrate_bot_bridge_v2.sql b/backend/scripts/migrate_bot_bridge_v2.sql new file mode 100644 index 00000000..dc98228d --- /dev/null +++ b/backend/scripts/migrate_bot_bridge_v2.sql @@ -0,0 +1,23 @@ +-- Bot-Bridge + Verify 合并改造:数据库迁移脚本 (表创建部分) +-- CREATE TABLE 在表不存在时安全执行 +-- ALTER TABLE 由 Python 脚本处理 + +CREATE TABLE IF NOT EXISTS mpm_results ( + id INT AUTO_INCREMENT PRIMARY KEY, + entity_id INT NOT NULL, + period VARCHAR(20) NOT NULL, + source VARCHAR(50) DEFAULT 'finance-bot', + raw_data JSON NOT NULL COMMENT '完整的MPM计算结果', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (entity_id) REFERENCES entities(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS bot_bridge_config ( + id INT AUTO_INCREMENT PRIMARY KEY, + bot_name VARCHAR(50) NOT NULL UNIQUE, + token VARCHAR(64) NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT IGNORE INTO bot_bridge_config (bot_name, token) VALUES ('finance-bot', 'cma-bot-bridge-2026'); diff --git a/frontend/src/permission.ts b/frontend/src/permission.ts index 17194d9f..cdc574c4 100644 --- a/frontend/src/permission.ts +++ b/frontend/src/permission.ts @@ -1,4 +1,11 @@ -import { MenuItem } from './types/menu' +// MenuItem接口定义 +interface MenuItem { + path: string + label: string + icon: string + roles: string[] + group?: string +} // ── 角色路由映射 ── export const ROLE_ROUTES: Record = { @@ -73,3 +80,6 @@ export function hasAction(role: string, action: string): boolean { const actions = ROLE_ACTIONS[role] return actions ? actions.includes(action) : false } + +// 防止tree-shaking优化掉菜单数据 +void MENU_ITEMS;