""" 自动验证引擎 API — 管理会计OS POST /api/cma/verify/{plan_id} 手动验证行动计划执行结果 数据流: ActionPlan.auto_verify_rule (JSON) → 条件判断 → passed/failed → KPI值回填 (kpi_current_before/after + KPIValue source_type=verify) → 验证通过 → 所属OKR progress +15% → 通知任总 (send_wecom_message) 规则格式(新): { "kpi_code": "C_REBATE_RATE", "condition": "LESS_THAN", # LESS_THAN/GREATER_THAN/WITHIN_RANGE/NOT_NULL "target_value": 80, "baseline_value": 86.4, "verify_after_days": 7, "retry_max": 3, "escalate_to": "任富海", "notify": true } 兼容格式(旧): {"condition": "value < 75", "description": "..."} → 走 bot_bridge_v2._evaluate_condition """ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from datetime import datetime, timedelta import json import logging from app.database import get_db from app.models import ActionPlan, KPIDefinition, KPIValue, Objective from app.utils.notifier import send_wecom_message from app.auth_middleware import require_role logger = logging.getLogger("cma.verify") router = APIRouter( prefix="/api/cma/verify", tags=["自动验证"], dependencies=[Depends(require_role("ceo", "finance", "business", "it"))], ) CONDITION_FUNCS = { "LESS_THAN": lambda actual, target: actual is not None and actual < target, "GREATER_THAN": lambda actual, target: actual is not None and actual > target, "WITHIN_RANGE": lambda actual, target: target is not None and len(target) == 2 and float(target[0]) <= actual <= float(target[1]), "NOT_NULL": lambda actual, target: actual is not None, } def _parse_rule(rule) -> dict: """解析auto_verify_rule(兼容JSON字符串)""" if rule is None: return {} if isinstance(rule, str): try: return json.loads(rule) except Exception: return {"condition": rule} return rule def evaluate_rule(rule: dict, actual, kpi_data: dict = None) -> bool: """ 按规则判断actual是否达标 - 新格式: condition为枚举 LESS_THAN/GREATER_THAN/WITHIN_RANGE/NOT_NULL - 旧格式: condition为表达式字符串(如 "value < 75")→ bot_bridge_v2引擎 """ condition = (rule or {}).get("condition", "") if not condition: return actual is not None if condition in CONDITION_FUNCS: target = rule.get("target_value") return CONDITION_FUNCS[condition](actual, target) # 旧格式表达式(bot-bridge兼容) try: from app.api.bot_bridge_v2 import _evaluate_condition kd = kpi_data or {"value": actual, "target": rule.get("target_value"), "baseline": rule.get("baseline_value")} return _evaluate_condition(condition, kd) except Exception as e: logger.warning(f"旧格式条件评估失败({condition}): {e}") return False def update_okr_progress(db: Session, plan: ActionPlan) -> dict: """验证通过 → 所属OKR progress +15%(每通过1个KR)""" if not plan.objective_id: return {"updated": False, "reason": "no_objective"} obj = db.query(Objective).filter(Objective.id == plan.objective_id).first() if not obj: return {"updated": False, "reason": "objective_not_found"} before = obj.progress or 0 obj.progress = min(100, before + 15) db.flush() return {"updated": True, "objective_id": obj.id, "before": before, "after": obj.progress} def build_auto_verify_rule(kpi, baseline_value=None, verify_after_days: int = 7) -> dict: """根据KPI阈值自动生成验证规则(验收#1: 创建ActionPlan自动带auto_verify_rule) 从KPI的绿灯阈值(green)推导达标方向: - 绿灯 '<=X' → 目标是把值压到 X 以下 → LESS_THAN X - 绿灯 '>=X' → 目标是把值抬到 X 以上 → GREATER_THAN X - 有绿色区间 'X~Y' → WITHIN_RANGE - 无阈值 → NOT_NULL """ expr = (kpi.threshold_green or "").strip() condition = "NOT_NULL" target = None import re m = re.match(r"^(<=|>=|<|>|=)\s*([\d.]+)$", expr) if m: op, val = m.group(1), float(m.group(2)) if op in ("<", "<="): condition = "LESS_THAN" elif op in (">", ">="): condition = "GREATER_THAN" else: condition = "NOT_NULL" target = val elif "~" in expr: parts = expr.split("~") if len(parts) == 2: try: condition = "WITHIN_RANGE" target = [float(parts[0]), float(parts[1])] except ValueError: pass else: # 无阈值表达式 → 用KPI目标值推方向 condition = "NOT_NULL" target = None return { "kpi_code": kpi.kpi_code, "condition": condition, "target_value": target, "baseline_value": float(baseline_value) if baseline_value is not None else None, "verify_after_days": verify_after_days, "retry_max": 3, "escalate_to": "任富海", "notify": True, } def backfill_kpi_value(db: Session, plan: ActionPlan, actual, rule: dict, source: str = "verify"): """回填KPI当前值: 写入kpi_current_before/after + 新KPIValue记录""" kpi = None kpi_code = rule.get("kpi_code") if rule else None # 优先按验证规则指定的KPI编码查询;无规则时才回退到plan.kpi_id if kpi_code: kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first() elif plan.kpi_id: kpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first() if actual is None: return None plan.kpi_current_before = plan.kpi_current_before if plan.kpi_current_before is not None else rule.get("baseline_value") plan.kpi_current_after = actual if kpi: new_val = KPIValue( kpi_id=kpi.id, period=datetime.now().strftime("%Y-%m"), actual_value=actual, source_type="verify", source_batch=f"verify-plan-{plan.id}", data_status="verified", calculated_at=datetime.now(), remark=f"行动计划#{plan.id}验证回填", ) db.add(new_val) return {"kpi_id": kpi.id, "kpi_code": kpi.kpi_code} return None @router.post("/{plan_id}") def verify_action_plan(plan_id: int, payload: dict, db: Session = Depends(get_db)): """验证行动计划执行结果(手动验证 / Bot回填) 请求体: { "actual_value": 78.5, # 可选,缺省时取KPI最新值 "source": "财务Bot分析", # 来源 "note": "渠补谈判后...", # 备注/验证结果详情 "passed": true # 可选,无规则时手动指定 } """ plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first() if not plan: raise HTTPException(404, "行动计划不存在") rule = _parse_rule(plan.auto_verify_rule) actual = payload.get("actual_value") note = payload.get("note", "") source = payload.get("source", "手动验证") # 1. 无规则 → 按手动指定 passed 标记 if not rule or not rule.get("condition"): passed = payload.get("passed", True) plan.verify_status = "passed" if passed else "failed" plan.verify_result = "pass" if passed else "fail" plan.verify_log = (plan.verify_log or []) + [{ "timestamp": datetime.now().isoformat(), "passed": passed, "note": note, "source": source, }] plan.verified_at = datetime.now() db.commit() return {"plan_id": plan_id, "verify_status": plan.verify_status, "passed": passed} # 2. 缺省actual → 取KPI最新值 if actual is None: kpi = None kpi_code = rule.get("kpi_code") if plan.kpi_id: kpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first() elif kpi_code: kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first() if kpi: latest = db.query(KPIValue).filter( KPIValue.kpi_id == kpi.id, KPIValue.actual_value.isnot(None), ).order_by(KPIValue.calculated_at.desc(), KPIValue.id.desc()).first() if latest: actual = latest.actual_value # 3. 执行条件判断 passed = evaluate_rule(rule, actual, kpi_data={"value": actual, "target": rule.get("target_value"), "baseline": rule.get("baseline_value")}) # 4. 回写KPI当前值 backfill = backfill_kpi_value(db, plan, actual, rule, source=source) # 5. 更新状态 plan.verify_status = "passed" if passed else "failed" plan.verify_result = "pass" if passed else "fail" plan.verify_log = (plan.verify_log or []) + [{ "timestamp": datetime.now().isoformat(), "condition": rule.get("condition"), "actual_value": actual, "target_value": rule.get("target_value"), "passed": passed, "source": source, "note": note, }] if passed: plan.status = "done" plan.progress = 100 plan.verified_at = datetime.now() # 6. OKR进度联动(验证通过 → +15%) okr_update = None if passed: okr_update = update_okr_progress(db, plan) db.commit() # 7. 通知任总 if rule.get("notify", True): delta = "" if plan.kpi_current_before is not None and actual is not None: try: d = float(actual) - float(plan.kpi_current_before) delta = f"({d:+.1f})" except (TypeError, ValueError): delta = "" title = f"✅ 行动计划#{plan.id}验证通过" if passed else f"❌ 行动计划#{plan.id}验证失败" content = ( f"{plan.title}\n" f"KPI: {rule.get('kpi_code', '')} {plan.kpi_current_before}→{actual} {delta}\n" f"规则: {rule.get('condition')} {rule.get('target_value')}\n" f"来源: {source}\n" f"{note}" ) try: send_wecom_message(content=content, title=title, alert_level="green" if passed else "red") except Exception as e: logger.warning(f"验证通知发送失败: {e}") return { "plan_id": plan_id, "verify_status": plan.verify_status, "kpi_current_before": plan.kpi_current_before, "kpi_current_after": plan.kpi_current_after, "improvement": f"{float(actual) - float(plan.kpi_current_before):+.1f}" if plan.kpi_current_before is not None and actual is not None else None, "passed": passed, "okr_progress": okr_update, "kpi_backfill": backfill, "message": ( f"验证通过:{plan.title}" if passed else f"验证失败:{plan.title}(条件 {rule.get('condition')} {rule.get('target_value')} 未达成,当前值 {actual})" ), }