fix(cma): 行动计划自动生成补账套 — 第 4 处写入点 + 写前同源校验
ci/woodpecker/push/main Pipeline failed

任务:0-P1-project-20260922-actionplan-entity-write-fullstack
父单:project-20260921-cma-entity-isolation-batch5(§B 治本 0c2e57a)
同族已修(勿重做):alerts.py / deviation_push.py / ai_suggestions.py(d03e322)

缺陷(唯一活写入点):
  scripts/alert_generator.py::run_alert_check 为 red/yellow 预警自动建 ActionPlan 时
  不传 entity_id(模型 default=1 + 生产 DDL `DEFAULT 1` 双默认静默兜底)⇒ 非 e1 账套的
  KPI 触发阈值时「同一次事务里预警归属正确、计划归属落 1」;读侧 dashboard.py 工作台按
  ActionPlan.entity_id 取出计划、KPI 名不做账套过滤渲染 ⇒ 酣客(e1) 工作台显示博海(e2)/
  演示(e6) 的 KPI 名(实测 19/83 行归属不符,09-17/18/22 07:00 各新增 6/7/6 行,每日复发)。

改动:
  1. 写入点补账套:ActionPlan(entity_id=kpi.entity_id, …) —— 归属唯一事实源 = 来源 KPI 账套;
  2. 写前同源校验 action_plan_entity_guard():来源账套(kpi.entity_id) 与目标账套
     (alert.entity_id,即本次遍历账套) 不一致或任一为空 → 不建计划 + OperationLog
     (action="skip_plan", reason=plan_entity_attribution_mismatch) 留痕 + 计入统计输出,
     不 raise、不中断流水线(口径照抄 bot_bridge_v2 R1 账套守卫 / B3(b) skip_alert 留痕姿势);
  3. 统计/汇总:stats 增 skipped_plan_entity_mismatch / skipped_plan_kpis,并在
     generate_and_push 返回值与 coverage_note 中显式输出(不得静默丢弃);
  4. 新增回归 tests/test_alert_generator_entity_attribution_20260922.py(T1/T2/T3 改前 RED、
     改后 GREEN;T4 正对照改前即绿,不构成本单判据)。

边界(零越界):零 DDL(未动列默认、无 alembic/migration/SQL 脚本)、未改 models、
未动同族三处已修文件、未写生产库、未调写端点、未改 cron/前端、未 push、未部署、未重启。

