任务: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:
@@ -15,7 +15,7 @@ from datetime import datetime, timedelta
|
|||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
from app.database import get_session_local
|
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
|
from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY
|
||||||
# 裁定2a(2026-09-17 财务口径):比率类指标统一「百分比(%)」口径(取数×100),
|
# 裁定2a(2026-09-17 财务口径):比率类指标统一「百分比(%)」口径(取数×100),
|
||||||
# 未核对/疑似单位错配的项降级待核(不落预警、不推送)
|
# 未核对/疑似单位错配的项降级待核(不落预警、不推送)
|
||||||
@@ -90,6 +90,53 @@ def _check_threshold(actual: float, threshold_expr: str, level: str) -> tuple:
|
|||||||
return True, msg
|
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,
|
def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = False,
|
||||||
stats: Optional[dict] = None, now: Optional[datetime] = None) -> int:
|
stats: Optional[dict] = None, now: Optional[datetime] = None) -> int:
|
||||||
"""检查所有KPI的实际值是否触发预警,返回(将)新生成的预警数
|
"""检查所有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月);
|
(`out_of_window`),与 2b 叠加 = max(各 KPI 最近可用期间, 当月-6月);
|
||||||
· **R3 伪期间**:仅规整月期间 YYYY-MM 参与,H1/H2/FY 跳过(`skipped_pseudo_period`)。
|
· **R3 伪期间**:仅规整月期间 YYYY-MM 参与,H1/H2/FY 跳过(`skipped_pseudo_period`)。
|
||||||
· now 参数仅用于口径复算/测试(缺省=系统当前时间)。
|
· 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 {}
|
st = stats if stats is not None else {}
|
||||||
window_lower = window_lower_period(now)
|
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),
|
("window_months", ALERT_WINDOW_MONTHS),
|
||||||
("skipped_demo_kpis", 0), ("demo_kpis", []),
|
("skipped_demo_kpis", 0), ("demo_kpis", []),
|
||||||
("skipped_pseudo_period", 0), ("pseudo_period_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)
|
st.setdefault(k, v)
|
||||||
|
|
||||||
kpis = db_session.query(KPIDefinition).filter(
|
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} —— 下游渠道返利谈判"
|
plan_title = f"渠补谈判: {msg} —— 下游渠道返利谈判"
|
||||||
elif kpi.kpi_code == "F_COST_RATIO":
|
elif kpi.kpi_code == "F_COST_RATIO":
|
||||||
plan_title = f"费用压缩: {msg} —— 管理费削减计划"
|
plan_title = f"费用压缩: {msg} —— 管理费削减计划"
|
||||||
plan = ActionPlan(
|
# 2026-09-22(本单主修·第 4 处写入点):写前同源校验 + 显式写归属。
|
||||||
alert_id=alert.id,
|
# 修复前构造 ActionPlan 不传 entity_id → 模型 default=1 / DDL DEFAULT 1
|
||||||
kpi_id=kpi.id,
|
# 静默兜底,非 e1 账套的 KPI 触发即产生「预警归属正确、计划归属落 1」的
|
||||||
title=plan_title,
|
# 跨账套计划(酣客工作台渲染博海/demo KPI 名的唯一活写入点)。
|
||||||
assignee="财务部",
|
if action_plan_entity_guard(db_session, kpi=kpi, alert=alert, stats=st):
|
||||||
priority="high" if level == "red" else "medium",
|
plan = ActionPlan(
|
||||||
due_date=datetime.now().date() + timedelta(days=3 if level == "red" else 7),
|
entity_id=kpi.entity_id, # 归属唯一事实源:来源 KPI 的账套
|
||||||
created_by="alert_generator",
|
alert_id=alert.id,
|
||||||
)
|
kpi_id=kpi.id,
|
||||||
db_session.add(plan)
|
title=plan_title,
|
||||||
db_session.flush()
|
assignee="财务部",
|
||||||
alert.action_plan_linked_id = plan.id
|
priority="high" if level == "red" else "medium",
|
||||||
logger.info(f" ↳ 自动创建行动计划 #{plan.id}: {plan_title}")
|
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 # 只取最高级别
|
break # 只取最高级别
|
||||||
elif level == "red" and not triggered:
|
elif level == "red" and not triggered:
|
||||||
# 红没触发(判定顺序中 red 最先)→ 若该期间该KPI已有 pending/processing 红灯,
|
# 红没触发(判定顺序中 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"),
|
"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", []),
|
"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),
|
"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:
|
def coverage_note(result: dict) -> str:
|
||||||
@@ -476,6 +538,11 @@ def coverage_note(result: dict) -> str:
|
|||||||
if result.get("push_skipped_no_channel"):
|
if result.get("push_skipped_no_channel"):
|
||||||
parts.append(" | R4 推送出口缺失(启用渠道 %s) → 仅入库、不阻断"
|
parts.append(" | R4 推送出口缺失(启用渠道 %s) → 仅入库、不阻断"
|
||||||
% result.get("channels_enabled"))
|
% 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)
|
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
|
||||||
Reference in New Issue
Block a user