R2 rule-gate auto-promotion + R3 exception interception in the same collect transaction; merge duplicate rows, keep single verified main per (kpi_id, period); reuse kpi_bep_deriver._is_period_closed; no schema change (data_status/remark only). Task: finance-20260914-autocollect-reviewflow-impl
90 lines
3.9 KiB
Python
90 lines
3.9 KiB
Python
"""auto_collect 复核闸门(kpi_review_gate.py)纯函数单测 — 2026-09-14
|
||
|
||
覆盖:阈值换算(金额/比率)、规则版本指纹、锚点判定、主值 remark 回写幂等与长度安全。
|
||
不连库(只构造 KPIValueSource 内存对象)。
|
||
"""
|
||
import os
|
||
import sys
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
|
||
from app.models import KPIValueSource # noqa: E402
|
||
from scripts.kpi_review_gate import ( # noqa: E402
|
||
RATIO_PP, _with_recheck_remark, anchor_check, rule_version, tolerance,
|
||
)
|
||
|
||
|
||
def _mapping(rule, unit=1.0, status="active", kpi_id=1):
|
||
m = KPIValueSource()
|
||
m.entity_id = 1
|
||
m.kpi_id = kpi_id
|
||
m.source_table = "voucher_details"
|
||
m.source_field = "credit_amount"
|
||
m.aggregate = "sum"
|
||
m.period_field = "period"
|
||
m.unit_conversion = unit
|
||
m.status = status
|
||
m.filter_rule = rule
|
||
return m
|
||
|
||
|
||
def test_tolerance_amount_class():
|
||
# 万元口径(unit=0.0001):max(1000元, 0.5%×主值) → 换算回万元
|
||
m = _mapping({"calc": "sum", "subject_codes": ["5001"]}, unit=0.0001)
|
||
tol, cls = tolerance(m, 81.08)
|
||
assert cls == "amount"
|
||
assert abs(tol - 0.4054) < 1e-9 # 0.5%×810800元=4054元=0.4054万
|
||
tol_small, _ = tolerance(m, 1.0) # 主值 1 万元 → 0.5%=50元 < 下限 1000元
|
||
assert abs(tol_small - 0.1) < 1e-9
|
||
|
||
|
||
def test_tolerance_ratio_class():
|
||
m = _mapping({"calc": "ratio", "subject_codes": ["5001", "5401"]}, unit=1.0, kpi_id=2)
|
||
tol, cls = tolerance(m, 0.05)
|
||
assert cls == "ratio" and tol == RATIO_PP == 0.005
|
||
# 0.34 个百分点在容差内、0.6 个百分点超容差
|
||
assert abs(0.3766 - 0.38) <= tol
|
||
assert abs(0.3860 - 0.38) > tol
|
||
|
||
|
||
def test_rule_version_stable_and_changes_on_rule_edit():
|
||
r1 = {"calc": "sum", "subject_codes": ["5001"], "validation": {"matched": 3}}
|
||
r2 = {"calc": "sum", "subject_codes": ["5001"], "validation": {"matched": 2}}
|
||
v1a = rule_version(_mapping(r1))
|
||
v1b = rule_version(_mapping(r1))
|
||
assert v1a == v1b # 确定性 + validation 不参与指纹
|
||
assert rule_version(_mapping(r2)) == v1a
|
||
r3 = {"calc": "sum", "subject_codes": ["5001", "6001"], "validation": {"matched": 3}}
|
||
assert rule_version(_mapping(r3)) != v1a # 科目组合变更 → 版本变
|
||
assert rule_version(_mapping({"version": 7, "calc": "sum"})) == "7"
|
||
|
||
|
||
def test_anchor_check_period_level():
|
||
rule = {"calc": "sum", "validation": {"matched": 2, "ref_periods": ["2026-06", "2026-07", "2026-08"],
|
||
"checks": [{"period": "2026-06", "match": True},
|
||
{"period": "2026-07", "match": False},
|
||
{"period": "2026-08", "match": True}]}}
|
||
m = _mapping(rule, unit=0.0001, kpi_id=3)
|
||
ok_07, detail_07, agg = anchor_check(m, "2026-07")
|
||
ok_08, _, _ = anchor_check(m, "2026-08")
|
||
ok_09, detail_09, _ = anchor_check(m, "2026-09")
|
||
assert ok_07 is False and "match=False" in detail_07
|
||
assert ok_08 is True # 该期锚点通过(规则级 matched=2/3 一并回报)
|
||
assert agg == {"matched": 2, "anchors": 3}
|
||
assert ok_09 is False and "无锚点记录" in detail_09
|
||
|
||
|
||
def test_main_remark_idempotent_and_bounded():
|
||
seg = ("2026-09-14 voucher_details 复算一致(自动归集id=2128, Δ=0.0000, rule=1 v6eac76a3)"
|
||
";主值批次2026-08-24 15:50:01早于源同步2026-09-11 16:35:14(时点差,不改数)")
|
||
once = _with_recheck_remark(None, seg)
|
||
twice = _with_recheck_remark(once, seg)
|
||
assert once == twice, "同一自动行重跑不得重复追加(否则撑爆 varchar(500))"
|
||
long_old = "旧正文" * 300 # 远超 500
|
||
got = _with_recheck_remark(long_old, seg)
|
||
assert len(got) <= 500 and got.endswith(seg)
|
||
# 两条不同自动行的留痕都要在(不同 id → 不互相覆盖)
|
||
seg2 = seg.replace("id=2128", "id=2129")
|
||
both = _with_recheck_remark(once, seg2)
|
||
assert "id=2128" in both and "id=2129" in both and len(both) <= 500
|