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)