From 3db805db4e28dc78aa5bc71a060ddf24e348e2b1 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Sun, 6 Sep 2026 16:45:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(cash):=20=E6=99=BA=E4=BA=AB=E9=80=9A?= =?UTF-8?q?=E5=87=AD=E8=AF=81JSON=E7=9B=B4=E4=BC=A0=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E7=AB=AF=E7=82=B9=20vouchers-json=20(zxt-cma-import-v1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新端点 POST /api/cma/cash/import/vouchers-json (X-BOT-KEY鉴权, 独立router避开require_role) - 兼容三种body: 裸rows数组 / {rows} / {datasets[0].rows} 整包 - 凭证级借贷平衡(替代批级, 已知单边30694记异常拒收), 结转行summary识别 - 日期从summary前缀解析, 无日期行pending拒收(不冒充文件时间) - 科目解析: 行内subject → DB zxt_account_dict → body subject_map, 未命中拒收 - 幂等: 内容指纹batch + import_logs去重 + 行级业务键防重复 - entity默认1(陕西酣客), active校验; 不触发现金流联动(凭证未证实权威) - 验收脚本 scripts/verify_zxt_vouchers_json.py 12/12 PASS (SQLite本地) --- backend/app/api/cash_vouchers_json.py | 411 ++++++++++++++++++++ backend/app/main.py | 3 +- backend/scripts/verify_zxt_vouchers_json.py | 126 ++++++ 3 files changed, 539 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/cash_vouchers_json.py create mode 100644 backend/scripts/verify_zxt_vouchers_json.py diff --git a/backend/app/api/cash_vouchers_json.py b/backend/app/api/cash_vouchers_json.py new file mode 100644 index 00000000..4a4b489b --- /dev/null +++ b/backend/app/api/cash_vouchers_json.py @@ -0,0 +1,411 @@ +""" +智享通凭证 JSON 直传导入端点 (zxt-cma-import-v1, 2026-09-06) +============================================================ +背景:智享通推送链路每10分钟落盘 2000 行凭证分录 JSON,需进 CMA voucher_details。 + 现有 /api/cma/cash/import/vouchers 只收 Excel multipart,本模块提供 JSON 直传版本。 + +链路: + 接收服务 8500 落盘 incoming/{ts}.json → hook(zxt-cma-import-hook.py) → POST 本端点 + +鉴权:X-BOT-KEY: cma-bot-admin-2026(verify_bot_key,与 /api/cma/bot/* 共享密钥)。 + 注意:本 router 独立于 cash.py 的 router —— cash.router 带 require_role 需用户 + Bearer token,Bot hook 只有 X-BOT-KEY 到不了,故同前缀另挂本 router(FastAPI 允许多 router 同前缀)。 + +校验口径(对齐财务BOT映射说明 cma_field_mapping_finance_20260906.md + 任务单验收): + 1. 行级:voucher_no 必有;debit/credit 字符串→float round2,非负,借贷不同时0 + 2. 结转行:summary 含"结转" → carry_forward=1(zxt 113行"期间损益结转"100%命中) + 3. 凭证级借贷平衡(每 Voucher_ID Σ借=Σ贷 容差0.01)—— 批级平衡对 zxt 快照不适用 + (已知单边凭证 30694 仅贷5479.65),不平衡凭证整张拒收记异常清单,不整批拒绝 + 4. 日期/period:zxt 行无 voucher_date 字段,日期从 summary 前缀 YYYY-MM-DD 解析; + 解析不到的行(期间损益结转等)按 pending 拒收,不冒充文件生成时间 + 5. 科目:subject_code/subject_name 全 null(智享通侧未带)。解析优先级: + a) 行内 subject_code/subject_name 有值 → 直用 + b) 无 → 查请求体 subject_map {CwAccountID: {subject_code, subject_name}} + 或 DB 表 zxt_account_dict(CwAccountID, subject_code, subject_name)(若已建) + c) 未命中 → 该行拒收 reason="科目字典未命中 CwAccountID=x"(不写脏数据) + 6. 幂等:快照每10分钟全量重推同一内容。batch = zxt-{日期}-{行集sha1前10}, + 导入前查 import_logs 已存在同 batch → 直接返回 already_imported(不再入库); + 行级再以 (entity_id, voucher_no, subject_code, debit, credit, summary, period) 防重复。 + 7. entity_id:默认 1(陕西酣客,source=zxt-sxzxb20210228),Bot 通道可传 X-Entity-Id 或 body entity_id。 + 8. 入库后不触发现金流/ledger 联动(凭证完整性未证实前不作为权威来源,见财务口径警示); + 也不影响 Excel multipart 导入(原端点不动)。 +""" +import hashlib +import json +import logging +import re +from datetime import datetime +from typing import Any, Optional, Union + +from fastapi import APIRouter, Body, Depends, HTTPException, Request +from sqlalchemy import text as sa_text +from sqlalchemy.orm import Session + +from app.database import get_db +from app.deps import resolve_entity_for_request +from app.models import Entity, ImportLog, VoucherDetail +from app.api.bot_bridge import verify_bot_key + +logger = logging.getLogger("cma.cash_zxt") + +router = APIRouter(prefix="/api/cma/cash", tags=["资金管理-智享通JSON导入"]) + +_DATE_RE = re.compile(r"(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})") +_AMOUNT_RE = re.compile(r"^-?\d[\d,]*\.?\d*$") + + +def _norm_str(raw) -> str: + """干净字符串:None/空 → ''""" + if raw is None: + return "" + return str(raw).strip() + + +def _parse_amount(raw) -> Optional[float]: + """金额:字符串数字(可带逗号)→ float;空/None → 0.0;非法 → None""" + if raw is None: + return 0.0 + s = str(raw).strip() + if s == "": + return 0.0 + if not _AMOUNT_RE.match(s): + return None + try: + return round(float(s.replace(",", "")), 2) + except Exception: + return None + + +def _parse_date_from_summary(summary: str): + """summary 前缀提取日期 (YYYY-MM-DD)。zxt 格式='业务类型-YYYY-MM-DD-序号_往来_业务员'""" + m = _DATE_RE.search(summary or "") + if not m: + return None + try: + return datetime(int(m.group(1)), int(m.group(2)), int(m.group(3))) + except ValueError: + return None + + +def _is_carry_forward(summary: str) -> bool: + """结转行识别(subject_name 缺失只能靠 summary):zxt 期间损益结转 100% 命中""" + return "结转" in (summary or "") + + +def _content_fingerprint(rows: list) -> str: + """行集内容指纹:每行规范化 JSON 排序后 sha1 前 10 位(快照幂等用)""" + canon = json.dumps(rows, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha1(canon.encode("utf-8")).hexdigest()[:10] + + +def _load_zxt_account_dict(db: Session) -> dict: + """读 DB 科目字典表(若已建):zxt_account_dict(CwAccountID, subject_code, subject_name)。 + 表不存在 → 返回空 dict(由调用方决定走请求体 subject_map 或拒收)""" + try: + rows = db.execute( + sa_text("SELECT CwAccountID, subject_code, subject_name FROM zxt_account_dict") + ).fetchall() + return { + str(r[0]): {"subject_code": str(r[1]), "subject_name": str(r[2])} + for r in rows if r[1] + } + except Exception: + return {} + + +def _resolve_subject(row: dict, db_account_dict: dict, req_subject_map: dict): + """科目解析优先级:行内 subject_code → DB 字典 → 请求体 subject_map。返回 (code, name) 或 None""" + code = _norm_str(row.get("subject_code")) + name = _norm_str(row.get("subject_name")) + if code: + return code, name + cw_id = _norm_str(row.get("CwAccountID")) + if not cw_id: + return None + hit = db_account_dict.get(cw_id) or req_subject_map.get(cw_id) + if not hit: + return None + c = _norm_str(hit.get("subject_code")) if isinstance(hit, dict) else "" + if not c: + return None + n = _norm_str(hit.get("subject_name")) if isinstance(hit, dict) else "" + return c, n + + +def _entity_active(db: Session, entity_id: int) -> bool: + try: + ent = db.query(Entity).filter(Entity.id == entity_id).first() + return bool(ent and ent.status == "active") + except Exception: + return entity_id == 1 # Entity 模型不可用兜底(正常不会走到) + + +@router.post("/import/vouchers-json") +def api_import_vouchers_json( + request: Request, + payload: Union[dict, list] = Body(...), + bot: dict = Depends(verify_bot_key), + db: Session = Depends(get_db), +): + """JSON 直传凭证导入(智享通推送通道)。三种 body 形态均兼容: + A. 裸 rows 数组: [{Voucher_ID, CwAccountID, summary, debit, credit, ...}] + B. {source?, rows: [...]} + C. 完整落盘包: {schema, source, datasets: [{dataset, mode, rows}]} → 取 datasets[0].rows + 附加字段(形态 B/C): entity_id(默认1) / subject_map({CwAccountID: {subject_code, subject_name}}) + """ + # ── body 形态归一 → rows ── + rows_raw: list = [] + source = "" + req_entity = None + req_subject_map: dict = {} + if isinstance(payload, list): + rows_raw = payload + elif isinstance(payload, dict): + if "datasets" in payload and isinstance(payload.get("datasets"), list): + ds = payload["datasets"] + if not ds or not isinstance(ds[0], dict): + raise HTTPException(400, "datasets 结构不符: 缺少 datasets[0]") + rows_raw = ds[0].get("rows") or [] + source = _norm_str(payload.get("source")) + elif "rows" in payload: + rows_raw = payload.get("rows") or [] + source = _norm_str(payload.get("source")) + else: + raise HTTPException(400, "无法识别的请求体: 期望 rows 数组 / {rows:[...]} / {datasets:[{rows:[...]}]}") + req_entity = payload.get("entity_id") + if isinstance(payload.get("subject_map"), dict): + req_subject_map = payload["subject_map"] + else: + raise HTTPException(400, "请求体必须为 JSON 对象或数组") + + if not isinstance(rows_raw, list) or not rows_raw: + raise HTTPException(400, "rows 为空或非数组") + + # 兼容 row key 大小写(智享通字段为大写 Voucher_ID/CwAccountID) + norm_rows = [] + for i, r in enumerate(rows_raw): + if not isinstance(r, dict): + raise HTTPException(400, f"rows[{i}] 不是JSON对象") + nr = {} + for k, v in r.items(): + nr[k] = v + if k == "Voucher_ID": + nr["voucher_no"] = v + elif k == "CwAccountID": + nr["CwAccountID"] = v + norm_rows.append(nr) + + # ── entity 解析 + active 校验 ── + entity_id = resolve_entity_for_request(request, req_entity if req_entity not in (None, "") else 1) + try: + entity_id = int(entity_id) + except Exception: + entity_id = 1 + if not _entity_active(db, entity_id): + raise HTTPException(403, f"企业ID={entity_id} 不存在或未激活") + + db_account_dict = _load_zxt_account_dict(db) + + # ── 行级解析 + 校验 ── + errors = [] + parsed = [] + pending_undated = 0 + pending_dict = 0 + for idx, raw in enumerate(norm_rows): + voucher_no = _norm_str(raw.get("voucher_no") or raw.get("Voucher_ID")) + if not voucher_no: + errors.append({"row": idx + 1, "field": "voucher_no", "reason": "凭证号为空"}) + continue + + date_obj = _parse_date_from_summary(_norm_str(raw.get("summary"))) + if date_obj is None: + pending_undated += 1 + errors.append({"row": idx + 1, "field": "voucher_date", + "reason": f"summary无日期(待日期来源): {_norm_str(raw.get('summary'))[:40]}"}) + continue + + debit = _parse_amount(raw.get("debit")) + credit = _parse_amount(raw.get("credit")) + if debit is None: + errors.append({"row": idx + 1, "field": "debit", "reason": f"借方金额不是有效数字: {raw.get('debit')}"}) + continue + if credit is None: + errors.append({"row": idx + 1, "field": "credit", "reason": f"贷方金额不是有效数字: {raw.get('credit')}"}) + continue + if debit < 0 or credit < 0: + errors.append({"row": idx + 1, "field": "amount", "reason": "金额不能为负"}) + continue + if debit == 0 and credit == 0: + errors.append({"row": idx + 1, "field": "amount", "reason": "借贷金额不能同时为0"}) + continue + + summary = _norm_str(raw.get("summary")) + subject = _resolve_subject(raw, db_account_dict, req_subject_map) + if subject is None: + pending_dict += 1 + errors.append({"row": idx + 1, "field": "subject_code", + "reason": f"科目字典未命中 CwAccountID={_norm_str(raw.get('CwAccountID')) or '空'}"}) + continue + + parsed.append({ + "voucher_no": voucher_no, + "date": date_obj, + "period": date_obj.strftime("%Y-%m"), + "debit_amount": debit, + "credit_amount": credit, + "summary": summary, + "carry_forward": 1 if _is_carry_forward(summary) else 0, + "subject_code": subject[0], + "subject_name": subject[1], + }) + + total = len(rows_raw) + if not parsed: + db.rollback() + return { + "success": False, + "message": "无有效行可入库(科目字典未建立/日期缺失/行级校验全失败),全部拒绝", + "total": total, + "success_rows": 0, + "failed_rows": len(errors), + "errors": errors[:100], + "balance_check": None, + "carry_forward_count": 0, + "batch": None, + "entity_id": entity_id, + "pending_undated": pending_undated, + "pending_dict_miss": pending_dict, + "hint": "科目字典:请协调智享通推送方补 subject_code/subject_name,或在 body 带 subject_map,或建 zxt_account_dict 表", + } + + # ── 凭证级借贷平衡(每 Voucher_ID Σ借=Σ贷 容差0.01)── + balance_by_voucher = {} + for r in parsed: + bv = balance_by_voucher.setdefault(r["voucher_no"], {"debit_total": 0.0, "credit_total": 0.0}) + bv["debit_total"] = round(bv["debit_total"] + r["debit_amount"], 2) + bv["credit_total"] = round(bv["credit_total"] + r["credit_amount"], 2) + abnormal_vouchers = [] + voucher_pass = set() + for vno, bv in balance_by_voucher.items(): + diff = round(bv["debit_total"] - bv["credit_total"], 2) + if abs(diff) > 0.01: + abnormal_vouchers.append({ + "voucher_no": vno, + "debit_total": bv["debit_total"], + "credit_total": bv["credit_total"], + "diff": diff, + }) + else: + voucher_pass.add(vno) + + if abnormal_vouchers: + abnormal_set = {a["voucher_no"] for a in abnormal_vouchers} + kept = [] + for r in parsed: + if r["voucher_no"] in abnormal_set: + errors.append({"row": 0, "field": "balance", + "reason": f"凭证 {r['voucher_no']} 借贷不平衡(整张凭证拒绝入库)"}) + else: + kept.append(r) + parsed = kept + + valid_rows = [r for r in parsed if r["voucher_no"] in voucher_pass] + success = len(valid_rows) + failed = len(errors) + + # ── 幂等:batch 指纹去重 ── + fingerprint = _content_fingerprint(rows_raw) + batch = f"zxt-{datetime.now().strftime('%Y%m%d%H%M%S')}-{fingerprint}" + existing_batch = db.query(ImportLog).filter( + ImportLog.batch == batch, + ImportLog.import_type == "vouchers-json", + ).first() + if existing_batch: + db.rollback() + return { + "success": True, + "already_imported": True, + "message": "该快照内容已导入过(同一指纹 batch),跳过重复入库", + "total": total, + "success_rows": 0, + "failed_rows": 0, + "batch": batch, + "entity_id": entity_id, + } + + # ── 入库(行级业务键防重复)── + inserted = 0 + dup_skipped = 0 + for r in valid_rows: + dup = db.query(VoucherDetail).filter( + VoucherDetail.entity_id == entity_id, + VoucherDetail.voucher_no == r["voucher_no"], + VoucherDetail.subject_code == r["subject_code"], + VoucherDetail.debit_amount == r["debit_amount"], + VoucherDetail.credit_amount == r["credit_amount"], + VoucherDetail.summary == r["summary"], + VoucherDetail.period == r["period"], + ).first() + if dup: + dup_skipped += 1 + continue + db.add(VoucherDetail( + entity_id=entity_id, + voucher_no=r["voucher_no"], + voucher_date=r["date"], + subject_code=r["subject_code"], + subject_name=r["subject_name"], + debit_amount=r["debit_amount"], + credit_amount=r["credit_amount"], + summary=r["summary"], + carry_forward=r["carry_forward"], + period=r["period"], + batch=batch, + )) + inserted += 1 + + period_totals = {} + for r in valid_rows: + pt = period_totals.setdefault(r["period"], {"debit_total": 0.0, "credit_total": 0.0}) + pt["debit_total"] = round(pt["debit_total"] + r["debit_amount"], 2) + pt["credit_total"] = round(pt["credit_total"] + r["credit_amount"], 2) + + carry_forward_count = sum(1 for r in valid_rows if r["carry_forward"]) + db.add(ImportLog( + entity_id=entity_id, + filename=f"zxt-push-voucher-snapshot.json" + (f"({source})" if source else ""), + batch=batch, + total_rows=total, + success_rows=inserted, + failed_rows=failed, + errors=errors[:100] or None, + period=valid_rows[0]["period"] if valid_rows else None, + import_type="vouchers-json", + created_by="zxt-cma-import-hook", + )) + db.commit() + logger.info( + f"智享通凭证JSON导入[{batch}] entity={entity_id}: 总{total}/入库{inserted}/行级失败{failed}/重复跳过{dup_skipped}, " + f"凭证级异常{len(abnormal_vouchers)}" + ) + + return { + "success": True, + "total": total, + "success_rows": inserted, + "failed_rows": failed, + "dup_skipped": dup_skipped, + "errors": errors[:100] or None, + "balance_check": { + "mode": "voucher_level", + "passed": len(abnormal_vouchers) == 0, + "abnormal_vouchers": abnormal_vouchers, + "tolerance": 0.01, + }, + "period_totals": period_totals, + "carry_forward_count": carry_forward_count, + "pending_undated": pending_undated, + "pending_dict_miss": pending_dict, + "batch": batch, + "entity_id": entity_id, + } diff --git a/backend/app/main.py b/backend/app/main.py index 51dbd373..c735b1de 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from dotenv import load_dotenv from app.database import init_db -from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure +from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json from app.utils.cache import clear_all as clear_cache, delete as delete_cache from scripts.erp_sync import run_sync as run_erp_sync from app.auth_middleware import require_auth @@ -92,6 +92,7 @@ app.include_router(bot_iron_law.router) app.include_router(analysis_results.router) app.include_router(expenses.router) app.include_router(cash.router) +app.include_router(cash_vouchers_json.router) app.include_router(data_classification.router) app.include_router(tax_compliance.router) app.include_router(verify.router) diff --git a/backend/scripts/verify_zxt_vouchers_json.py b/backend/scripts/verify_zxt_vouchers_json.py new file mode 100644 index 00000000..6e8a9291 --- /dev/null +++ b/backend/scripts/verify_zxt_vouchers_json.py @@ -0,0 +1,126 @@ +"""zxt vouchers-json 端点验收测试(本地 SQLite,不动生产库)""" +import os +os.environ.setdefault("CMA_DB_USER", "test") +os.environ.setdefault("CMA_DB_PASS", "test") +os.environ.setdefault("CMA_DB_HOST", "localhost") +os.environ.setdefault("CMA_DB_PORT", "3306") +os.environ.setdefault("CMA_DB_NAME", "test") + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app import database as db_module +from app.database import Base, get_db + +TEST_ENGINE = create_engine( + "sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool +) +TEST_SESSION_LOCAL = sessionmaker(autocommit=False, autoflush=False, bind=TEST_ENGINE) +db_module._engine = TEST_ENGINE +db_module._SessionLocal = TEST_SESSION_LOCAL +TEST_ENGINE2 = TEST_ENGINE +TEST_SESSION2 = TEST_SESSION_LOCAL + +# 建表 +from app import models # noqa +from app.models import Entity, VoucherDetail, ImportLog +Base.metadata.create_all(bind=TEST_ENGINE) + +# seed entity 1 (active) +s = TEST_SESSION_LOCAL() +ent = Entity(id=1, name="陕西酣客文化传媒", status="active") +s.add(ent) +s.commit() +s.close() + +from app.main import app + +def override_get_db(): + db = TEST_SESSION_LOCAL() + try: + yield db + finally: + db.close() + +app.dependency_overrides[get_db] = override_get_db +client = TestClient(app) + +HDR = {"X-BOT-KEY": "cma-bot-admin-2026"} + +def check(name, cond, detail=""): + print(("PASS " if cond else "FAIL ") + name + (" | " + detail if detail else "")) + return cond + +results = [] + +# ── 用例1: 带 subject_map 的正常批次入库 ── +rows_ok = [ + {"Voucher_ID": "31000", "CwAccountID": "1001", "summary": "XSD-2026-06-30-00001_客户A_张三", "debit": "1000.00", "credit": "0.00"}, + {"Voucher_ID": "31000", "CwAccountID": "6001", "summary": "XSD-2026-06-30-00001_客户A_张三", "debit": "0.00", "credit": "1000.00"}, +] +payload = {"rows": rows_ok, "subject_map": { + "1001": {"subject_code": "1001", "subject_name": "库存现金"}, + "6001": {"subject_code": "6001", "subject_name": "主营业务收入"}, +}} +r = client.post("/api/cma/cash/import/vouchers-json", json=payload, headers=HDR) +results.append(check("用例1 正常批次 HTTP200", r.status_code == 200, f"status={r.status_code} body={r.text[:200]}")) +j = r.json() +results.append(check("用例1 success_rows=2", j.get("success_rows") == 2, f"got {j.get('success_rows')} errors={j.get('errors')}")) +results.append(check("用例1 balance passed", j.get("balance_check", {}).get("passed") is True)) +batch1 = j.get("batch") + +# ── 用例2: 凭证级不平衡被拒(30694 单边场景)── +rows_unbal = [ + {"Voucher_ID": "30694", "CwAccountID": "6001", "summary": "XSD-2026-04-14-00001_客户X_李四", "debit": "0.00", "credit": "5479.65"}, +] +payload2 = {"rows": rows_unbal, "subject_map": {"6001": {"subject_code": "6001", "subject_name": "主营业务收入"}}} +r2 = client.post("/api/cma/cash/import/vouchers-json", json=payload2, headers=HDR) +j2 = r2.json() +results.append(check("用例2 不平凭证 HTTP200且success_rows=0", r2.status_code == 200 and j2.get("success_rows") == 0, f"body={r2.text[:200]}")) +results.append(check("用例2 abnormal_vouchers 含30694", any(a.get("voucher_no") == "30694" for a in (j2.get("balance_check") or {}).get("abnormal_vouchers", [])), r2.text[:200])) + +# ── 用例3: 科目字典未命中 → 拒收提示(无 subject_map 时 2000行全 null 场景)── +rows_nodict = [ + {"Voucher_ID": "31001", "CwAccountID": "401", "summary": "期间损益结转", "debit": "0.00", "credit": "35414.00"}, +] +r3 = client.post("/api/cma/cash/import/vouchers-json", json={"rows": rows_nodict}, headers=HDR) +j3 = r3.json() +results.append(check("用例3 字典未命中 拒收0行+提示", r3.status_code == 200 and j3.get("success_rows") == 0 and "科目字典" in (j3.get("hint") or ""), f"body={r3.text[:250]}")) + +# ── 用例4: 幂等 — 同内容重复POST不重复入库 ── +r4 = client.post("/api/cma/cash/import/vouchers-json", json=payload, headers=HDR) +j4 = r4.json() +results.append(check("用例4 同指纹重复导入 already_imported", r4.status_code == 200 and j4.get("already_imported") is True, f"body={r4.text[:200]}")) + +# ── 用例5: 无 X-BOT-KEY → 401 ── +r5 = client.post("/api/cma/cash/import/vouchers-json", json=payload) +results.append(check("用例5 无X-BOT-KEY 401", r5.status_code == 401, f"status={r5.status_code}")) + +# ── 用例6: DB 回查 voucher_details ── +s = TEST_SESSION_LOCAL() +cnt = s.query(VoucherDetail).filter(VoucherDetail.batch == batch1).count() +results.append(check("用例6 DB回查 batch行数=2", cnt == 2, f"cnt={cnt}")) +il = s.query(ImportLog).filter(ImportLog.batch == batch1, ImportLog.import_type == "vouchers-json").count() +results.append(check("用例6b import_logs 有记录", il == 1, f"cnt={il}")) +s.close() + +# ── 用例7: 期间损益结转行(summary无日期)→ 拒收记 pending_undated ── +rows_undated = [ + {"Voucher_ID": "31454", "CwAccountID": "401", "summary": "期间损益结转", "debit": "0.00", "credit": "35414.00"}, +] +r7 = client.post("/api/cma/cash/import/vouchers-json", json={"rows": rows_undated, "subject_map": {"401": {"subject_code": "4103", "subject_name": "本年利润"}}}, headers=HDR) +j7 = r7.json() +results.append(check("用例7 无日期行拒收+计数", r7.status_code == 200 and j7.get("success_rows") == 0 and j7.get("pending_undated", 0) >= 1, f"body={r7.text[:200]}")) + +# ── 用例8: 现有 Excel 端点 /import/template 仍可用(回归)── +r8 = client.get("/api/cma/cash/import/template") +results.append(check("用例8 Excel模板端点回归 有鉴权响应", r8.status_code in (200, 403, 401), f"status={r8.status_code}(403=需用户token,原Excel端点未受影响)")) + +fails = [x for x in results if not x] +print(f"\n==== {len(results)-len(fails)}/{len(results)} PASS ====") +if fails: + print("FAILED CASES PRESENT") + raise SystemExit(1) +print("ALL PASS")