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。
This commit is contained in:
@@ -21,6 +21,11 @@ from app.utils.alert_entity_scope import guard_alert_period, is_excluded_entity
|
||||
# 「实测区 / 预估区」分区口径(财务裁定 2026-10-02):**预估行禁作预警依据**——
|
||||
# 本文件的取数读路径一律加 actual_only_clause(),动态阈值白名单去掉 estimated。
|
||||
from app.utils.kpi_value_scope import actual_only_clause
|
||||
# G1(2026-10-17):资金缺口阈值的单一事实源(默认 20/10 万 + 参数校验)
|
||||
from app.utils.cash_thresholds import (
|
||||
CASH_GAP_RULE_TYPE,
|
||||
validate_params as validate_cash_gap_params,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("alert_rules")
|
||||
|
||||
@@ -32,6 +37,7 @@ VALID_RULE_TYPES = (
|
||||
"trend_down", # 环比下降
|
||||
"forecast_deviation", # 预测偏差(升级2b 2026-08-25)
|
||||
"customer_concentration", # 客户集中度(P2 2026-09-10)
|
||||
"cash_gap_threshold", # 资金缺口阈值配置(G1 2026-10-17,配置行,不进预警生成链)
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
@@ -130,6 +136,12 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
if rule_type not in VALID_RULE_TYPES: # 升级2b: 预测偏差 / P2-20260910: 客户集中度
|
||||
raise HTTPException(400, f"不支持的规则类型: {rule_type}")
|
||||
# G1(2026-10-17):资金缺口阈值为引擎级参数,入库前校验数值(非法配置一律拒收,
|
||||
# 防「配置写坏 → 预警静默回落默认值」的假生效)。
|
||||
if rule_type == CASH_GAP_RULE_TYPE:
|
||||
err = validate_cash_gap_params(data.get("params"))
|
||||
if err:
|
||||
raise HTTPException(400, err)
|
||||
|
||||
rule = AlertRule(
|
||||
entity_id=entity_id,
|
||||
@@ -162,6 +174,12 @@ def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db), e
|
||||
for field in ("rule_type", "trigger_on", "enabled", "params"):
|
||||
if field in data:
|
||||
setattr(rule, field, data[field])
|
||||
# G1(2026-10-17):改完仍是资金阈值配置行 → 参数必须合法(拒收非法值)
|
||||
if rule.rule_type == CASH_GAP_RULE_TYPE:
|
||||
err = validate_cash_gap_params(rule.params)
|
||||
if err:
|
||||
db.rollback()
|
||||
raise HTTPException(400, err)
|
||||
db.commit()
|
||||
db.refresh(rule)
|
||||
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}}
|
||||
@@ -189,6 +207,8 @@ def batch_create_rules(data: dict, db: Session = Depends(get_db), entity_id: int
|
||||
rule_type = rule_data.get("rule_type", "static")
|
||||
if rule_type not in VALID_RULE_TYPES:
|
||||
continue
|
||||
if rule_type == CASH_GAP_RULE_TYPE and validate_cash_gap_params(rule_data.get("params")):
|
||||
continue
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi or kpi.entity_id != entity_id:
|
||||
continue
|
||||
@@ -283,6 +303,10 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
|
||||
|
||||
for rule in rules:
|
||||
try:
|
||||
# G1(2026-10-17):cash_gap_threshold 是资金引擎的参数配置行,不是 KPI 预警规则,
|
||||
# 不进 KPIValue 比价链(否则未来若绑定到有值的 KPI 会误生成预警)。
|
||||
if rule.rule_type == CASH_GAP_RULE_TYPE:
|
||||
continue
|
||||
kpi = kpi_cache.get(rule.kpi_id)
|
||||
if kpi is None:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
|
||||
|
||||
@@ -13,9 +13,17 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger("cma.cash_forecast")
|
||||
|
||||
# 默认现金阈值(万元)
|
||||
DEFAULT_CASH_WARNING = 20.0 # 黄灯 — 低于20万
|
||||
DEFAULT_CASH_CRITICAL = 10.0 # 红灯 — 低于10万
|
||||
# 默认现金阈值(万元)—— 单一事实源迁至 app/utils/cash_thresholds.py(G1,2026-10-17)
|
||||
# 此处保留同名常量以兼容既有 import(app/api/cash.py 等);数值未变:黄 20 / 红 10。
|
||||
from app.utils.cash_thresholds import ( # noqa: E402
|
||||
DEFAULT_CRITICAL as _CFG_DEFAULT_CRITICAL,
|
||||
DEFAULT_WARNING as _CFG_DEFAULT_WARNING,
|
||||
get_cash_gap_thresholds,
|
||||
describe_source as describe_cash_threshold_source,
|
||||
)
|
||||
|
||||
DEFAULT_CASH_WARNING = _CFG_DEFAULT_WARNING # 黄灯 — 低于20万
|
||||
DEFAULT_CASH_CRITICAL = _CFG_DEFAULT_CRITICAL # 红灯 — 低于10万
|
||||
|
||||
# 历史KPI编码映射(含候选编码,兼容F_*标准编码)
|
||||
KPI_CODES = {
|
||||
@@ -306,17 +314,27 @@ def forecast_cash_flow(
|
||||
db: Session,
|
||||
days: int = 30,
|
||||
current_cash: Optional[float] = None,
|
||||
warning_line: Optional[float] = None,
|
||||
critical_line: Optional[float] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
预测未来30天现金流
|
||||
|
||||
|
||||
算法:
|
||||
1. 获取历史经营现金流、应收、应付趋势
|
||||
2. 推算每日现金流入/流出
|
||||
3. 生成每日预测值+置信区间
|
||||
|
||||
阈值(G1,2026-10-17):warning_line / critical_line 传 None 时取本账套配置
|
||||
(alert_rules: cash_gap_threshold),未配置则默认 20/10 万;显式传入优先(便于复算对拍)。
|
||||
"""
|
||||
from app.models import KPIDefinition, KPIValue
|
||||
|
||||
if warning_line is None or critical_line is None:
|
||||
cfg_warning, cfg_critical = get_cash_gap_thresholds(db, entity_id)
|
||||
warning_line = cfg_warning if warning_line is None else warning_line
|
||||
critical_line = cfg_critical if critical_line is None else critical_line
|
||||
|
||||
# 获取当前现金余额
|
||||
if current_cash is None:
|
||||
base_cash = get_current_cash_balance(db, entity_id)
|
||||
@@ -375,9 +393,9 @@ def forecast_cash_flow(
|
||||
upper = cash + std * 0.5
|
||||
|
||||
# 预警状态
|
||||
if cash < DEFAULT_CASH_CRITICAL:
|
||||
if cash < critical_line:
|
||||
status = "red"
|
||||
elif cash < DEFAULT_CASH_WARNING:
|
||||
elif cash < warning_line:
|
||||
status = "yellow"
|
||||
else:
|
||||
status = "green"
|
||||
@@ -396,20 +414,20 @@ def forecast_cash_flow(
|
||||
min_date = next(f["date"] for f in forecast if f["predicted_cash"] == min_cash)
|
||||
|
||||
suggestions = []
|
||||
if min_cash < DEFAULT_CASH_CRITICAL:
|
||||
if min_cash < critical_line:
|
||||
suggestions.append({
|
||||
"type": "critical",
|
||||
"message": f"预计{min_date}现金余额降至{min_cash:.1f}万,低于警戒线{DEFAULT_CASH_CRITICAL}万",
|
||||
"message": f"预计{min_date}现金余额降至{min_cash:.1f}万,低于警戒线{critical_line}万",
|
||||
"actions": [
|
||||
"立即催收大额应收账款",
|
||||
"暂停非必要支出",
|
||||
"准备短期融资安排",
|
||||
]
|
||||
})
|
||||
elif min_cash < DEFAULT_CASH_WARNING:
|
||||
elif min_cash < warning_line:
|
||||
suggestions.append({
|
||||
"type": "warning",
|
||||
"message": f"预计{min_date}现金余额降至{min_cash:.1f}万,低于关注线{DEFAULT_CASH_WARNING}万",
|
||||
"message": f"预计{min_date}现金余额降至{min_cash:.1f}万,低于关注线{warning_line}万",
|
||||
"actions": [
|
||||
"加快应收账款回款",
|
||||
"控制采购付款节奏",
|
||||
@@ -606,8 +624,8 @@ def forecast_cash_flow_with_plans(
|
||||
db: Session,
|
||||
days: int = 30,
|
||||
current_cash: Optional[float] = None,
|
||||
warning_line: float = DEFAULT_CASH_WARNING,
|
||||
critical_line: float = DEFAULT_CASH_CRITICAL,
|
||||
warning_line: Optional[float] = None,
|
||||
critical_line: Optional[float] = None,
|
||||
include_plans: bool = True,
|
||||
) -> dict:
|
||||
"""
|
||||
@@ -616,11 +634,21 @@ def forecast_cash_flow_with_plans(
|
||||
2. 叠加 cash_plans 的应收(收) / 应付(付)
|
||||
3. 识别资金缺口日期(余额 < 警戒线)
|
||||
4. 生成缺口前3天预警点
|
||||
|
||||
阈值来源(G1,2026-10-17):None → 本账套配置(alert_rules: cash_gap_threshold)
|
||||
→ 未配置时默认 20/10 万;显式传入值优先(复算对拍用)。响应内 `threshold_source`
|
||||
明写本次取值来源,便于页面标注与回执取证。
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from app.models import CashPlan
|
||||
|
||||
base = forecast_cash_flow(entity_id, db, days, current_cash)
|
||||
if warning_line is None or critical_line is None:
|
||||
cfg_warning, cfg_critical = get_cash_gap_thresholds(db, entity_id)
|
||||
warning_line = cfg_warning if warning_line is None else warning_line
|
||||
critical_line = cfg_critical if critical_line is None else critical_line
|
||||
|
||||
base = forecast_cash_flow(entity_id, db, days, current_cash,
|
||||
warning_line=warning_line, critical_line=critical_line)
|
||||
base_cash = base["base_cash"]
|
||||
|
||||
# 预算校准:本月经营现金流预算优先作为基线(万元/月)
|
||||
@@ -805,6 +833,7 @@ def forecast_cash_flow_with_plans(
|
||||
"days": days,
|
||||
"warning_line": warning_line,
|
||||
"critical_line": critical_line,
|
||||
"threshold_source": describe_cash_threshold_source(db, entity_id),
|
||||
"budget_monthly_ocf": budget_ocf,
|
||||
"forecast": forecast,
|
||||
"gap_dates": gap_dates,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# -*- 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}
|
||||
Reference in New Issue
Block a user