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:
@@ -9,7 +9,7 @@ from sqlalchemy import func, desc
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.deps import get_entity_id, require_bot_entity_id
|
||||
from app.models import (
|
||||
User, UserEntity, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
|
||||
DataSourceConfig, KPIAlert, OperationLog, NotificationChannel,
|
||||
@@ -666,7 +666,8 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
|
||||
kpi_id = data.get("kpi_id")
|
||||
actual_value = data.get("actual_value")
|
||||
period = data.get("period")
|
||||
entity_id = int(data.get("entity_id") or 1)
|
||||
# ASK-20260924-17(任总 2026-09-24 放行):Bot 通道缺省账套 = 报错,不再静默落 entity 1
|
||||
entity_id = require_bot_entity_id(data, source="bot_bridge.kpi-value-with-check")
|
||||
run_check = bool(data.get("run_check", True))
|
||||
if not kpi_id or actual_value is None:
|
||||
raise HTTPException(400, "kpi_id 和 actual_value 必填")
|
||||
@@ -753,7 +754,7 @@ def bot_kpi_create_with_links(data: dict, db: Session = Depends(get_db), bot: di
|
||||
考核基准类(目标值)= 人拍板;target_value 一律显式置 0(满足治理必填,
|
||||
后续由人工设定或走 POST /api/cma/bot/ai/suggestions 建议通道待审批)。
|
||||
"""
|
||||
entity_id = int(data.get("entity_id") or 1)
|
||||
entity_id = require_bot_entity_id(data, source="bot_bridge.kpis.create-with-links")
|
||||
from app.api.kpis import _validate_kpi_data, apply_calc_type_inference
|
||||
kpi_data = {k: v for k, v in data.items() if k not in ("entity_id", "link_map_id", "link_causality")}
|
||||
|
||||
@@ -1202,6 +1203,13 @@ def bot_subjects_upsert(
|
||||
true → 更新 name/parent/level/category —— **但只更新本账套私有行**;
|
||||
同码若只以**全局标准科目**(`entity_id IS NULL`)存在,则**永不被账套 UPSERT 改写**(计入 `skipped_global`),
|
||||
同时也不再插入本账套同码副本(避免标准科目重复)。
|
||||
- **幂等语义(2026-09-24 R6 收口,判据勿误读)**:`update_existing=true` ⇒ **批内既存行全量重写**
|
||||
(逐行 UPDATE,不做值级 diff)⇒ 返回的 `updated=N` 是「**批内既存行数**」,**不是**「值发生变化的行数」,
|
||||
且**含本轮 `inserted` 的新行**:同一 payload 连跑两次,首跑 `inserted=k, updated=m`,
|
||||
复跑 `inserted=0, updated=k+m`(实测例:首跑 `inserted=5/updated=2` → 复跑 `updated=7`,
|
||||
见 `operation_logs` detail 原文 id=347/348)。此形态**属既定行为、非异常写入**:库内不产生重复行
|
||||
(唯一键 `(entity_id, subject_code)` + 只 UPDATE 不 DELETE/INSERT)。幂等判据取 `inserted=0`
|
||||
且 `before == after`(叠加 `COUNT(*)`/`MAX(id)` 不变),**不得用 `updated` 判幂等**。
|
||||
- dry_run=true → 只校验与回显,零写入
|
||||
返回: {"ok":true,"inserted":n,"updated":m,"skipped":k,"skipped_global":g,"entity_id":e,"errors":[]};未授权 401。
|
||||
"""
|
||||
|
||||
@@ -18,6 +18,7 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import require_bot_entity_id
|
||||
from app.models import (
|
||||
MpmResult, BotBridgeConfig,
|
||||
KPIDefinition, KPIValue, KPIAlert,
|
||||
@@ -486,7 +487,7 @@ def push_kpi_result(
|
||||
2. 写入KPIValue (source_type=bot, data_status=verified) — 与auto-verify引擎兼容
|
||||
3. 调用 run_alert_check 触发阈值预警 → 返回 new_alerts
|
||||
"""
|
||||
entity_id = data.get("entity_id", 1)
|
||||
entity_id = require_bot_entity_id(data, source="bot_bridge_v2.kpi-values")
|
||||
kpi_code = data.get("kpi_code")
|
||||
period = data.get("period")
|
||||
value = data.get("value")
|
||||
|
||||
@@ -24,6 +24,7 @@ from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import AISuggestion, KPIDefinition, Entity
|
||||
from app.api.bot_bridge import verify_bot_key
|
||||
from app.deps import require_bot_entity_id
|
||||
from app.api.ai_suggestions import _sug_dict
|
||||
from app.risk_levels import risk_level
|
||||
|
||||
@@ -41,10 +42,13 @@ _TARGET_TYPE_MAP = {
|
||||
|
||||
|
||||
def _resolve_bot_entity(request: Request, data: dict, db: Session) -> int:
|
||||
"""账套解析(Bot无token通道): body.entity_id → X-Entity-Id header → 默认1,校验active。
|
||||
"""账套解析(Bot无token通道): body.entity_id → X-Entity-Id header → **缺省即报错**。
|
||||
|
||||
与 deps.get_entity_id 的 Bot 白名单语义一致(query/header/body 任一来源),
|
||||
本端点不接收登录token,故不在此处理 Bearer。
|
||||
|
||||
ASK-20260924-17(任总 2026-09-24 放行):**取消「默认 1(酣客)」兜底**——
|
||||
缺省账套改用 `deps.require_bot_entity_id` 收口(缺 → 400,非数字 → 422)。
|
||||
"""
|
||||
raw = None
|
||||
if data.get("entity_id") not in (None, ""):
|
||||
@@ -53,10 +57,7 @@ def _resolve_bot_entity(request: Request, data: dict, db: Session) -> int:
|
||||
xh = request.headers.get("X-Entity-Id")
|
||||
if xh and str(xh).isdigit():
|
||||
raw = int(xh)
|
||||
try:
|
||||
eid = int(raw) if raw is not None else 1
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(422, f"entity_id 无效: {raw!r}")
|
||||
eid = require_bot_entity_id({"entity_id": raw}, source="bot_suggestions")
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||
if not ent or ent.status != "active":
|
||||
raise HTTPException(403, f"企业 entity_id={eid} 不存在或未激活")
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
—— period 是 voucher_date 派生属性,**不进幂等键**(2026-09-11 加固:日期口径
|
||||
在 summary 派生/主表 VoucherDate 之间切换时,含 period 会判重失效导致重复入库)。
|
||||
命中既有行即跳过;仅当命中行 period 与新行不一致时计入 period_conflict 留痕(不覆盖)。
|
||||
7. entity_id:默认 1(陕西酣客,source=zxt-sxzxb20210228),Bot 通道可传 X-Entity-Id 或 body entity_id。
|
||||
7. entity_id:**必传**(ASK-20260924-17,任总 2026-09-24 放行)——body `entity_id` 或
|
||||
header `X-Entity-Id`(1=陕西酣客、2=陕西博海);**缺省 → 400**,
|
||||
不再兜底默认账套 1(原口径:默认 1 陕西酣客,source=zxt-sxzxb20210228)。
|
||||
8. 入库后不触发现金流/ledger 联动(凭证完整性未证实前不作为权威来源,见财务口径警示);
|
||||
也不影响 Excel multipart 导入(原端点不动)。
|
||||
"""
|
||||
@@ -45,7 +47,7 @@ from sqlalchemy import text as sa_text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import resolve_entity_for_request
|
||||
from app.deps import resolve_entity_for_request, require_bot_entity_id
|
||||
from app.models import Entity, ImportLog, VoucherDetail
|
||||
from app.api.bot_bridge import verify_bot_key
|
||||
|
||||
@@ -225,12 +227,18 @@ def api_import_vouchers_json(
|
||||
nr["CwAccountID"] = v
|
||||
norm_rows.append(nr)
|
||||
|
||||
# ── entity 解析 + active 校验 ──
|
||||
entity_id = resolve_entity_for_request(request, req_entity if req_entity not in (None, "") else 1)
|
||||
# ── entity 解析 + active 校验(ASK-20260924-17:Bot 通道必须显式传账套)──
|
||||
entity_id = resolve_entity_for_request(
|
||||
request,
|
||||
require_bot_entity_id(
|
||||
{"entity_id": req_entity if req_entity not in (None, "") else request.headers.get("X-Entity-Id")},
|
||||
source="cash_vouchers_json.import/vouchers-json",
|
||||
),
|
||||
)
|
||||
try:
|
||||
entity_id = int(entity_id)
|
||||
except Exception:
|
||||
entity_id = 1
|
||||
raise HTTPException(422, f"entity_id 无效: {entity_id!r}")
|
||||
if not _entity_active(db, entity_id):
|
||||
raise HTTPException(403, f"企业ID={entity_id} 不存在或未激活")
|
||||
|
||||
|
||||
+55
-2
@@ -1,4 +1,6 @@
|
||||
"""多租户公共依赖 — 账套模式:token优先,query/header降级为Bot服务白名单"""
|
||||
import logging
|
||||
|
||||
from fastapi import Request, Header, Query, Depends, HTTPException
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -7,6 +9,8 @@ from app.database import get_db
|
||||
from app.auth_middleware import get_token_entity_id
|
||||
from app.models import Entity
|
||||
|
||||
logger = logging.getLogger("cma.deps")
|
||||
|
||||
|
||||
def get_entity_id(
|
||||
request: Request,
|
||||
@@ -20,7 +24,8 @@ def get_entity_id(
|
||||
1. Authorization Bearer token → 优先返回 token.entity_id(唯一可信来源)
|
||||
2. query参数 / X-Entity-Id header → 仅Bot服务通道使用(校验entity状态active)
|
||||
3. body中的 entity_id(POST场景)→ Bot服务通道兼容
|
||||
4. 兜底默认 1(酣客)
|
||||
4. **Bot 通道(带 X-BOT-KEY)无任何显式账套 → 400 显式报错**;
|
||||
无 X-BOT-KEY 的存量公开接口 → 兜底默认 1(酣客)
|
||||
|
||||
安全说明:登录用户必须通过token绑定账套,query/header传入的entity_id
|
||||
在token存在时被忽略,防止越权传参(旧漏洞:query优先且无授权校验)。
|
||||
@@ -65,7 +70,23 @@ def get_entity_id(
|
||||
# Bot通道校验不通过:不返回默认,直接拒绝(防越权注入无效entity)
|
||||
raise HTTPException(403, f"企业 entity_id={candidate} 不存在或未激活")
|
||||
|
||||
return 1 # 默认酣客(无token、无参数时兜底,兼容存量公开接口)
|
||||
# 4. 兜底(ASK-20260924-17,任总 2026-09-24 16:40「放行 17」):
|
||||
# **Bot 通道缺省账套 = 报错,不再静默兜底 entity 1(酣客)**。
|
||||
# 背景:Bot 取数接口不带账套参数时兜底 entity 1 ⇒ 跨 Bot/脚本漏传参数即用酣客数字
|
||||
# 回答博海的问题(实测:不带参数 GET /api/cma/bot/overview → {"entity_id":1, "kpis_total":44})。
|
||||
# 「问错账套」比看不到更危险,故 Bot 通道(带 X-BOT-KEY)要求**显式传账套**。
|
||||
# 边界:无 X-BOT-KEY 的存量公开/历史接口保持原兜底行为(不在 ASK-17 范围)。
|
||||
if str(request.headers.get("X-BOT-KEY") or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"缺少账套参数 entity_id:Bot 通道必须显式传账套"
|
||||
"(query `?entity_id=<n>` 或 header `X-Entity-Id: <n>`;"
|
||||
"1=陕西酣客、2=陕西博海),不再兜底默认账套(ASK-20260924-17)。"
|
||||
),
|
||||
)
|
||||
|
||||
return 1 # 无 token / 无参数的存量公开接口:保持原兜底(酣客)
|
||||
|
||||
|
||||
def resolve_entity_for_request(request: Request, fallback: int = 1) -> int:
|
||||
@@ -81,6 +102,38 @@ def resolve_entity_for_request(request: Request, fallback: int = 1) -> int:
|
||||
return fallback
|
||||
|
||||
|
||||
def require_bot_entity_id(data: Optional[dict], *, source: str = "") -> int:
|
||||
"""Bot 通道写端点:**账套参数必填**(ASK-20260924-17,任总 2026-09-24 16:40「放行 17」)
|
||||
|
||||
`deps.get_entity_id` 只能覆盖「用 Depends 取账套」的 GET 端点;Bot 写端点多从
|
||||
body 里读 `entity_id`,原写法 `int(data.get("entity_id") or 1)` 会**静默兜底账套 1
|
||||
(酣客)**——跨 Bot/脚本漏传参数即「用酣客数字回答博海的问题」。
|
||||
|
||||
本函数把「缺省即报错」收口到一处(`_require_*` 三处 + 建议通道 + 凭证导入共用):
|
||||
· 显式传 `entity_id`(int 或数字字符串,含 0/负数由调用方各自的 active 校验接手)→ 返回 int;
|
||||
· 缺失 / 空串 → **HTTP 400**,文案说明必须显式传账套及可选值;
|
||||
· 非数字 → HTTP 422(与既有 `entity_id 无效` 文案同族,不静默回落)。
|
||||
|
||||
`source` 仅用于日志定位(哪条写入通道漏传)。
|
||||
"""
|
||||
raw = (data or {}).get("entity_id")
|
||||
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
||||
logger.warning(
|
||||
"Bot 通道缺少账套参数 entity_id → 400(source=%s)", source or "-")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"缺少账套参数 entity_id:Bot 通道必须显式传账套"
|
||||
"(body.entity_id / header `X-Entity-Id`;1=陕西酣客、2=陕西博海),"
|
||||
"不再兜底默认账套(ASK-20260924-17)。"
|
||||
),
|
||||
)
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(422, f"entity_id 无效: {raw!r}")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 跨租户读取防护(cma-cross-entity-endpoints-auth-20260914 补单)
|
||||
#
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user