Files
cma-management/backend/tests/test_cash_vouchers_json_idemkey.py
T
wecom-fullstack 52b693ddbb ASK-20260924-15/17: Bot 通道缺省账套→400 + 差异/趋势入口期间口径回落
- deps.get_entity_id: Bot 通道(带 X-BOT-KEY)无显式账套 → 400(旧为兜底 entity 1=酣客);
  无 key 的存量公开/用户端点行为不变(仍 401/兜底 1)
- 新增 deps.require_bot_entity_id():写端点缺省账套一处收口 → 400(406/422 同族)
  接入 bot_bridge(×2) / bot_bridge_v2 / bot_suggestions / cash_vouchers_json,并同步文档口径
- deviation_engine.run_deviation_check: period=None 不再硬编码当月,逐 KPI 回落
  「该 KPI 自己的最近可用期间」(裁定2b,单一实现收口到 alert_entity_scope);(当月无
  kpi_values ⇒ 差异/趋势入口永久 0 新增);R2/R3 窗口判定改为逐 KPI;显式 period 语义不变
- alert_generator.resolve_latest_period 委托同一实现(消除期间口径两份)
- 测试:新增 tests/test_ask15_17_engine_and_entity_required_20260925.py(12 例);
  4 个既有测试文件把「旧兜底账套 1」显式化(断言未改)
2026-09-25 22:42:25 +08:00

