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:
@@ -38,6 +38,15 @@ from app.models import (
|
||||
KPIValueSource, KPIValueCollectLog, KPIValue, KPIDefinition,
|
||||
VoucherDetail, CashPlan,
|
||||
)
|
||||
# 作废/撤单标记守卫(全库唯一实现,见 app/utils/voucher_guard.py)
|
||||
from app.utils.voucher_guard import ( # noqa: F401 (别名供本模块历史调用点使用)
|
||||
VOID_MARK_TABLE,
|
||||
apply_voucher_guard as _apply_voucher_guard,
|
||||
exclude_void as _exclude_void,
|
||||
reset_cache as _reset_void_mark_cache,
|
||||
void_mark_table as _void_mark_table,
|
||||
void_mark_table_exists as _void_mark_table_exists,
|
||||
)
|
||||
|
||||
|
||||
# 源头表 → ORM模型映射(动态 import 避免循环依赖)
|
||||
@@ -67,68 +76,15 @@ def _field_expression(model, field: str, aggregate: str = "sum"):
|
||||
return func.sum(col)
|
||||
|
||||
|
||||
# ── 作废/撤单标记过滤(2026-09-14 接入;单一事实来源 = cma.voucher_void_marks)──────
|
||||
# 口径: 本模块**所有**消费 voucher_details 的归集路径统一排除「已作废/被更正」凭证。
|
||||
# ── 作废/撤单标记过滤(2026-09-14 接入 · 同日晚收口至单一实现)──────────────────
|
||||
# 口径**不在本模块**:统一由 app/utils/voucher_guard.py 提供(全库唯一实现),
|
||||
# 本模块只做转发,避免「采集器已过滤、报表/归因仍含虚增」的口径漂移。
|
||||
# 判定键 = (entity_id, voucher_no, voucher_date) 且 revoked_at IS NULL —— 与 DB 视图
|
||||
# `cma.v_voucher_details_active` 的定义逐字一致(视图供探针/报表等其它消费方使用)。
|
||||
# `cma.v_voucher_details_active` 的定义逐字一致。
|
||||
# 纪律: 禁硬编码凭证号;标记表是唯一数据源 —— 今后任何撤单只需写标记表,代码零改动。
|
||||
# 兼容: 标记表不存在(测试库/旧库)→ 跳过过滤,不阻断采集(告警一次)。
|
||||
_VOID_MARK_TABLE = "voucher_void_marks"
|
||||
_void_mark_table_ref = None # 反射缓存: Table(存在)| False(不存在)
|
||||
_void_mark_warned = False
|
||||
|
||||
|
||||
def _void_mark_table(db):
|
||||
"""作废标记表(反射 + 缓存);表不存在/不可读 → None。"""
|
||||
global _void_mark_table_ref, _void_mark_warned
|
||||
if _void_mark_table_ref is not None:
|
||||
# 注意: Table 对象无真值(__bool__ 未定义),必须显式判 False,不能写 `ref or None`
|
||||
return None if _void_mark_table_ref is False else _void_mark_table_ref
|
||||
try:
|
||||
# 走 Session 自身连接(而非 engine): 测试库 StaticPool 下两者是同一 DBAPI 连接,
|
||||
# 另开 engine 连接会在归还时回滚掉 Session 未提交的写入;此处保持同一事务。
|
||||
bind = db.connection() if hasattr(db, "connection") else db.get_bind()
|
||||
_void_mark_table_ref = Table(_VOID_MARK_TABLE, MetaData(), autoload_with=bind)
|
||||
logger.info("作废标记过滤已接入: %s(撤销标记后自动重新计入)", _VOID_MARK_TABLE)
|
||||
except Exception as e: # noqa: BLE001 - 兼容无标记表的环境
|
||||
_void_mark_table_ref = False
|
||||
if not _void_mark_warned:
|
||||
logger.warning("作废标记表 %s 不可用 → 本次不做作废过滤: %s", _VOID_MARK_TABLE, e)
|
||||
_void_mark_warned = True
|
||||
return None if _void_mark_table_ref is False else _void_mark_table_ref
|
||||
|
||||
|
||||
def _void_mark_table_exists(db) -> bool:
|
||||
return _void_mark_table(db) is not None
|
||||
|
||||
|
||||
def _has_col(src, name: str) -> bool:
|
||||
cols = getattr(src, "c", None)
|
||||
return (name in cols) if cols is not None else hasattr(src, name)
|
||||
|
||||
|
||||
def _exclude_void(db, query, src):
|
||||
"""给凭证明细查询挂上 NOT EXISTS(voucher_void_marks) 过滤(本模块唯一注入点)。
|
||||
|
||||
src = VoucherDetail(或 aliased(VoucherDetail));标记表不存在 → 原样返回。
|
||||
"""
|
||||
marks = _void_mark_table(db)
|
||||
if marks is None:
|
||||
return query
|
||||
clause = ~exists().where(and_(
|
||||
marks.c.entity_id == src.entity_id,
|
||||
marks.c.voucher_no == src.voucher_no,
|
||||
marks.c.voucher_date == src.voucher_date,
|
||||
marks.c.revoked_at.is_(None),
|
||||
))
|
||||
return query.filter(clause)
|
||||
|
||||
|
||||
def _apply_voucher_guard(db, query, src):
|
||||
"""凡指向凭证明细的查询一律过作废过滤(非凭证明细源表原样返回)。"""
|
||||
if _has_col(src, "voucher_no") and _has_col(src, "voucher_date"):
|
||||
return _exclude_void(db, query, src)
|
||||
return query
|
||||
_VOID_MARK_TABLE = VOID_MARK_TABLE # 兼容别名(历史引用)
|
||||
_reset_cache = _reset_void_mark_cache # 兼容别名(单测/外部复位)
|
||||
|
||||
|
||||
def collect_for_mapping(db, mapping: KPIValueSource, period: str, write_kpi: bool = True) -> Tuple[Optional[float], str]:
|
||||
|
||||
Reference in New Issue
Block a user