ASK-20260924-15/17: Bot 通道缺省账套→400 + 差异/趋势入口期间口径回落

- deps.get_entity_id: Bot 通道(带 X-BOT-KEY)无显式账套 → 400(旧为兜底 entity 1=酣客);
  无 key 的存量公开/用户端点行为不变(仍 401/兜底 1)
- 新增 deps.require_bot_entity_id():写端点缺省账套一处收口 → 400(406/422 同族)
  接入 bot_bridge(×2) / bot_bridge_v2 / bot_suggestions / cash_vouchers_json,并同步文档口径
- deviation_engine.run_deviation_check: period=None 不再硬编码当月,逐 KPI 回落
  「该 KPI 自己的最近可用期间」(裁定2b,单一实现收口到 alert_entity_scope);(当月无
  kpi_values ⇒ 差异/趋势入口永久 0 新增);R2/R3 窗口判定改为逐 KPI;显式 period 语义不变
- alert_generator.resolve_latest_period 委托同一实现(消除期间口径两份)
- 测试:新增 tests/test_ask15_17_engine_and_entity_required_20260925.py(12 例);
  4 个既有测试文件把「旧兜底账套 1」显式化(断言未改)
This commit is contained in:
wecom-fullstack
2026-09-25 22:42:25 +08:00
parent 38787a006f
commit 52b693ddbb
13 changed files with 353 additions and 43 deletions
+29
View File
@@ -87,6 +87,35 @@ def period_in_window(period, now: Optional[datetime] = None) -> bool:
return str(period) >= window_lower_period(now)
# ── 裁定 2b(2026-09-17):「各 KPI 自己的最近可用期间」,单一实现 ──
#
# 2026-09-25(ASK-20260924-15):原实现只在 `scripts/alert_generator.resolve_latest_period`,
# 而 `app/utils/deviation_engine.run_deviation_check` 仍硬编码 `datetime.now()`(当月)⇒
# 期间口径在**两个生成入口之间漂移**,差异/趋势预警对「当月无 kpi_values」的账套永久 0 新增。
# 现收口到本模块(预警口径唯一事实源):两处入口共同调用,禁止再各写一份。
def resolve_latest_available_period(db_session, kpi_id=None) -> Optional[str]:
"""裁定 2b:返回(该 KPI 的)「最近可用期间」——`kpi_values` 内最新的规整月 `YYYY-MM`。
给定 kpi_id → 返回**该 KPI 自己**的最近可用期间:不硬编码当月,也不被别人
(他账套/演示账套)更新的新期间带偏——实证:全局 MAX(period)=2026-09 但那是
演示账套 e6 的 8 条数据,酣客(e1) 的最新可用期间是 2026-08。
实现说明:伪期间过滤在 **Python 侧**做(`SELECT DISTINCT period` 只有十几行),
不依赖 MySQL 的 `REGEXP`——SQLite 测试库不注册 REGEXP,用 SQL 侧正则会让单测
与生产走两套语义。判据用 R3 单一正则(`is_month_period`,严格 01–12)。
"""
from app.models import KPIValue # 局部导入:避免 models ↔ utils 的循环导入风险
q = db_session.query(KPIValue.period).filter(KPIValue.actual_value.isnot(None))
if kpi_id is not None:
q = q.filter(KPIValue.kpi_id == kpi_id)
for (p,) in q.distinct().order_by(KPIValue.period.desc()).all():
if is_month_period(p):
return p
return None
# ── R5(裁定 2026-09-19,R3 的一致延伸):可归期原则 + 生成侧守卫 ──
def is_attributable_period(period) -> bool:
+40 -14
View File
@@ -18,6 +18,7 @@ from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan, SystemConf
from app.utils.alert_entity_scope import (
excluded_entity_ids, kpi_excluded, period_in_window, window_lower_period,
guard_alert_period, # R5-补1(2026-09-19):期间可归期 + 关联 kpi_values
resolve_latest_available_period, # 裁定2b(2026-09-25 ASK-15):期间回落单一实现
)
logger = logging.getLogger("cma.deviation")
@@ -381,14 +382,18 @@ def run_deviation_check(db_session, period: str = None) -> int:
文案期间不可解析为 `YYYY-MM` 或该 (kpi, period) 无 `kpi_values` 行 → 跳过不入库;
趋势类以「计算基准期(趋势末端当期)」为关联期间;差异类去重键
= `(entity_id, kpi_id, kpi_value_id, alert_type)`。
"""
if period is None:
period = datetime.now().strftime("%Y-%m")
if not period_in_window(period):
logger.warning(" 差异预警跳过(R2/R3): 期间 %s 非规整月或早于窗口下限 %s",
period, window_lower_period())
return 0
2026-09-25(ASK-20260924-15,任总 2026-09-24 16:40「放行 15」)——**期间口径修复**:
· **period=None → 每个 KPI 回落「自己的最近可用期间」**(裁定2b,
`alert_entity_scope.resolve_latest_available_period`),与 `alert_generator.run_alert_check`
同源;**不再硬编码 `datetime.now()`(当月)**。
缺陷实证:当月(2026-09)尚无 `kpi_values` ⇒ 差异分支取不到实际值、趋势末端期落在
空月 ⇒ 本入口**永久 0 新增**(这正是「当期为当月 → 0 新增」的缺陷形态;同一缺陷
2026-09-17 只在 alert_generator 侧修过,本入口漏改)。
· 显式传 period → 固定该期间(原语义不变:抽查复核/测试用)。
· R2/R3 窗口判定**逐 KPI**做(回落期间各不相同,不能再用一次全局判定)。
"""
fixed_period = period # None = 逐 KPI 回落(裁定2b);有值 = 固定期间(原语义)
excluded = excluded_entity_ids(db_session)
kpis = db_session.query(KPIDefinition).filter(
KPIDefinition.status == "active"
@@ -398,17 +403,35 @@ def run_deviation_check(db_session, period: str = None) -> int:
skipped_demo = 0
skipped_period_linkage = 0
skipped_linkage_kpis = []
skipped_no_period = 0
skipped_out_of_window = 0
periods_used = {}
for kpi in kpis:
# R1(裁定 2026-09-19):demo 演示账套不参与预警遍历
if kpi_excluded(db_session, kpi, excluded):
skipped_demo += 1
continue
# 期间口径(裁定2b,2026-09-25):显式 → 固定;缺省 → 该 KPI 自己的最近可用期间
p = fixed_period or resolve_latest_available_period(db_session, kpi.id)
if not p:
skipped_no_period += 1
logger.warning(" 差异/趋势预警跳过(无可用期间): %s", kpi.kpi_name)
continue
# R2/R3(逐 KPI):期间须为规整月且落在窗口(当月往前 6 个自然月,含当月)
if not period_in_window(p):
skipped_out_of_window += 1
logger.info(" 差异/趋势预警跳过(R2/R3,窗口下限 %s): %s[%s]",
window_lower_period(), kpi.kpi_name, p)
continue
periods_used[p] = periods_used.get(p, 0) + 1
# 1. 差异预警:实际 vs 预算(统一逻辑)
result = build_deviation_alert(db_session, kpi, period)
result = build_deviation_alert(db_session, kpi, p)
if result["triggered"]:
deviation = result["deviation"]
alert_msg = (
f"{kpi.kpi_name}[{period}] 差异预警: 实际{deviation['actual_value']} "
f"{kpi.kpi_name}[{p}] 差异预警: 实际{deviation['actual_value']} "
f"vs 预算{deviation['budget_value']},"
f"差异率{deviation['deviation_rate']}%"
)
@@ -416,10 +439,10 @@ def run_deviation_check(db_session, period: str = None) -> int:
# R5-补1(裁定 2026-09-19):期间可归期 + 必须落 kpi_value_id 关联
#(文案里的期间不构成可归期;无 kpi_values 行 → 跳过不入库)
linked_kv, _skip_reason = resolve_alert_kpi_value(db_session, kpi, period)
linked_kv, _skip_reason = resolve_alert_kpi_value(db_session, kpi, p)
if linked_kv is None:
skipped_period_linkage += 1
skipped_linkage_kpis.append("%s[%s]" % (kpi.kpi_code, period))
skipped_linkage_kpis.append("%s[%s]" % (kpi.kpi_code, p))
else:
# 去重键(R5-补1 变更):(entity_id, kpi_id, kpi_value_id, alert_type)
#(原按 `alert_message contains period` 判断,改关联后按可归期目标去重)
@@ -446,13 +469,13 @@ def run_deviation_check(db_session, period: str = None) -> int:
new_count += 1
logger.info(f" 新增差异预警 [{result['kpi_alert_level']}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
# 2. 趋势异常检测(每期检查连续3期)
trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3)
# 2. 趋势异常检测(每期检查连续3期;末端期 = 本 KPI 的回落期间 p)
trend = check_trend_anomaly(db_session, kpi.id, p, consecutive=3)
if trend.get("anomaly") and trend.get("level") in ("yellow", "red"):
trend_alert_msg = trend["message"]
# R5-补1:趋势类以「计算基准期(趋势末端当期)」为关联期间;该期无行 → 跳过
trend_periods = trend.get("periods") or []
base_period = trend_periods[-1] if trend_periods else period
base_period = trend_periods[-1] if trend_periods else p
linked_kv, _skip_reason = resolve_alert_kpi_value(db_session, kpi, base_period)
if linked_kv is None:
skipped_period_linkage += 1
@@ -479,6 +502,9 @@ def run_deviation_check(db_session, period: str = None) -> int:
if skipped_demo:
logger.info(" 差异预警跳过 demo 账套 KPI %s 项(R1,裁定 2026-09-19)", skipped_demo)
logger.info(" 差异/趋势预警期间口径: %s;判定期间分布=%s;跳过(无可用期间)%s 项 / (R2-R3 窗口外)%s 项 / (R5-补1 无 kpi_values 行)%s 项",
("固定 %s" % fixed_period) if fixed_period else "各 KPI 最近可用期间(裁定2b)",
periods_used, skipped_no_period, skipped_out_of_window, skipped_period_linkage)
if skipped_period_linkage:
logger.info(" 差异/趋势预警跳过(R5-补1 期间不可归期或无 kpi_values 行)KPI %s 项: %s",
skipped_period_linkage, skipped_linkage_kpis)