feat(kpi): route all voucher_details consumers through single void-mark guard

Single source: app/utils/voucher_guard.py (NOT EXISTS on voucher_void_marks,

key = entity_id+voucher_no+voucher_date and revoked_at IS NULL; same caliber as

the DB view cma.v_voucher_details_active). No hardcoded voucher numbers.

Consumers now guarded: cash.py import linkage (2 fns), reports.py subject/BS

amount (2 sites, also entity_id isolation added), dashboard.py _bep_sum_field,

alert_attribution.py build_subject_attribution. kpi_value_collector.py now

delegates to the shared guard (removes the previous duplicate implementation).

Tests: new tests/test_void_marker_consumers_20260914.py (6 cases).
This commit is contained in:
Hermes CI Fix
2026-09-14 19:05:16 +08:00
parent a8a82ff837
commit 8693c8e342
8 changed files with 318 additions and 69 deletions
@@ -0,0 +1,154 @@
"""作废/撤单标记过滤 — 4 个 voucher_details 消费方接入单测 — 2026-09-14
任务: fullstack-20260914184947(作废标记机制收口 · 4 个消费方接过滤)
覆盖:
1. 口径实现全库唯一(采集器与 4 个消费方共用 app.utils.voucher_guard,无第二份实现);
2. reports.py `_get_subject_amount` / `_get_bs_amount`:被标记凭证不参与 + 账套隔离;
3. dashboard.py `_bep_sum_field`:被标记凭证不参与 BEP 均值;
4. alert_attribution.py `build_subject_attribution`:归因拆解与归集同口径。
标记表 DDL / 夹具复用 tests/test_void_marker_filter_20260914.py(单一夹具来源,防漂移)。
"""
import inspect
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.models import KPISubjectMap # noqa: E402
from app.utils import voucher_guard as VG # noqa: E402
from scripts import kpi_value_collector as C # noqa: E402
from app.api import reports as R, dashboard as D, cash as CASH # noqa: E402
from app.utils import alert_attribution as A # noqa: E402
from tests.test_void_marker_filter_20260914 import ( # noqa: E402
_add_voucher, _drop_marks_table, _ensure_marks_table, _mark, _reset_cache,
)
from tests.conftest import create_test_kpi # noqa: E402
def _setup(db):
_reset_cache()
_ensure_marks_table(db)
def _teardown(db):
db.rollback()
_drop_marks_table(db)
_reset_cache()
def test_single_source_guard():
"""全库唯一实现:采集器别名与 3 个模块级消费方都指向 voucher_guard 同一函数对象。"""
assert C._exclude_void is VG.exclude_void
assert C._apply_voucher_guard is VG.apply_voucher_guard
assert R.apply_voucher_guard is VG.apply_voucher_guard
assert D.apply_voucher_guard is VG.apply_voucher_guard
assert A.apply_voucher_guard is VG.apply_voucher_guard
# cash.py 采用函数内局部 import(与该文件既有风格一致)→ 源码级断言
assert "from app.utils.voucher_guard import apply_voucher_guard" in inspect.getsource(CASH)
# 口径子句只有一份(三键 + revoked_at IS NULL)
src = inspect.getsource(VG)
assert src.count("marks.c.revoked_at.is_(None)") == 1
assert src.count("def void_exists_clause") == 1
def test_reports_subject_amount_excludes_voided_and_isolates_entity(db):
"""reports._get_subject_amount:作废剔除 + 账套隔离(两处 2026-09-14 补过滤)。"""
_setup(db)
try:
_add_voucher(db, 91001, "5001", credit=100000.0) # entity 1
_add_voucher(db, 91002, "5001", credit=5000.0, period="2026-08")
from app.models import VoucherDetail
db.query(VoucherDetail).filter(VoucherDetail.voucher_no == "91002").update(
{"entity_id": 2}, synchronize_session=False)
db.flush()
# 账套隔离:entity2 不能看到 entity1 的 100000(口径=借-贷,贷方为负)
assert R._get_subject_amount(db, "5001", "2026-08", 1) == -100000.0
assert R._get_subject_amount(db, "5001", "2026-08", 2) == -5000.0
# 作废剔除:标记 entity1 的凭证 → 该账套无余额
_mark(db, 91001)
assert R._get_subject_amount(db, "5001", "2026-08", 1) is None
assert R._get_subject_amount(db, "5001", "2026-08", 2) == -5000.0
finally:
_teardown(db)
def test_reports_bs_amount_excludes_voided_and_isolates_entity(db):
"""reports._get_bs_amount:资产负债表余额同口径(含 entity_id 过滤)。"""
_setup(db)
try:
_add_voucher(db, 91011, "100201", debit=80000.0)
_add_voucher(db, 91012, "100201", debit=2000.0)
from app.models import VoucherDetail
db.query(VoucherDetail).filter(VoucherDetail.voucher_no == "91012").update(
{"entity_id": 2}, synchronize_session=False)
db.flush()
assert R._get_bs_amount(db, [("100201", 1)], "2026-08", 1) == 80000.0
assert R._get_bs_amount(db, [("100201", 1)], "2026-08", 2) == 2000.0
_mark(db, 91011)
assert R._get_bs_amount(db, [("100201", 1)], "2026-08", 1) is None
assert R._get_bs_amount(db, [("100201", 1)], "2026-08", 2) == 2000.0
finally:
_teardown(db)
def test_dashboard_bep_excludes_voided(db):
"""dashboard._bep_sum_field:BEP 均值口径剔除已作废凭证。"""
_setup(db)
try:
_add_voucher(db, 91021, "5001", credit=120000.0)
_add_voucher(db, 91022, "5001", credit=30000.0)
assert D._bep_sum_field(db, 1, "2026-01", "2026-08",
"credit_amount", ("eq", "5001")) == 150000.0
_mark(db, 91021)
assert D._bep_sum_field(db, 1, "2026-01", "2026-08",
"credit_amount", ("eq", "5001")) == 30000.0
# 前缀匹配口径同样生效
assert D._bep_sum_field(db, 1, "2026-01", "2026-08",
"credit_amount", ("like", "50")) == 30000.0
finally:
_teardown(db)
def test_alert_attribution_excludes_voided(db):
"""alert_attribution.build_subject_attribution:归因拆解与归集同口径。"""
_setup(db)
try:
kpi = create_test_kpi(db, kpi_code="T_VOID_ATTR", entity_id=1)
db.add(KPISubjectMap(kpi_id=kpi.id, subject_code="5001", calc_type="sum",
weight=1.0, remark="测试科目"))
db.flush()
_add_voucher(db, 91031, "5001", credit=100000.0)
_add_voucher(db, 91032, "5001", credit=20000.0)
rows = A.build_subject_attribution(db, kpi.id, "2026-08")
assert len(rows) == 1, rows
assert rows[0]["amount_diff"] == 120000.0, rows
assert rows[0]["rows"] == 2, rows
_mark(db, 91031)
rows2 = A.build_subject_attribution(db, kpi.id, "2026-08")
assert rows2[0]["amount_diff"] == 20000.0, rows2
assert rows2[0]["rows"] == 1, rows2
finally:
_teardown(db)
def test_guard_skipped_when_marks_table_missing(db):
"""标记表不存在(旧库/测试库)→ 消费方不做过滤,业务不中断。"""
_reset_cache()
_add_voucher(db, 91041, "5001", credit=7000.0)
try:
assert VG.void_mark_table_exists(db) is False
assert R._get_subject_amount(db, "5001", "2026-08", 1) == -7000.0
assert D._bep_sum_field(db, 1, "2026-01", "2026-08",
"credit_amount", ("eq", "5001")) == 7000.0
finally:
db.rollback()
_reset_cache()
@@ -44,8 +44,8 @@ CREATE TABLE IF NOT EXISTS voucher_void_marks (
def _reset_cache():
C._void_mark_table_ref = None
C._void_mark_warned = False
# 口径实现已收口到 app.utils.voucher_guard(2026-09-14),复位其反射缓存
C._reset_cache()
def _ensure_marks_table(db):