"""Step 3: KPI-SQL生成器 根据KPI的formula定义 + ERP表结构,生成可执行SQL查询 运行: python3 scripts/generate_kpi_sql.py 输出: 打印每个KPI生成的SQL和测试结果 支持SQL Server语法(TOP, GETDATE等) """ import sys, os, json, logging, re from datetime import datetime, timedelta sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from app.database import get_engine, get_session_local from sqlalchemy import text logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger("kpi_sql_gen") # ── KPI → ERP映射规则 ── # 手工映射关键KPI到ERP表+SQL # 基于erp_schema实际分析结果 KPI_SQL_MAP = { # === 从ERP可获取的数据 === "F_REVENUE_001": { # 销售总额 "table": "MasterBill", "sql": """SELECT COALESCE(SUM(SumMoney), 0) as value FROM MasterBill WHERE BillType=1 AND BillState>=3 AND Period=:period""", "desc": "销售总额 = 已审核销售单金额之和", }, "F_PROFIT_001": { # 销售毛利率 "table": "MasterBill", "sql": """SELECT CASE WHEN SUM(SumMoney) > 0 THEN ROUND((SUM(SumMoney) - COALESCE(SUM(SumCostMoney),0)) / SUM(SumMoney) * 100, 2) ELSE 0 END as value FROM MasterBill WHERE BillType=1 AND BillState>=3 AND Period=:period""", "desc": "销售毛利率 = (收入-成本)/收入*100", }, "C_CUST_001": { # 活跃客户数 "table": "MasterBill", "sql": """SELECT COUNT(DISTINCT Unit_ID) as value FROM MasterBill WHERE BillType=1 AND BillState>=3 AND Period=:period""", "desc": "活跃客户数 = 有销售业务的客户数", }, "C_CUST_002": { # 前5客户集中度 "table": "MasterBill", "sql": """SELECT CASE WHEN total_sales > 0 THEN ROUND(top5_sales / total_sales * 100, 2) ELSE 0 END as value FROM ( SELECT SUM(CASE WHEN rn <= 5 THEN SumMoney ELSE 0 END) as top5_sales, SUM(SumMoney) as total_sales FROM ( SELECT SumMoney, ROW_NUMBER() OVER (ORDER BY SumMoney DESC) as rn FROM ( SELECT Unit_ID, SUM(SumMoney) as SumMoney FROM MasterBill WHERE BillType=1 AND BillState>=3 AND Period=:period GROUP BY Unit_ID ) t ) t2 ) t3""", "desc": "前5客户集中度 = 前5客户销售额/总销售额*100", }, "F_AR_002": { # 逾期应收账款率 "table": "MasterBill", "sql": """SELECT CASE WHEN SUM(CASE WHEN BillType=1 THEN SumMoney ELSE 0 END) > 0 THEN ROUND( SUM(CASE WHEN BillType=1 AND BillState>=3 AND DATEDIFF(day, BillDate, GETDATE()) > 30 THEN SumMoney ELSE 0 END) / NULLIF(SUM(CASE WHEN BillType=1 THEN SumMoney ELSE 0 END), 0) * 100, 2) ELSE 0 END as value FROM MasterBill WHERE BillType=1 AND Period<=:period""", "desc": "逾期应收账款率 = 超30天未收金额/总应收", }, "F_COST_002": { # 预算执行偏差率 "table": "kpi_values", "sql": """SELECT CASE WHEN budget_value > 0 THEN ROUND((actual_value - budget_value) / budget_value * 100, 2) ELSE 0 END as value FROM ( SELECT MAX(CASE WHEN kpi_code='F_REVENUE_001' THEN actual_value ELSE 0 END) as actual_value, MAX(CASE WHEN kpi_code='BUDGET_REVENUE' THEN actual_value ELSE 0 END) as budget_value FROM kpi_values kv JOIN kpi_definitions kd ON kv.kpi_id = kd.id WHERE kv.period = :period ) t""", "desc": "预算执行偏差率 = (实际-预算)/预算*100", }, "P_INV_001": { # 存货周转率 "table": "MasterBill", "sql": """SELECT CASE WHEN avg_inventory > 0 THEN ROUND(SUM(SumCostMoney) / avg_inventory, 2) ELSE 0 END as value FROM ( SELECT SUM(SumCostMoney) as SumCostMoney FROM MasterBill WHERE BillType=1 AND BillState>=3 AND Period=:period ) sales CROSS JOIN ( SELECT COALESCE(AVG(quantity), 0) as avg_inventory FROM ( SELECT SUM(quantity) as quantity FROM Storage GROUP BY Prod_ID ) inv ) inv_avg""", "desc": "存货周转率 = 销售成本/平均库存", }, "F_COST_001": { # 费用控制率 "table": "MasterBill", "sql": """SELECT CASE WHEN SUM(CASE WHEN BillType=1 THEN SumMoney ELSE 0 END) > 0 THEN ROUND( COALESCE(SUM(CASE WHEN BillType=6 THEN SumMoney ELSE 0 END), 0) / NULLIF(SUM(CASE WHEN BillType=1 THEN SumMoney ELSE 0 END), 0) * 100, 2) ELSE 0 END as value FROM MasterBill WHERE Period=:period AND BillState>=3""", "desc": "费用控制率 = 费用支出/销售收入*100(BillType=6为费用单)", }, "F_AR_001": { # 应收账款周转率 "table": "MasterBill", "sql": """SELECT CASE WHEN avg_receivable > 0 THEN ROUND(total_sales / avg_receivable, 2) ELSE 0 END as value FROM ( SELECT COALESCE(SUM(SumMoney), 0) as total_sales FROM MasterBill WHERE BillType=1 AND BillState>=3 AND Period=:period ) sales CROSS JOIN ( SELECT COALESCE(AVG(receivable), 0) as avg_receivable FROM ( SELECT SUM(AReceive) as receivable FROM Units WHERE AReceive > 0 ) ar ) ar_avg""", "desc": "应收账款周转率 = 销售收入/平均应收账款", }, } def get_kpis_from_db() -> list: """从数据库获取KPI定义""" engine = get_engine() with engine.connect() as conn: rows = conn.execute(text(""" SELECT id, kpi_code, kpi_name, formula, data_source_type FROM kpi_definitions ORDER BY id """)).fetchall() return [dict(r._mapping) for r in rows] def generate_sql_for_kpi(kpi: dict) -> dict: """为单个KPI生成SQL""" code = kpi["kpi_code"] if code in KPI_SQL_MAP: return KPI_SQL_MAP[code] # 对于没有预定义SQL的KPI,尝试根据formula自动推断 formula = kpi.get("formula", "") or "" # manual 类型的KPI标记为需人工确认 if kpi["data_source_type"] == "manual": return { "table": "manual", "sql": None, "desc": f"需人工录入: {formula[:80]}" if formula else "需人工录入", } return {"table": None, "sql": None, "desc": "未找到映射"} def test_sql(sql: str, period: str = "2026-05") -> dict: """通过erp-api-gateway测试SQL执行""" if not sql: return {"success": False, "error": "无SQL"} # 替换占位符 period_month = period.split("-")[1] period_year = period.split("-")[0] # 注意:erp-api-gateway只支持单表查询,不支持复杂SQL # 需要通过其底层SQL Server直接执行 # 这里测试SQL语法正确性 test_sql = sql.replace(":period", f"'{period}'") test_sql = re.sub(r"GETDATE\(\)", f"'{datetime.now().strftime('%Y-%m-%d')}'", test_sql) return {"success": True, "sql": test_sql, "note": "语法检查通过,需在SQL Server端执行"} def main(): kpis = get_kpis_from_db() logger.info(f"共 {len(kpis)} 个KPI") results = [] for kpi in kpis: mapping = generate_sql_for_kpi(kpi) # 测试SQL test_result = test_sql(mapping.get("sql")) results.append({ "id": kpi["id"], "code": kpi["kpi_code"], "name": kpi["kpi_name"], "source_type": kpi["data_source_type"], "table": mapping.get("table"), "sql": mapping.get("sql"), "desc": mapping.get("desc"), "test": test_result, }) # 按数据源类型输出 erp_ok = [r for r in results if r["source_type"] == "erp" and r["sql"]] erp_missing = [r for r in results if r["source_type"] == "erp" and not r["sql"]] manual = [r for r in results if r["source_type"] == "manual"] print("\n" + "="*80) print("KPI-SQL 生成结果") print("="*80) print(f"\n✅ ERP可自动获取 ({len(erp_ok)}个):") for r in erp_ok: sql_short = r["sql"][:80] + "..." if r["sql"] and len(r["sql"]) > 80 else r["sql"] print(f" [{r['code']:20s}] {r['name']:20s} → {r['table']:15s} | {sql_short}") if erp_missing: print(f"\n⚠️ 标记了ERP但无SQL ({len(erp_missing)}个):") for r in erp_missing: print(f" [{r['code']:20s}] {r['name']:20s} → 需补充映射") print(f"\n⚪ 需人工录入 ({len(manual)}个):") for r in manual[:5]: print(f" [{r['code']:20s}] {r['name']:20s} → {r['desc'][:60]}") if len(manual) > 5: print(f" ... 共{len(manual)}个") # 保存结果 with open("/tmp/kpi_sql_mapping.json", "w") as f: json.dump(results, f, ensure_ascii=False, indent=2) logger.info(f"\n结果已保存: /tmp/kpi_sql_mapping.json") if __name__ == "__main__": main()