From 6910d90288e4fbe59d45e8e250616e9c2fdeedf4 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Sun, 30 Aug 2026 19:16:19 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20auto-verify=E9=AA=8C=E8=AF=81=E5=BC=95?= =?UTF-8?q?=E6=93=8E4=E9=A1=B9=E7=BC=BA=E9=99=B7=E4=BF=AE=E5=A4=8D=20verif?= =?UTF-8?q?y-fix=20(OKR=E5=B9=82=E7=AD=89=E9=98=B2=E9=87=8D=E7=B4=AF?= =?UTF-8?q?=E5=8A=A0/entity=5Fid=E5=A4=9A=E7=A7=9F=E6=88=B7=E5=9B=9E?= =?UTF-8?q?=E5=A1=AB/period=E6=9C=9F=E9=97=B4=E8=BF=87=E6=BB=A4/status=20d?= =?UTF-8?q?one->completed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/bot_bridge_v2.py | 9 +- backend/app/api/verify.py | 63 +++++- .../scripts/migrate_verify_fix_20260830.py | 84 ++++++++ backend/tests/test_auto_verify_engine.py | 196 ++++++++++++++++++ 4 files changed, 340 insertions(+), 12 deletions(-) create mode 100644 backend/scripts/migrate_verify_fix_20260830.py create mode 100644 backend/tests/test_auto_verify_engine.py diff --git a/backend/app/api/bot_bridge_v2.py b/backend/app/api/bot_bridge_v2.py index 93de44ae..8e3fbf20 100644 --- a/backend/app/api/bot_bridge_v2.py +++ b/backend/app/api/bot_bridge_v2.py @@ -532,7 +532,14 @@ def verify_action_plan( ): """ 验证ActionPlan的执行结果 - + + ⚠️ 双 verify 入口关系(2026-08-30 评审收敛,暂不重构): + - 本函数(/api/cma/bot-bridge/verify/{action_plan_id}): 轻量版 — 仅读取KPI并按condition + 校验,记录 verify_log,不写KPIValue回填、不联动OKR、不发企微通知。供Bot桥接通道调用。 + - verify.py(/api/cma/verify/{plan_id}): 完整链路 — 回填KPIValue + OKR progress联动 + + 企微通知。业务侧手动/自动重验走那个入口。 + - 两者行为不一致,勿混用。 + 1. 读取ActionPlan的auto_verify_rule 2. 读取关联KPI的当前值 3. 按condition校验 diff --git a/backend/app/api/verify.py b/backend/app/api/verify.py index e6745e7d..9f2da86b 100644 --- a/backend/app/api/verify.py +++ b/backend/app/api/verify.py @@ -8,6 +8,12 @@ POST /api/cma/verify/{plan_id} 手动验证行动计划执行结果 → 验证通过 → 所属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", @@ -86,14 +92,22 @@ def evaluate_rule(rule: dict, actual, kpi_data: dict = None) -> bool: return False -def update_okr_progress(db: Session, plan: ActionPlan) -> dict: - """验证通过 → 所属OKR progress +15%(每通过1个KR)""" +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} @@ -148,12 +162,26 @@ def build_auto_verify_rule(kpi, baseline_value=None, verify_after_days: int = 7) def backfill_kpi_value(db: Session, plan: ActionPlan, actual, rule: dict, source: str = "verify"): - """回填KPI当前值: 写入kpi_current_before/after + 新KPIValue记录""" + """回填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 - # 优先按验证规则指定的KPI编码查询;无规则时才回退到plan.kpi_id + # 确定 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: - kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first() + 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() @@ -166,6 +194,7 @@ def backfill_kpi_value(db: Session, plan: ActionPlan, actual, rule: dict, source 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", @@ -215,19 +244,27 @@ def verify_action_plan(plan_id: int, payload: dict, db: Session = Depends(get_db db.commit() return {"plan_id": plan_id, "verify_status": plan.verify_status, "passed": passed} - # 2. 缺省actual → 取KPI最新值 + # 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: - kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first() + # 缺陷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), - ).order_by(KPIValue.calculated_at.desc(), KPIValue.id.desc()).first() + KPIValue.period <= period_limit, # 缺陷3修复:只取当前月及之前的期间 + ).order_by(KPIValue.period.desc(), KPIValue.calculated_at.desc(), KPIValue.id.desc()).first() if latest: actual = latest.actual_value @@ -237,6 +274,10 @@ def verify_action_plan(plan_id: int, payload: dict, db: Session = Depends(get_db # 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" @@ -250,14 +291,14 @@ def verify_action_plan(plan_id: int, payload: dict, db: Session = Depends(get_db "note": note, }] if passed: - plan.status = "done" + plan.status = "completed" # 缺陷4修复:"done" 不在枚举(pending/in_progress/completed/cancelled),改 completed plan.progress = 100 plan.verified_at = datetime.now() - # 6. OKR进度联动(验证通过 → +15%) + # 6. OKR进度联动(验证通过 → +15%;缺陷1修复:已通过过的 plan 不再重复累加,force_recalc 可强制重算) okr_update = None if passed: - okr_update = update_okr_progress(db, plan) + okr_update = update_okr_progress(db, plan, already_verified=already_verified, force_recalc=force_recalc) db.commit() diff --git a/backend/scripts/migrate_verify_fix_20260830.py b/backend/scripts/migrate_verify_fix_20260830.py new file mode 100644 index 00000000..e5cf0b87 --- /dev/null +++ b/backend/scripts/migrate_verify_fix_20260830.py @@ -0,0 +1,84 @@ +"""verify-engine-fix-20260830 DB 迁移脚本 + +1. kpi_values.entity_id 回填:kpi_id → kpi_definitions.entity_id(现有 67 条 NULL) +2. action_plans.status 'done' → 'completed'(枚举修正;当前 0 条,防御性执行) + +执行前先 SELECT 预览影响行数,再执行 UPDATE,最后回查验证。 +用法: venv/bin/python3 scripts/migrate_verify_fix_20260830.py +""" +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import text +from app.database import get_engine + + +def main(): + engine = get_engine() + with engine.connect() as conn: + # ── 1. 预览 ── + preview_null = conn.execute(text( + "SELECT COUNT(*) AS cnt FROM kpi_values kv " + "LEFT JOIN kpi_definitions kd ON kv.kpi_id = kd.id " + "WHERE kv.entity_id IS NULL AND kd.entity_id IS NOT NULL" + )).fetchone() + preview_orphan = conn.execute(text( + "SELECT COUNT(*) AS cnt FROM kpi_values kv " + "LEFT JOIN kpi_definitions kd ON kv.kpi_id = kd.id " + "WHERE kv.entity_id IS NULL AND kd.id IS NULL" + )).fetchone() + preview_done = conn.execute(text( + "SELECT COUNT(*) AS cnt FROM action_plans WHERE status = 'done'" + )).fetchone() + + print(f"[预览] 可回填(entity_id NULL 且 kpi 存在): {preview_null.cnt}") + print(f"[预览] 无法回填(kpi 不存在): {preview_orphan.cnt}") + print(f"[预览] action_plans status='done': {preview_done.cnt}") + + # ── 2. 执行回填 ── + r = conn.execute(text( + "UPDATE kpi_values kv " + "JOIN kpi_definitions kd ON kv.kpi_id = kd.id " + "SET kv.entity_id = kd.entity_id " + "WHERE kv.entity_id IS NULL AND kd.entity_id IS NOT NULL" + )) + print(f"[执行] kpi_values entity_id 回填 {r.rowcount} 条") + + # 无法关联的孤儿行(kpi 不存在)→ 置默认 entity_id=1 并留 remark + r2 = conn.execute(text( + "UPDATE kpi_values kv " + "LEFT JOIN kpi_definitions kd ON kv.kpi_id = kd.id " + "SET kv.entity_id = 1, kv.remark = CONCAT(COALESCE(kv.remark, ''), '; verify-fix-20260830 孤儿行默认entity_id=1') " + "WHERE kv.entity_id IS NULL AND kd.id IS NULL" + )) + if r2.rowcount: + print(f"[执行] 孤儿行置默认 entity_id=1: {r2.rowcount} 条") + else: + print("[执行] 无孤儿行需处理") + + # ── 3. status done → completed ── + r3 = conn.execute(text( + "UPDATE action_plans SET status = 'completed' WHERE status = 'done'" + )) + print(f"[执行] action_plans status done→completed: {r3.rowcount} 条") + + conn.commit() + + # ── 4. 验证 ── + after_null = conn.execute(text( + "SELECT COUNT(*) AS cnt FROM kpi_values WHERE entity_id IS NULL" + )).fetchone() + after_done = conn.execute(text( + "SELECT COUNT(*) AS cnt FROM action_plans WHERE status = 'done'" + )).fetchone() + print(f"[验证] kpi_values entity_id IS NULL 残留: {after_null.cnt}(目标 0)") + print(f"[验证] action_plans status='done' 残留: {after_done.cnt}(目标 0)") + if after_null.cnt != 0: + print("[结果] ❌ 回填不彻底,请人工检查") + sys.exit(1) + print("[结果] ✅ 迁移完成") + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_auto_verify_engine.py b/backend/tests/test_auto_verify_engine.py new file mode 100644 index 00000000..67b218f5 --- /dev/null +++ b/backend/tests/test_auto_verify_engine.py @@ -0,0 +1,196 @@ +"""auto-verify 验证引擎修复测试 (2026-08-30 P1) + +覆盖 verify.py 4 项缺陷修复: +1. OKR progress 防重复累加(同一 plan 重复 verify 不再 +15%) +2. KPIValue 回填 entity_id 多租户隔离 +3. 取 KPI 最新值按 period <= 当前月 过滤(跨月验证不取未来/历史期间) +4. status "done" → "completed"(枚举外值修正) +""" +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models import ActionPlan, KPIValue, Objective +from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi + +BASE = "/api/cma/verify" + + +def _seed_objective(db: Session, progress: int = 10) -> Objective: + obj = Objective( + entity_id=1, + title="测试目标", + quarter="2026Q3", + owner="任富海", + progress=progress, + ) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def _seed_plan(db: Session, kpi_id: int, objective_id: int, rule: dict, status: str = "pending") -> ActionPlan: + plan = ActionPlan( + kpi_id=kpi_id, + objective_id=objective_id, + title="测试行动计划", + status=status, + priority="high", + auto_verify_rule=rule, + ) + db.add(plan) + db.commit() + db.refresh(plan) + return plan + + +def _make_rule(kpi_code: str) -> dict: + return { + "kpi_code": kpi_code, + "condition": "LESS_THAN", # actual < target → passed + "target_value": 80, + "notify": False, # 测试不发企微 + } + + +class TestOkrIdempotent: + def test_repeat_verify_does_not_accumulate(self, client: TestClient, db: Session): + """缺陷1修复:同一 plan 重复 verify,OKR progress 只累加一次""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="IDEM_001", kpi_name="幂等KPI") + obj = _seed_objective(db, progress=10) + plan = _seed_plan(db, kpi.id, obj.id, _make_rule("IDEM_001")) + + # 第一次验证通过 → +15% + r1 = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"actual_value": 50}) + assert r1.status_code == 200 + assert r1.json()["passed"] is True + assert r1.json()["okr_progress"]["updated"] is True + assert r1.json()["okr_progress"]["after"] == 25 # 10 + 15 + + # 第二次验证通过 → 不再累加(保持 25) + r2 = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"actual_value": 40}) + assert r2.status_code == 200 + assert r2.json()["okr_progress"]["updated"] is False + assert r2.json()["okr_progress"]["reason"] == "already_verified" + assert r2.json()["okr_progress"]["after"] == 25 # 保持原值 + + db.expire_all() + assert db.query(Objective).filter(Objective.id == obj.id).first().progress == 25 + + def test_force_recalc_accumulates(self, client: TestClient, db: Session): + """缺陷1修复:force_recalc=True 保留强制重新累加入口""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="IDEM_002", kpi_name="幂等KPI2") + obj = _seed_objective(db, progress=10) + plan = _seed_plan(db, kpi.id, obj.id, _make_rule("IDEM_002")) + + client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"actual_value": 50}) + r2 = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), + json={"actual_value": 40, "force_recalc": True}) + assert r2.json()["okr_progress"]["updated"] is True + assert r2.json()["okr_progress"]["after"] == 40 # 25 + 15 + + +class TestEntityBackfill: + def test_kpi_value_gets_entity_id(self, client: TestClient, db: Session): + """缺陷2修复:回填的 KPIValue 带 entity_id(与 KPI 定义一致,非默认1)""" + create_test_user(db) + token = get_token_for_user(client) + # entity_id=2 的 KPI(模拟第二个账套) + kpi = create_test_kpi(db, kpi_code="ENT_001", kpi_name="多租户KPI", entity_id=2) + obj = _seed_objective(db) + plan = _seed_plan(db, kpi.id, obj.id, _make_rule("ENT_001")) + + r = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"actual_value": 60}) + assert r.status_code == 200 + + val = db.query(KPIValue).filter(KPIValue.source_batch == f"verify-plan-{plan.id}").first() + assert val is not None + assert val.entity_id == 2 # 从 KPI 定义继承,而非默认 1 + assert val.kpi_id == kpi.id + + +class TestPeriodFilter: + def test_latest_value_respects_period_limit(self, client: TestClient, db: Session): + """缺陷3修复:缺省 actual 时只取 period <= 当前月的值,不取未来期间""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="PER_001", kpi_name="期间KPI") + + # 未来月(2026-09)有值 200 → GREATER_THAN 100 会通过;但当前月前(2026-07)值为 50 → 应取到 50 + from datetime import datetime + db.add_all([ + KPIValue(kpi_id=kpi.id, entity_id=1, period="2026-07", actual_value=50, + source_type="manual", data_status="verified"), + KPIValue(kpi_id=kpi.id, entity_id=1, period="2099-12", actual_value=200, + source_type="manual", data_status="verified"), + ]) + db.commit() + + obj = _seed_objective(db) + plan = _seed_plan(db, kpi.id, obj.id, { + "kpi_code": "PER_001", + "condition": "GREATER_THAN", # actual > target + "target_value": 100, + "notify": False, + }) + + # 缺省 actual → 应取 2026-07 的 50 → 不通过(若错误取到 2099-12 的 200 则会通过) + r = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={}) + assert r.status_code == 200 + assert r.json()["passed"] is False + assert r.json()["kpi_current_after"] == 50 + + def test_explicit_period_override(self, client: TestClient, db: Session): + """缺陷3修复:调用方显式传 period 覆盖默认当前月""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="PER_002", kpi_name="期间KPI2") + db.add_all([ + KPIValue(kpi_id=kpi.id, entity_id=1, period="2026-06", actual_value=30, + source_type="manual", data_status="verified"), + KPIValue(kpi_id=kpi.id, entity_id=1, period="2026-07", actual_value=90, + source_type="manual", data_status="verified"), + ]) + db.commit() + + obj = _seed_objective(db) + plan = _seed_plan(db, kpi.id, obj.id, { + "kpi_code": "PER_002", + "condition": "GREATER_THAN", + "target_value": 50, + "notify": False, + }) + + # 显式 period=2026-06 → 取 30 → 不通过 + r1 = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"period": "2026-06"}) + assert r1.json()["kpi_current_after"] == 30 + assert r1.json()["passed"] is False + # 显式 period=2026-07 → 取 90 → 通过 + r2 = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"period": "2026-07"}) + assert r2.json()["kpi_current_after"] == 90 + assert r2.json()["passed"] is True + + +class TestStatusEnum: + def test_passed_plan_status_is_completed(self, client: TestClient, db: Session): + """缺陷4修复:验证通过后 plan.status 写入枚举内值 completed,而非 done""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="ST_001", kpi_name="状态KPI") + obj = _seed_objective(db) + plan = _seed_plan(db, kpi.id, obj.id, _make_rule("ST_001")) + + r = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"actual_value": 50}) + assert r.status_code == 200 + assert r.json()["passed"] is True + + db.expire_all() + refreshed = db.query(ActionPlan).filter(ActionPlan.id == plan.id).first() + assert refreshed.status == "completed" + assert refreshed.status != "done" + assert refreshed.progress == 100