300 lines
11 KiB
Python
300 lines
11 KiB
Python
"""
|
||
自动验证引擎 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})"
|
||
),
|
||
}
|