Files
cma-management/backend/app/utils/caliber_units.py
T
Hermes CI Fix 8631ce718f
ci/woodpecker/push/main Pipeline failed
feat(profitability): real ROI (op profit / invested capital), enable EVA, add RI
Kaliber: wecom-finance 2026-10-02 ruling (Boss 11:59 CST "li; but (1) add real ROI").

- kpi_definitions seed scripts/sql/profitability_kpi_defs_20261003.sql:
  F_ROI renamed to F_ROA (same kpi_id=20, all 8 historical values kept), new F_ROI
  (real IMA caliber = operating profit / invested capital), F_EVA activated with unit
  and thresholds aligned to 万元, new F_RI (residual income).
- parameters are NOT hardcoded in formula text: they live in
  kpi_definitions.data_source_config.params (tax_rate / wacc / min_return_rate /
  include_factory_subsidy / include_channel_subsidy / include_sales_discount /
  annualize / capital_basis); single reader = app/utils/profitability_params.py.
- deriver scripts/kpi_profitability_deriver.py: YTD operating profit from
  voucher_details (carry_forward=0 net), invested capital from subject_balances
  level-1 rows only, idempotent upsert into kpi_values (source_type=derived, no
  data_status downgrade, fail-closed when invested capital is 0).
- read-only endpoint GET /reports/value-management reads the derived rows only
  (single source of truth, no recomputation) and exposes the parameter block.
- DupontAnalysis.vue: value management section (ROI / ROA / ROE / EVA / RI).
- caliber_units.py: RATIO_CALIBER_TABLE key F_ROI renamed to F_ROA, new F_ROI row.
- tests: backend/tests/test_profitability_indicators.py (21 cases).
2026-10-03 02:10:47 +08:00

