Files
cma-management/backend/scripts/generate_kpi_sql.py
T

262 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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": "费用控制率 = 费用支出/销售收入*100BillType=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()