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
+8 -2
View File
@@ -775,7 +775,10 @@ def _sync_cash_balance_from_ledger(db: Session, entity_id: int, batch: str) -> f
"""货币资金类科目(1001/1002开头)期末余额 → set_current_cash_balance(万元)"""
from app.models import VoucherDetail
from app.utils.cash_forecast_engine import set_current_cash_balance
rows = db.query(VoucherDetail).filter(VoucherDetail.entity_id == entity_id).all()
from app.utils.voucher_guard import apply_voucher_guard
# 作废/撤单标记过滤(2026-09-14 收口): 已作废凭证不参与货币资金余额
q = db.query(VoucherDetail).filter(VoucherDetail.entity_id == entity_id)
rows = apply_voucher_guard(db, q, VoucherDetail).all()
balance_yuan = round(sum(
(r.debit_amount or 0) - (r.credit_amount or 0)
for r in rows
@@ -795,10 +798,13 @@ def _sync_cash_kpis(db: Session, entity_id: int, batch: str, periods: list) -> l
② F_CASH_SAFETY 现金安全垫(万元 = 货币资金余额 - 短期借款EXT_139,entity_id隔离,不存在则创建)
"""
from app.models import KPIDefinition, KPIValue, VoucherDetail
from app.utils.voucher_guard import apply_voucher_guard
updates = []
period = periods[-1] if periods else datetime.now().strftime("%Y-%m")
rows = db.query(VoucherDetail).filter(VoucherDetail.entity_id == entity_id).all()
# 作废/撤单标记过滤(2026-09-14 收口): 已作废凭证不参与联动取数
q_cash = db.query(VoucherDetail).filter(VoucherDetail.entity_id == entity_id)
rows = apply_voucher_guard(db, q_cash, VoucherDetail).all()
monetary = [
r for r in rows
if r.carry_forward == 0 and r.subject_code
+6 -1
View File
@@ -9,6 +9,7 @@ from app.auth_middleware import require_auth, require_role
from app.deps import get_entity_id
from app.models import KPIDefinition, KPIValue, KPIAlert, User, VoucherDetail
from app.utils.cache import get as cache_get, set as cache_set
from app.utils.voucher_guard import apply_voucher_guard
import json
import logging
@@ -38,7 +39,10 @@ BEP_REBATE_POLICY_PCT = 84.61
def _bep_sum_field(db, entity_id: int, period_from: str, period_to: str,
field: str, subject_filter=None) -> float:
"""聚合 voucher_details 金额(元),subject_filter: ('eq', code) / ('like', prefix)"""
"""聚合 voucher_details 金额(元),subject_filter: ('eq', code) / ('like', prefix)
作废/撤单标记过滤(2026-09-14 收口): 已作废凭证不参与 BEP 均值。
"""
q = db.query(func.sum(getattr(VoucherDetail, field))).filter(
VoucherDetail.entity_id == entity_id,
VoucherDetail.carry_forward == 0,
@@ -51,6 +55,7 @@ def _bep_sum_field(db, entity_id: int, period_from: str, period_to: str,
q = q.filter(VoucherDetail.subject_code == code)
elif kind == "like":
q = q.filter(VoucherDetail.subject_code.like(code + "%"))
q = apply_voucher_guard(db, q, VoucherDetail)
val = q.scalar()
return float(val or 0.0)
+12 -5
View File
@@ -19,6 +19,7 @@ from app.database import get_db
from app.auth_middleware import require_role, require_auth
from app.models import KPIDefinition, KPIValue, BudgetPlan, StrategicMap, KPIAlert, User, Subject, ActionPlan, OperationLog, ReportHistory
from app.utils.deviation_engine import calc_period_deviation, calc_period_diff, calc_deviation, get_budget_for_kpi
from app.utils.voucher_guard import apply_voucher_guard
from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
import logging
@@ -565,12 +566,15 @@ def _get_subject_amount(db: Session, code: str, period: str, entity_id: int = 1)
# 2. 从 subjects + voucher_details 取(如果存在)
try:
from app.models import VoucherDetail
result = db.query(
q = db.query(
func.sum(VoucherDetail.debit_amount - VoucherDetail.credit_amount)
).filter(
VoucherDetail.subject_code == code,
VoucherDetail.period == period
).scalar()
VoucherDetail.period == period,
VoucherDetail.entity_id == entity_id, # 账套隔离(2026-09-14 补)
)
# 作废/撤单标记过滤(2026-09-14 收口)
result = apply_voucher_guard(db, q, VoucherDetail).scalar()
if result is not None:
return float(result)
except Exception:
@@ -1465,12 +1469,15 @@ def _get_bs_amount(db: Session, codes: list, period: str, entity_id: int = 1) ->
try:
from app.models import VoucherDetail
for code, sign in codes:
result = db.query(
q = db.query(
func.sum(VoucherDetail.debit_amount - VoucherDetail.credit_amount)
).filter(
VoucherDetail.subject_code == code,
VoucherDetail.period == period,
).scalar()
VoucherDetail.entity_id == entity_id, # 账套隔离(2026-09-14 补)
)
# 作废/撤单标记过滤(2026-09-14 收口)
result = apply_voucher_guard(db, q, VoucherDetail).scalar()
if result is not None:
total += float(result) * sign
has_data = True
+3
View File
@@ -21,6 +21,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import func
from app.models import KPIHierarchy, KPISubjectMap, VoucherDetail, ScenarioSuggestion, KPIDefinition
from app.utils.voucher_guard import apply_voucher_guard
logger = logging.getLogger("cma.alert_attribution")
@@ -134,6 +135,8 @@ def build_subject_attribution(db: Session, kpi_id: int, period: str) -> list:
q = q.filter(VoucherDetail.subject_code == p["code"])
if entity_id is not None:
q = q.filter(VoucherDetail.entity_id == entity_id)
# 作废/撤单标记过滤(2026-09-14 收口): 归因拆解与归集同口径
q = apply_voucher_guard(db, q, VoucherDetail)
row = q.first()
credit_sum = float(row[0] or 0)
debit_sum = float(row[1] or 0)
+118
View File
@@ -0,0 +1,118 @@
"""作废/撤单标记守卫 — 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