Files
wecom-fullstack 885cfddbff
ci/woodpecker/push/main Pipeline failed
feat(warning-p0a): 资金缺口阈值外置 + 复算对拍 + 讲人话(数字守恒) + 数据就绪核验
P0-A(任务 fullstack-20261017-warning-capability-build-p0):
- cash_thresholds.py:阈值迁 per-entity(复用 alert_rules.rule_type=cash_gap_threshold,
  默认 20/10 万不动;无配置/非法/异常一律回落默认,fail-safe 不改预警行为)
- cash_forecast_engine.py:阈值改从配置读取,响应新增 threshold_source 明写来源
- verify_cash_gap_replay.py:同输入双跑逐字段 diff=0 + 手算 3/3 + 端点逐字段对拍(--live)
- cash_gap_narrative.py:复用 ai_brief.call_deepseek,只写原因/建议;
  断言生成文本数字集合 ⊆ 规则层数字集合,违例即不带文案上线
- audit_warning_data_readiness.py:G0 只读核验(cma_reader SELECT only),
  行数/期间/枚举 + 数据质量缺口(期初全 0、负期末、单批次、无员工维度表)

未 push(push 即部署,须项目侧书面放行);未改生产库/未动 cron。
2026-10-17 23:33:51 +08:00

134 lines
5.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""资金缺口阈值 —— per-entity 配置(复用 alert_rules 表,不新建表)
任务:fullstack-20261017-warning-capability-build-p0 / G1(P0-A)
方案依据:`_reports/yanxue-20261017-warning-capability-build-p0.md` §G1 + 决策点 D4(建议 B)
口径(默认值不动):
· 黄灯(warning) = 20.0 万元 —— 与改前 `cash_forecast_engine.DEFAULT_CASH_WARNING` 一致
· 红灯(critical)= 10.0 万元 —— 与改前 `cash_forecast_engine.DEFAULT_CASH_CRITICAL` 一致
· 配置存在时按配置;未配置 / 配置非法 / 读取异常 → **一律回落默认值**(fail-safe,不让预警失效)
存储形态(零 DDL,复用现有 `alert_rules`):
entity_id = 账套
rule_type = "cash_gap_threshold"
kpi_id = 该账套现金余额 KPI(CASH_001 / BH_CASH_BALANCE 等),满足表 NOT NULL 约束
enabled = 1
params = {"warning": 20.0, "critical": 10.0} # 单位:万元
为什么单独一个 rule_type 而不复用 static:
static 规则走 `_check_static` 与 KPIValue 比价,参数语义是 `>=90` 之类表达式;
资金阈值是**引擎级参数**(决定缺口日判定与红黄分级),不是单条 KPI 的阈值。
独立 rule_type 让 `run_all_alert_checks` 的 if/elif 链自然跳过它(alert_level 保持 None),
不会生成预警行,也不影响现有 static/trend 规则。
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional, Tuple
logger = logging.getLogger("cma.cash_thresholds")
#: alert_rules.rule_type 取值(配置行,不进预警生成链)
CASH_GAP_RULE_TYPE = "cash_gap_threshold"
#: 默认阈值(万元)—— 与改前硬编码常量逐字一致
DEFAULT_WARNING = 20.0
DEFAULT_CRITICAL = 10.0
PARAM_KEYS = ("warning", "critical")
def _as_float(value: Any) -> Optional[float]:
try:
return float(value)
except (TypeError, ValueError):
return None
def validate_params(params: Any) -> Optional[str]:
"""校验阈值参数;合法返回 None,非法返回中文错误信息(供 API 层直接回 400)。"""
if not isinstance(params, dict):
return "params 必须是对象,形如 {\"warning\": 20.0, \"critical\": 10.0}(单位:万元)"
warning = _as_float(params.get("warning"))
critical = _as_float(params.get("critical"))
if warning is None or critical is None:
return "params 必须同时包含数值型 warning 与 critical(单位:万元)"
if warning <= 0 or critical <= 0:
return "warning / critical 必须大于 0(单位:万元)"
if critical > warning:
return "critical(红灯线)不得高于 warning(黄灯线)"
return None
def parse_params(params: Any) -> Optional[Tuple[float, float]]:
"""解析参数 → (warning, critical);非法返回 None(调用方回落默认值)。"""
if validate_params(params) is not None:
return None
return float(params["warning"]), float(params["critical"])
def get_cash_gap_thresholds(db, entity_id: int) -> Tuple[float, float]:
"""读取账套资金缺口阈值;无配置/非法/异常 → 默认 (20.0, 10.0)。
· 只认 enabled=1、最新 id 的一行(同一账套多行时以最新为准,不累加)
· 任何异常都不上抛:预警不能因为配置表读失败而消失(fail-safe)
"""
try:
from app.api.alert_rules import AlertRule # 延迟导入,避免 engine ↔ api 循环
rule = (
db.query(AlertRule)
.filter(
AlertRule.entity_id == int(entity_id),
AlertRule.rule_type == CASH_GAP_RULE_TYPE,
AlertRule.enabled == 1,
)
.order_by(AlertRule.id.desc())
.first()
)
if rule is None:
return DEFAULT_WARNING, DEFAULT_CRITICAL
params = rule.params
if isinstance(params, str):
import json
try:
params = json.loads(params)
except ValueError:
logger.warning("资金阈值配置 params 非合法 JSON(rule_id=%s)→ 回落默认值", rule.id)
return DEFAULT_WARNING, DEFAULT_CRITICAL
parsed = parse_params(params)
if parsed is None:
logger.warning("资金阈值配置参数非法(rule_id=%s params=%r)→ 回落默认值", rule.id, params)
return DEFAULT_WARNING, DEFAULT_CRITICAL
return parsed
except Exception as exc: # noqa: BLE001 — 配置读取失败不得使预警整体失效
logger.warning("读取资金阈值配置失败(entity_id=%s):%s → 回落默认值", entity_id, exc)
return DEFAULT_WARNING, DEFAULT_CRITICAL
def describe_source(db, entity_id: int) -> Dict[str, Any]:
"""取证用:说明本次阈值来自「配置」还是「默认」,便于回执与页面标注。"""
try:
from app.api.alert_rules import AlertRule
rule = (
db.query(AlertRule)
.filter(
AlertRule.entity_id == int(entity_id),
AlertRule.rule_type == CASH_GAP_RULE_TYPE,
AlertRule.enabled == 1,
)
.order_by(AlertRule.id.desc())
.first()
)
if rule is not None and parse_params(rule.params) is not None:
return {"source": "alert_rules", "rule_id": rule.id,
"params": rule.params, "entity_id": int(entity_id)}
except Exception as exc: # noqa: BLE001
logger.warning("读取资金阈值来源失败:%s", exc)
return {"source": "default", "rule_id": None,
"params": {"warning": DEFAULT_WARNING, "critical": DEFAULT_CRITICAL},
"entity_id": int(entity_id) if entity_id is not None else None}