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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user