diff --git a/backend/app/api/cash.py b/backend/app/api/cash.py index 6e0b2fb0..fafae6cb 100644 --- a/backend/app/api/cash.py +++ b/backend/app/api/cash.py @@ -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 diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py index 2674a906..68283f92 100644 --- a/backend/app/api/dashboard.py +++ b/backend/app/api/dashboard.py @@ -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) diff --git a/backend/app/api/reports.py b/backend/app/api/reports.py index 82504d3e..14b9a018 100644 --- a/backend/app/api/reports.py +++ b/backend/app/api/reports.py @@ -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 diff --git a/backend/app/utils/alert_attribution.py b/backend/app/utils/alert_attribution.py index fa7e0681..567465de 100644 --- a/backend/app/utils/alert_attribution.py +++ b/backend/app/utils/alert_attribution.py @@ -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) diff --git a/backend/app/utils/voucher_guard.py b/backend/app/utils/voucher_guard.py new file mode 100644 index 00000000..4334586e --- /dev/null +++ b/backend/app/utils/voucher_guard.py @@ -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 diff --git a/backend/scripts/kpi_value_collector.py b/backend/scripts/kpi_value_collector.py index c6050c58..02580a0a 100644 --- a/backend/scripts/kpi_value_collector.py +++ b/backend/scripts/kpi_value_collector.py @@ -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]: diff --git a/backend/tests/test_void_marker_consumers_20260914.py b/backend/tests/test_void_marker_consumers_20260914.py new file mode 100644 index 00000000..6e7b7ec6 --- /dev/null +++ b/backend/tests/test_void_marker_consumers_20260914.py @@ -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() diff --git a/backend/tests/test_void_marker_filter_20260914.py b/backend/tests/test_void_marker_filter_20260914.py index 5a4f2bbc..57d78ab8 100644 --- a/backend/tests/test_void_marker_filter_20260914.py +++ b/backend/tests/test_void_marker_filter_20260914.py @@ -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):