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
+11 -3
View File
@@ -9,7 +9,7 @@ from sqlalchemy import func, desc
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from app.database import get_db 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 ( from app.models import (
User, UserEntity, StrategicMap, KPIDefinition, KPITemplate, KPIValue, User, UserEntity, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
DataSourceConfig, KPIAlert, OperationLog, NotificationChannel, 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") kpi_id = data.get("kpi_id")
actual_value = data.get("actual_value") actual_value = data.get("actual_value")
period = data.get("period") 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)) run_check = bool(data.get("run_check", True))
if not kpi_id or actual_value is None: if not kpi_id or actual_value is None:
raise HTTPException(400, "kpi_id 和 actual_value 必填") 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(满足治理必填, 考核基准类(目标值)= 人拍板;target_value 一律显式置 0(满足治理必填,
后续由人工设定或走 POST /api/cma/bot/ai/suggestions 建议通道待审批)。 后续由人工设定或走 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 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")} 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 —— **但只更新本账套私有行**; true → 更新 name/parent/level/category —— **但只更新本账套私有行**;
同码若只以**全局标准科目**(`entity_id IS NULL`)存在,则**永不被账套 UPSERT 改写**(计入 `skipped_global`), 同码若只以**全局标准科目**(`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 → 只校验与回显,零写入 - dry_run=true → 只校验与回显,零写入
返回: {"ok":true,"inserted":n,"updated":m,"skipped":k,"skipped_global":g,"entity_id":e,"errors":[]};未授权 401。 返回: {"ok":true,"inserted":n,"updated":m,"skipped":k,"skipped_global":g,"entity_id":e,"errors":[]};未授权 401。
""" """
+2 -1
View File
@@ -18,6 +18,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import func from sqlalchemy import func
from app.database import get_db from app.database import get_db
from app.deps import require_bot_entity_id
from app.models import ( from app.models import (
MpmResult, BotBridgeConfig, MpmResult, BotBridgeConfig,
KPIDefinition, KPIValue, KPIAlert, KPIDefinition, KPIValue, KPIAlert,
@@ -486,7 +487,7 @@ def push_kpi_result(
2. 写入KPIValue (source_type=bot, data_status=verified) — 与auto-verify引擎兼容 2. 写入KPIValue (source_type=bot, data_status=verified) — 与auto-verify引擎兼容
3. 调用 run_alert_check 触发阈值预警 → 返回 new_alerts 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") kpi_code = data.get("kpi_code")
period = data.get("period") period = data.get("period")
value = data.get("value") value = data.get("value")
+6 -5
View File
@@ -24,6 +24,7 @@ from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models import AISuggestion, KPIDefinition, Entity from app.models import AISuggestion, KPIDefinition, Entity
from app.api.bot_bridge import verify_bot_key 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.api.ai_suggestions import _sug_dict
from app.risk_levels import risk_level 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: 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 任一来源), 与 deps.get_entity_id 的 Bot 白名单语义一致(query/header/body 任一来源),
本端点不接收登录token,故不在此处理 Bearer。 本端点不接收登录token,故不在此处理 Bearer。
ASK-20260924-17(任总 2026-09-24 放行):**取消「默认 1(酣客)」兜底**——
缺省账套改用 `deps.require_bot_entity_id` 收口(缺 → 400,非数字 → 422)。
""" """
raw = None raw = None
if data.get("entity_id") not in (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") xh = request.headers.get("X-Entity-Id")
if xh and str(xh).isdigit(): if xh and str(xh).isdigit():
raw = int(xh) raw = int(xh)
try: eid = require_bot_entity_id({"entity_id": raw}, source="bot_suggestions")
eid = int(raw) if raw is not None else 1
except (TypeError, ValueError):
raise HTTPException(422, f"entity_id 无效: {raw!r}")
ent = db.query(Entity).filter(Entity.id == eid).first() ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent or ent.status != "active": if not ent or ent.status != "active":
raise HTTPException(403, f"企业 entity_id={eid} 不存在或未激活") raise HTTPException(403, f"企业 entity_id={eid} 不存在或未激活")
+13 -5
View File
@@ -29,7 +29,9 @@
—— period 是 voucher_date 派生属性,**不进幂等键**(2026-09-11 加固:日期口径 —— period 是 voucher_date 派生属性,**不进幂等键**(2026-09-11 加固:日期口径
在 summary 派生/主表 VoucherDate 之间切换时,含 period 会判重失效导致重复入库)。 在 summary 派生/主表 VoucherDate 之间切换时,含 period 会判重失效导致重复入库)。
命中既有行即跳过;仅当命中行 period 与新行不一致时计入 period_conflict 留痕(不覆盖)。 命中既有行即跳过;仅当命中行 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 联动(凭证完整性未证实前不作为权威来源,见财务口径警示); 8. 入库后不触发现金流/ledger 联动(凭证完整性未证实前不作为权威来源,见财务口径警示);
也不影响 Excel multipart 导入(原端点不动)。 也不影响 Excel multipart 导入(原端点不动)。
""" """
@@ -45,7 +47,7 @@ from sqlalchemy import text as sa_text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db 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.models import Entity, ImportLog, VoucherDetail
from app.api.bot_bridge import verify_bot_key from app.api.bot_bridge import verify_bot_key
@@ -225,12 +227,18 @@ def api_import_vouchers_json(
nr["CwAccountID"] = v nr["CwAccountID"] = v
norm_rows.append(nr) norm_rows.append(nr)
# ── entity 解析 + active 校验 ── # ── entity 解析 + active 校验(ASK-20260924-17:Bot 通道必须显式传账套)──
entity_id = resolve_entity_for_request(request, req_entity if req_entity not in (None, "") else 1) 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: try:
entity_id = int(entity_id) entity_id = int(entity_id)
except Exception: except Exception:
entity_id = 1 raise HTTPException(422, f"entity_id 无效: {entity_id!r}")
if not _entity_active(db, entity_id): if not _entity_active(db, entity_id):
raise HTTPException(403, f"企业ID={entity_id} 不存在或未激活") raise HTTPException(403, f"企业ID={entity_id} 不存在或未激活")
+55 -2
View File
@@ -1,4 +1,6 @@
"""多租户公共依赖 — 账套模式:token优先,query/header降级为Bot服务白名单""" """多租户公共依赖 — 账套模式:token优先,query/header降级为Bot服务白名单"""
import logging
from fastapi import Request, Header, Query, Depends, HTTPException from fastapi import Request, Header, Query, Depends, HTTPException
from typing import Optional from typing import Optional
from sqlalchemy.orm import Session 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.auth_middleware import get_token_entity_id
from app.models import Entity from app.models import Entity
logger = logging.getLogger("cma.deps")
def get_entity_id( def get_entity_id(
request: Request, request: Request,
@@ -20,7 +24,8 @@ def get_entity_id(
1. Authorization Bearer token → 优先返回 token.entity_id(唯一可信来源) 1. Authorization Bearer token → 优先返回 token.entity_id(唯一可信来源)
2. query参数 / X-Entity-Id header → 仅Bot服务通道使用(校验entity状态active) 2. query参数 / X-Entity-Id header → 仅Bot服务通道使用(校验entity状态active)
3. body中的 entity_id(POST场景)→ Bot服务通道兼容 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/header传入的entity_id
在token存在时被忽略,防止越权传参(旧漏洞:query优先且无授权校验)。 在token存在时被忽略,防止越权传参(旧漏洞:query优先且无授权校验)。
@@ -65,7 +70,23 @@ def get_entity_id(
# Bot通道校验不通过:不返回默认,直接拒绝(防越权注入无效entity) # Bot通道校验不通过:不返回默认,直接拒绝(防越权注入无效entity)
raise HTTPException(403, f"企业 entity_id={candidate} 不存在或未激活") 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: 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 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 补单) # 跨租户读取防护(cma-cross-entity-endpoints-auth-20260914 补单)
# #
+29
View File
@@ -87,6 +87,35 @@ def period_in_window(period, now: Optional[datetime] = None) -> bool:
return str(period) >= window_lower_period(now) 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 的一致延伸):可归期原则 + 生成侧守卫 ── # ── R5(裁定 2026-09-19,R3 的一致延伸):可归期原则 + 生成侧守卫 ──
def is_attributable_period(period) -> bool: 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 ( from app.utils.alert_entity_scope import (
excluded_entity_ids, kpi_excluded, period_in_window, window_lower_period, excluded_entity_ids, kpi_excluded, period_in_window, window_lower_period,
guard_alert_period, # R5-补1(2026-09-19):期间可归期 + 关联 kpi_values 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") 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` 行 → 跳过不入库; 文案期间不可解析为 `YYYY-MM` 或该 (kpi, period) 无 `kpi_values` 行 → 跳过不入库;
趋势类以「计算基准期(趋势末端当期)」为关联期间;差异类去重键 趋势类以「计算基准期(趋势末端当期)」为关联期间;差异类去重键
= `(entity_id, kpi_id, kpi_value_id, alert_type)`。 = `(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) excluded = excluded_entity_ids(db_session)
kpis = db_session.query(KPIDefinition).filter( kpis = db_session.query(KPIDefinition).filter(
KPIDefinition.status == "active" KPIDefinition.status == "active"
@@ -398,17 +403,35 @@ def run_deviation_check(db_session, period: str = None) -> int:
skipped_demo = 0 skipped_demo = 0
skipped_period_linkage = 0 skipped_period_linkage = 0
skipped_linkage_kpis = [] skipped_linkage_kpis = []
skipped_no_period = 0
skipped_out_of_window = 0
periods_used = {}
for kpi in kpis: for kpi in kpis:
# R1(裁定 2026-09-19):demo 演示账套不参与预警遍历 # R1(裁定 2026-09-19):demo 演示账套不参与预警遍历
if kpi_excluded(db_session, kpi, excluded): if kpi_excluded(db_session, kpi, excluded):
skipped_demo += 1 skipped_demo += 1
continue 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 预算(统一逻辑) # 1. 差异预警:实际 vs 预算(统一逻辑)
result = build_deviation_alert(db_session, kpi, period) result = build_deviation_alert(db_session, kpi, p)
if result["triggered"]: if result["triggered"]:
deviation = result["deviation"] deviation = result["deviation"]
alert_msg = ( alert_msg = (
f"{kpi.kpi_name}[{period}] 差异预警: 实际{deviation['actual_value']} " f"{kpi.kpi_name}[{p}] 差异预警: 实际{deviation['actual_value']} "
f"vs 预算{deviation['budget_value']}," f"vs 预算{deviation['budget_value']},"
f"差异率{deviation['deviation_rate']}%" 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 关联 # R5-补1(裁定 2026-09-19):期间可归期 + 必须落 kpi_value_id 关联
#(文案里的期间不构成可归期;无 kpi_values 行 → 跳过不入库) #(文案里的期间不构成可归期;无 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: if linked_kv is None:
skipped_period_linkage += 1 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: else:
# 去重键(R5-补1 变更):(entity_id, kpi_id, kpi_value_id, alert_type) # 去重键(R5-补1 变更):(entity_id, kpi_id, kpi_value_id, alert_type)
#(原按 `alert_message contains period` 判断,改关联后按可归期目标去重) #(原按 `alert_message contains period` 判断,改关联后按可归期目标去重)
@@ -446,13 +469,13 @@ def run_deviation_check(db_session, period: str = None) -> int:
new_count += 1 new_count += 1
logger.info(f" 新增差异预警 [{result['kpi_alert_level']}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%") logger.info(f" 新增差异预警 [{result['kpi_alert_level']}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
# 2. 趋势异常检测(每期检查连续3期) # 2. 趋势异常检测(每期检查连续3期;末端期 = 本 KPI 的回落期间 p)
trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3) trend = check_trend_anomaly(db_session, kpi.id, p, consecutive=3)
if trend.get("anomaly") and trend.get("level") in ("yellow", "red"): if trend.get("anomaly") and trend.get("level") in ("yellow", "red"):
trend_alert_msg = trend["message"] trend_alert_msg = trend["message"]
# R5-补1:趋势类以「计算基准期(趋势末端当期)」为关联期间;该期无行 → 跳过 # R5-补1:趋势类以「计算基准期(趋势末端当期)」为关联期间;该期无行 → 跳过
trend_periods = trend.get("periods") or [] 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) linked_kv, _skip_reason = resolve_alert_kpi_value(db_session, kpi, base_period)
if linked_kv is None: if linked_kv is None:
skipped_period_linkage += 1 skipped_period_linkage += 1
@@ -479,6 +502,9 @@ def run_deviation_check(db_session, period: str = None) -> int:
if skipped_demo: if skipped_demo:
logger.info(" 差异预警跳过 demo 账套 KPI %s 项(R1,裁定 2026-09-19)", 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: if skipped_period_linkage:
logger.info(" 差异/趋势预警跳过(R5-补1 期间不可归期或无 kpi_values 行)KPI %s 项: %s", logger.info(" 差异/趋势预警跳过(R5-补1 期间不可归期或无 kpi_values 行)KPI %s 项: %s",
skipped_period_linkage, skipped_linkage_kpis) skipped_period_linkage, skipped_linkage_kpis)
+8 -10
View File
@@ -44,17 +44,15 @@ def resolve_latest_period(db_session, kpi_id=None):
也不被别人(他账套/演示账套)更新的新期间带偏——实证:全局 MAX(period)=2026-09 也不被别人(他账套/演示账套)更新的新期间带偏——实证:全局 MAX(period)=2026-09
但那是演示账套 e6 的 8 条数据,酣客(e1) 的最新可用期间是 2026-08。 但那是演示账套 e6 的 8 条数据,酣客(e1) 的最新可用期间是 2026-08。
实现说明:伪期间过滤在 **Python 侧**做(`SELECT DISTINCT period` 只有十几行), 2026-09-25(ASK-20260924-15):实现**收口到唯一事实源**
不依赖 MySQL 的 `REGEXP` —— SQLite 测试库不注册 REGEXP,用 SQL 侧正则会让 `app.utils.alert_entity_scope.resolve_latest_available_period`——原实现与
单测与生产走两套语义。 `deviation_engine` 的期间口径各自一份,导致差异/趋势入口仍硬编码当月。
语义差异(有意):判据由本文件旧正则 `^[0-9]{4}-[0-9]{2}$`(会接受 `2026-13`
这类非法月)改为 R3 单一正则 `is_month_period`(严格 01–12);现网数据无
非法月期间,故行为逐项一致(本单 A/B 实测)。
""" """
q = db_session.query(KPIValue.period).filter(KPIValue.actual_value.isnot(None)) from app.utils.alert_entity_scope import resolve_latest_available_period
if kpi_id is not None: return resolve_latest_available_period(db_session, kpi_id)
q = q.filter(KPIValue.kpi_id == kpi_id)
for (p,) in q.distinct().order_by(KPIValue.period.desc()).all():
if _PERIOD_RE.fullmatch(str(p or "")):
return p
return None
def _parse_threshold(expr: str) -> tuple: def _parse_threshold(expr: str) -> tuple:
@@ -0,0 +1,174 @@
"""ASK-20260924-15 / -17 新判据回归(2026-09-25,任务单
`yanxue-20260924-ask15-17-fullstack-engine-and-bot-default`)。
- **#17**:Bot 通道(带 `X-BOT-KEY`)**缺省账套 → HTTP 400**(旧为兜底 entity 1=酣客);
显式 `?entity_id=` / `X-Entity-Id:` / body `entity_id` 行为不变;无 key → 401;写端点缺省 → 400 且零写入。
- **#15**:告警引擎差异/趋势入口 `run_deviation_check(db, period=None)` 的期间口径
**回落「该 KPI 自己的最近可用期间」**,不再硬编码当月(当月无 kpi_values ⇒ 永久 0 新增)。
"""
from datetime import datetime, timedelta
import pytest
from fastapi.testclient import TestClient
from starlette.requests import Request
from app.deps import get_entity_id, require_bot_entity_id
from app.models import Entity, KPIDefinition, KPIValue
from app.utils.alert_entity_scope import resolve_latest_available_period
from tests.conftest import create_test_kpi
BOT_KEY = "cma-bot-finance-2026"
HEAD = {"X-BOT-KEY": BOT_KEY}
def _prev_month() -> str:
return (datetime.now().replace(day=1) - timedelta(days=1)).strftime("%Y-%m")
def _cur_month() -> str:
return datetime.now().strftime("%Y-%m")
def _raw_request(headers=None, method="GET", query_string=b"") -> Request:
return Request({
"type": "http", "method": method, "path": "/api/cma/bot/overview",
"headers": (headers or []) + [(b"host", b"test")],
"query_string": query_string, "client": ("127.0.0.1", 1),
"server": ("test", 80), "scheme": "http",
})
# ─────────────────────────── #17:Bot 通道缺省账套 → 400 ───────────────────────────
class TestBotChannelEntityRequired:
def test_overview_without_entity_400(self, client, db):
"""① 带 key、不带 entity_id → 400 且 message 说明须显式传账套"""
r = client.get("/api/cma/bot/overview", headers=HEAD)
assert r.status_code == 400, r.text
assert "缺少账套参数 entity_id" in r.text
def test_overview_entity1_ok(self, client, db):
"""② ?entity_id=1 → 200 且 entity_id=1(酣客口径)"""
r = client.get("/api/cma/bot/overview?entity_id=1", headers=HEAD)
assert r.status_code == 200, r.text
assert r.json()["entity_id"] == 1
def test_overview_entity2_ok(self, client, db):
"""③ ?entity_id=2 → 200 且 entity_id=2(博海口径,另建账套)"""
if not db.query(Entity).filter(Entity.id == 2).first():
db.add(Entity(id=2, name="另一企业", short_name="B", status="active"))
db.commit()
r = client.get("/api/cma/bot/overview?entity_id=2", headers=HEAD)
assert r.status_code == 200, r.text
assert r.json()["entity_id"] == 2
def test_x_entity_id_header_ok(self, client, db):
"""显式 header 形态同样放行(三种传参形态之一)"""
r = client.get("/api/cma/bot/overview",
headers={**HEAD, "X-Entity-Id": "1"})
assert r.status_code == 200, r.text
assert r.json()["entity_id"] == 1
def test_no_bot_key_401(self, client, db):
"""④ 无 key → 401(鉴权在前;本单未改鉴权)"""
r = client.get("/api/cma/bot/overview?entity_id=1")
assert r.status_code == 401
def test_write_endpoint_without_entity_400_and_no_write(self, client, db):
"""⑤ 写端点缺省账套 → 400 且**库内零写入**(旧写法会静默写 entity 1)"""
kpi = create_test_kpi(db, kpi_code="ENTITY_REQ_1")
before = db.query(KPIValue).filter(KPIValue.kpi_id == kpi.id).count()
r = client.post("/api/cma/bot/kpi-value-with-check",
json={"kpi_id": kpi.id, "actual_value": 1.0, "period": _prev_month()},
headers=HEAD)
assert r.status_code == 400, r.text
assert "缺少账套参数 entity_id" in r.text
db.expire_all()
after = db.query(KPIValue).filter(KPIValue.kpi_id == kpi.id).count()
assert after == before == 0
def test_require_bot_entity_id_helper(self):
"""helper 语义:显式 int/数字串放行;缺失/空串 400;非数字 422"""
from fastapi import HTTPException
assert require_bot_entity_id({"entity_id": 2}) == 2
assert require_bot_entity_id({"entity_id": "2"}) == 2
for bad in (None, {}, {"entity_id": ""}, {"entity_id": " "}):
with pytest.raises(HTTPException) as e:
require_bot_entity_id(bad, source="unit")
assert e.value.status_code == 400
with pytest.raises(HTTPException) as e:
require_bot_entity_id({"entity_id": "abc"})
assert e.value.status_code == 422
def test_legacy_no_key_fallback_kept(self):
"""边界:**无 X-BOT-KEY** 的存量公开接口仍兜底 entity 1(本单未动)"""
assert get_entity_id(request=_raw_request(), entity_id=None,
x_entity_id=None, db=None) == 1
with pytest.raises(Exception) as e:
get_entity_id(request=_raw_request(headers=[(b"x-bot-key", b"k")]),
entity_id=None, x_entity_id=None, db=None)
assert getattr(e.value, "status_code", None) == 400
# ─────────────────────── #15:差异/趋势入口期间口径(裁定2b) ───────────────────────
class TestDeviationEnginePeriodCaliber:
def test_fallback_is_kpi_latest_available_period(self, db):
"""单一事实源:回落期间 = 该 KPI 自己的最近可用期间(最大 YYYY-MM)"""
kpi = create_test_kpi(db, kpi_code="PERIOD_FB_1")
assert resolve_latest_available_period(db, kpi.id) is None
db.add(KPIValue(kpi_id=kpi.id, entity_id=1, period="2026-03", actual_value=1.0))
db.add(KPIValue(kpi_id=kpi.id, entity_id=1, period="2026-05", actual_value=2.0))
db.commit()
assert resolve_latest_available_period(db, kpi.id) == "2026-05"
def test_deviation_check_uses_fallback_not_current_month(self, db, monkeypatch):
"""改前用当月(2026-09 无值 ⇒ 永久 0 新增);改后逐 KPI 回落上一可用月"""
prev, cur = _prev_month(), _cur_month()
assert prev != cur
kpi = create_test_kpi(db, kpi_code="PERIOD_FB_2")
db.add(KPIValue(kpi_id=kpi.id, entity_id=1, period=prev, actual_value=100.0))
db.commit()
import app.utils.deviation_engine as de
seen = []
def _fake_build(db_, kpi_, period):
seen.append((kpi_.id, period))
return {"triggered": False}
monkeypatch.setattr(de, "build_deviation_alert", _fake_build)
monkeypatch.setattr(de, "check_trend_anomaly",
lambda *a, **k: {"anomaly": False})
de.run_deviation_check(db) # period=None ⇒ 逐 KPI 回落
assert (kpi.id, prev) in seen, "缺省期间应回落该 KPI 的最近可用期间 %s" % prev
assert (kpi.id, cur) not in seen, "不应再拿当月(无数据月)当口径"
def test_deviation_check_explicit_period_still_fixed(self, db, monkeypatch):
"""显式传 period → 固定该期间(原语义不变,抽查/测试用)"""
prev = _prev_month()
kpi = create_test_kpi(db, kpi_code="PERIOD_FB_3")
db.add(KPIValue(kpi_id=kpi.id, entity_id=1, period=prev, actual_value=100.0))
db.commit()
import app.utils.deviation_engine as de
seen = []
monkeypatch.setattr(de, "build_deviation_alert",
lambda db_, kpi_, period: (seen.append((kpi_.id, period)),
{"triggered": False})[1])
monkeypatch.setattr(de, "check_trend_anomaly", lambda *a, **k: {"anomaly": False})
de.run_deviation_check(db, period="2026-06")
assert (kpi.id, "2026-06") in seen
def test_active_kpi_without_values_is_skipped_not_crashed(self, db, monkeypatch):
"""无任何 kpi_values 的 active KPI:跳过(不抛、不计入期间分布)"""
kpi = create_test_kpi(db, kpi_code="PERIOD_FB_4")
import app.utils.deviation_engine as de
seen = []
monkeypatch.setattr(de, "build_deviation_alert",
lambda db_, kpi_, period: (seen.append((kpi_.id, period)),
{"triggered": False})[1])
monkeypatch.setattr(de, "check_trend_anomaly", lambda *a, **k: {"anomaly": False})
de.run_deviation_check(db)
assert (kpi.id, None) not in seen
+5 -1
View File
@@ -22,7 +22,11 @@ from app.models.budget_plan import BudgetPlan
from app.models.cost_model import StandardCost, ActualCost from app.models.cost_model import StandardCost, ActualCost
from tests.conftest import create_test_user, get_token_for_user, auth_header from tests.conftest import create_test_user, get_token_for_user, auth_header
BOT_KEY = {"X-BOT-KEY": "cma-bot-finance-2026"} BOT_KEY = {"X-BOT-KEY": "cma-bot-finance-2026",
# ASK-20260924-17(2026-09-25):Bot 通道必须显式传账套——旧行为是缺省兜底
# entity 1(酣客),本文件全部用例的账套语义即 1(无他账套断言),故在此**显式化**,
# 断言与用例语义不变(不是放宽:缺省 400 的新判据由 test_entity_required_* 覆盖)。
"X-Entity-Id": "1"}
def _make_excel(kpi_code: str, period: str, value: float) -> bytes: def _make_excel(kpi_code: str, period: str, value: float) -> bytes:
@@ -29,6 +29,9 @@ def _bot_create(client, kpi_id, **kw):
"suggestion_data": {"kpi_id": kpi_id, "target_value": 150.0}, "suggestion_data": {"kpi_id": kpi_id, "target_value": 150.0},
} }
body.update(kw) body.update(kw)
# ASK-20260924-17:Bot 通道缺省账套 → 400(旧为兜底 entity 1)。本 helper 默认账套 1(显式化),
# 显式传 entity_id 的用例(如跨账套 404/403 断言)经 body.update(kw) 覆盖,语义不变。
body.setdefault("entity_id", 1)
return client.post(BOT_PATH, json=body, headers=BOT_KEY) return client.post(BOT_PATH, json=body, headers=BOT_KEY)
@@ -16,7 +16,10 @@ from app.models import VoucherDetail
BASE = "/api/cma/cash/import/vouchers-json" BASE = "/api/cma/cash/import/vouchers-json"
BOT_KEY = "cma-bot-admin-2026" BOT_KEY = "cma-bot-admin-2026"
H = {"X-BOT-KEY": BOT_KEY} H = {"X-BOT-KEY": BOT_KEY,
# ASK-20260924-17(2026-09-25):Bot 通道必须显式传账套(旧为缺省兜底 entity 1);
# 本文件全部用例账套语义即 1,故在夹具处显式化,断言不变。
"X-Entity-Id": "1"}
def _row(vno, subject, debit, credit, summary, **kw): def _row(vno, subject, debit, credit, summary, **kw):
+3 -1
View File
@@ -17,7 +17,9 @@ from app.models import KPIDefinition
from app.risk_levels import API_RISK_MAP, RISK_LEVELS from app.risk_levels import API_RISK_MAP, RISK_LEVELS
import app.api.audit_log as audit_log_module import app.api.audit_log as audit_log_module
BOT_KEY = {"X-BOT-KEY": "cma-bot-finance-2026"} BOT_KEY = {"X-BOT-KEY": "cma-bot-finance-2026",
# ASK-20260924-17:Bot 通道缺省账套 → 400(旧为兜底 entity 1);本文件用例账套语义即 1
"X-Entity-Id": "1"}
_HTTP_METHODS = ("GET", "POST", "PUT", "DELETE", "PATCH") _HTTP_METHODS = ("GET", "POST", "PUT", "DELETE", "PATCH")