Files
cma-management/backend/tests/test_caliber_rulings_20260917.py
T
Hermes CI Fix 3bb55189b1
ci/woodpecker/push/main Pipeline failed
feat(cma): 口径裁定 2026-09-17 落地(现金流投资=0 二维判定 / 预警单位+期间口径)
依据:wecom-finance/reports/CMA_口径裁定_20260917.md(任总 2026-09-17 23:14「按建议执行」)

裁定1(现金流「投资活动净额=0」参与判定)
- classify_enterprise():0 为合法输入;投资恒为 0 → 经营(±)×筹资(±) 二维映射出 2 个
  候选类型(candidate_type_nos/candidates),不再整体判「未判定」;投资栏 investing_note
  标注「本期无投资活动」;经营/筹资亦为 0 → 回退「未判定(无投资活动)」,不报错不静默
- /analysis 增 classification_dimension / investing_note / nets.investing.note;前端出
  二维横幅 + 候选行高亮 + 投资栏「本期无投资活动」(不给单一类型名)
- import_cashflow_statements.py:打印层修 2D(type_no=None)TypeError 崩溃;NA 措辞改为
  「按 0 参与判定」(机读短语 NA_PHRASE_TMPL/parse_na_activities 未动)

裁定2a(比率类单位统一「百分比(%)」口径,先做)
- 新增 app/utils/caliber_units.py:逐 KPI 对照表 RATIO_CALIBER_TABLE(唯一事实源)+ 判定侧
  唯一入口 resolve_caliber();3 项需归一 ×100(流动/速动/毛利率),12 项取数已百分数不归一;
  表外且命中「疑似错配」窄形态(阈值子句量级≥100 且 0<|实测|<10 且单位属比率类)→
  dimension_suspect:不落预警、不推送(唯一规则,防 586/587 假红灯复发)
- alert_generator:判定前归一,文案写「口径归一 ×100(原值 1.29 → 129%)」留审计线索
- 新增 scripts/kpi_unit_crosscheck.py(只读对照表产出器)

裁定2b(期间由「当月硬编码」→「最近可用期间」,后做)
- resolve_latest_period():取该 KPI 自己的最新 YYYY-MM(禁全局 MAX,防演示账套劫持);
  伪期间(2026-H1/H2、2025-FY)在 Python 侧过滤(保「单测=生产」语义)
- 过渡期覆盖度自检扩展:记录回落后的期间分布 + 当月 0 条归因提示
- --dry-run(零写入零推送)作放行前抽查证据;generate_and_push/run_alert_check 支持 dry_run

测试:backend/tests/test_caliber_rulings_20260917.py 17 项;全量 1158 passed/1 xfailed
2026-09-18 00:34:04 +08:00

