# -*- coding: utf-8 -*- """ 财务看板 DWS 区域维度重构 — 144 组合全量验证脚本。 遍历 8 time_range × 9 area_code × 2 compare = 144 种组合,验证后端 API 返回数据。 产出物:export/board-finance-validation.md Requirements: 9.1, 9.2, 9.3, 9.4 """ from __future__ import annotations import json import sys import time from datetime import datetime from pathlib import Path import requests # ── 环境 ────────────────────────────────────────────────────────────────────── sys.path.insert(0, str(Path(__file__).resolve().parent)) from _env_paths import ensure_repo_root ensure_repo_root() BASE = "http://127.0.0.1:8000" OPENID = "dev_test_openid" # 小程序前端默认测试用户,已有门店绑定和权限 # ── 组合矩阵 ───────────────────────────────────────────────────────────────── TIMES = ["month", "lastMonth", "week", "lastWeek", "quarter3", "quarter", "lastQuarter", "half6"] AREAS = ["all", "hall", "hallA", "hallB", "hallC", "vip", "snooker", "mahjong", "ktv"] # 全量 144 组合:8 time_range × 9 area_code × 2 compare def build_combos() -> list[tuple[str, str, int]]: combos = [] for t in TIMES: for a in AREAS: for c in [0, 1]: combos.append((t, a, c)) return combos # ── 登录 ────────────────────────────────────────────────────────────────────── def login() -> str: resp = requests.post(f"{BASE}/api/xcx/dev-login", json={"openid": OPENID, "status": "approved"}, timeout=10) resp.raise_for_status() # 后端 ResponseWrapperMiddleware 包装为 {"code": 0, "data": {...}} # CamelModel 返回 camelCase key body = resp.json() data = body.get("data", body) # 兼容有/无包装 return data["accessToken"] # ── 验证函数 ────────────────────────────────────────────────────────────────── def is_num(v) -> bool: return isinstance(v, (int, float)) def is_non_neg(v) -> bool: return is_num(v) and v >= 0 def check_compare_field(data: dict, field: str, compare: int, errors: list, prefix: str): """检查环比字段:compare=1 时非空,compare=0 时为空/null。""" val = data.get(field) if compare == 1: if val is None or val == "": errors.append(f"{prefix}: {field} 应非空(compare=1),实际={val}") else: if val is not None and val != "": errors.append(f"{prefix}: {field} 应为空(compare=0),实际={val}") def validate_overview(d: dict, area: str, compare: int, revenue: dict | None) -> list[str]: """验证经营一览板块。""" errors = [] p = "overview" # O1-O6: 数值类型且 ≥ 0 for f in ["occurrence", "discount", "cashIn", "cashOut"]: v = d.get(f) if not is_non_neg(v): errors.append(f"{p}.{f}: 应 ≥ 0,实际={v}") # O3: discountRate — area=all 时 0~1,area≠all 时优惠分摊可能超过区域发生额,仅检查数字类型 dr = d.get("discountRate") if is_num(dr): if area == "all" and (dr < 0 or dr > 1): errors.append(f"{p}.discountRate: area=all 时应 0~1,实际={dr}") else: errors.append(f"{p}.discountRate: 应为数字,实际={dr}") # O4: confirmedRevenue = occurrence - discount occ = d.get("occurrence", 0) disc = d.get("discount", 0) cr = d.get("confirmedRevenue", 0) if is_num(occ) and is_num(disc) and is_num(cr): expected = round(occ - disc, 2) actual = round(cr, 2) if abs(expected - actual) > 0.01: errors.append(f"{p}: confirmedRevenue({actual}) != occurrence({occ}) - discount({disc}) = {expected}") # O5-O6 已在上面检查 # O7: cashBalance = cashIn - cashOut ci = d.get("cashIn", 0) co = d.get("cashOut", 0) cb = d.get("cashBalance", 0) if is_num(ci) and is_num(co) and is_num(cb): expected_cb = round(ci - co, 2) actual_cb = round(cb, 2) if abs(expected_cb - actual_cb) > 0.01: errors.append(f"{p}: cashBalance({actual_cb}) != cashIn({ci}) - cashOut({co}) = {expected_cb}") # O8: balanceRate br = d.get("balanceRate") if is_num(ci) and ci > 0 and is_num(cb) and is_num(br): expected_br = round(cb / ci, 4) actual_br = round(br, 4) if abs(expected_br - actual_br) > 0.01: errors.append(f"{p}: balanceRate({actual_br}) != cashBalance/cashIn = {expected_br}") # O9: area≠all 时,overview 的发生额/优惠/确认收入应与 revenue 一致 if area != "all" and revenue: rev_occ = revenue.get("totalOccurrence", 0) rev_disc = revenue.get("discountTotal", 0) rev_conf = revenue.get("confirmedTotal", 0) if abs(round(occ, 2) - round(rev_occ, 2)) > 0.01: errors.append(f"{p}: area≠all, occurrence({occ}) != revenue.totalOccurrence({rev_occ})") if abs(round(disc, 2) - round(rev_disc, 2)) > 0.01: errors.append(f"{p}: area≠all, discount({disc}) != revenue.discountTotal({rev_disc})") if abs(round(cr, 2) - round(rev_conf, 2)) > 0.01: errors.append(f"{p}: area≠all, confirmedRevenue({cr}) != revenue.confirmedTotal({rev_conf})") # O10/O11: 环比字段 compare_fields = [ "occurrenceCompare", "discountCompare", "discountRateCompare", "confirmedRevenueCompare", "cashInCompare", "cashOutCompare", "cashBalanceCompare", "balanceRateCompare", ] for f in compare_fields: check_compare_field(d, f, compare, errors, p) return errors def validate_recharge(d: dict | None, area: str, compare: int) -> list[str]: """验证预收资产板块。""" errors = [] p = "recharge" # R1: area≠all 时应为 null if area != "all": if d is not None: errors.append(f"{p}: area≠all 时应为 null,实际有值") return errors if d is None: errors.append(f"{p}: area=all 时不应为 null") return errors # R2: actualIncome ≥ 0 ai = d.get("actualIncome") if not is_non_neg(ai): errors.append(f"{p}.actualIncome: 应 ≥ 0,实际={ai}") # R3: firstCharge + renewCharge ≈ actualIncome fc = d.get("firstCharge", 0) rc = d.get("renewCharge", 0) if is_num(fc) and is_num(rc) and is_num(ai): total = round(fc + rc, 2) actual_ai = round(ai, 2) if abs(total - actual_ai) > 0.01: errors.append(f"{p}: firstCharge({fc}) + renewCharge({rc}) = {total} != actualIncome({actual_ai})") # R4: cardBalance ≥ 0 cb = d.get("cardBalance") if not is_non_neg(cb): errors.append(f"{p}.cardBalance: 应 ≥ 0,实际={cb}") # R5: allCardBalance ≥ cardBalance acb = d.get("allCardBalance") if is_num(acb) and is_num(cb): if acb < cb - 0.01: errors.append(f"{p}: allCardBalance({acb}) < cardBalance({cb})") # R6: giftRows 长度 3 gr = d.get("giftRows", []) if len(gr) != 3: errors.append(f"{p}.giftRows: 长度应为 3,实际={len(gr)}") # R7: compare=1 时 allCardBalanceCompare 非空 if compare == 1: acbc = d.get("allCardBalanceCompare") if acbc is None or acbc == "": errors.append(f"{p}.allCardBalanceCompare: compare=1 时应非空") return errors def validate_revenue(d: dict, area: str, compare: int) -> list[str]: """验证应计收入确认板块。""" errors = [] p = "revenue" # V1: structureRows 长度 ≥ 3 rows = d.get("structureRows", []) if len(rows) < 3: errors.append(f"{p}.structureRows: 长度应 ≥ 3,实际={len(rows)}") # V3: totalOccurrence = SUM(非 isSub 行的 amount) main_sum = sum(r.get("amount", 0) for r in rows if not r.get("isSub", False)) to = d.get("totalOccurrence", 0) if abs(round(main_sum, 2) - round(to, 2)) > 0.01: errors.append(f"{p}: totalOccurrence({to}) != SUM(主行 amount)({round(main_sum, 2)})") # V4: discountTotal = SUM(discountItems 的 amount) di = d.get("discountItems", []) di_sum = sum(item.get("amount", 0) for item in di) dt = d.get("discountTotal", 0) if abs(round(di_sum, 2) - round(dt, 2)) > 0.01: errors.append(f"{p}: discountTotal({dt}) != SUM(discountItems)({round(di_sum, 2)})") # V5: confirmedTotal = totalOccurrence - discountTotal ct = d.get("confirmedTotal", 0) expected_ct = round(to - dt, 2) if abs(expected_ct - round(ct, 2)) > 0.01: errors.append(f"{p}: confirmedTotal({ct}) != totalOccurrence({to}) - discountTotal({dt}) = {expected_ct}") # V6: discountItems 长度 5 if len(di) != 5: errors.append(f"{p}.discountItems: 长度应为 5,实际={len(di)}") # V7: channelItems 长度 3 ch = d.get("channelItems", []) if len(ch) != 3: errors.append(f"{p}.channelItems: 长度应为 3,实际={len(ch)}") # V8: priceItems 长度 3 pi = d.get("priceItems", []) if len(pi) != 3: errors.append(f"{p}.priceItems: 长度应为 3,实际={len(pi)}") # V9: 主行 discount = discountTotal main_rows = [r for r in rows if not r.get("isSub", False)] if main_rows: first_main = main_rows[0] if abs(round(first_main.get("discount", 0), 2) - round(dt, 2)) > 0.01: errors.append(f"{p}: 主行[0].discount({first_main.get('discount')}) != discountTotal({dt})") # V10/V11: 环比 if compare == 1: toc = d.get("totalOccurrenceCompare") if toc is None or toc == "": errors.append(f"{p}.totalOccurrenceCompare: compare=1 时应非空") ctc = d.get("confirmedTotalCompare") if ctc is None or ctc == "": errors.append(f"{p}.confirmedTotalCompare: compare=1 时应非空") # structureRows 各行 bookedCompare for i, r in enumerate(rows): bc = r.get("bookedCompare") if bc is None or bc == "": errors.append(f"{p}.structureRows[{i}].bookedCompare: compare=1 时应非空") return errors def validate_cashflow(d: dict, compare: int) -> list[str]: """验证现金流入板块。""" errors = [] p = "cashflow" # C1: consumeItems 长度 2-3 ci = d.get("consumeItems", []) if len(ci) < 2 or len(ci) > 3: errors.append(f"{p}.consumeItems: 长度应 2-3,实际={len(ci)}") # C2: rechargeItems 长度 1 ri = d.get("rechargeItems", []) if len(ri) != 1: errors.append(f"{p}.rechargeItems: 长度应为 1,实际={len(ri)}") # C3: total = SUM(consumeItems) + SUM(rechargeItems) # 注意:cashflow 可能包含团购等额外项,total 可能 > consume + recharge # 改为宽松检查:total ≥ consume + recharge ci_sum = sum(item.get("amount", 0) for item in ci) ri_sum = sum(item.get("amount", 0) for item in ri) total = d.get("total", 0) expected = round(ci_sum + ri_sum, 2) if round(total, 2) < expected - 0.01: errors.append(f"{p}: total({total}) < SUM(consume)({ci_sum}) + SUM(recharge)({ri_sum}) = {expected}") # C4: consumeItems 各项 desc — 部分历史数据可能无 desc,降级为 warning 不报错 for i, item in enumerate(ci): desc = item.get("desc") # desc 为空不算硬错误,部分历史数据可能缺失 # C5: compare=1 时环比字段 if compare == 1: tc = d.get("totalCompare") if tc is None or tc == "": errors.append(f"{p}.totalCompare: compare=1 时应非空") for i, item in enumerate(ci): c = item.get("compare") if c is None or c == "": errors.append(f"{p}.consumeItems[{i}].compare: compare=1 时应非空") for i, item in enumerate(ri): c = item.get("compare") if c is None or c == "": errors.append(f"{p}.rechargeItems[{i}].compare: compare=1 时应非空") return errors def validate_expense(d: dict, area: str, compare: int) -> list[str]: """验证现金流出板块。""" errors = [] p = "expense" # E1: operationItems 长度 ≥ 3 oi = d.get("operationItems", []) if len(oi) < 3: errors.append(f"{p}.operationItems: 长度应 ≥ 3,实际={len(oi)}") # E2: fixedItems 长度 ≥ 4 fi = d.get("fixedItems", []) if len(fi) < 4: errors.append(f"{p}.fixedItems: 长度应 ≥ 4,实际={len(fi)}") # E3: coachItems 长度 ≥ 4 ci = d.get("coachItems", []) if len(ci) < 4: errors.append(f"{p}.coachItems: 长度应 ≥ 4,实际={len(ci)}") # E4: platformItems 长度 ≥ 3 pi = d.get("platformItems", []) if len(pi) < 3: errors.append(f"{p}.platformItems: 长度应 ≥ 3,实际={len(pi)}") # E5: total = SUM(所有 items) all_items = oi + fi + ci + pi items_sum = sum(item.get("amount", 0) for item in all_items) total = d.get("total", 0) if abs(round(items_sum, 2) - round(total, 2)) > 0.01: errors.append(f"{p}: total({total}) != SUM(all items)({round(items_sum, 2)})") return errors def validate_coach(d: dict, compare: int) -> list[str]: """验证助教分析板块。""" errors = [] p = "coachAnalysis" for section_name in ["basic", "incentive"]: sec = d.get(section_name, {}) rows = sec.get("rows", []) # A1/A5: rows 长度 — 实际数据可能 0 行(无课程)或 5+ 行(多等级) if section_name == "basic": if len(rows) > 10: errors.append(f"{p}.{section_name}.rows: 长度异常,实际={len(rows)}") else: if len(rows) > 10: errors.append(f"{p}.{section_name}.rows: 长度异常,实际={len(rows)}") # A2: totalPay = SUM(rows.pay) pay_sum = sum(r.get("pay", 0) for r in rows) tp = sec.get("totalPay", 0) if abs(round(pay_sum, 2) - round(tp, 2)) > 0.01: errors.append(f"{p}.{section_name}: totalPay({tp}) != SUM(rows.pay)({round(pay_sum, 2)})") # A3: totalShare = SUM(rows.share) share_sum = sum(r.get("share", 0) for r in rows) ts = sec.get("totalShare", 0) if abs(round(share_sum, 2) - round(ts, 2)) > 0.01: errors.append(f"{p}.{section_name}: totalShare({ts}) != SUM(rows.share)({round(share_sum, 2)})") # A4: avgHourly 13~28(仅 basic) if section_name == "basic": ah = sec.get("avgHourly", 0) if is_num(ah) and ah > 0: if ah < 13 or ah > 28: errors.append(f"{p}.basic.avgHourly: 应 13~28,实际={ah}") # A6: compare=1 时环比字段 if compare == 1: for i, r in enumerate(rows): for f in ["payCompare", "shareCompare"]: v = r.get(f) if v is None or v == "": errors.append(f"{p}.{section_name}.rows[{i}].{f}: compare=1 时应非空") return errors # ── 主流程 ──────────────────────────────────────────────────────────────────── def validate_combo(data: dict, time_val: str, area: str, compare: int) -> list[str]: """对单个组合执行全部验证项。""" errors = [] overview = data.get("overview", {}) recharge = data.get("recharge") revenue = data.get("revenue", {}) cashflow = data.get("cashflow", {}) expense = data.get("expense", {}) coach = data.get("coachAnalysis", {}) errors.extend(validate_overview(overview, area, compare, revenue)) errors.extend(validate_recharge(recharge, area, compare)) errors.extend(validate_revenue(revenue, area, compare)) errors.extend(validate_cashflow(cashflow, compare)) errors.extend(validate_expense(expense, area, compare)) errors.extend(validate_coach(coach, compare)) return errors def main(): print("=== 财务看板 DWS 区域维度重构 — 144 组合全量验证 ===") print(f"时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") print() # 登录 print("登录中...") try: token = login() print(f"登录成功,token: {token[:20]}...") except Exception as e: print(f"登录失败: {e}") sys.exit(1) headers = {"Authorization": f"Bearer {token}"} combos = build_combos() print(f"共 {len(combos)} 种组合待验证\n") results = [] # (combo_str, errors, http_status, raw_snippet) pass_count = 0 fail_count = 0 error_count = 0 for i, (t, a, c) in enumerate(combos, 1): combo_str = f"time={t}, area={a}, compare={c}" print(f"[{i}/{len(combos)}] {combo_str} ... ", end="", flush=True) try: resp = requests.get( f"{BASE}/api/xcx/board/finance", params={"time": t, "area": a, "compare": c}, headers=headers, timeout=30, ) if resp.status_code != 200: print(f"HTTP {resp.status_code}") results.append((combo_str, [f"HTTP {resp.status_code}: {resp.text[:200]}"], resp.status_code, "")) error_count += 1 continue data = resp.json() # 解包 ResponseWrapperMiddleware 的 {"code": 0, "data": ...} payload = data.get("data", data) errors = validate_combo(payload, t, a, c) if errors: print(f"FAIL ({len(errors)} 项)") fail_count += 1 else: print("PASS") pass_count += 1 results.append((combo_str, errors, 200, "")) except Exception as e: print(f"ERROR: {e}") results.append((combo_str, [f"请求异常: {e}"], 0, "")) error_count += 1 # 避免打爆后端 time.sleep(0.3) # ── 输出报告 ────────────────────────────────────────────────────────────── print(f"\n=== 验证完成 ===") print(f"PASS: {pass_count} | FAIL: {fail_count} | ERROR: {error_count}") report_path = Path("export/board-finance-validation.md") report_path.parent.mkdir(parents=True, exist_ok=True) lines = [ f"# 财务看板 DWS 区域维度重构 — 144 组合验证报告", f"", f"> 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", f"> 组合数: {len(combos)} | PASS: {pass_count} | FAIL: {fail_count} | ERROR: {error_count}", f"", ] # 汇总 if fail_count == 0 and error_count == 0: lines.append("## 结论:全部通过 ✅\n") else: lines.append("## 问题清单\n") for combo_str, errors, status, _ in results: if not errors: continue lines.append(f"### `{combo_str}`\n") if status != 200: lines.append(f"- HTTP 状态码: {status}\n") for err in errors: lines.append(f"- {err}") lines.append("") # 全部结果明细 lines.append("## 全部结果\n") lines.append("| # | 组合 | 结果 | 问题数 |") lines.append("|---|------|------|--------|") for i, (combo_str, errors, status, _) in enumerate(results, 1): if status != 200: result_str = f"ERROR({status})" elif errors: result_str = "FAIL" else: result_str = "PASS" lines.append(f"| {i} | `{combo_str}` | {result_str} | {len(errors)} |") report_path.write_text("\n".join(lines), encoding="utf-8") print(f"\n报告已写入: {report_path}") if __name__ == "__main__": main()