#!/usr/bin/env python3 # -*- coding: utf-8 -*- """资金缺口复算对拍 — G1 验收件(P0-A) 任务:fullstack-20261017-warning-capability-build-p0(上游 yanxue-20261017-warning-capability-build-p0) 方案:`_reports/yanxue-20261017-warning-capability-build-p0.md` §G1 + §四「数字零错误(可复算)」 本脚本做三件事,全部机器可判: 1. **同输入双跑逐字段 diff=0**:固定输入(内存 SQLite 夹具 + 固定锚点日期)跑两遍 `forecast_cash_flow_with_plans`,把两次结果整体 JSON 归一后逐字段比对; 再用「独立重建的同一夹具」跑第三遍,证明无隐藏状态(跨会话也一致)。 2. **手算 3/3 案例对拍**:3 个案例的每日余额由人工算式预先算出并**写死为常量** (不调用引擎反推期望值),逐字段断言:每日余额、缺口日、缺口金额、红黄绿、最小余额、 预警点、阈值来源。 3. **端点逐字段一致**(`--live`):脚本内直接调引擎的结果 vs 运行中服务的 `GET /api/cma/cash/gap-forecast` 响应,逐字段比对(含 threshold_source)。 手算口径(与引擎实现逐条对应,锚点固定 2026-10-05 周一 09:00): 锚点 today = 2026-10-05;offset 1..N 依次是其后 N 天。 无 KPI 历史 → ocf_avg 取引擎兜底 0.5(万元/日),趋势 0;无预算 → budget_ocf=None。 每日基础增量 = 0.5;`day>=25` 且无月底窗口(本例日期均在 10-06..10-15);周末 ×0.5。 叠加收付款计划后:cash_i = cash_{i-1} + base_delta_i + planned_in_i - planned_out_i(逐步 round 2)。 用法: 夹具复算(默认,不连生产): python3 backend/scripts/verify_cash_gap_replay.py 端点对拍(需服务在线,默认 127.0.0.1:8010,读 .secrets 登录): python3 backend/scripts/verify_cash_gap_replay.py --live --base-url http://127.0.0.1:8010 --entity 1 退出码:0 = 全部 PASS(diff=0 且 3/3 案例 PASS,live 模式下端点逐字段一致);1 = 有 FAIL """ from __future__ import annotations import argparse import json import os import sys import urllib.request from datetime import datetime as _RealDatetime BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if BACKEND_DIR not in sys.path: sys.path.insert(0, BACKEND_DIR) from sqlalchemy import create_engine # noqa: E402 from sqlalchemy.orm import sessionmaker # noqa: E402 import app.models as models # noqa: E402 from app.api.alert_rules import AlertRule # noqa: E402 from app.utils import cash_forecast_engine as engine # noqa: E402 from app.utils.cash_thresholds import ( # noqa: E402 CASH_GAP_RULE_TYPE, DEFAULT_CRITICAL, DEFAULT_WARNING, ) # ── 固定锚点(2026-10-05 周一,保证手算窗口内无周末/无月底)──────────────────── FIXED_NOW = _RealDatetime(2026, 10, 5, 9, 0, 0) ENTITY = 1 FIXTURE_TABLES = None class FixedDatetime(_RealDatetime): """把引擎里的 datetime.now() 钉死在 FIXED_NOW,保证复算可重现。""" @classmethod def now(cls, tz=None): # noqa: D401 return FIXED_NOW def build_fixture_session(with_config=None, cash_plans=None, budget=None): """内存 SQLite 夹具:建最小表集,按需插入收付款计划 / 阈值配置 / 预算。""" from sqlalchemy.pool import StaticPool global FIXTURE_TABLES if FIXTURE_TABLES is None: FIXTURE_TABLES = [ models.KPIDefinition.__table__, models.KPIValue.__table__, models.SystemConfig.__table__, models.CashPlan.__table__, models.BudgetPlan.__table__, AlertRule.__table__, ] eng = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) models.Base.metadata.create_all(eng, tables=FIXTURE_TABLES) db = sessionmaker(bind=eng)() for plan in (cash_plans or []): db.add(models.CashPlan(**plan)) if with_config: db.add(AlertRule(entity_id=ENTITY, kpi_id=0, rule_type=CASH_GAP_RULE_TYPE, trigger_on="actual", enabled=1, params=with_config)) if budget: # 预算校准会走 KPI 查找;夹具无 KPI → _budget_monthly_ocf 恒 None(保持手算口径) pass db.commit() return db def normalise(obj): """JSON 归一(Decimal/日期 → 字符串,浮点保持原值),用于逐字段严格比对。""" return json.loads(json.dumps(obj, ensure_ascii=False, default=str)) def deep_diff(left, right, path="root"): """返回逐字段差异列表;空列表 = diff=0。""" diffs = [] if type(left) is not type(right): return [{"path": path, "left": left, "right": right, "kind": "type"}] if isinstance(left, dict): for key in sorted(set(left) | set(right)): if key not in left: diffs.append({"path": f"{path}.{key}", "left": "", "right": right[key], "kind": "missing_left"}) elif key not in right: diffs.append({"path": f"{path}.{key}", "left": left[key], "right": "", "kind": "missing_right"}) else: diffs.extend(deep_diff(left[key], right[key], f"{path}.{key}")) return diffs if isinstance(left, list): if len(left) != len(right): diffs.append({"path": path + ".len", "left": len(left), "right": len(right), "kind": "length"}) for idx, (a, b) in enumerate(zip(left, right)): diffs.extend(deep_diff(a, b, f"{path}[{idx}]")) return diffs if left != right: diffs.append({"path": path, "left": left, "right": right, "kind": "value"}) return diffs # ══════════════════════════════════════════════════════════════════════ # 手算案例(期望值写死;算式见各案例注释,禁止用引擎反推) # ══════════════════════════════════════════════════════════════════════ HAND_CASES = [ { "name": "H1 无计划·全绿", "input": {"current_cash": 30.0, "days": 10}, "plans": [], "config": None, # 手算:cash_i = 30 + 0.5*i(工作日);10-10 周六 / 10-11 周日 增量 ×0.5 = 0.25 # 30 → 30.5 → 31.0 → 31.5 → 32.0 →(六) 32.25 →(日) 32.5 → 33.0 → 33.5 → 34.0 → 34.5 "expect": { "warning_line": 20.0, "critical_line": 10.0, "threshold_source": "default", "predicted_cash": [30.5, 31.0, 31.5, 32.0, 32.25, 32.5, 33.0, 33.5, 34.0, 34.5], "alert_status": ["green"] * 10, "gap_days": 0, "gap_dates": [], "min_cash": 30.5, "min_cash_date": "2026-10-06", "pre_alerts": 0, "planned_out_total": 0.0, }, }, { "name": "H2 单笔付款·缺口前1天预警", "input": {"current_cash": 21.0, "days": 4}, "plans": [ # 10-08(Thu)对外付款 4.0 万 {"entity_id": ENTITY, "plan_type": "pay", "amount": 4.0, "status": "pending", "plan_date": _RealDatetime(2026, 10, 8, 0, 0, 0), "counterparty": "手算案例供应商", "description": "FIXTURE-H2", "source": "fixture"}, ], "config": None, # 手算:21 → +0.5 = 21.5 → +0.5 = 22.0 → +0.5 -4.0 = 18.5 → +0.5 = 19.0 # 缺口日 = 余额 < 20 → 10-08(18.5)、10-09(19.0),缺额 1.5 / 1.0 # 缺口区间首日 idx=2 → 前1天预警落在 idx=1(10-07,余额 22.0 为 green) "expect": { "warning_line": 20.0, "critical_line": 10.0, "threshold_source": "default", "predicted_cash": [21.5, 22.0, 18.5, 19.0], "alert_status": ["green", "green", "yellow", "yellow"], "gap_days": 2, "gap_dates": [ {"date": "2026-10-08", "predicted_cash": 18.5, "gap_amount": 1.5, "level": "yellow"}, {"date": "2026-10-09", "predicted_cash": 19.0, "gap_amount": 1.0, "level": "yellow"}, ], "min_cash": 18.5, "min_cash_date": "2026-10-08", "pre_alerts": 1, "pre_alert_0": {"alert_date": "2026-10-07", "alert_offset": 2, "gap_date": "2026-10-08", "gap_cash": 18.5, "gap_amount": 1.5, "worst_cash": 18.5, "worst_date": "2026-10-08", "lead_days": 1, "level": "yellow"}, "planned_out_total": 4.0, }, }, { "name": "H3 配置阈值 25/15·全红", "input": {"current_cash": 8.0, "days": 5}, "plans": [], # alert_rules: cash_gap_threshold {"warning":25.0,"critical":15.0} "config": {"warning": 25.0, "critical": 15.0}, # 手算:8 → 8.5 → 9.0 → 9.5 → 10.0 →(六) 10.25,全部 < 15(红灯) # 缺额(相对黄灯线 25):16.5 / 16.0 / 15.5 / 15.0 / 14.75 "expect": { "warning_line": 25.0, "critical_line": 15.0, "threshold_source": "alert_rules", "predicted_cash": [8.5, 9.0, 9.5, 10.0, 10.25], "alert_status": ["red"] * 5, "gap_days": 5, "gap_dates_first": {"date": "2026-10-06", "predicted_cash": 8.5, "gap_amount": 16.5, "level": "red"}, "min_cash": 8.5, "min_cash_date": "2026-10-06", "pre_alerts": 0, # 缺口从第一天开始 → 无「缺口前1天」可落 "planned_out_total": 0.0, }, }, ] def run_case(case): db = build_fixture_session(with_config=case.get("config"), cash_plans=case.get("plans")) try: result = engine.forecast_cash_flow_with_plans( ENTITY, db, days=case["input"]["days"], current_cash=case["input"]["current_cash"]) finally: db.close() return normalise(result) def assert_case(case, result, failures): exp = case["expect"] name = case["name"] checks = [ ("warning_line", result.get("warning_line"), exp["warning_line"]), ("critical_line", result.get("critical_line"), exp["critical_line"]), ("threshold_source.source", (result.get("threshold_source") or {}).get("source"), exp["threshold_source"]), ("min_cash", result.get("min_cash"), exp["min_cash"]), ("min_cash_date", result.get("min_cash_date"), exp["min_cash_date"]), ("gap_days", len(result.get("gap_dates") or []), exp["gap_days"]), ("pre_alerts", len(result.get("pre_alerts") or []), exp["pre_alerts"]), ("predicted_cash", [row["predicted_cash"] for row in result.get("forecast", [])], exp["predicted_cash"]), ("alert_status", [row["alert_status"] for row in result.get("forecast", [])], exp["alert_status"]), ("summary.total_planned_out", round((result.get("summary") or {}).get("total_planned_out") or 0.0, 2), exp["planned_out_total"]), ] if "gap_dates" in exp: checks.append(("gap_dates", [ {k: g[k] for k in ("date", "predicted_cash", "gap_amount", "level")} for g in (result.get("gap_dates") or []) ], exp["gap_dates"])) if "gap_dates_first" in exp: first = (result.get("gap_dates") or [{}])[0] checks.append(("gap_dates[0]", {k: first.get(k) for k in ("date", "predicted_cash", "gap_amount", "level")}, exp["gap_dates_first"])) if "pre_alert_0" in exp: first = (result.get("pre_alerts") or [{}])[0] checks.append(("pre_alerts[0]", {k: first.get(k) for k in exp["pre_alert_0"].keys()}, exp["pre_alert_0"])) for label, got, want in checks: if got != want: failures.append({"case": name, "check": label, "expected": want, "got": got}) print(" FAIL %-8s %-28s expected=%r got=%r" % (name.split()[0], label, want, got)) else: print(" PASS %-8s %-28s = %r" % (name.split()[0], label, got)) def assert_invalid_config_falls_back(failures): """非法配置(critical > warning)→ 必须回落默认 20/10(配置写坏不能静默生效)。""" db = build_fixture_session(with_config={"warning": 10.0, "critical": 20.0}) try: got = engine.get_cash_gap_thresholds(db, ENTITY) finally: db.close() if got != (DEFAULT_WARNING, DEFAULT_CRITICAL): failures.append({"case": "GUARD", "check": "invalid_config_fallback", "expected": [DEFAULT_WARNING, DEFAULT_CRITICAL], "got": list(got)}) print(" FAIL GUARD invalid config must fall back to defaults, got=%r" % (got,)) else: print(" PASS GUARD invalid config → default (%.1f, %.1f)" % got) def http_json(base_url, path, method="GET", payload=None, token=None, timeout=30): headers = {} if token: headers["Authorization"] = "Bearer " + token data = json.dumps(payload).encode() if payload is not None else None if data: headers["Content-Type"] = "application/json" req = urllib.request.Request(base_url.rstrip("/") + path, data=data, headers=headers, method=method) with urllib.request.urlopen(req, timeout=timeout) as resp: return json.loads(resp.read().decode()) def _dotenv(path): env = {} with open(path, encoding="utf-8") as fh: for line in fh: line = line.strip() if line and "=" in line and not line.startswith("#"): key, val = line.split("=", 1) env[key.strip()] = val.strip().strip('"').strip("'") return env def live_compare(base_url, entity_id, days, failures): """脚本内引擎结果 vs 在线端点响应(逐字段,含 threshold_source)。""" # 夹具阶段把 now() 钉死在 FIXED_NOW;对拍真实端点必须还原真实时钟, # 否则日期序列整体错位(脚本 2026-10-06 vs 服务 2026-10-18)。 engine.datetime = _RealDatetime reader_env = os.environ.get("CMA_READER_ENV", "/root/.hermes/profiles/wecom-fullstack/.secrets/cma-reader.env") admin_env = os.environ.get("CMA_ADMIN_ENV", "/root/.hermes/profiles/wecom-fullstack/.secrets/cma-admin.env") rd = _dotenv(reader_env) url = ("mysql+pymysql://%s:%s@%s:%s/%s?charset=utf8mb4" % (rd["CMA_READER_DB_USER"], rd["CMA_READER_DB_PASS"], rd["CMA_READER_DB_HOST"], rd["CMA_READER_DB_PORT"], rd["CMA_READER_DB_NAME"])) eng = create_engine(url, pool_pre_ping=True) db = sessionmaker(bind=eng)() try: script_side = normalise(engine.forecast_cash_flow_with_plans(entity_id, db, days=days)) finally: db.close() adm = _dotenv(admin_env) login = http_json(base_url, "/api/cma/auth/login", "POST", {"username": adm.get("CMA_ADMIN_USER"), "password": adm.get("CMA_ADMIN_PASS"), "entity_id": entity_id}) token = login.get("token") if not token: failures.append({"case": "LIVE", "check": "login", "got": login}) print(" FAIL LIVE login 未拿到 token") return api_side = normalise(http_json(base_url, "/api/cma/cash/gap-forecast?days=%d" % days, token=token)) diffs = deep_diff(script_side, api_side, path="gap-forecast") print(" %s LIVE 引擎 vs 端点 /api/cma/cash/gap-forecast diff=%d" % ("PASS" if not diffs else "FAIL", len(diffs))) for d in diffs[:20]: print(" %(path)s script=%(left)r api=%(right)r" % d) if diffs: failures.append({"case": "LIVE", "check": "endpoint_field_parity", "diffs": diffs[:20], "diff_count": len(diffs)}) def main(): ap = argparse.ArgumentParser() ap.add_argument("--live", action="store_true", help="对拍运行中服务端点") ap.add_argument("--base-url", default="http://127.0.0.1:8010") ap.add_argument("--entity", type=int, default=ENTITY) ap.add_argument("--days", type=int, default=30) ap.add_argument("--json-out", default="") args = ap.parse_args() failures = [] summary = {"fixed_now": FIXED_NOW.isoformat(), "entity_id": args.entity, "cases": []} # 固定锚点 engine.datetime = FixedDatetime print("== 1. 同输入双跑逐字段 diff=0 ==") for case in HAND_CASES: first = run_case(case) second = run_case(case) third = run_case(case) # 独立重建夹具,验证无隐藏状态 diffs_12 = deep_diff(first, second, path="run1_vs_run2") diffs_13 = deep_diff(first, third, path="run1_vs_run3") print(" %s %-24s diff(run1,run2)=%d diff(run1,run3)=%d" % ("PASS" if not (diffs_12 or diffs_13) else "FAIL", case["name"], len(diffs_12), len(diffs_13))) for d in (diffs_12 + diffs_13)[:10]: print(" %(path)s left=%(left)r right=%(right)r" % d) if diffs_12 or diffs_13: failures.append({"case": case["name"], "check": "double_run_diff", "diffs": (diffs_12 + diffs_13)[:10]}) summary["cases"].append({"case": case["name"], "diff_run2": len(diffs_12), "diff_run3": len(diffs_13)}) print("== 2. 手算案例对拍 ==") for case in HAND_CASES: assert_case(case, run_case(case), failures) print("== 3. 配置健壮性 ==") assert_invalid_config_falls_back(failures) if args.live: print("== 4. 端点逐字段一致(--live)==") live_compare(args.base_url, args.entity, args.days, failures) passed = 3 if not failures else 0 print("=" * 60) print("RESULT: %s hand_calc=%d/%d failures=%d" % ("PASS" if not failures else "FAIL", passed, len(HAND_CASES), len(failures))) if failures: for f in failures[:5]: print(" ", json.dumps(f, ensure_ascii=False)[:400]) if args.json_out: payload = {"task": "fullstack-20261017-warning-capability-build-p0", "stage": "G1 cash gap replay", "result": "PASS" if not failures else "FAIL", "fixed_now": FIXED_NOW.isoformat(), "entity_id": args.entity, "hand_cases_passed": passed, "hand_cases_total": len(HAND_CASES), "failures": failures, "summary": summary} with open(args.json_out, "w", encoding="utf-8") as fh: fh.write(json.dumps(payload, ensure_ascii=False, indent=2, default=str) + "\n") print("json evidence: %s" % args.json_out) return 0 if not failures else 1 if __name__ == "__main__": sys.exit(main())