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
+83 -16
View File
@@ -15,7 +15,7 @@ from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import get_session_local
from app.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan
from app.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan, OperationLog
from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY
# 裁定2a(2026-09-17 财务口径):比率类指标统一「百分比(%)」口径(取数×100),
# 未核对/疑似单位错配的项降级待核(不落预警、不推送)
@@ -90,6 +90,53 @@ def _check_threshold(actual: float, threshold_expr: str, level: str) -> tuple:
return True, msg
def action_plan_entity_guard(db_session, *, kpi, alert, stats: Optional[dict] = None) -> bool:
"""写前同源校验(2026-09-22 派生单 B4)—— 建行动计划的账套归属门。
口径照抄同族守卫姿势(`app/api/bot_bridge_v2.py` 的 R1 账套守卫 / B3(b) `skip_alert` 留痕):
· 来源账套 = KPI 定义归属(`kpi.entity_id`);
· 目标账套 = **同一次事务里刚建的预警归属**(`alert.entity_id`,即本次遍历账套);
· 两者都存在且相等 → 返回 True(调用方建计划并**显式写** `entity_id=kpi.entity_id`);
· 任一为空 / 不相等 → 返回 False:**不建计划**、落 `OperationLog(action="skip_plan")` 留痕、
计入统计输出;**不 raise、不中断流水线**(预警与后续 KPI 照常处理)。
为什么必须设门而不是「照传 None」:`action_plans.entity_id` 为 NOT NULL DEFAULT 1
(模型 `default=1` 与生产 DDL 双默认)——传空值会被**静默兜底到账套 1**,
正是「酣客(e1) 工作台渲染博海(e2)/演示(e6) KPI 名」的成因(同事务内预警归属正确、
计划归属落 1,只有 KPI 不属于 e1 时暴露)。
"""
kpi_entity_id = getattr(kpi, "entity_id", None)
target_entity_id = getattr(alert, "entity_id", None)
if (kpi_entity_id is not None and target_entity_id is not None
and int(kpi_entity_id) == int(target_entity_id)):
return True
st = stats if stats is not None else {}
st["skipped_plan_entity_mismatch"] = st.get("skipped_plan_entity_mismatch", 0) + 1
st.setdefault("skipped_plan_kpis", []).append(
"%s[kpi_e=%s→plan_e=%s]" % (getattr(kpi, "kpi_code", None), kpi_entity_id, target_entity_id))
db_session.add(OperationLog(
user_id=None,
action="skip_plan",
target_type="action_plan",
target_id=getattr(alert, "id", None),
detail={
"reason": "plan_entity_attribution_mismatch",
"kpi_id": getattr(kpi, "id", None),
"kpi_code": getattr(kpi, "kpi_code", None),
"kpi_entity_id": kpi_entity_id,
"entity_id": target_entity_id,
"alert_id": getattr(alert, "id", None),
"source": "scripts.alert_generator.run_alert_check",
},
))
logger.warning(
"B4 账套守卫:跳过建计划(KPI 归属=%s / 目标账套=%s 不一致或为空;"
"不再静默落默认账套 1)kpi_code=%s alert_id=%s",
kpi_entity_id, target_entity_id, getattr(kpi, "kpi_code", None), getattr(alert, "id", None))
return False
def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = False,
stats: Optional[dict] = None, now: Optional[datetime] = None) -> int:
"""检查所有KPI的实际值是否触发预警,返回(将)新生成的预警数
@@ -108,6 +155,10 @@ def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = Fa
(`out_of_window`),与 2b 叠加 = max(各 KPI 最近可用期间, 当月-6月);
· **R3 伪期间**:仅规整月期间 YYYY-MM 参与,H1/H2/FY 跳过(`skipped_pseudo_period`)。
· now 参数仅用于口径复算/测试(缺省=系统当前时间)。
2026-09-22(本单·第 4 处写入点):red/yellow 自动建计划前经 `action_plan_entity_guard`
写前同源校验,并**显式写** `ActionPlan.entity_id = kpi.entity_id` —— 修复前不传归属、
由模型 default=1 / 生产 DDL DEFAULT 1 静默兜底,导致非 e1 账套的 KPI 触发时落跨账套计划。
"""
st = stats if stats is not None else {}
window_lower = window_lower_period(now)
@@ -122,7 +173,9 @@ def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = Fa
("window_months", ALERT_WINDOW_MONTHS),
("skipped_demo_kpis", 0), ("demo_kpis", []),
("skipped_pseudo_period", 0), ("pseudo_period_kpis", []),
("out_of_window", 0), ("out_of_window_kpis", [])):
("out_of_window", 0), ("out_of_window_kpis", []),
# 2026-09-22 计划归属守卫(B4)证据
("skipped_plan_entity_mismatch", 0), ("skipped_plan_kpis", [])):
st.setdefault(k, v)
kpis = db_session.query(KPIDefinition).filter(
@@ -257,19 +310,25 @@ def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = Fa
plan_title = f"渠补谈判: {msg} —— 下游渠道返利谈判"
elif kpi.kpi_code == "F_COST_RATIO":
plan_title = f"费用压缩: {msg} —— 管理费削减计划"
plan = ActionPlan(
alert_id=alert.id,
kpi_id=kpi.id,
title=plan_title,
assignee="财务部",
priority="high" if level == "red" else "medium",
due_date=datetime.now().date() + timedelta(days=3 if level == "red" else 7),
created_by="alert_generator",
)
db_session.add(plan)
db_session.flush()
alert.action_plan_linked_id = plan.id
logger.info(f" ↳ 自动创建行动计划 #{plan.id}: {plan_title}")
# 2026-09-22(本单主修·第 4 处写入点):写前同源校验 + 显式写归属。
# 修复前构造 ActionPlan 不传 entity_id → 模型 default=1 / DDL DEFAULT 1
# 静默兜底,非 e1 账套的 KPI 触发即产生「预警归属正确、计划归属落 1」的
# 跨账套计划(酣客工作台渲染博海/demo KPI 名的唯一活写入点)。
if action_plan_entity_guard(db_session, kpi=kpi, alert=alert, stats=st):
plan = ActionPlan(
entity_id=kpi.entity_id, # 归属唯一事实源:来源 KPI 的账套
alert_id=alert.id,
kpi_id=kpi.id,
title=plan_title,
assignee="财务部",
priority="high" if level == "red" else "medium",
due_date=datetime.now().date() + timedelta(days=3 if level == "red" else 7),
created_by="alert_generator",
)
db_session.add(plan)
db_session.flush()
alert.action_plan_linked_id = plan.id
logger.info(f" ↳ 自动创建行动计划 #{plan.id}: {plan_title}")
break # 只取最高级别
elif level == "red" and not triggered:
# 红没触发(判定顺序中 red 最先)→ 若该期间该KPI已有 pending/processing 红灯,
@@ -442,7 +501,10 @@ def generate_and_push(db_session, period: Optional[str] = None, dry_run: bool =
"window_lower_period": st.get("window_lower_period"), "window_months": st.get("window_months"),
"out_of_window": st.get("out_of_window", 0), "out_of_window_kpis": st.get("out_of_window_kpis", []),
"skipped_pseudo_period": st.get("skipped_pseudo_period", 0),
"pseudo_period_kpis": st.get("pseudo_period_kpis", [])}
"pseudo_period_kpis": st.get("pseudo_period_kpis", []),
# 2026-09-22 计划归属守卫(B4):归属不一致/为空 → 跳过建计划的留痕计数
"skipped_plan_entity_mismatch": st.get("skipped_plan_entity_mismatch", 0),
"skipped_plan_kpis": st.get("skipped_plan_kpis", [])}
def coverage_note(result: dict) -> str:
@@ -476,6 +538,11 @@ def coverage_note(result: dict) -> str:
if result.get("push_skipped_no_channel"):
parts.append(" | R4 推送出口缺失(启用渠道 %s) → 仅入库、不阻断"
% result.get("channels_enabled"))
# 2026-09-22 计划归属守卫(B4):跨账套计划不再静默落默认账套 1
if result.get("skipped_plan_entity_mismatch"):
parts.append(" | B4 计划归属守卫:跳过建计划 %s 项 %s(KPI 归属与目标账套不一致/为空)"
% (result["skipped_plan_entity_mismatch"],
(result.get("skipped_plan_kpis") or [])[:3]))
return "".join(parts)
@@ -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