187 lines
9.0 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.
"""智享通凭证 JSON 导入 — 行级幂等键加固测试 (dama-zxt-idemkey-period-20260911)
验收覆盖:
1. 幂等键不含 period(同业务键、period 不同 → 不重复入库 + period_conflict 计数)
2. 同 payload 复推:dup_skipped = 载荷行数、0 新增(含 batch 指纹 already_imported 回归)
3. 跨批次同键多重数:「库 1 份 + 载荷 2 份」→ 入库后库内 2 份(不丢行)
4. 回归:负数行入库 / 行内 voucher_date 优先 / 借贷不平整张拒收 / 无日期拒收
"""
import logging
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import VoucherDetail
BASE = "/api/cma/cash/import/vouchers-json"
BOT_KEY = "cma-bot-admin-2026"
H = {"X-BOT-KEY": BOT_KEY,
# ASK-20260924-17(2026-09-25):Bot 通道必须显式传账套(旧为缺省兜底 entity 1);
# 本文件全部用例账套语义即 1,故在夹具处显式化,断言不变。
"X-Entity-Id": "1"}
def _row(vno, subject, debit, credit, summary, **kw):
r = {"voucher_no": vno, "subject_code": subject, "subject_name": f"科目{subject}",
"debit": debit, "credit": credit, "summary": summary}
r.update(kw)
return r
def _balanced(vno, summary, amount="100.00", **kw):
"""一张平衡凭证:借方一行 + 贷方一行(同摘要,不同科目)"""
return [
_row(vno, "1001", amount, "0", summary, **kw),
_row(vno, "2001", "0", amount, summary, **kw),
]
def _count(db: Session, vno: str) -> int:
return db.query(VoucherDetail).filter(VoucherDetail.voucher_no == vno).count()
def _keys(resp_json: dict) -> None:
"""返回体计数键齐全性断言"""
for k in ("success_rows", "failed_rows", "dup_skipped", "period_conflict",
"neg_rows", "pending_undated", "pending_dict_miss"):
assert k in resp_json, f"返回体缺计数键 {k}: {resp_json}"
class TestIdempotencyKeyWithoutPeriod:
def test_same_key_diff_period_no_dup_and_conflict(self, client: TestClient, db: Session):
"""复现 V17756 场景:首次 period 由 summary 派生(2022-07),重推改为行内日期(2022-06)
→ 不重复入库,period_conflict+2,库内 period 保持 2022-07(不覆盖)"""
rows = _balanced("17756", "固定资产折旧-2022-07-01-1")
r1 = client.post(BASE, json=rows, headers=H)
assert r1.status_code == 200, r1.text
j1 = r1.json()
_keys(j1)
assert j1["success_rows"] == 2 and j1["dup_skipped"] == 0
assert j1["period_conflict"] == 0
assert _count(db, "17756") == 2
rows2 = _balanced("17756", "固定资产折旧-2022-07-01-1", voucher_date="2022-06-30")
r2 = client.post(BASE, json=rows2, headers=H)
assert r2.status_code == 200, r2.text
j2 = r2.json()
_keys(j2)
assert j2["success_rows"] == 0, "幂等键含 period 的缺陷:重推被重复入库"
assert j2["dup_skipped"] == 2
assert j2["period_conflict"] == 2
assert j2["period_conflicts"] and j2["period_conflicts"][0]["incoming_period"] == "2022-06"
# 库内仍 2 行、period 保持首推口径(不自动覆盖)
assert _count(db, "17756") == 2
periods = {d.period for d in db.query(VoucherDetail).filter(VoucherDetail.voucher_no == "17756")}
assert periods == {"2022-07"}
def test_period_conflict_logged(self, client: TestClient, db: Session, caplog):
"""period_conflict 需在日志中留痕"""
rows = _balanced("17757", "折旧-2022-07-01-1")
client.post(BASE, json=rows, headers=H)
with caplog.at_level(logging.WARNING, logger="cma.cash_zxt"):
resp = client.post(BASE, json=_balanced("17757", "折旧-2022-07-01-1",
voucher_date="2022-06-30"), headers=H)
assert resp.json()["period_conflict"] == 2
assert any("period" in rec.message and "不一致" in rec.message for rec in caplog.records), \
[r.message for r in caplog.records]
class TestRepushNoNewRows:
def test_exact_same_payload_already_imported(self, client: TestClient, db: Session):
"""同内容快照重推 → batch 指纹命中 already_imported(既有机制回归)"""
rows = _balanced("20001", "同内容-2022-06-01-1")
j1 = client.post(BASE, json=rows, headers=H).json()
assert j1["success_rows"] == 2
j2 = client.post(BASE, json=rows, headers=H).json()
assert j2.get("already_imported") is True
_keys(j2)
assert _count(db, "20001") == 2
def test_same_rows_repush_dup_skipped_equals_payload_rows(self, client: TestClient, db: Session):
"""同 payload(行集相同、仅顺序不同 → 指纹不同)复推:dup_skipped = 载荷行数、0 新增"""
rows = _balanced("20002", "复推-2022-06-01-1")
assert client.post(BASE, json=rows, headers=H).json()["success_rows"] == 2
reordered = list(reversed(rows)) # 同载荷行集,指纹不同,走行级幂等
j2 = client.post(BASE, json=reordered, headers=H).json()
_keys(j2)
assert j2["success_rows"] == 0
assert j2["dup_skipped"] == len(reordered)
assert j2["period_conflict"] == 0
assert _count(db, "20002") == 2
def test_cross_batch_multi_occurrence_no_row_loss(self, client: TestClient, db: Session):
"""跨批次同键多重数:「库 1 份 + 载荷 2 份」→ 入库后库内 2 份(不丢行)"""
# 同额多行凭证:借 2 行(键相同) / 贷 2 行(键相同)
dup_debit = _row("20003", "1001", "100.00", "0", "同额多行-2022-06-01-1")
dup_credit = _row("20003", "2001", "0", "100.00", "同额多行-2022-06-01-1")
batch1 = [dict(dup_debit), dict(dup_credit)]
j1 = client.post(BASE, json=batch1, headers=H).json()
assert j1["success_rows"] == 2
assert _count(db, "20003") == 2
batch2 = [dict(dup_debit), dict(dup_debit), dict(dup_credit), dict(dup_credit)]
j2 = client.post(BASE, json=batch2, headers=H).json()
_keys(j2)
assert j2["dup_skipped"] == 2, "库内 1 份 + 载荷 2 份:第 2 份必须入库而非被吞"
assert j2["success_rows"] == 2
assert _count(db, "20003") == 4
# 再推一次同载荷(4 行已全部在库)→ 全跳过,不增长
j3 = client.post(BASE, json=list(reversed(batch2)), headers=H).json()
assert j3["success_rows"] == 0 and j3["dup_skipped"] == 4
assert _count(db, "20003") == 4
class TestRegressions:
def test_negative_rows_imported(self, client: TestClient, db: Session):
"""回归:负数(红字记账)行仍入库并计数 neg_rows"""
rows = [
_row("30001", "1001", "-100.00", "0", "红字-2022-06-01-1"),
_row("30001", "2001", "0", "-100.00", "红字-2022-06-01-1"),
]
j = client.post(BASE, json=rows, headers=H).json()
_keys(j)
assert j["success_rows"] == 2
assert j["neg_rows"] == 2
assert _count(db, "30001") == 2
def test_inrow_voucher_date_priority_over_summary(self, client: TestClient, db: Session):
"""回归:行内 voucher_date 优先于 summary 前缀日期"""
rows = _balanced("30002", "口径-2022-07-01-1", voucher_date="2022-06-30")
j = client.post(BASE, json=rows, headers=H).json()
assert j["success_rows"] == 2
periods = {d.period for d in db.query(VoucherDetail).filter(VoucherDetail.voucher_no == "30002")}
assert periods == {"2022-06"}, "行内日期未优先"
def test_unbalanced_voucher_rejected_whole(self, client: TestClient, db: Session):
"""回归:借贷不平 → 整张凭证拒收"""
rows = [_row("30003", "1001", "100.00", "0", "不平-2022-06-01-1")]
j = client.post(BASE, json=rows, headers=H).json()
_keys(j)
assert j["success_rows"] == 0
assert j["balance_check"]["passed"] is False
assert j["balance_check"]["abnormal_vouchers"][0]["voucher_no"] == "30003"
assert _count(db, "30003") == 0
def test_undated_row_rejected(self, client: TestClient, db: Session):
"""回归:无任何日期来源 → 拒收(不冒充当前时间)"""
rows = [_row("30004", "1001", "100.00", "0", "无日期摘要")]
resp = client.post(BASE, json=rows, headers=H)
assert resp.status_code == 200
j = resp.json()
_keys(j)
assert j["success"] is False
assert j["pending_undated"] == 1
assert j["success_rows"] == 0
assert _count(db, "30004") == 0
def test_dict_miss_row_rejected(self, client: TestClient, db: Session):
"""回归:科目字典未命中 → 该行拒收并计数 pending_dict_miss"""
rows = [{"voucher_no": "30005", "CwAccountID": "999999",
"debit": "100.00", "credit": "0", "summary": "缺字典-2022-06-01-1"}]
j = client.post(BASE, json=rows, headers=H).json()
_keys(j)
assert j["pending_dict_miss"] == 1
assert _count(db, "30005") == 0