回归:backend `pytest -q` 1374 passed / 0 failed(基线 1370 + 新增 4)
This commit is contained in:
Hermes CI Fix
2026-09-22 11:35:01 +08:00
parent 30fa745135
commit 2c2dbc2b2d
2 changed files with 265 additions and 16 deletions
@@ -0,0 +1,182 @@
# -*- coding: utf-8 -*-
"""CMA 行动计划自动生成写入点 —— 补账套 + 写前同源校验(2026-09-22 派生单)
任务:0-P1-project-20260922-actionplan-entity-write-fullstack
父单:project-20260921-cma-entity-isolation-batch5(§B 治本 = commit 0c2e57a)
同族已修(勿重做):app/api/alerts.py · deviation_push.py · ai_suggestions.py
—— commit d03e322(2026-09-18 派生单,已发布)
缺陷(代码级):
scripts/alert_generator.py::run_alert_check 为 red/yellow 预警自动建 ActionPlan 时
**不传 entity_id**(models.ActionPlan.entity_id default=1 + 生产 DDL `DEFAULT 1` 双默认静默兜底)
⇒ 非 e1 账套的 KPI 触发阈值时,同一次事务里「预警归属正确、计划归属落 1」。
读侧 dashboard.py 工作台按 `ActionPlan.entity_id == 会话账套` 取出计划、
KPI 名不做账套过滤 → 酣客(e1) 工作台直接渲染博海(e2)/演示(e6) 的 KPI 名。
实测:action_plans 中 entity_id ≠ 其 KPI 的 entity_id 的行 19/83,且 09-17/09-18/09-22
07:00 各新增 6/7/6 行(每日复发)。
本套件(RED/GREEN 两态原始输出见 pending-tasks/_reports/ 同名单回报):
· T1(改前 RED)e2 的 KPI 触发 → 计划 entity_id == 2;**同用例内**断言
`alert.entity_id == plan.entity_id`(防只断言一侧)
· T2(改前 RED)KPI 归属为空 → 不建计划 + `OperationLog(action='skip_plan')` 恰 1 条,
且**流水线不中断**(预警仍按既有行为落库),统计计数 `skipped_plan_entity_mismatch == 1`
· T3(改前 RED)守卫单元级:`kpi.entity_id(2) != 目标账套(1)` → 不建计划 + 1 条 skip_plan
· T4(改前即绿、不构成本单判据,仅作正对照)e1 的 KPI 触发 → 计划 entity_id == 1,
守卫零误杀、零 skip_plan
"""
from datetime import datetime
from sqlalchemy.orm import Session
from app.models import (
ActionPlan, KPIAlert, KPIDefinition, KPIValue, OperationLog,
)
from tests.conftest import create_test_kpi
from scripts import alert_generator
#: 固定「当月」= 2026-09(口径复算不依赖真实时钟;窗口下限 = 2026-03)
NOW = datetime(2026, 9, 22, 10, 0)
PERIOD = "2026-09"
#: 阈值/实测形态沿用同族既有用例(test_alert_caliber_r1r4_20260919):
#: 避免命中裁定2a「疑似单位错配」降级门(阈值百分数写法 + 实测比率量级)
RED_EXPR = "<58.4"
ACTUAL = 14.45
def _kpi(db: Session, code: str, eid, **kw) -> KPIDefinition:
"""本账套 KPI(entity_id 可为 None —— 模拟「归属缺失」的生产形态)"""
return create_test_kpi(db, kpi_code=code, kpi_name=f"{code}-名称", entity_id=eid,
unit="%", threshold_red=RED_EXPR, **kw)
def _value(db: Session, kpi: KPIDefinition, period: str, value: float, entity_id: int):
db.add(KPIValue(entity_id=entity_id, kpi_id=kpi.id, period=period, actual_value=value))
db.commit()
def _plans(db: Session, kpi: KPIDefinition):
db.expire_all()
return db.query(ActionPlan).filter(ActionPlan.kpi_id == kpi.id).all()
def _alerts(db: Session, kpi: KPIDefinition):
db.expire_all()
return db.query(KPIAlert).filter(KPIAlert.kpi_id == kpi.id).all()
def _skip_logs(db: Session):
db.expire_all()
return db.query(OperationLog).filter(OperationLog.action == "skip_plan").all()
# ──────────────────────────────────────────────
# T1. 写入点补账套:计划归属 = 来源 KPI 的账套(改前 RED)
# ──────────────────────────────────────────────
def test_t1_plan_entity_follows_kpi_entity_of_other_tenant(db: Session):
"""e2 的 KPI 触发 → 计划 entity_id 必须是 2,且与同事务预警归属一致"""
k2 = _kpi(db, "ZZ_APEN_E2_KPI", 2)
_value(db, k2, PERIOD, ACTUAL, entity_id=2)
st = {}
alert_generator.run_alert_check(db, period=PERIOD, stats=st, now=NOW)
alerts = _alerts(db, k2)
plans = _plans(db, k2)
assert len(alerts) == 1, f"e2 KPI 应生成 1 条预警,实际 {len(alerts)}"
assert len(plans) == 1, f"e2 KPI 应生成 1 条行动计划,实际 {len(plans)}"
assert plans[0].entity_id == 2, (
f"计划 entity_id 应为来源 KPI 的账套 2,实际 {plans[0].entity_id}"
" —— alert_generator 构造 ActionPlan 未写 entity_id,落模型/DDL 默认值 1"
)
# 同用例内同时断言两侧归属(防「只断言一侧」漏掉预警侧回归)
assert alerts[0].entity_id == plans[0].entity_id == 2, (
f"预警与计划归属必须同源:alert.entity_id={alerts[0].entity_id} "
f"plan.entity_id={plans[0].entity_id}"
)
assert plans[0].alert_id == alerts[0].id, "计划未挂到同事务预警上"
# ──────────────────────────────────────────────
# T2. 写前同源校验:归属缺失 → 不建计划 + skip_plan 留痕(改前 RED)
# ──────────────────────────────────────────────
def test_t2_missing_kpi_owner_skips_plan_and_logs(db: Session):
"""KPI 归属为空(任一为空即命中守卫)→ 不建计划 + 1 条 skip_plan + 流水线不中断
夹具说明:`KPIDefinition.entity_id` 有 ORM 侧 `default=1`,经 ORM 建行时 None 会被兜底成 1,
故「归属为空」只能用 **Core UPDATE 显式置 NULL** 构造(对应生产里绕过 ORM 的直插/导入行)。
"""
k = _kpi(db, "ZZ_APEN_NO_OWNER_KPI", 2)
db.query(KPIDefinition).filter(KPIDefinition.id == k.id).update({"entity_id": None})
db.commit()
db.expire_all()
k = db.query(KPIDefinition).filter(KPIDefinition.id == k.id).first()
assert k.entity_id is None, "夹具失败:KPI 归属未置空"
_value(db, k, PERIOD, ACTUAL, entity_id=2)
st = {}
alert_generator.run_alert_check(db, period=PERIOD, stats=st, now=NOW)
assert _plans(db, k) == [], (
"KPI 归属为空时不得建计划(否则静默落默认账套 1 = 跨账套污染)"
)
logs = _skip_logs(db)
assert len(logs) == 1, f"应恰有 1 条 skip_plan 留痕,实际 {len(logs)}"
log = logs[0]
assert log.target_type == "action_plan", f"target_type 应为 action_plan,实际 {log.target_type}"
detail = log.detail or {}
assert detail.get("kpi_code") == "ZZ_APEN_NO_OWNER_KPI", detail
assert "kpi_entity_id" in detail and "entity_id" in detail, detail
assert detail.get("kpi_entity_id") is None, detail
# 不中断流水线:预警侧既有行为不变(本单不改预警归属,仍按 `kpi.entity_id or 1` 落库)
assert len(_alerts(db, k)) == 1, "守卫不得中断预警生成(只跳过计划)"
# 统计输出必须计入(不得静默丢弃)
assert st.get("skipped_plan_entity_mismatch") == 1, st
assert st.get("skipped_plan_kpis"), st
# ──────────────────────────────────────────────
# T3. 守卫单元级:KPI 账套 ≠ 目标账套(改前 RED:守卫函数尚不存在)
# ──────────────────────────────────────────────
def test_t3_guard_rejects_kpi_entity_not_matching_target(db: Session):
"""kpi.entity_id(=2) != 目标账套(=1) → 守卫返回 False + 1 条 skip_plan + 零计划"""
k2 = _kpi(db, "ZZ_APEN_GUARD_KPI", 2)
alert = KPIAlert(entity_id=1, kpi_id=k2.id, alert_level="red",
alert_message="ZZ_APEN 跨账套守卫", status="pending")
db.add(alert)
db.commit()
db.refresh(alert)
st = {}
ok = alert_generator.action_plan_entity_guard(db, kpi=k2, alert=alert, stats=st)
db.commit() # 守卫只 add 留痕,落库由调用方提交(此处模拟 run_alert_check 的 commit;会话 autoflush=False)
assert ok is False, "KPI 账套与目标账套不一致时不得建计划"
assert db.query(ActionPlan).count() == 0
logs = _skip_logs(db)
assert len(logs) == 1, f"应恰有 1 条 skip_plan 留痕,实际 {len(logs)}"
detail = logs[0].detail or {}
assert detail.get("kpi_entity_id") == 2 and detail.get("entity_id") == 1, detail
assert st.get("skipped_plan_entity_mismatch") == 1, st
# ──────────────────────────────────────────────
# T4. 正对照(改前即绿、不构成本单判据):e1 本账套计划不得被守卫误杀
# ──────────────────────────────────────────────
def test_t4_same_tenant_plan_still_created(db: Session):
"""e1 的 KPI 触发 → 计划 entity_id == 1,且零 skip_plan(守卫零误杀)"""
k1 = _kpi(db, "ZZ_APEN_E1_KPI", 1)
_value(db, k1, PERIOD, ACTUAL, entity_id=1)
st = {}
alert_generator.run_alert_check(db, period=PERIOD, stats=st, now=NOW)
plans = _plans(db, k1)
assert len(plans) == 1, f"e1 KPI 应生成 1 条计划,实际 {len(plans)}"
assert plans[0].entity_id == 1, f"e1 计划 entity_id 应为 1,实际 {plans[0].entity_id}"
assert _skip_logs(db) == [], "本账套同源不得产生 skip_plan"
assert st.get("skipped_plan_entity_mismatch", 0) == 0, st