feat(kpi): 归集侧接作废/撤单标记过滤(voucher_void_marks)

- kpi_value_collector: 所有消费 voucher_details 的归集路径统一排除已作废凭证
  (NOT EXISTS voucher_void_marks, entity_id+voucher_no+voucher_date+revoked_at IS NULL),
  与 DB 视图 v_voucher_details_active 定义一致;标记表不存在时自动跳过(兼容旧库)
- 禁硬编码:凭证号不出现在代码/配置,撤单只需写标记表
- verify_cashflow_direct_caliber: 2026-08 从已知差异放行改为硬断言(8 期逐期一致)
- tests/test_void_marker_filter_20260914.py: 标记表在/不在 两分支 3 例
This commit is contained in:
Hermes CI Fix
2026-09-14 18:44:39 +08:00
parent 4d918f5786
commit a8a82ff837
3 changed files with 234 additions and 3 deletions
@@ -0,0 +1,159 @@
"""作废/撤单标记过滤(kpi_value_collector.py)单测 — 2026-09-14
任务: dama-void-marker-collector-fullstack-20260914(采集侧接作废标记过滤)
覆盖两条分支:
1. 标记表缺失(SQLite 测试库 / 旧库)→ 过滤自动跳过,归集结果与无标记时一致(不阻断采集);
2. 标记表存在 → 被标记凭证从归集口径中排除,且过滤由表内容驱动
(换一条标记即换结果 / 软撤回即恢复 → 无硬编码凭证号)。
口径说明: 生产 MySQL 上 voucher_details.voucher_date 与 voucher_void_marks.voucher_date
均为 DATE(日期对日期比较);SQLite 无 DATE 亲和类型,ORM 侧按 DateTime 存储,
故本测试的标记表 DDL 用 DATETIME + 统一传 datetime,保证两侧字面量同形。
生产侧真实过滤效果由 `scripts/verify_cashflow_direct_caliber.py`(MySQL 8 期硬断言)覆盖。
"""
import datetime
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import String, bindparam, text # noqa: E402
from app.models import VoucherDetail # noqa: E402
from scripts import kpi_value_collector as C # noqa: E402
D = datetime.datetime(2026, 8, 31) # 统一日期对象(两侧同形)
MARKS_DDL = """
CREATE TABLE IF NOT EXISTS voucher_void_marks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_id INTEGER NOT NULL,
voucher_no VARCHAR(32) NOT NULL,
voucher_date DATETIME NOT NULL,
mark_type VARCHAR(16) NOT NULL DEFAULT 'void',
replaced_by VARCHAR(32),
reason VARCHAR(500) NOT NULL DEFAULT '',
source_ref VARCHAR(255),
marked_by VARCHAR(64) NOT NULL,
marked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
revoked_at DATETIME,
revoked_by VARCHAR(64)
)
"""
def _reset_cache():
C._void_mark_table_ref = None
C._void_mark_warned = False
def _ensure_marks_table(db):
"""建同结构标记表并清空(本表不在 Base.metadata,setup_db 不负责它)。"""
db.execute(text(MARKS_DDL))
db.execute(text("DELETE FROM voucher_void_marks"))
db.flush()
def _drop_marks_table(db):
db.execute(text("DROP TABLE IF EXISTS voucher_void_marks"))
# 标记行的 voucher_date 取「凭证行在库内的实际字面量」(SQLite 无 DATE 亲和类型,
# 只有保证两侧同形才能比较;生产 MySQL 是 DATE 对 DATE,见模块头说明)。
_INS_MARK = text("""INSERT INTO voucher_void_marks
(entity_id, voucher_no, voucher_date, marked_by, reason, revoked_at)
VALUES (1, :no, :d, 'test', '单测', :rev)""").bindparams(
bindparam("d", type_=String), bindparam("rev", type_=String))
def _mark(db, no, revoked_at=None):
ts = db.execute(text("SELECT voucher_date FROM voucher_details "
"WHERE entity_id=1 AND voucher_no=:n LIMIT 1"),
{"n": str(no)}).scalar()
assert ts is not None, f"凭证 {no} 未入库"
db.execute(_INS_MARK, {"no": str(no), "d": str(ts), "rev": revoked_at})
db.flush()
def _add_voucher(db, no, code, credit=0.0, debit=0.0, period="2026-08"):
db.add(VoucherDetail(entity_id=1, voucher_no=str(no), voucher_date=D,
subject_code=code, subject_name="测试科目",
credit_amount=credit, debit_amount=debit,
summary="单测", carry_forward=0, period=period))
db.flush()
def test_filter_skipped_when_marks_table_missing(db):
"""标记表不存在 → 不做过滤(兼容旧库/测试库),归集照常出数。"""
_reset_cache()
try:
assert C._void_mark_table_exists(db) is False
_add_voucher(db, 90001, "5001", credit=120000.0)
amount, rows = C._subject_amount(db, 1, "5001", "2026-08", "credit")
assert rows == 1 and abs(amount - 120000.0) < 1e-6
finally:
_reset_cache()
def test_marked_voucher_excluded(db):
"""标记表存在 → 被标记凭证整体排除;软撤回后恢复计入(表是唯一数据源)。"""
_reset_cache()
_ensure_marks_table(db)
try:
_add_voucher(db, 90011, "5001", credit=100000.0)
_add_voucher(db, 90012, "5001", credit=20000.0)
_add_voucher(db, 90012, "100201", debit=20000.0)
# 未标记:两张凭证都参与
amount, rows = C._subject_amount(db, 1, "5001", "2026-08", "credit")
assert rows == 2 and abs(amount - 120000.0) < 1e-6, (amount, rows)
# 标记 90011 作废 → 只剩 90012 的 20000
_mark(db, 90011)
amount2, rows2 = C._subject_amount(db, 1, "5001", "2026-08", "credit")
assert rows2 == 1 and abs(amount2 - 20000.0) < 1e-6, (amount2, rows2)
# 软撤回 revoked_at 非空 → 该凭证重新计入
db.execute(text("UPDATE voucher_void_marks SET revoked_at = :d WHERE voucher_no = '90011'"),
{"d": str(D)})
db.flush()
amount3, rows3 = C._subject_amount(db, 1, "5001", "2026-08", "credit")
assert rows3 == 2 and abs(amount3 - 120000.0) < 1e-6, (amount3, rows3)
# 换一条标记(90012)→ 结果随表变化,证明无硬编码
_mark(db, 90012)
amount4, rows4 = C._subject_amount(db, 1, "5001", "2026-08", "credit")
assert rows4 == 1 and abs(amount4 - 100000.0) < 1e-6, (amount4, rows4)
finally:
db.rollback()
_drop_marks_table(db)
_reset_cache()
def test_cashflow_path_excludes_marked_voucher(db):
"""现金流直接法路径同样过过滤:被标记凭证从「现金凭证集合」中剔除。"""
_reset_cache()
_ensure_marks_table(db)
try:
# 现金凭证 A:收 30000(经营对手方);现金凭证 B:收 5000
_add_voucher(db, 90021, "100201", debit=30000.0)
_add_voucher(db, 90021, "5001", credit=30000.0)
_add_voucher(db, 90022, "100201", debit=5000.0)
_add_voucher(db, 90022, "5001", credit=5000.0)
mapping = type("M", (), {"filter_rule": {"calc": "cashflow_direct", "bucket": "operating",
"cash_subjects": ["1001%", "1002%"]},
"unit_conversion": 1, "entity_id": 1})()
v_all, msg_all = C.collect_cashflow_direct(db, mapping, "2026-08", write_kpi=False)
assert abs(v_all - 35000.0) < 1e-6, (v_all, msg_all)
assert "现金凭证2张" in msg_all, msg_all
_mark(db, 90021)
v_f, msg_f = C.collect_cashflow_direct(db, mapping, "2026-08", write_kpi=False)
assert abs(v_f - 5000.0) < 1e-6, (v_f, msg_f)
assert "现金凭证1张" in msg_f, msg_f
finally:
db.rollback()
_drop_marks_table(db)
_reset_cache()