- 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」显式化(断言未改)
187 lines
9.0 KiB
Python
187 lines
9.0 KiB
Python
"""智享通凭证 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
|