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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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]:
|
||||
|
||||
@@ -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