Files
cma-management/backend/app/api/verify.py
T

341 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
自动验证引擎 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)
⚠️ 双 verify 入口关系(2026-08-30 评审收敛,暂不重构):
- 本文件: /api/cma/verify/{plan_id} — 完整链路(回填KPIValue + OKR联动 + 企微通知)
- bot_bridge_v2.py: /api/cma/bot-bridge/verify/{action_plan_id} — 轻量版(仅记 verify_log
不回填KPIValue、不联动OKR、不通知),供财务Bot/研学Bot桥接通道调用
- 两者行为不一致,勿混用:Bot 通道走 bot_bridge_v2,业务侧手动/自动重验走本文件。
规则格式(新):
{
"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, already_verified: bool = False, force_recalc: bool = False) -> dict:
"""验证通过 → 所属OKR progress +15%(每通过1个KR
缺陷1修复(2026-08-30):幂等防重复累加
- already_verified=True(调用前 plan 已 verify_status=='passed' 且 verified_at 非空)
→ 跳过累加,返回当前值(保持原值)
- force_recalc=True 时强制重新累加(业务确需重验场景由调用方显式开启;默认 False)
"""
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
if already_verified and not force_recalc:
return {"updated": False, "reason": "already_verified", "objective_id": obj.id, "before": before, "after": before}
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记录
缺陷2修复(2026-08-30)多租户隔离:
- KPIValue 创建时设置 entity_id(从 plan 关联 KPI 定义取,即 plan.kpi_id → kpi_definitions.entity_id
- kpi_code 查询 KPIDefinition 时带 entity_id 过滤(防跨租户误匹配 kpi_code)
"""
kpi = None
kpi_code = rule.get("kpi_code") if rule else None
# 确定 plan 所属 entity_id(从 plan.kpi_id → KPIDefinition.entity_id 向上取)
entity_id = None
if plan.kpi_id:
pkpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
if pkpi:
entity_id = pkpi.entity_id
# 优先按验证规则指定的KPI编码查询(带 entity_id 过滤);无规则时才回退到plan.kpi_id
if kpi_code:
q = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code)
if entity_id is not None:
q = q.filter(KPIDefinition.entity_id == entity_id)
kpi = q.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,
entity_id=kpi.entity_id, # 缺陷2修复:多租户回填 entity_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最新值(缺陷3修复:按 period <= 当前月过滤,跨月验证不取历史期间;支持调用方显式传 period 覆盖,默认当前月)
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:
# 缺陷2修复:kpi_code 查询带 entity_id 过滤(防跨租户误匹配)
q = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code)
if plan.kpi_id:
pkpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
if pkpi and pkpi.entity_id is not None:
q = q.filter(KPIDefinition.entity_id == pkpi.entity_id)
kpi = q.first()
if kpi:
period_limit = payload.get("period") or datetime.now().strftime("%Y-%m")
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.actual_value.isnot(None),
KPIValue.period <= period_limit, # 缺陷3修复:只取当前月及之前的期间
).order_by(KPIValue.period.desc(), 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)
# 缺陷1修复:调用前先记录 plan 是否已处于"验证通过"状态(防止重复累加 OKR progress
already_verified = bool(plan.verify_status == "passed" and plan.verified_at is not None)
force_recalc = bool(payload.get("force_recalc", False))
# 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 = "completed" # 缺陷4修复:"done" 不在枚举(pending/in_progress/completed/cancelled),改 completed
plan.progress = 100
plan.verified_at = datetime.now()
# 6. OKR进度联动(验证通过 → +15%;缺陷1修复:已通过过的 plan 不再重复累加,force_recalc 可强制重算)
okr_update = None
if passed:
okr_update = update_okr_progress(db, plan, already_verified=already_verified, force_recalc=force_recalc)
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}"
),
}