# -*- coding: utf-8 -*- """ v3-fixed: API 参考文档 (.md) 响应字段详解 vs ODS 实际列 — 精确比对 核心改进(相对 v3): 1. 仅从"四、响应字段详解"章节提取字段(排除请求参数、跨表关联等章节) 2. 对 settlement_records / recharge_settlements 特殊处理: - settleList 内层字段 → 直接比对 ODS 列 - siteProfile → ODS 中存为 siteprofile jsonb 单列(不展开子字段) 3. 对 table_fee_discount_records / payment_transactions 等含 siteProfile/tableProfile 的表: - siteProfile/tableProfile 作为嵌套对象 → ODS 中存为 jsonb 单列 4. 对 stock_goods_category_tree:goodsCategoryList/categoryBoxes 是结构包装器,不是业务字段 5. JSON 样本作为补充来源(union) CHANGE P20260214-003000: 完全重写字段提取逻辑 intent: 精确限定提取范围到"响应字段详解"章节,避免误提取请求参数和跨表关联字段 assumptions: 所有 .md 文档均以"## 四、响应字段详解"开始响应字段章节,以"## 五、"结束 edge cases: settlement_records/recharge_settlements 的 siteProfile 子字段不应与 ODS 列比对 """ import json import os import re from datetime import datetime DOCS_DIR = os.path.join(os.path.dirname(__file__), "..", "docs", "api-reference") SAMPLES_DIR = os.path.join(DOCS_DIR, "samples") REPORT_DIR = os.path.join(os.path.dirname(__file__), "..", "docs", "reports") ODS_META = {"source_file", "source_endpoint", "fetched_at", "payload", "content_hash"} TABLES = [ "assistant_accounts_master", "settlement_records", "assistant_service_records", "assistant_cancellation_records", "table_fee_transactions", "table_fee_discount_records", "payment_transactions", "refund_transactions", "platform_coupon_redemption_records", "tenant_goods_master", "store_goods_sales_records", "store_goods_master", "stock_goods_category_tree", "goods_stock_movements", "member_profiles", "member_stored_value_cards", "recharge_settlements", "member_balance_changes", "group_buy_packages", "group_buy_redemption_records", "goods_stock_summary", "site_tables_master", ] # 这些字段在 API JSON 中是嵌套对象,ODS 中存为 jsonb 单列 NESTED_OBJECTS = {"siteprofile", "tableprofile"} # 这些字段是结构包装器,不是业务字段 # 注意:categoryboxes 虽然是嵌套数组,但 ODS 中确实有 categoryboxes 列(jsonb),所以不排除 WRAPPER_FIELDS = {"goodscategorylist", "total"} # 跨表关联章节中常见的"本表字段"列标题 CROSS_REF_HEADERS = {"本表字段", "关联表字段", "关联表", "参数", "字段"} def extract_response_fields_from_md(table_name: str) -> tuple[set[str], list[str]]: """ 从 API 参考文档中精确提取"响应字段详解"章节的字段名。 返回: (fields_set_lowercase, debug_messages) 提取策略: - 找到"## 四、响应字段详解"章节 - 在该章节内提取所有 Markdown 表格第一列的反引号字段名 - 遇到"## 五、"或更高级别标题时停止 - 对 settlement_records / recharge_settlements: - siteProfile 子字段(带 siteProfile. 前缀的)→ 不提取,ODS 中存为 siteprofile jsonb - settleList 内层字段 → 正常提取 - 对含 siteProfile/tableProfile 的表:这些作为顶层字段名提取(ODS 中是 jsonb 列) """ md_path = os.path.join(DOCS_DIR, f"{table_name}.md") debug = [] if not os.path.exists(md_path): debug.append(f"[WARN] 文档不存在: {md_path}") return set(), debug with open(md_path, "r", encoding="utf-8") as f: lines = f.readlines() fields = set() in_response_section = False in_siteprofile_subsection = False field_pattern = re.compile(r'^\|\s*`([^`]+)`\s*\|') # 用于检测 siteProfile 子章节(如 "### A. siteProfile" 或 "### 4.1 门店信息快照(siteProfile)") siteprofile_header = re.compile(r'^###.*siteProfile', re.IGNORECASE) for line in lines: stripped = line.strip() # 检测进入"响应字段详解"章节 if stripped.startswith("## 四、") and "响应字段" in stripped: in_response_section = True in_siteprofile_subsection = False continue # 检测离开(遇到下一个 ## 级别标题) if in_response_section and stripped.startswith("## ") and not stripped.startswith("## 四"): break if not in_response_section: continue # 检测 siteProfile 子章节(仅对 settlement_records / recharge_settlements) if table_name in ("settlement_records", "recharge_settlements"): if siteprofile_header.search(stripped): in_siteprofile_subsection = True continue # 遇到下一个 ### 标题,退出 siteProfile 子章节 if stripped.startswith("### ") and in_siteprofile_subsection: if not siteprofile_header.search(stripped): in_siteprofile_subsection = False # 提取字段名 m = field_pattern.match(stripped) if m: raw_field = m.group(1).strip() # 跳过表头行 if raw_field in CROSS_REF_HEADERS: continue # 对 settlement_records / recharge_settlements:跳过 siteProfile 子字段 if table_name in ("settlement_records", "recharge_settlements"): if in_siteprofile_subsection: # siteProfile 子字段不提取(ODS 中存为 siteprofile jsonb) continue # 带 siteProfile. 前缀的也跳过 if raw_field.startswith("siteProfile."): continue # 跳过结构包装器字段 if raw_field.lower() in WRAPPER_FIELDS: continue fields.add(raw_field.lower()) debug.append(f"从 .md 提取 {len(fields)} 个响应字段") return fields, debug def extract_fields_from_json(table_name: str) -> tuple[set[str], list[str]]: """从 JSON 样本提取字段(作为补充)""" path = os.path.join(SAMPLES_DIR, f"{table_name}.json") debug = [] if not os.path.exists(path): debug.append("[INFO] 无 JSON 样本") return set(), debug with open(path, "r", encoding="utf-8") as f: data = json.load(f) # settlement_records / recharge_settlements: 提取 settleList 内层字段 if table_name in ("settlement_records", "recharge_settlements"): settle = data.get("settleList", {}) if isinstance(settle, list): settle = settle[0] if settle else {} fields = {k.lower() for k in settle.keys()} # siteProfile 作为整体(ODS 中不存 siteProfile 的子字段,但可能有 siteprofile jsonb 列) # 不添加 siteProfile 的子字段 debug.append(f"从 JSON settleList 提取 {len(fields)} 个字段") return fields, debug # stock_goods_category_tree: 提取 goodsCategoryList 内层字段 if table_name == "stock_goods_category_tree": cat_list = data.get("goodsCategoryList", []) if cat_list: fields = set() for k in cat_list[0].keys(): kl = k.lower() if kl not in WRAPPER_FIELDS: fields.add(kl) debug.append(f"从 JSON goodsCategoryList 提取 {len(fields)} 个字段") return fields, debug return set(), debug # 通用:提取顶层字段 fields = set() for k in data.keys(): kl = k.lower() # siteProfile/tableProfile 作为整体保留(ODS 中是 jsonb 列) if kl in NESTED_OBJECTS: fields.add(kl) elif kl not in WRAPPER_FIELDS: fields.add(kl) debug.append(f"从 JSON 提取 {len(fields)} 个字段") return fields, debug def classify_ods_only(table_name: str, field: str) -> str: """对 ODS 独有字段进行分类说明""" # table_fee_discount_records 的展开字段 if table_name == "table_fee_discount_records" and field in ( "area_type_id", "charge_free", "site_table_area_id", "site_table_area_name", "sitename", "table_name", "table_price", "tenant_name" ): return "从 tableProfile/siteProfile 嵌套对象展开的字段" # site_tables_master 的 order_id if table_name == "site_tables_master" and field == "order_id": return "ODS 后续版本新增字段(当前使用中的台桌关联订单 ID)" # tenant_id 在某些表中是 ODS 额外添加的 if field == "tenant_id" and table_name in ( "assistant_cancellation_records", "payment_transactions" ): return "ODS 额外添加的租户 ID 字段(API 响应中不含,ETL 入库时补充)" # API 后续版本新增字段(文档快照未覆盖) api_version_fields = { "assistant_service_records": { "assistantteamname": "API 后续版本新增(助教团队名称)", "real_service_money": "API 后续版本新增(实际服务金额)", }, "table_fee_transactions": { "activity_discount_amount": "API 后续版本新增(活动折扣金额)", "order_consumption_type": "API 后续版本新增(订单消费类型)", "real_service_money": "API 后续版本新增(实际服务金额)", }, "tenant_goods_master": { "not_sale": "API 后续版本新增(是否禁售标记)", }, "store_goods_sales_records": { "coupon_share_money": "API 后续版本新增(优惠券分摊金额)", }, "store_goods_master": { "commodity_code": "API 后续版本新增(商品编码)", "not_sale": "API 后续版本新增(是否禁售标记)", }, "member_profiles": { "pay_money_sum": "API 后续版本新增(累计消费金额)", "person_tenant_org_id": "API 后续版本新增(人事组织 ID)", "person_tenant_org_name": "API 后续版本新增(人事组织名称)", "recharge_money_sum": "API 后续版本新增(累计充值金额)", "register_source": "API 后续版本新增(注册来源)", }, "member_stored_value_cards": { "able_share_member_discount": "API 后续版本新增(是否共享会员折扣)", "electricity_deduct_radio": "API 后续版本新增(电费抵扣比例)", "electricity_discount": "API 后续版本新增(电费折扣)", "electricitycarddeduct": "API 后续版本新增(电费卡扣金额)", "member_grade": "API 后续版本新增(会员等级)", "principal_balance": "API 后续版本新增(本金余额)", "rechargefreezebalance": "API 后续版本新增(充值冻结余额)", }, "member_balance_changes": { "principal_after": "API 后续版本新增(变动后本金)", "principal_before": "API 后续版本新增(变动前本金)", "principal_data": "API 后续版本新增(本金明细数据)", }, "group_buy_packages": { "is_first_limit": "API 后续版本新增(是否限首单)", "sort": "API 后续版本新增(排序序号)", "tenantcouponsaleorderitemid": "API 后续版本新增(租户券销售订单项 ID)", }, "group_buy_redemption_records": { "assistant_service_share_money": "API 后续版本新增(助教服务分摊金额)", "assistant_share_money": "API 后续版本新增(助教分摊金额)", "coupon_sale_id": "API 后续版本新增(券销售 ID)", "good_service_share_money": "API 后续版本新增(商品服务分摊金额)", "goods_share_money": "API 后续版本新增(商品分摊金额)", "member_discount_money": "API 后续版本新增(会员折扣金额)", "recharge_share_money": "API 后续版本新增(充值分摊金额)", "table_service_share_money": "API 后续版本新增(台费服务分摊金额)", "table_share_money": "API 后续版本新增(台费分摊金额)", }, } table_fields = api_version_fields.get(table_name, {}) if field in table_fields: return table_fields[field] return "ODS 独有(待确认来源)" def main(): ods_cols_path = os.path.join(os.path.dirname(__file__), "ods_columns.json") with open(ods_cols_path, "r", encoding="utf-8") as f: ods_all = json.load(f) results = [] total_api_only = 0 total_ods_only = 0 all_debug = {} for table in TABLES: debug_lines = [f"\n{'='*60}", f"表: {table}", f"{'='*60}"] # 从文档提取字段(主要来源) md_fields, md_debug = extract_response_fields_from_md(table) debug_lines.extend(md_debug) # 从 JSON 样本提取字段(补充) json_fields, json_debug = extract_fields_from_json(table) debug_lines.extend(json_debug) # 合并:文档字段 ∪ JSON 样本字段 api_fields = md_fields | json_fields # 特殊处理:settlement_records / recharge_settlements # ODS 中有 siteprofile 列但不展开子字段;也有 settlelist jsonb 列 # API 文档中 siteProfile 子字段已被排除,但需要确保 siteprofile 作为整体列被考虑 if table in ("settlement_records", "recharge_settlements"): # 不把 siteprofile 加入 api_fields(因为 ODS 中 siteprofile 不是从 API 直接映射的列名) # settlelist 也是 ODS 的 jsonb 列,不在 API 字段中 pass # 特殊处理:含 siteProfile/tableProfile 的表 # 这些在 API 中是嵌套对象,ODS 中存为 jsonb 列 # 确保 api_fields 中包含 siteprofile/tableprofile(如果 ODS 有这些列) ods_cols = set(ods_all.get(table, [])) - ODS_META ods_cols_lower = set() ods_case_map = {} for c in ods_cols: cl = c.lower() ods_cols_lower.add(cl) ods_case_map[cl] = c # 如果 ODS 有 siteprofile/tableprofile 列,且 API 文档中有 siteProfile/tableProfile 字段 for nested in NESTED_OBJECTS: if nested in ods_cols_lower and nested not in api_fields: # 检查 API 文档/JSON 中是否有这个嵌套对象 # 对于 settlement_records/recharge_settlements,siteProfile 确实存在于 API 响应中 # 对于 payment_transactions 等,siteProfile 也存在 api_fields.add(nested) debug_lines.append(f" 补充嵌套对象字段: {nested}") matched = sorted(api_fields & ods_cols_lower) api_only = sorted(api_fields - ods_cols_lower) ods_only = sorted(ods_cols_lower - api_fields) # 对 ODS 独有字段分类 ods_only_classified = [] for f in ods_only: reason = classify_ods_only(table, f) ods_only_classified.append({"field": f, "ods_original": ods_case_map.get(f, f), "reason": reason}) total_api_only += len(api_only) total_ods_only += len(ods_only) result = { "table": table, "api_count": len(api_fields), "ods_count": len(ods_cols_lower), "matched": len(matched), "matched_fields": matched, "api_only": api_only, "ods_only": ods_only_classified, "api_only_count": len(api_only), "ods_only_count": len(ods_only), "md_fields_count": len(md_fields), "json_fields_count": len(json_fields), } results.append(result) status = "✓ 完全对齐" if not api_only and not ods_only else "" print(f"{table}: API={len(api_fields)}(md={len(md_fields)},json={len(json_fields)}) " f"ODS={len(ods_cols_lower)} 匹配={len(matched)} " f"API独有={len(api_only)} ODS独有={len(ods_only)} {status}") if api_only: print(f" API独有: {api_only}") if ods_only: for item in ods_only_classified: print(f" ODS独有: {item['ods_original']} — {item['reason']}") all_debug[table] = debug_lines print(f"\n{'='*60}") print(f"总计: API独有={total_api_only}, ODS独有={total_ods_only}") print(f"{'='*60}") # 写 JSON 报告 os.makedirs(REPORT_DIR, exist_ok=True) json_out = os.path.join(REPORT_DIR, "api_ods_comparison_v3_fixed.json") with open(json_out, "w", encoding="utf-8") as f: json.dump(results, f, ensure_ascii=False, indent=2) print(f"\nJSON 报告: {json_out}") # 写 Markdown 报告 md_out = os.path.join(REPORT_DIR, "api_ods_comparison_v3_fixed.md") write_md_report(results, md_out, total_api_only, total_ods_only) print(f"MD 报告: {md_out}") def write_md_report(results, path, total_api_only, total_ods_only): now = datetime.now().strftime("%Y-%m-%d %H:%M") lines = [ f"# API 响应字段 vs ODS 表结构比对报告(v3-fixed)", f"", f"> 生成时间:{now}(Asia/Shanghai)", f"> 数据来源:API 参考文档(docs/api-reference/*.md)+ JSON 样本 + PostgreSQL information_schema", f'> 比对方法:从文档"响应字段详解"章节精确提取字段,与 ODS 实际列比对(排除 meta 列)', f"", f"## 汇总", f"", f"| 指标 | 值 |", f"|------|-----|", f"| 比对表数 | {len(results)} |", f"| API 独有字段总数 | {total_api_only} |", f"| ODS 独有字段总数 | {total_ods_only} |", f"| 完全对齐表数 | {sum(1 for r in results if r['api_only_count'] == 0 and r['ods_only_count'] == 0)} |", f"", f"## 逐表比对", f"", ] for r in results: status = "✅ 完全对齐" if r["api_only_count"] == 0 and r["ods_only_count"] == 0 else "⚠️ 有差异" lines.append(f"### {r['table']} — {status}") lines.append(f"") lines.append(f"| 指标 | 值 |") lines.append(f"|------|-----|") lines.append(f"| API 字段数 | {r['api_count']}(文档={r['md_fields_count']},JSON={r['json_fields_count']}) |") lines.append(f"| ODS 列数(排除 meta) | {r['ods_count']} |") lines.append(f"| 匹配 | {r['matched']} |") lines.append(f"| API 独有 | {r['api_only_count']} |") lines.append(f"| ODS 独有 | {r['ods_only_count']} |") lines.append(f"") if r["api_only"]: lines.append(f"**API 独有字段(ODS 中缺失):**") lines.append(f"") for f in r["api_only"]: lines.append(f"- `{f}`") lines.append(f"") if r["ods_only"]: lines.append(f"**ODS 独有字段(API 文档中未出现):**") lines.append(f"") lines.append(f"| ODS 列名 | 分类说明 |") lines.append(f"|----------|----------|") for item in r["ods_only"]: lines.append(f"| `{item['ods_original']}` | {item['reason']} |") lines.append(f"") lines.append(f"---") lines.append(f"") # AI_CHANGELOG lines.extend([ f"", ]) with open(path, "w", encoding="utf-8") as f: f.write("\n".join(lines)) if __name__ == "__main__": main() # AI_CHANGELOG: # - 日期: 2026-02-14 # - Prompt: P20260214-003000 — "还是不准,比如assistant_accounts_master的last_update_name,命名Json里就有,再仔细比对下" # - 直接原因: v3 仅从 JSON 样本提取字段导致遗漏条件性字段;需改用 .md 文档响应字段详解章节作为主要来源 # - 变更摘要: 完全重写脚本,精确限定提取范围到"四、响应字段详解"章节,排除请求参数和跨表关联; # 对 settlement_records/recharge_settlements 的 siteProfile 子字段不提取;对所有 ODS 独有字段分类说明 # - 风险与验证: 纯分析脚本,无运行时影响;验证:确认 assistant_accounts_master 62:62 完全对齐,last_update_name 正确匹配 # # - 日期: 2026-02-14 # - Prompt: P20260214-030000 — 上下文传递续接,执行 settlelist 删除后的收尾工作 # - 直接原因: settlelist 列已从 ODS 删除,classify_ods_only 中的 settlelist 特殊分类不再需要 # - 变更摘要: 移除 classify_ods_only 函数中 settlelist 的特殊分类逻辑 # - 风险与验证: 纯分析脚本;验证:重新运行脚本确认 ODS 独有=47,settlement_records 和 recharge_settlements 完全对齐 # # - 日期: 2026-02-14 # - Prompt: P20260214-070000 — ODS 清理与文档标注(5 项任务) # - 直接原因: option_name(store_goods_sales_records)和 able_site_transfer(member_stored_value_cards)已从 ODS 删除 # - 变更摘要: 从 classify_ods_only 的 api_version_fields 字典中移除 option_name 和 able_site_transfer 条目 # - 风险与验证: 纯分析脚本;验证:重新运行脚本确认两表 ODS 独有数减少