From 52b693ddbb6197458d4e2525374912ff8d52b53e Mon Sep 17 00:00:00 2001 From: wecom-fullstack Date: Fri, 25 Sep 2026 22:42:25 +0800 Subject: [PATCH] =?UTF-8?q?ASK-20260924-15/17:=20Bot=20=E9=80=9A=E9=81=93?= =?UTF-8?q?=E7=BC=BA=E7=9C=81=E8=B4=A6=E5=A5=97=E2=86=92400=20+=20?= =?UTF-8?q?=E5=B7=AE=E5=BC=82/=E8=B6=8B=E5=8A=BF=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E6=9C=9F=E9=97=B4=E5=8F=A3=E5=BE=84=E5=9B=9E=E8=90=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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」显式化(断言未改) --- backend/app/api/bot_bridge.py | 14 +- backend/app/api/bot_bridge_v2.py | 3 +- backend/app/api/bot_suggestions.py | 11 +- backend/app/api/cash_vouchers_json.py | 18 +- backend/app/deps.py | 57 +++++- backend/app/utils/alert_entity_scope.py | 29 +++ backend/app/utils/deviation_engine.py | 54 ++++-- backend/scripts/alert_generator.py | 18 +- ..._17_engine_and_entity_required_20260925.py | 174 ++++++++++++++++++ backend/tests/test_bot_bridge.py | 6 +- backend/tests/test_bot_suggestion_channel.py | 3 + .../tests/test_cash_vouchers_json_idemkey.py | 5 +- backend/tests/test_risk_levels.py | 4 +- 13 files changed, 353 insertions(+), 43 deletions(-) create mode 100644 backend/tests/test_ask15_17_engine_and_entity_required_20260925.py diff --git a/backend/app/api/bot_bridge.py b/backend/app/api/bot_bridge.py index 3bb1e705..ac9ed024 100644 --- a/backend/app/api/bot_bridge.py +++ b/backend/app/api/bot_bridge.py @@ -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。 """ diff --git a/backend/app/api/bot_bridge_v2.py b/backend/app/api/bot_bridge_v2.py index 36419857..64fb3e17 100644 --- a/backend/app/api/bot_bridge_v2.py +++ b/backend/app/api/bot_bridge_v2.py @@ -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") diff --git a/backend/app/api/bot_suggestions.py b/backend/app/api/bot_suggestions.py index 3f655a02..aba023b7 100644 --- a/backend/app/api/bot_suggestions.py +++ b/backend/app/api/bot_suggestions.py @@ -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} 不存在或未激活") diff --git a/backend/app/api/cash_vouchers_json.py b/backend/app/api/cash_vouchers_json.py index b207d455..36ae9842 100644 --- a/backend/app/api/cash_vouchers_json.py +++ b/backend/app/api/cash_vouchers_json.py @@ -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} 不存在或未激活") diff --git a/backend/app/deps.py b/backend/app/deps.py index 150d4ef1..29488510 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -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=` 或 header `X-Entity-Id: `;" + "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 补单) # diff --git a/backend/app/utils/alert_entity_scope.py b/backend/app/utils/alert_entity_scope.py index 2e8fc767..3e20bf6e 100644 --- a/backend/app/utils/alert_entity_scope.py +++ b/backend/app/utils/alert_entity_scope.py @@ -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: diff --git a/backend/app/utils/deviation_engine.py b/backend/app/utils/deviation_engine.py index daf152bb..2c2e96d7 100644 --- a/backend/app/utils/deviation_engine.py +++ b/backend/app/utils/deviation_engine.py @@ -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) diff --git a/backend/scripts/alert_generator.py b/backend/scripts/alert_generator.py index 732a89c0..6cd0465f 100644 --- a/backend/scripts/alert_generator.py +++ b/backend/scripts/alert_generator.py @@ -44,17 +44,15 @@ def resolve_latest_period(db_session, kpi_id=None): 也不被别人(他账套/演示账套)更新的新期间带偏——实证:全局 MAX(period)=2026-09 但那是演示账套 e6 的 8 条数据,酣客(e1) 的最新可用期间是 2026-08。 - 实现说明:伪期间过滤在 **Python 侧**做(`SELECT DISTINCT period` 只有十几行), - 不依赖 MySQL 的 `REGEXP` —— SQLite 测试库不注册 REGEXP,用 SQL 侧正则会让 - 单测与生产走两套语义。 + 2026-09-25(ASK-20260924-15):实现**收口到唯一事实源** + `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)) - 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 _PERIOD_RE.fullmatch(str(p or "")): - return p - return None + from app.utils.alert_entity_scope import resolve_latest_available_period + return resolve_latest_available_period(db_session, kpi_id) def _parse_threshold(expr: str) -> tuple: diff --git a/backend/tests/test_ask15_17_engine_and_entity_required_20260925.py b/backend/tests/test_ask15_17_engine_and_entity_required_20260925.py new file mode 100644 index 00000000..e6b39a6a --- /dev/null +++ b/backend/tests/test_ask15_17_engine_and_entity_required_20260925.py @@ -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 diff --git a/backend/tests/test_bot_bridge.py b/backend/tests/test_bot_bridge.py index 28282353..8d60c5f4 100644 --- a/backend/tests/test_bot_bridge.py +++ b/backend/tests/test_bot_bridge.py @@ -22,7 +22,11 @@ from app.models.budget_plan import BudgetPlan from app.models.cost_model import StandardCost, ActualCost 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: diff --git a/backend/tests/test_bot_suggestion_channel.py b/backend/tests/test_bot_suggestion_channel.py index be749400..9f5f8407 100644 --- a/backend/tests/test_bot_suggestion_channel.py +++ b/backend/tests/test_bot_suggestion_channel.py @@ -29,6 +29,9 @@ def _bot_create(client, kpi_id, **kw): "suggestion_data": {"kpi_id": kpi_id, "target_value": 150.0}, } 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) diff --git a/backend/tests/test_cash_vouchers_json_idemkey.py b/backend/tests/test_cash_vouchers_json_idemkey.py index 7310438d..c80e3938 100644 --- a/backend/tests/test_cash_vouchers_json_idemkey.py +++ b/backend/tests/test_cash_vouchers_json_idemkey.py @@ -16,7 +16,10 @@ from app.models import VoucherDetail BASE = "/api/cma/cash/import/vouchers-json" 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): diff --git a/backend/tests/test_risk_levels.py b/backend/tests/test_risk_levels.py index abb25d26..40c8b02e 100644 --- a/backend/tests/test_risk_levels.py +++ b/backend/tests/test_risk_levels.py @@ -17,7 +17,9 @@ from app.models import KPIDefinition from app.risk_levels import API_RISK_MAP, RISK_LEVELS 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")