Files
cma-management/backend/scripts/auto_verify_cron.py
T
Hermes CI Fix 9b3a131070 fix(cma): cashflow statements importer + auto_verify/alert_generator observability
- add backend/scripts/import_cashflow_statements.py: load zxt cashflow stat (CNY) into cashflow_statements in 10k CNY, dry-run default, --apply gated

- auto_verify_cron: per-reason skip counters + legacy-condition rule now resolves kpi_code via plan.kpi_id (was unreachable) + --dry-run

- alert_generator: coverage self-check warns when current-month KPI values are absent (0 alerts != no trigger)

task: finance-20260916-cma-health-gaps-3items (items 1/2/3)
2026-09-16 23:46:48 +08:00

192 lines
9.1 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.
#!/usr/bin/env python3
"""Auto-Verify 定时验证任务 — 扫描到期pending计划,自动验证执行效果
运行: 30 9 * * * cd /root/cma-management/backend && python3 scripts/auto_verify_cron.py
"""
import sys, os
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import get_db
from app.models import ActionPlan, KPIDefinition, KPIValue, Objective
from app.utils.notifier import send_wecom_message
from app.api.verify import CONDITION_FUNCS
def evaluate_condition(condition, actual, target):
"""按条件判断验证结果(兼容旧表达式)"""
if isinstance(condition, str) and ('<' in condition or '>' in condition):
try:
return eval(condition.replace('value', str(actual)), {"__builtins__": {}})
except Exception:
return False
func = CONDITION_FUNCS.get(condition)
if not func:
return False
return func(actual, target)
def run_auto_verify(dry_run=False):
"""扫描到期pending计划,按 auto_verify_rule 自动验证执行效果。
2026-09-16 修复(任务 finance-20260916-cma-health-gaps-3items · 项2):
① 统计口径透明化——原汇总行「共处理N个到期计划」把「扫描到但全部 continue 跳过」的
计划也算作"处理",导致连续 36 天「验证通过0个」无法区分空转与失效。现输出
逐原因计数(无规则 / 无KPI代码 / KPI定义未匹配 / 无KPI值 / 实际评估)。
② 旧表达式规则可达——原 `if not kpi_code: continue` 位于 evaluate_condition 的
旧表达式兼容分支之前,使 rule={"condition":"value < 75"}(无 kpi_code,仅挂
plan.kpi_id)永远无法验证,该兼容分支成死代码。现先用 plan.kpi_id 反查
kpi_code 再判定(只用于本轮取数,不回写 DB)。
③ 新增 --dry-run:只读复算、不写库(供巡检/复核留痕用)。
"""
db = next(get_db())
try:
now = datetime.now()
# 扫描到期pending计划(due_date <= now+1天)
plans = db.query(ActionPlan).filter(
ActionPlan.verify_status == "pending",
ActionPlan.status.in_(["pending", "in_progress"]),
ActionPlan.due_date <= now + timedelta(days=1),
).all()
verified_count = 0
escalated_count = 0
messages = []
# 跳过原因计数(2026-09-16 新增,用于区分「确无对象」与「判据失效」)
skip = {"no_rule": 0, "no_kpi_code": 0, "kpi_def_missing": 0, "no_kpi_value": 0}
evaluated = 0
for plan in plans:
rule = plan.auto_verify_rule
if not rule or not isinstance(rule, dict):
skip["no_rule"] += 1
continue
kpi_code = rule.get("kpi_code")
if not kpi_code and plan.kpi_id:
# 旧表达式规则(只有 condition,无 kpi_code)→ 用计划关联 KPI 反查
link_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
if link_kpi and link_kpi.kpi_code:
kpi_code = link_kpi.kpi_code
rule = dict(rule)
rule["kpi_code"] = kpi_code
if not kpi_code:
skip["no_kpi_code"] += 1
continue
# 查最新KPI值
# 缺陷2残留修复(对齐 verify.py):从 plan 关联 KPI 向上取 entity_id,kpi_code 查询带 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
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.order_by(KPIDefinition.id.desc()).first()
if not kpi:
skip["kpi_def_missing"] += 1
continue
# 缺陷3残留修复(对齐 verify.py):KPIValue 按 period <= 当前月过滤,跨月验证不取未来期间
period_limit = now.strftime("%Y-%m")
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.actual_value.isnot(None),
KPIValue.period <= period_limit,
).order_by(KPIValue.period.desc(), KPIValue.calculated_at.desc(), KPIValue.id.desc()).first()
if not latest or latest.actual_value is None:
skip["no_kpi_value"] += 1
continue
actual = latest.actual_value
condition = rule.get("condition", "LESS_THAN")
target = rule.get("target_value")
retry_max = rule.get("retry_max", 3)
passed = evaluate_condition(condition, actual, target)
evaluated += 1
if not dry_run:
plan.kpi_current_before = rule.get("baseline_value")
plan.kpi_current_after = actual
if passed:
# 缺陷1残留修复(对齐 verify.py):plan 已处于"验证通过"状态(passed + verified_at 非空)则跳过 OKR 累加,防重复累加
already_verified = bool(plan.verify_status == "passed" and plan.verified_at is not None)
if not dry_run:
plan.verify_status = "passed"
plan.verified_at = now
plan.status = "completed" # 缺陷4残留修复:"done" 不在枚举(pending/in_progress/completed/cancelled),改 completed
plan.progress = 100
# 阶段3: OKR progress更新
update_okr_progress(db, plan, +15, already_verified=already_verified)
messages.append(f"✅ 行动计划#{plan.id}验证通过: {plan.title} ({kpi_code}: {rule.get('baseline_value')}→{actual})")
verified_count += 1
else:
attempts = (plan.verify_attempts or 0) + (0 if dry_run else 1)
if not dry_run:
plan.verify_attempts = attempts
if attempts >= retry_max:
if not dry_run:
plan.verify_status = "escalated"
messages.append(f"🚨 行动计划#{plan.id}连续{retry_max}次验证失败,升级人工: {plan.title}")
escalated_count += 1
else:
if not dry_run:
plan.verify_status = "retrying"
messages.append(f"⚠️ 行动计划#{plan.id}验证失败(第{attempts}次),待重试: {plan.title}")
if not dry_run:
db.commit()
# 汇总通知
if messages and not dry_run:
summary = "\n".join(messages)
try:
send_wecom_message(f"📋 行动计划自动验证日报\n{summary}", title="Auto-Verify")
except Exception:
pass
# 汇总行:保留「验证通过N个」前缀(巡检脚本 cma-daily-healthcheck.py 按该字面量取数),
# 追加逐原因明细 —— 使「0 通过」可区分为「无验证对象」还是「判据失效」
print(f"[{now.strftime('%Y-%m-%d %H:%M')}] 验证通过{verified_count}个,升级{escalated_count}个,"
f"共处理{len(plans)}个到期计划 | 实际评估{evaluated}个;跳过明细: "
f"无规则{skip['no_rule']}个/无KPI代码{skip['no_kpi_code']}个/"
f"KPI定义未匹配{skip['kpi_def_missing']}个/无KPI值{skip['no_kpi_value']}个"
+ ("(dry-run 未写库)" if dry_run else ""))
return {"verified": verified_count, "escalated": escalated_count, "total": len(plans),
"evaluated": evaluated, "skip": skip, "dry_run": dry_run}
finally:
db.close()
def update_okr_progress(db, plan, delta, already_verified=False):
"""验证通过→更新所属OKR progress
缺陷1残留修复(对齐 verify.py 2026-08-30):幂等防重复累加
- already_verified=True(plan 已处于验证通过状态且 verified_at 非空)→ 跳过累加,保持原值
"""
okr_id = getattr(plan, "okr_id", None) or getattr(plan, "objective_id", None)
if not okr_id:
return {"updated": False, "reason": "no_objective"}
obj = db.query(Objective).filter(Objective.id == okr_id).first()
if not obj:
return {"updated": False, "reason": "objective_not_found"}
if already_verified:
return {"updated": False, "reason": "already_verified", "objective_id": obj.id, "progress": obj.progress or 0}
current = obj.progress or 0
obj.progress = min(current + delta, 100)
db.add(obj)
return {"updated": True, "objective_id": obj.id, "progress": obj.progress}
if __name__ == "__main__":
import sys as _sys
_dry = "--dry-run" in _sys.argv
res = run_auto_verify(dry_run=_dry)
if _dry:
import json as _json
print(_json.dumps(res, ensure_ascii=False))