305 lines
16 KiB
Python
Raw 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 -*-
"""比率类 KPI 单位口径归一(取数层) —— 口径裁定 20260917(2a) + 20260924(v2.1 取代两项)
依据(唯一有效出处):`wecom-finance/reports/CMA_口径裁定_20260924_比率类单位口径_取代2a两指标.md`
—— 口径标识 `CMA-CALIBER-v2.1/kpi-unit@2026-09-24`(任总 2026-09-24 16:38 放行 ASK-20260924-11)
· **取代范围仅两项**:`F_CURRENT_RATIO` / `F_QUICK_RATIO` → 统一「倍」(无量纲),**判定层不再 ×100**
(同量纲直判)、`kpi_definitions` 的 unit/阈值/target_value 同步改倍口径;历史 `kpi_values` **不重算**;
· **其余比率类指标仍按 20260917 裁定2a(百分比 %)执行**:毛利率、净利率、资产负债率、渠补率、返利率、
收入增长率等;本表内 `F_GROSS_MARGIN` 那行**保持 ×100 不动**;
· **不得类推扩用**:再发现第三个「比率类单位/量纲矛盾」指标须**另行裁定**,不得以 v2.1 件为据类推改倍数;
· 被取代件 `wecom-finance/reports/CMA_口径裁定_20260917.md`:其 2a 对其余比率类仍有效、2b 仍有效,
**原件保留、一字节不改**。
· 2a 的过渡纪律(单位未核对前的「疑似错配」预警不推送)继续生效。
问题实证(2026-09-17 只读侦查 / 2026-09-24 v2.1 复核,entity 1 酣客):
`kpi_definitions` 原声明单位 = %,但**取数**以比率(倍)存储:流动比率 1.29 / 速动比率 0.54 / 毛利率 0.38,
而阈值按百分数书写(>=200 / >=100 / <15)→ 跨 2 个数量级比较,判定不可信(09-09 的 586/587 即此形态)。
**v2.1 处置**:流动/速动两项按**倍**口径落地(unit='倍';绿>=2.0 / 黄>=1.0 / 红<1.0 与
绿>=1.0 / 黄>=0.5 / 红<0.5)⇒ 2026-08 的 1.29 → **黄**、0.54 → **黄**;
本模块对这两项**不再 ×100**(旧写法 `1.29×100=129` 去比 `>=2.0` 会**恒绿**,比原「恒红」更坏)。
其余百分数类(返利率 48.44、收入增长率 -82.2、资产负债率 39.9、费用率 30.0、渠补率 75.0 …)
**取数已是百分数**,不得再 ×100(反过来会成为量级越界)。
实现约定(唯一事实源):
· 归一因子表 `RATIO_CALIBER_TABLE`(kpi_code → 口径/因子/证据/是否已核对);
· 判定侧只调 `resolve_caliber(actual, kpi)`,**不自行做字符串/量级判断**;
· 表外且命中「疑似错配形态」的 KPI → status='dimension_suspect' → 调用方**不落预警、不推送**;
· `norm_factor` 只影响**判定/推送口径**:2a 项不改 `kpi_values` 存储值、不改 `kpi_definitions` 阈值
(统一取数);v2.1 取代的两项反之——**DB 的 unit/阈值已同步改倍口径**,故此处必须同量纲直判(factor=1):
**两侧只改一侧 ⇒ 恒红(未改本表)或恒绿(未改 DB)**。
"""
from __future__ import annotations
from typing import Optional, Sequence
from app.utils.threshold_parser import parse_clauses
#: 百分比口径的归一因子
FACTOR_PERCENT = 100
FACTOR_IDENTITY = 1
#: 比率类 KPI 单位对照表(裁定2a「逐个核对定义与单位」的落地产物;2026-09-24 v2.1 取代其中两项)
#: norm_factor=1 → 取数已是百分数/同量纲,直接判定(含 v2.1 两项「倍」口径:取数与阈值同量纲)
#: norm_factor=100 → 取数为比率(倍),判定前 ×100 归一到百分数口径(裁定2a;**本表仅毛利率仍用**)
RATIO_CALIBER_TABLE = {
# ---- 倍口径(v2.1 @2026-09-24 取代 2a 之两项;取数与 DB 阈值同量纲 ⇒ 不再归一)----
"F_CURRENT_RATIO": {
"name": "流动比率", "declared_unit": "倍", "caliber": "倍(无量纲)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "口径 `CMA-CALIBER-v2.1/kpi-unit@2026-09-24`(取代 2a 之 F_CURRENT_RATIO):unit=倍 / 绿>=2.0 / 黄>=1.0 / 红<1.0;2026-08 实测 1.29(倍)→ 同量纲直判 = 黄;不再 ×100(旧 129% 判红已作废)",
},
"F_QUICK_RATIO": {
"name": "速动比率", "declared_unit": "倍", "caliber": "倍(无量纲)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "口径 `CMA-CALIBER-v2.1/kpi-unit@2026-09-24`(取代 2a 之 F_QUICK_RATIO):unit=倍 / 绿>=1.0 / 黄>=0.5 / 红<0.5;2026-08 实测 0.54(倍)→ 同量纲直判 = 黄;经验值 1:1 = 1.0 倍",
},
# ---- 需归一(取数=倍,阈值=百分数写法;裁定2a 对其余比率类仍有效,不得顺手改)----
"F_GROSS_MARGIN": {
"name": "毛利率", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_PERCENT, "verified": True,
"evidence": "2026-08 实测 0.38(同期另有 0.3766 行,源不同)→ ×100 = 38%;阈值 >=20|>=18|<15 为百分数写法(38% 合理,原口径 0.38<15 属假红)",
},
# ---- 无需归一(取数已是百分数/同量纲,×100 会造成反向越界)----
"F_OP_PROFIT_MARGIN": {
"name": "经营利润率", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "2026-08 实测 -161.45 已是百分数(×100 得 -16145,量级越界);阈值 >=10|>=8|<5 同量纲",
},
"F_REBATE_RATE": {
"name": "返利率", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "2026-08 实测 48.44 已是百分数;量纲一致、判定有效(裁定 §4.4:不改阈值,48.44% 口径本身待财务确认 → 保留并标注)",
},
"F_FACTORY_REBATE_RATE": {
"name": "上游厂补率", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "2026-08 实测 278.7 已是百分数(厂补/进货口径 >100% 属正常)",
},
"F_REVENUE_GROWTH": {
"name": "收入增长率", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "2026-08 实测 -82.2 已是百分数",
},
"F_ROE": {
"name": "净资产收益率(ROE)", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "2026-08 实测 -0.95 已是百分数(小体量亏损月,-0.95% 量级合理)",
},
"F_ROA": {
"name": "总资产报酬率(ROA)", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "2026-08 实测 -0.57 已是百分数(kpi_code 2026-10-03 由 F_ROI 正名为 F_ROA,"
"kpi_id/历史值不变;真 ROI 另立 F_ROI)",
},
"F_ROI": {
"name": "投入资本回报率(ROI)", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "2026-08 派生实测 -6.74(YTD 营业利润÷投入资本,已是百分数;口径见 "
"wecom-finance 2026-10-02 裁定)",
},
"F_DEBT_RATIO": {
"name": "资产负债率", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "2026-08 实测 39.9 已是百分数",
},
"F_COST_RATIO": {
"name": "费用率", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "2026-08 实测 30.0 已是百分数(阈值 <=25|<=30|>30)",
},
"C_REBATE_RATE": {
"name": "渠补率", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "2026-08 实测 75.0 已是百分数(阈值含区间子句 52.33~107.67)",
},
"F_BEP_COVER": {
"name": "保本覆盖率", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "2026-08 实测 60.4 已是百分数(阈值 >=100|>=85|<85)",
},
"F_BEP_COVER_CASH": {
"name": "现金口径保本覆盖率", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "2026-08 实测 85.2 已是百分数",
},
"F_BEP_GAP": {
"name": "保本缺口", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "2026-08 实测 39.6 已是百分数",
},
}
#: 疑似错配形态的参数:阈值按百分数书写(子句量级 ≥ 100)而实测为比率量级(0 < |v| < 10)
SUSPECT_THRESHOLD_ORDERS = 100.0
SUSPECT_ACTUAL_ABS_MAX = 10.0
#: 参与「疑似错配」判定的声明单位(比率/百分数类)
SUSPECT_UNITS = ("%", "倍", "", "百分比", None)
def _exprs_of(kpi) -> list:
"""取 KPI 三档阈值表达式(ORM 对象或同名 duck type 均可)"""
return [getattr(kpi, "threshold_green", None),
getattr(kpi, "threshold_yellow", None),
getattr(kpi, "threshold_red", None)]
def _clause_values(expr: str) -> list:
"""阈值表达式 → 比较值列表(区间取两端;解析失败返回空表)"""
try:
clauses = parse_clauses(expr)
except Exception:
return []
vals = []
for _op, val in clauses or []:
if isinstance(val, tuple):
vals.extend([v for v in val if isinstance(v, (int, float))])
elif isinstance(val, (int, float)):
vals.append(val)
return vals
def suspected_unit_mismatch(actual, exprs: Sequence, unit=None) -> dict:
"""「疑似单位错配」形态检测(裁定2a 的降级门)。
形态:**所有**可解析阈值子句的量级都 ≥ 100(百分数写法),而实测值量级 < 10(比率写法)
且声明单位属比率/百分数类 → 跨量级比较,判定不可信。
为什么不用通用的「量级差 ≥1 个数量级」守卫:那会把**真实的小值告警**误伤
(实证:总资产周转率 2026-08 = 0.008 次 vs 阈值 1.0 次,量级差 2.1——那是真红,不是错配)。
"""
if actual is None:
return {"suspect": False, "reason": "无实测值"}
try:
a = abs(float(actual))
except (TypeError, ValueError):
return {"suspect": False, "reason": "实测值非数值"}
if a == 0:
return {"suspect": False, "reason": "实测值为 0,量级不可判"}
vals = []
for e in exprs or []:
if e:
vals.extend(_clause_values(e))
if not vals:
return {"suspect": False, "reason": "阈值不可解析"}
all_percent_written = all(abs(float(v)) >= SUSPECT_THRESHOLD_ORDERS for v in vals)
ratio_magnitude = a < SUSPECT_ACTUAL_ABS_MAX
unit_ok = unit in SUSPECT_UNITS
if all_percent_written and ratio_magnitude and unit_ok:
return {"suspect": True, "nearest": min(vals, key=lambda v: abs(a - abs(float(v)))),
"reason": ("疑似单位错配:阈值按百分数书写(子句量级 ≥%g)而实测 %s 为比率量级(|v|<%g)"
",声明单位 %r → 判定不可信,降级待核(不推送)"
% (SUSPECT_THRESHOLD_ORDERS, actual, SUSPECT_ACTUAL_ABS_MAX, unit))}
return {"suspect": False, "reason": "形态不匹配(阈值量级/实测量级/单位 至少一项不符)"}
def resolve_caliber(actual, kpi) -> dict:
"""判定侧唯一入口:返回取数归一结果与可信度状态。
return: {
status: 'ok' | 'normalized' | 'dimension_suspect',
value: 归一后用于判定/展示的数值,
factor: 归一因子(1/100),
verified: 对照表核对状态(表外=False),
kpi_code, note, reason
}
· status='dimension_suspect' → 调用方**不落预警、不推送**(裁定2a 过渡纪律)
"""
code = getattr(kpi, "kpi_code", None)
unit = getattr(kpi, "unit", None)
row = RATIO_CALIBER_TABLE.get(code) if code else None
if row:
factor = row["norm_factor"] if row.get("verified") else FACTOR_IDENTITY
if factor != FACTOR_IDENTITY and actual is not None:
try:
value = float(actual) * factor
except (TypeError, ValueError):
value = actual
else:
value = actual
note = None
if factor != FACTOR_IDENTITY:
note = "口径归一 ×%d(原值 %s %s → %s%%)" % (factor, actual, row["declared_unit"], _fmt(value))
return {"status": "normalized" if factor != FACTOR_IDENTITY else "ok", "value": value,
"factor": factor, "verified": bool(row.get("verified")), "kpi_code": code,
"caliber": row.get("caliber"), "note": note, "reason": row.get("evidence")}
sus = suspected_unit_mismatch(actual, _exprs_of(kpi), unit)
if sus["suspect"]:
return {"status": "dimension_suspect", "value": actual, "factor": FACTOR_IDENTITY,
"verified": False, "kpi_code": code, "note": None, "reason": sus["reason"]}
return {"status": "ok", "value": actual, "factor": FACTOR_IDENTITY, "verified": False,
"kpi_code": code, "note": None, "reason": sus["reason"]}
def _fmt(v) -> str:
try:
return ("%.2f" % float(v)).rstrip("0").rstrip(".")
except (TypeError, ValueError):
return str(v)
class _K:
"""self-test 用的最小 KPI 载体"""
def __init__(self, kpi_code, unit, green=None, yellow=None, red=None):
self.kpi_code, self.unit = kpi_code, unit
self.threshold_green, self.threshold_yellow, self.threshold_red = green, yellow, red
def _self_test() -> int:
"""零库自证(裁定2a + 2026-09-24 v2.1):倍口径直判、2a 归一、疑似错配降级、真实小值不误伤"""
from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY
def level(actual, kpi):
cal = resolve_caliber(actual, kpi)
if cal["status"] == "dimension_suspect":
return "dimension_suspect"
v = cal["value"]
exprs = {"green": kpi.threshold_green, "yellow": kpi.threshold_yellow, "red": kpi.threshold_red}
for lv in LEVEL_PRIORITY:
if exprs.get(lv) and eval_threshold(v, exprs[lv]):
return lv
return "info"
cases = (
# (说明, 实测, KPI, 期望)
("倍口径(v2.1) 流动比率 1.29 → 直判 1.29 ∈ [1.0,2.0) 黄", 1.29,
_K("F_CURRENT_RATIO", "倍", ">=2.0", ">=1.0", "<1.0"), "yellow"),
("倍口径(v2.1) 速动比率 0.54 → 直判 0.54 ∈ [0.5,1.0) 黄", 0.54,
_K("F_QUICK_RATIO", "倍", ">=1.0", ">=0.5", "<0.5"), "yellow"),
("毛利率 0.38 → 归一 38% vs 绿>=20", 0.38,
_K("F_GROSS_MARGIN", "%", ">=20", ">=18", "<15"), "green"),
("返利率 48.44 已百分数 → 红>12(不 ×100)", 48.44,
_K("F_REBATE_RATE", "%", "<=10", ">10", ">12"), "red"),
("收入增长率 -82.2 已百分数 → 红<15", -82.2,
_K("F_REVENUE_GROWTH", "%", ">=20", ">=15", "<15"), "red"),
("总资产周转率 0.008 次 vs 阈值 1.0(真实小值,不误伤)", 0.008,
_K("F_ASSET_TURNOVER", "次", ">=1.0", ">=0.8", "<0.8"), "red"),
("表外比率 KPI(阈值百分数写法)→ 降级待核不推送", 1.29,
_K("X_UNKNOWN_RATIO", "%", ">=200", ">=150", "<150"), "dimension_suspect"),
("高值不误判:营业收入 14.45 万 vs 阈值 97.33(非比率量级)", 14.45,
_K("F_REVENUE", "万元", ">=97.33", ">=77.87", "<58.4"), "red"),
)
ok = 0
print("=== 比率类单位口径归一 self-test ===")
for desc, actual, kpi, expect in cases:
got = level(actual, kpi)
flag = "PASS" if got == expect else "FAIL"
ok += (got == expect)
cal = resolve_caliber(actual, kpi)
print(" [%s] %-46s → %-18s (期望 %s) value=%s factor=%s"
% (flag, desc, got, expect, cal["value"], cal["factor"]))
print("=== %d/%d PASS | 对照表 %d 项(归一 %d 项 / 同量纲 %d 项)===" % (
ok, len(cases), len(RATIO_CALIBER_TABLE),
sum(1 for v in RATIO_CALIBER_TABLE.values() if v["norm_factor"] != FACTOR_IDENTITY),
sum(1 for v in RATIO_CALIBER_TABLE.values() if v["norm_factor"] == FACTOR_IDENTITY)))
return 0 if ok == len(cases) else 1
if __name__ == "__main__":
raise SystemExit(_self_test())