ci/woodpecker/push/main Pipeline failed
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。
134 lines
5.7 KiB
Python
134 lines
5.7 KiB
Python
# -*- 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}
|