fix: auto-verify验证引擎4项缺陷修复 verify-fix (OKR幂等防重累加/entity_id多租户回填/period期间过滤/status done->completed)
This commit is contained in:
@@ -532,7 +532,14 @@ def verify_action_plan(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
验证ActionPlan的执行结果
|
验证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
|
1. 读取ActionPlan的auto_verify_rule
|
||||||
2. 读取关联KPI的当前值
|
2. 读取关联KPI的当前值
|
||||||
3. 按condition校验
|
3. 按condition校验
|
||||||
|
|||||||
+52
-11
@@ -8,6 +8,12 @@ POST /api/cma/verify/{plan_id} 手动验证行动计划执行结果
|
|||||||
→ 验证通过 → 所属OKR progress +15%
|
→ 验证通过 → 所属OKR progress +15%
|
||||||
→ 通知任总 (send_wecom_message)
|
→ 通知任总 (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",
|
"kpi_code": "C_REBATE_RATE",
|
||||||
@@ -86,14 +92,22 @@ def evaluate_rule(rule: dict, actual, kpi_data: dict = None) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def update_okr_progress(db: Session, plan: ActionPlan) -> dict:
|
def update_okr_progress(db: Session, plan: ActionPlan, already_verified: bool = False, force_recalc: bool = False) -> dict:
|
||||||
"""验证通过 → 所属OKR progress +15%(每通过1个KR)"""
|
"""验证通过 → 所属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:
|
if not plan.objective_id:
|
||||||
return {"updated": False, "reason": "no_objective"}
|
return {"updated": False, "reason": "no_objective"}
|
||||||
obj = db.query(Objective).filter(Objective.id == plan.objective_id).first()
|
obj = db.query(Objective).filter(Objective.id == plan.objective_id).first()
|
||||||
if not obj:
|
if not obj:
|
||||||
return {"updated": False, "reason": "objective_not_found"}
|
return {"updated": False, "reason": "objective_not_found"}
|
||||||
before = obj.progress or 0
|
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)
|
obj.progress = min(100, before + 15)
|
||||||
db.flush()
|
db.flush()
|
||||||
return {"updated": True, "objective_id": obj.id, "before": before, "after": obj.progress}
|
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"):
|
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 = None
|
||||||
kpi_code = rule.get("kpi_code") if rule else 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:
|
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:
|
elif plan.kpi_id:
|
||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
|
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:
|
if kpi:
|
||||||
new_val = KPIValue(
|
new_val = KPIValue(
|
||||||
kpi_id=kpi.id,
|
kpi_id=kpi.id,
|
||||||
|
entity_id=kpi.entity_id, # 缺陷2修复:多租户回填 entity_id
|
||||||
period=datetime.now().strftime("%Y-%m"),
|
period=datetime.now().strftime("%Y-%m"),
|
||||||
actual_value=actual,
|
actual_value=actual,
|
||||||
source_type="verify",
|
source_type="verify",
|
||||||
@@ -215,19 +244,27 @@ def verify_action_plan(plan_id: int, payload: dict, db: Session = Depends(get_db
|
|||||||
db.commit()
|
db.commit()
|
||||||
return {"plan_id": plan_id, "verify_status": plan.verify_status, "passed": passed}
|
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:
|
if actual is None:
|
||||||
kpi = None
|
kpi = None
|
||||||
kpi_code = rule.get("kpi_code")
|
kpi_code = rule.get("kpi_code")
|
||||||
if plan.kpi_id:
|
if plan.kpi_id:
|
||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
|
||||||
elif kpi_code:
|
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:
|
if kpi:
|
||||||
|
period_limit = payload.get("period") or datetime.now().strftime("%Y-%m")
|
||||||
latest = db.query(KPIValue).filter(
|
latest = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kpi.id,
|
KPIValue.kpi_id == kpi.id,
|
||||||
KPIValue.actual_value.isnot(None),
|
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:
|
if latest:
|
||||||
actual = latest.actual_value
|
actual = latest.actual_value
|
||||||
|
|
||||||
@@ -237,6 +274,10 @@ def verify_action_plan(plan_id: int, payload: dict, db: Session = Depends(get_db
|
|||||||
# 4. 回写KPI当前值
|
# 4. 回写KPI当前值
|
||||||
backfill = backfill_kpi_value(db, plan, actual, rule, source=source)
|
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. 更新状态
|
# 5. 更新状态
|
||||||
plan.verify_status = "passed" if passed else "failed"
|
plan.verify_status = "passed" if passed else "failed"
|
||||||
plan.verify_result = "pass" if passed else "fail"
|
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,
|
"note": note,
|
||||||
}]
|
}]
|
||||||
if passed:
|
if passed:
|
||||||
plan.status = "done"
|
plan.status = "completed" # 缺陷4修复:"done" 不在枚举(pending/in_progress/completed/cancelled),改 completed
|
||||||
plan.progress = 100
|
plan.progress = 100
|
||||||
plan.verified_at = datetime.now()
|
plan.verified_at = datetime.now()
|
||||||
|
|
||||||
# 6. OKR进度联动(验证通过 → +15%)
|
# 6. OKR进度联动(验证通过 → +15%;缺陷1修复:已通过过的 plan 不再重复累加,force_recalc 可强制重算)
|
||||||
okr_update = None
|
okr_update = None
|
||||||
if passed:
|
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()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user