"""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