From 2c2dbc2b2da66fac5ed22e040be737e1e5822fc4 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Tue, 22 Sep 2026 11:35:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(cma):=20=E8=A1=8C=E5=8A=A8=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90=E8=A1=A5=E8=B4=A6?= =?UTF-8?q?=E5=A5=97=20=E2=80=94=20=E7=AC=AC=204=20=E5=A4=84=E5=86=99?= =?UTF-8?q?=E5=85=A5=E7=82=B9=20+=20=E5=86=99=E5=89=8D=E5=90=8C=E6=BA=90?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 任务: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) --- backend/scripts/alert_generator.py | 99 ++++++++-- ...t_generator_entity_attribution_20260922.py | 182 ++++++++++++++++++ 2 files changed, 265 insertions(+), 16 deletions(-) create mode 100644 backend/tests/test_alert_generator_entity_attribution_20260922.py diff --git a/backend/scripts/alert_generator.py b/backend/scripts/alert_generator.py index 14f967de..507951dd 100644 --- a/backend/scripts/alert_generator.py +++ b/backend/scripts/alert_generator.py @@ -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) diff --git a/backend/tests/test_alert_generator_entity_attribution_20260922.py b/backend/tests/test_alert_generator_entity_attribution_20260922.py new file mode 100644 index 00000000..3412f943 --- /dev/null +++ b/backend/tests/test_alert_generator_entity_attribution_20260922.py @@ -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