197 lines
10 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 -*-
"""财务口径裁定 2026-09-17 落地测试(裁定1 / 2a / 2b)
依据:`wecom-finance/reports/CMA_口径裁定_20260917.md`
· 裁定1:现金流「投资活动净额=0」= 本期无投资活动,**按 0 如实参与判定**(二维映射 + 标注);
· 裁定2a:比率类 KPI 统一「百分比(%)」口径(取数×100),未核对/疑似错配**不推送**;
· 裁定2b:预警期间由「当月硬编码」→「最近可用期间」(排除 2026-H1/H2 伪期间)。
"""
import pytest
from sqlalchemy.orm import Session
from tests.conftest import create_test_kpi
from app.api.cashflow_structure import classify_enterprise, INVESTING_ZERO_NOTE
from app.utils.caliber_units import resolve_caliber, RATIO_CALIBER_TABLE
from scripts.alert_generator import resolve_latest_period, run_alert_check
from app.models import KPIValue, KPIAlert
class _K:
"""最小 KPI 载体(duck type)"""
def __init__(self, kpi_code, unit, green=None, yellow=None, red=None, kpi_id=None):
self.id = kpi_id
self.kpi_code, self.unit = kpi_code, unit
self.threshold_green, self.threshold_yellow, self.threshold_red = green, yellow, red
# ───────────────────────── 裁定1:投资活动=0 ─────────────────────────
class TestRuling1InvestingZero:
def test_investing_zero_2d_mapping(self):
"""投资=0 + 经营(+)/筹资(-) → 二维映射候选 1/2,并标注「本期无投资活动」"""
c = classify_enterprise(14.45, 0.0, -3.2)
assert c["classified"] is True
assert c["dimension"] == "2D"
assert c["candidate_type_nos"] == [1, 2]
assert c["type_no"] is None
assert c["investing_note"] == INVESTING_ZERO_NOTE
assert c["o_sign"] == "+" and c["i_sign"] == "0" and c["f_sign"] == "-"
assert "二维" in c["reason"] and "类型1" in c["reason"] and "类型2" in c["reason"]
def test_investing_zero_all_pairs(self):
expect = {(1, 1): [3, 4], (1, -1): [1, 2], (-1, 1): [6, 7], (-1, -1): [5, 8]}
for (o, f), cands in expect.items():
c = classify_enterprise(o * 10.0, 0.0, f * 5.0)
assert c["classified"] is True and c["candidate_type_nos"] == cands, (o, f)
def test_investing_zero_with_other_zero_is_undetermined(self):
"""经营/筹资亦为 0 → 回退「未判定(无投资活动)」,不报错、不静默"""
c = classify_enterprise(0.0, 0.0, -3.0)
assert c["classified"] is False
assert "未判定" in c["reason"] and "无投资活动" in c["reason"]
assert c["investing_note"] == INVESTING_ZERO_NOTE
c2 = classify_enterprise(5.0, 0.0, 0.0)
assert c2["classified"] is False and "未判定" in c2["reason"]
def test_three_dim_still_exact_type(self):
"""三维齐备时判定保持原语义(无回归)"""
c = classify_enterprise(-10.0, 3.0, 12.0)
assert (c["classified"], c["dimension"], c["type_no"], c["name"]) == (True, "3D", 7, "输血生存型")
assert c["investing_note"] is None
def test_missing_value_reason(self):
c = classify_enterprise(None, 0.0, 12.0)
assert c["classified"] is False and "缺失" in c["reason"]
# ───────────────────────── 裁定2a:单位口径 ─────────────────────────
class TestRuling2aCaliber:
def test_current_ratio_normalized_to_percent(self):
cal = resolve_caliber(1.29, _K("F_CURRENT_RATIO", "%", ">=200", ">=150", "<150"))
assert cal["status"] == "normalized" and cal["factor"] == 100
assert round(cal["value"], 2) == 129.0
assert "口径归一" in cal["note"]
def test_percent_class_not_multiplied(self):
"""返利率/收入增长率等取数已是百分数 → 因子 1(再 ×100 会反向越界)"""
for code, val in (("F_REBATE_RATE", 48.44), ("F_REVENUE_GROWTH", -82.2),
("F_DEBT_RATIO", 39.9), ("F_COST_RATIO", 30.0)):
cal = resolve_caliber(val, _K(code, "%", ">=10", ">=8", "<5"))
assert cal["status"] == "ok" and cal["factor"] == 1, code
assert cal["value"] == val
def test_small_real_value_not_flagged(self):
"""总资产周转率 0.008 次 vs 阈值 1.0 次 = 真实小值(非错配),不得误伤"""
cal = resolve_caliber(0.008, _K("F_ASSET_TURNOVER", "次", ">=1.0", ">=0.8", "<0.8"))
assert cal["status"] == "ok" and cal["factor"] == 1
def test_unverified_ratio_kpi_is_suspect(self):
"""表外比率 KPI + 百分数写法阈值 → 疑似错配(降级待核,不推送)"""
cal = resolve_caliber(1.29, _K("X_UNKNOWN_RATIO", "%", ">=200", ">=150", "<150"))
assert cal["status"] == "dimension_suspect"
def test_table_entries_have_evidence(self):
assert RATIO_CALIBER_TABLE
for code, row in RATIO_CALIBER_TABLE.items():
assert row["norm_factor"] in (1, 100), code
assert row["verified"] is True, code
assert row["evidence"] and row["caliber"], code
# ───────────────────────── 裁定2b:期间回落 ─────────────────────────
class TestRuling2bPeriod:
def _mk(self, db, code, period, value, **kw):
kpi = create_test_kpi(db, kpi_code=code, kpi_name=code, unit=kw.pop("unit", "%"),
threshold_red=kw.pop("red", "<150"), **kw)
db.add(KPIValue(entity_id=1, kpi_id=kpi.id, period=period, actual_value=value))
db.commit()
return kpi
def test_pseudo_period_excluded(self, db: Session):
"""H1/H2/FY 伪期间必须被排除(否则期间回落会取到 2026-H2)"""
kpi = self._mk(db, "T_PERIOD", "2026-H2", 1.0)
db.add(KPIValue(entity_id=1, kpi_id=kpi.id, period="2026-08", actual_value=200.0))
db.commit()
assert resolve_latest_period(db, kpi.id) == "2026-08"
assert resolve_latest_period(db) == "2026-08"
def test_no_value_returns_none(self, db: Session):
kpi = create_test_kpi(db, kpi_code="T_EMPTY", kpi_name="空", unit="%", threshold_red="<1")
assert resolve_latest_period(db, kpi.id) is None
def test_run_alert_check_uses_kpi_latest_period(self, db: Session):
"""当前月无值 → 判定期间回落到该 KPI 最近可用期间(不再 0 预警)"""
kpi = self._mk(db, "T_FALLBACK", "2026-08", 14.45, red="<58.4")
st = {}
n = run_alert_check(db, period=None, dry_run=True, stats=st)
assert n == 1
assert st["period_source"] == "per_kpi_latest"
assert st["periods_used"] == {"2026-08": 1}
assert st["planned"][0]["kpi"].startswith("T_FALLBACK") and st["planned"][0]["level"] == "red"
# ───────────────────────── 2a×2b 合流:降级不推送 + 零写入 ─────────────────────────
class TestGateAndDryRun:
def test_suspect_kpi_not_alerted(self, db: Session):
"""疑似单位错配的 KPI:dry-run 下 0 预警 + 计入 dimension_suspect(不落库不推送)"""
kpi = create_test_kpi(db, kpi_code="X_UNKNOWN_RATIO", kpi_name="未知比率", unit="%",
threshold_green=">=200", threshold_yellow=">=150", threshold_red="<150")
db.add(KPIValue(entity_id=1, kpi_id=kpi.id, period="2026-08", actual_value=1.29))
db.commit()
st = {}
n = run_alert_check(db, period="2026-08", dry_run=True, stats=st)
assert n == 0, "疑似错配不得生成预警"
assert st["dimension_suspect"] == 1 and st["suspect_kpis"]
assert db.query(KPIAlert).count() == 0
def test_listed_ratio_kpi_alert_uses_normalized_value(self, db: Session):
"""表内比率 KPI:1.29 → 129% 参与判定,且消息里带口径归一说明"""
kpi = create_test_kpi(db, kpi_code="F_CURRENT_RATIO", kpi_name="流动比率", unit="%",
threshold_green=">=200", threshold_yellow=">=150", threshold_red="<150")
db.add(KPIValue(entity_id=1, kpi_id=kpi.id, period="2026-08", actual_value=1.29))
db.commit()
st = {}
n = run_alert_check(db, period="2026-08", dry_run=True, stats=st)
assert n == 1 and st["planned"][0]["level"] == "red"
assert st["planned"][0]["actual"] == 129.0 and st["planned"][0]["factor"] == 100
assert "口径归一" in st["planned"][0]["message"]
assert st["normalized"] == 1
def test_dry_run_writes_nothing(self, db: Session):
kpi = create_test_kpi(db, kpi_code="T_DRY", kpi_name="dry", unit="万元",
threshold_green=">=97", threshold_yellow=">=78", threshold_red="<58")
db.add(KPIValue(entity_id=1, kpi_id=kpi.id, period="2026-08", actual_value=14.45))
db.commit()
before = db.query(KPIAlert).count()
n_dry = run_alert_check(db, period=None, dry_run=True, stats={})
assert n_dry == 1 and db.query(KPIAlert).count() == before, "dry-run 必须零写入"
n_real = run_alert_check(db, period=None, dry_run=False, stats={})
assert n_real == 1 and db.query(KPIAlert).count() == before + 1
# 幂等:同 (kpi, value, level) 不重复落库
run_alert_check(db, period=None, dry_run=False, stats={})
assert db.query(KPIAlert).count() == before + 1
class TestAnalysisApiDimension:
def test_analysis_returns_2d_flag_and_note(self, client, db: Session):
from tests.conftest import create_test_user, get_token_for_user, auth_header
create_test_user(db)
token = get_token_for_user(client)
r = client.post("/api/cma/cashflow-structure/values", headers=auth_header(token), json={
"period": "2026-08", "operating_net": 14.45, "investing_net": 0.0,
"financing_net": -3.2, "source": "official", "remark": "pytest 裁定1",
})
assert r.status_code == 200
cls = r.json()["classification"]
assert cls["classified"] is True and cls["dimension"] == "2D"
assert cls["candidate_type_nos"] == [1, 2]
a = client.get("/api/cma/cashflow-structure/analysis", headers=auth_header(token),
params={"period": "2026-08"}).json()
assert a["classification_dimension"] == "2D"
assert a["investing_note"] == INVESTING_ZERO_NOTE
assert a["nets"]["investing"]["note"] == INVESTING_ZERO_NOTE