Files
cma-management/backend/app/utils/voucher_guard.py
T
Hermes CI Fix 8693c8e342 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).
2026-09-14 19:05:16 +08:00

119 lines
4.6 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.
"""作废/撤单标记守卫 — voucher_details 的统一过滤入口(2026-09-14)
**单一事实来源 = 标记表 `cma.voucher_void_marks`**(`entity_id + voucher_no + voucher_date`
三键 + `revoked_at IS NULL` 判定),与 DB 视图 `cma.v_voucher_details_active` 的定义逐字一致。
为什么用 NOT EXISTS 子查询而不是直接读视图:
`VoucherDetail` ORM 模型同时被**写入方**使用(智享通推送落 `voucher_details`),把 ORM
映射到视图会让写入路径失效(视图非可更新)。本模块的过滤条件与视图定义同口径,
且是全库**唯一**实现(`scripts/kpi_value_collector.py` 亦委派到此处)→ 无口径漂移。
用法(任何消费 voucher_details 的查询):
q = db.query(...).filter(...)
q = apply_voucher_guard(db, q, VoucherDetail) # 非凭证明细源表原样返回
纪律:**禁硬编码凭证号**。今后任何撤单只需往标记表写一行(或置 revoked_at),代码零改动。
兼容:标记表不存在/不可读(测试库、旧库)→ 自动跳过过滤并告警一次,不阻断业务。
"""
import logging
import threading
from sqlalchemy import MetaData, Table, and_, exists
logger = logging.getLogger("cma.voucher_guard")
VOID_MARK_TABLE = "voucher_void_marks"
_lock = threading.Lock()
_table_cache = {} # engine -> Table(存在)| False(不存在)
_warned = set() # engine(已告警过的,避免刷日志)
def reset_cache() -> None:
"""清空反射缓存(单测在「建表/删表」之间必须调用)。"""
with _lock:
_table_cache.clear()
_warned.clear()
def _bind_key(db):
try:
bind = db.get_bind()
except Exception: # noqa: BLE001 - 拿不到绑定对象时退化为不缓存
return None
return getattr(bind, "engine", bind)
def void_mark_table(db):
"""作废标记表(按 engine 反射并缓存);表不存在/不可读 → None。"""
key = _bind_key(db)
if key is not None:
with _lock:
if key in _table_cache:
cached = _table_cache[key]
# 注意: Table 对象无真值(__bool__ 未定义),必须显式判 False
return None if cached is False else cached
try:
# 走 Session 自身连接(而非 engine): 测试库 StaticPool 下两者是同一 DBAPI 连接,
# 另开 engine 连接会在归还时回滚掉 Session 未提交的写入;此处保持同一事务。
bind = db.connection() if hasattr(db, "connection") else db.get_bind()
table = Table(VOID_MARK_TABLE, MetaData(), autoload_with=bind)
except Exception as e: # noqa: BLE001 - 兼容无标记表的环境
with _lock:
if key is not None:
_table_cache[key] = False
if key not in _warned:
_warned.add(key)
logger.warning("作废标记表 %s 不可用 → 本次不做作废过滤: %s", VOID_MARK_TABLE, e)
return None
with _lock:
if key is not None:
_table_cache[key] = table
logger.info("作废标记过滤已接入: %s(撤销标记后自动重新计入)", VOID_MARK_TABLE)
return table
def void_mark_table_exists(db) -> bool:
return void_mark_table(db) is not None
def void_exists_clause(marks, src):
"""作废判定子句: NOT EXISTS(标记表 三键匹配 且 revoked_at IS NULL)。
marks = 标记表 Table;src = VoucherDetail 或 aliased(VoucherDetail)。
"""
return ~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),
))
def has_voucher_keys(src) -> bool:
"""src 是否带凭证键(voucher_no + voucher_date)——即「是凭证明细源表」。"""
cols = getattr(src, "c", None)
for name in ("voucher_no", "voucher_date"):
ok = (name in cols) if cols is not None else hasattr(src, name)
if not ok:
return False
return True
def exclude_void(db, query, src):
"""给凭证明细查询挂上 NOT EXISTS(voucher_void_marks) 过滤(全库唯一口径实现)。
标记表不存在 → 原样返回。
"""
marks = void_mark_table(db)
if marks is None:
return query
return query.filter(void_exists_clause(marks, src))
def apply_voucher_guard(db, query, src):
"""凡指向凭证明细的查询一律过作废过滤(非凭证明细源表原样返回)。"""
if has_voucher_keys(src):
return exclude_void(db, query, src)
return query