init: 管理会计OS初始代码
包含前后端完整代码: - 前端:Vue3+Vite+ElementPlus - 后端:FastAPI+SQLAlchemy - 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动 - 当前版本:v1.0.0
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
定时AI经营简报 — 管理会计OS
|
||||
每天凌晨自动生成经营分析报告,推送至企微
|
||||
在 daily_sync.py 之后运行
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv('/root/cma-management/backend/.env')
|
||||
|
||||
from app.database import get_session_local
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("cma.ai_brief")
|
||||
|
||||
|
||||
async def call_deepseek(prompt: str, system_prompt: str = None) -> str:
|
||||
"""调用DeepSeek API生成分析内容"""
|
||||
api_key = os.getenv("DEEPSEEK_API_KEY", "")
|
||||
api_url = "https://api.deepseek.com/v1/chat/completions"
|
||||
|
||||
if not system_prompt:
|
||||
system_prompt = "你是一名CMA管理会计师,擅长用数据驱动的方式分析企业经营状况,给出专业的财务分析和管理建议。回答要简洁、专业、有数据支撑。"
|
||||
|
||||
if not api_key:
|
||||
logger.warning("DEEPSEEK_API_KEY 未配置,跳过AI调用")
|
||||
return "(AI简报暂不可用:API Key未配置)"
|
||||
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(
|
||||
api_url,
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
json={
|
||||
"model": "deepseek-chat",
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"stream": False,
|
||||
"temperature": 0.3,
|
||||
}
|
||||
)
|
||||
data = resp.json()
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"DeepSeek API错误: {resp.status_code} {data}")
|
||||
return ""
|
||||
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
|
||||
|
||||
def generate_brief(db_session) -> dict:
|
||||
"""生成经营简报"""
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
kpis = db_session.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
kpi_lines = []
|
||||
for k in kpis:
|
||||
latest = db_session.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
).order_by(KPIValue.period.desc()).first()
|
||||
|
||||
prev_month = f"{int(period[:4])}-{int(period[5:7])-1:02d}" if int(period[5:7]) > 1 else f"{int(period[:4])-1}-12"
|
||||
prev = db_session.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id, KPIValue.period == prev_month
|
||||
).first()
|
||||
|
||||
alert = db_session.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == k.id, KPIAlert.status == "pending"
|
||||
).first()
|
||||
|
||||
if latest and latest.actual_value is not None:
|
||||
line = f"- {k.kpi_name}({k.kpi_code}): {latest.actual_value}{k.unit or ''}"
|
||||
if k.target_value:
|
||||
line += f" | 目标: {k.target_value}"
|
||||
if prev and prev.actual_value:
|
||||
diff = latest.actual_value - prev.actual_value
|
||||
direction = "↑" if diff > 0 else "↓"
|
||||
line += f" | 环比: {direction}{abs(diff):.1f}"
|
||||
if alert:
|
||||
line += f" | ⚠️ {alert.alert_level}预警"
|
||||
kpi_lines.append(line)
|
||||
|
||||
plans = db_session.query(ActionPlan).order_by(ActionPlan.created_at.desc()).all()
|
||||
plan_lines = []
|
||||
for p in plans:
|
||||
plan_lines.append(f"- {p.title} | 负责人: {p.assignee} | 状态: {p.status} | 进度: {p.progress}%")
|
||||
|
||||
kpi_text = "\n".join(kpi_lines) if kpi_lines else "暂无KPI数据"
|
||||
plan_text = "\n".join(plan_lines) if plan_lines else "暂无行动计划"
|
||||
|
||||
red_count = db_session.query(KPIAlert).filter(
|
||||
KPIAlert.status == "pending", KPIAlert.alert_level == "red"
|
||||
).count()
|
||||
yellow_count = db_session.query(KPIAlert).filter(
|
||||
KPIAlert.status == "pending", KPIAlert.alert_level == "yellow"
|
||||
).count()
|
||||
|
||||
today_str = datetime.now().strftime('%Y-%m-%d')
|
||||
prompt = f"""请为管理层生成一份今日经营简报(日期:{today_str})。
|
||||
|
||||
## 本月KPI数据
|
||||
{kpi_text}
|
||||
|
||||
## 待处理预警
|
||||
- 红色(紧急): {red_count}条
|
||||
- 黄色(预警): {yellow_count}条
|
||||
|
||||
## 正在执行的改善行动
|
||||
{plan_text}
|
||||
|
||||
请按以下结构生成简报(不超过800字):
|
||||
1. 📊 **经营概览**:一句话总结本月经营状况
|
||||
2. 🔍 **关键发现**:最重要的3个发现(数据驱动)
|
||||
3. ⚠️ **预警聚焦**:最需要关注的预警及其影响
|
||||
4. ✅ **行动进展**:改善计划执行情况
|
||||
5. 💡 **今日建议**:今天最应该做的1-2件事"""
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
analysis = asyncio.run(call_deepseek(prompt))
|
||||
except Exception as e:
|
||||
logger.error(f"AI简报生成异常: {e}")
|
||||
analysis = f"简报生成异常: {str(e)}"
|
||||
|
||||
return {
|
||||
"period": period,
|
||||
"brief": analysis,
|
||||
"kpi_count": len(kpi_lines),
|
||||
"red_alerts": red_count,
|
||||
"yellow_alerts": yellow_count,
|
||||
"plan_count": len(plan_lines),
|
||||
}
|
||||
|
||||
|
||||
def push_brief(brief: dict):
|
||||
"""将简报推送到企微"""
|
||||
from app.utils.notifier import send_wecom_app
|
||||
|
||||
db = get_session_local()()
|
||||
try:
|
||||
from app.models import NotificationChannel
|
||||
channels = db.query(NotificationChannel).filter(
|
||||
NotificationChannel.enabled == True,
|
||||
NotificationChannel.channel_type == "wecom_app",
|
||||
).all()
|
||||
|
||||
pushed = 0
|
||||
for ch in channels:
|
||||
config = ch.config or {}
|
||||
result = send_wecom_app(
|
||||
corp_id=config.get("corp_id", ""),
|
||||
corp_secret=config.get("corp_secret", ""),
|
||||
agent_id=config.get("agent_id", ""),
|
||||
touser=config.get("touser", "@all"),
|
||||
title=f"📋 {brief['period']} 经营简报",
|
||||
content=brief["brief"],
|
||||
alert_level="green",
|
||||
)
|
||||
if result.get("success"):
|
||||
pushed += 1
|
||||
logger.info(f"简报推送成功: {ch.name}")
|
||||
else:
|
||||
logger.warning(f"简报推送失败: {result.get('message')}")
|
||||
|
||||
return pushed
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def run_brief():
|
||||
"""主入口:生成简报并推送"""
|
||||
logger.info("开始生成AI经营简报...")
|
||||
db = get_session_local()()
|
||||
try:
|
||||
brief = generate_brief(db)
|
||||
logger.info(f"简报生成完成: {brief['period']}, KPI数={brief['kpi_count']}, 预警={brief['red_alerts']}红/{brief['yellow_alerts']}黄")
|
||||
|
||||
if brief["brief"] and len(brief["brief"]) > 50:
|
||||
pushed = push_brief(brief)
|
||||
logger.info(f"推送完成: {pushed} 个渠道")
|
||||
else:
|
||||
logger.warning("简报内容不足50字,跳过推送")
|
||||
|
||||
return brief
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
result = run_brief()
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
预警自动生成 — 管理会计OS
|
||||
比对 KPI 实际值与阈值配置(threshold_green/yellow/red),超出则写入 kpi_alerts
|
||||
在 daily_sync 之后运行
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.database import get_session_local
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert
|
||||
|
||||
logger = logging.getLogger("cma.alert_gen")
|
||||
|
||||
|
||||
def _parse_threshold(expr: str) -> tuple:
|
||||
"""解析阈值表达式,返回 (operator, value)
|
||||
示例:
|
||||
>=32000000 -> ('>=', 32000000)
|
||||
<20 -> ('<', 20)
|
||||
<=55 -> ('<=', 55)
|
||||
>60 -> ('>', 60)
|
||||
"""
|
||||
m = re.match(r"(>=|<=|>|<|=|!=)\s*([\d.]+)", str(expr).strip())
|
||||
if m:
|
||||
return m.group(1), float(m.group(2))
|
||||
return None, None
|
||||
|
||||
|
||||
def _check_threshold(actual: float, threshold_expr: str, level: str) -> tuple:
|
||||
"""检查实际值是否触发阈值,返回 (触发, 消息)"""
|
||||
if not threshold_expr or actual is None:
|
||||
return False, ""
|
||||
|
||||
op, val = _parse_threshold(threshold_expr)
|
||||
if op is None:
|
||||
return False, ""
|
||||
|
||||
triggered = False
|
||||
if op == ">=" and actual >= val:
|
||||
triggered = True
|
||||
elif op == "<=" and actual <= val:
|
||||
triggered = True
|
||||
elif op == ">" and actual > val:
|
||||
triggered = True
|
||||
elif op == "<" and actual < val:
|
||||
triggered = True
|
||||
elif op == "=" and actual == val:
|
||||
triggered = True
|
||||
|
||||
if triggered:
|
||||
level_names = {"green": "正常", "yellow": "预警", "red": "紧急"}
|
||||
msg = (f"KPI当前值 {actual:.2f},触发{level_names.get(level, level)}阈值 "
|
||||
f"({threshold_expr})")
|
||||
return True, msg
|
||||
|
||||
return False, ""
|
||||
|
||||
|
||||
def run_alert_check(db_session, period: str = None) -> int:
|
||||
"""检查所有KPI的实际值是否触发预警,返回新生成的预警数"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
kpis = db_session.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active"
|
||||
).all()
|
||||
|
||||
new_count = 0
|
||||
for kpi in kpis:
|
||||
# 跳过无阈值的KPI
|
||||
thr = {
|
||||
"red": kpi.threshold_red,
|
||||
"yellow": kpi.threshold_yellow,
|
||||
"green": kpi.threshold_green,
|
||||
}
|
||||
if not any(thr.values()):
|
||||
continue
|
||||
|
||||
# 获取该KPI当前期间的最新值
|
||||
latest = db_session.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.period == period,
|
||||
).order_by(KPIValue.calculated_at.desc()).first()
|
||||
|
||||
if not latest or latest.actual_value is None:
|
||||
continue
|
||||
|
||||
actual = latest.actual_value
|
||||
|
||||
# 从绿到红检查(绿灯最高优先级——满足即止)
|
||||
for level in ["green", "yellow", "red"]:
|
||||
expr = thr[level]
|
||||
if not expr:
|
||||
continue
|
||||
triggered, msg = _check_threshold(actual, expr, level)
|
||||
if triggered:
|
||||
# 检查是否已有该期间该KPI同级别的预警
|
||||
existing = db_session.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == kpi.id,
|
||||
KPIAlert.kpi_value_id == latest.id,
|
||||
KPIAlert.alert_level == level,
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# 已有预警,跳过
|
||||
break
|
||||
|
||||
# 创建新预警
|
||||
alert = KPIAlert(
|
||||
kpi_id=kpi.id,
|
||||
kpi_value_id=latest.id,
|
||||
alert_level=level,
|
||||
alert_message=f"{kpi.kpi_name}[{period}] {msg}",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(alert)
|
||||
new_count += 1
|
||||
logger.info(f" 新增预警 [{level}] {kpi.kpi_name}: {msg}")
|
||||
break # 只取最高级别
|
||||
elif level == "red" and not triggered:
|
||||
# 红没触发,如果已有红色预警但当前不满足,自动降级或关闭
|
||||
existing_red = db_session.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == kpi.id,
|
||||
KPIAlert.kpi_value_id == latest.id,
|
||||
KPIAlert.alert_level == "red",
|
||||
KPIAlert.status.in_(["pending", "processing"]),
|
||||
).first()
|
||||
if existing_red:
|
||||
existing_red.status = "resolved"
|
||||
existing_red.resolution = "自动解除: 当前值不再触发红色阈值"
|
||||
existing_red.resolved_at = datetime.now()
|
||||
logger.info(f" 自动解除预警 [red] {kpi.kpi_name}")
|
||||
|
||||
db_session.commit()
|
||||
return new_count
|
||||
|
||||
|
||||
def generate_and_push(db_session) -> dict:
|
||||
"""生成预警并推送,返回统计"""
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
# 1. 生成预警
|
||||
logger.info(f"开始预警检查 ({period})...")
|
||||
new_count = run_alert_check(db_session, period)
|
||||
logger.info(f"预警检查完成: 新增 {new_count} 条")
|
||||
|
||||
# 2. 推送
|
||||
pushed = 0
|
||||
if new_count > 0:
|
||||
try:
|
||||
from app.utils.notifier import push_pending_alerts
|
||||
pushed = push_pending_alerts(db_session)
|
||||
logger.info(f"推送完成: {pushed} 条")
|
||||
except Exception as e:
|
||||
logger.error(f"推送失败: {e}")
|
||||
|
||||
return {"period": period, "new_alerts": new_count, "pushed": pushed}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
db = get_session_local()()
|
||||
try:
|
||||
result = generate_and_push(db)
|
||||
print(f"预警检查: {result['new_alerts']}条新预警 / {result['pushed']}条已推送")
|
||||
finally:
|
||||
db.close()
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
管理会计OS — 每日自动同步入口
|
||||
|
||||
由 cron 每天 01:00 调用:
|
||||
0 1 * * * cd /root/cma-management/backend && python3 scripts/daily_sync.py >> /var/log/cma-daily-sync.log 2>&1
|
||||
|
||||
手动执行:
|
||||
python3 scripts/daily_sync.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import logging
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
from app.database import get_session_local
|
||||
|
||||
if __name__ == "__main__":
|
||||
db = get_session_local()()
|
||||
|
||||
# 1. ERP数据同步
|
||||
print("=" * 50)
|
||||
print("1/3 ERP数据同步")
|
||||
try:
|
||||
from scripts.erp_sync import run_sync
|
||||
run_sync(dry_run=False, use_api=True)
|
||||
print(" ✅ ERP同步完成")
|
||||
except Exception as e:
|
||||
print(f" ❌ ERP同步异常: {e}")
|
||||
|
||||
# 2. 预警生成
|
||||
print("\n" + "=" * 50)
|
||||
print("2/3 预警检查")
|
||||
try:
|
||||
from scripts.alert_generator import run_alert_check
|
||||
new_count = run_alert_check(db)
|
||||
print(f" ✅ 预警检查完成: 新增 {new_count} 条")
|
||||
except Exception as e:
|
||||
print(f" ❌ 预警生成异常: {e}")
|
||||
|
||||
# 3. 预警推送
|
||||
print("\n" + "=" * 50)
|
||||
print("3/3 预警推送")
|
||||
try:
|
||||
from app.utils.notifier import push_pending_alerts
|
||||
pushed = push_pending_alerts(db)
|
||||
print(f" ✅ 预警推送完成: {pushed} 条")
|
||||
except Exception as e:
|
||||
print(f" ❌ 预警推送异常: {e}")
|
||||
|
||||
# 4. 差异预警(实际vs预算)
|
||||
print("\n" + "=" * 50)
|
||||
print("4/4 差异预警检查")
|
||||
try:
|
||||
from app.utils.deviation_engine import run_deviation_check
|
||||
new_alerts = run_deviation_check(db)
|
||||
print(f" ✅ 差异预警检查完成: 新增 {new_alerts} 条")
|
||||
except Exception as e:
|
||||
print(f" ❌ 差异预警异常: {e}")
|
||||
|
||||
db.close()
|
||||
print("\n" + "=" * 50)
|
||||
print("✅ 全部完成")
|
||||
@@ -0,0 +1,374 @@
|
||||
"""
|
||||
ERP数据同步脚本 — 管理会计OS
|
||||
根据 kpi_definitions.formula 中的规则从ERP系统拉取数据并写入 kpi_values
|
||||
支持:
|
||||
- HTTP API 模式: 通过 erp-api-gateway 查询实时数据
|
||||
- Fallback 模式: API不可达时使用本地已有数据或标记待同步
|
||||
- 定时执行(crontab) + 手动触发
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from app.database import get_engine, get_session_local
|
||||
from app.models import KPIDefinition, KPIValue, OperationLog
|
||||
|
||||
logger = logging.getLogger("erp_sync")
|
||||
|
||||
# ERP API 配置
|
||||
ERP_API_BASE = os.getenv("ERP_API_BASE", "http://127.0.0.1:8300/api/v1")
|
||||
ERP_API_KEY = os.getenv("ERP_API_KEY", "erp-gateway-key-bhwl-2026")
|
||||
|
||||
# ============================================================
|
||||
# 公式解析
|
||||
# ============================================================
|
||||
|
||||
def parse_formula(formula: str) -> dict:
|
||||
"""解析KPI公式,提取ERP表和字段映射"""
|
||||
result = {"table": "MasterBill", "field": None, "agg": "SUM",
|
||||
"where": None, "raw": formula, "erp_direct": True}
|
||||
|
||||
# 特殊处理中文描述的公式
|
||||
ZH_PATTERNS = {
|
||||
"前5客户销售额/总销售额*100": ("TOP5_CUSTOMER", "MasterBill"),
|
||||
"前5客户集中度": ("TOP5_CUSTOMER", "MasterBill"),
|
||||
"满意客户数/总客户数*100": ("CUSTOMER_SAT_RATIO", "MasterBill"),
|
||||
"准时交付订单/总订单*100": ("DELIVERY_RATE", "MasterBill"),
|
||||
"完成培训人数/应培训人数*100": ("TRAINING_RATE", "MasterBill"),
|
||||
}
|
||||
for zh_pattern, (agg_type, table) in ZH_PATTERNS.items():
|
||||
if zh_pattern in formula:
|
||||
result.update({"agg": agg_type, "table": table, "field_expr": formula,
|
||||
"erp_direct": False}) # 不能直接跑SQL
|
||||
return result
|
||||
|
||||
# 优先检测比率型公式: SUM(A)/SUM(B)*100
|
||||
ratio_m = re.match(r"(SUM|COUNT|AVG)\s*\((.+?)\)\s*/\s*(SUM|COUNT|AVG)\s*\((.+?)\)", formula, re.I)
|
||||
if ratio_m:
|
||||
result["agg"] = f"RATIO_{ratio_m.group(1)}"
|
||||
result["field_expr"] = f"({ratio_m.group(2)})/({ratio_m.group(4)})"
|
||||
return result
|
||||
|
||||
# 匹配完整聚合: SUM(...), COUNT(DISTINCT ...), COUNT(...), AVG(...)
|
||||
m = re.match(r"(SUM|COUNT(?:\s+DISTINCT)?|AVG|MAX|MIN)\s*\((.+?)\)", formula, re.I)
|
||||
if not m:
|
||||
result["field_expr"] = "1"
|
||||
result["table"] = "MasterBill"
|
||||
result["erp_direct"] = False
|
||||
return result
|
||||
|
||||
agg_func = m.group(1).strip().upper()
|
||||
field_expr = m.group(2).strip()
|
||||
|
||||
if agg_func.startswith("COUNT") and field_expr.startswith("DISTINCT "):
|
||||
result["agg"] = "COUNT_DISTINCT"
|
||||
cleaned = field_expr.replace("DISTINCT ", "").strip()
|
||||
result["field_expr"] = cleaned
|
||||
parts = cleaned.split(".")
|
||||
if parts:
|
||||
result["table"] = parts[0]
|
||||
else:
|
||||
result["agg"] = agg_func
|
||||
result["field_expr"] = field_expr
|
||||
parts = field_expr.split(".")
|
||||
if len(parts) >= 2:
|
||||
candidate = parts[0].strip()
|
||||
if candidate and candidate[0].isupper():
|
||||
result["table"] = candidate
|
||||
|
||||
wm = re.search(r"WHERE\s+(.+)$", formula, re.I)
|
||||
if wm:
|
||||
result["where"] = wm.group(1).strip()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================
|
||||
# API 模式: 通过 ERP 接口查询
|
||||
# ============================================================
|
||||
|
||||
def fetch_via_api(kpi: KPIDefinition, parsed: dict, period: str) -> float:
|
||||
"""通过 erp-api-gateway 查询ERP数据"""
|
||||
period_month = int(period[5:7])
|
||||
period_year = int(period[:4])
|
||||
kpi_code = kpi.kpi_code
|
||||
|
||||
# 各KPI对应的API路径
|
||||
API_MAP = {
|
||||
"SALES_TOTAL": f"{ERP_API_BASE}/stats/monthly?year={period_year}",
|
||||
"CUSTOMER_COUNT": f"{ERP_API_BASE}/stats/monthly?year={period_year}",
|
||||
"SALES_PROFIT_RATE": f"{ERP_API_BASE}/stats/gross-profit?year={period_year}&month={period_month}",
|
||||
"TOP5_CUSTOMER_RATIO": f"{ERP_API_BASE}/stats/customer-top?year={period_year}&limit=5",
|
||||
}
|
||||
|
||||
headers = {"X-API-Key": ERP_API_KEY, "User-Agent": "CMA-ERP-SYNC/1.0"}
|
||||
|
||||
if kpi_code not in API_MAP:
|
||||
raise ValueError(f"未配置API映射: {kpi_code}")
|
||||
|
||||
url = API_MAP[kpi_code]
|
||||
logger.info(f" [{kpi_code}] API请求: {url}")
|
||||
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
raise ConnectionError(f"API返回 {e.code}: {e.read().decode()[:200]}")
|
||||
except Exception as e:
|
||||
raise ConnectionError(f"API请求失败: {e}")
|
||||
|
||||
if kpi_code == "SALES_TOTAL":
|
||||
# 从 monthly trend 中取对应月份
|
||||
for m in data.get("data", []):
|
||||
if m["period"] == period:
|
||||
return float(m["amount"])
|
||||
# fallback: 取汇总
|
||||
return float(data.get("summary", {}).get("total_amount", 0))
|
||||
|
||||
elif kpi_code == "CUSTOMER_COUNT":
|
||||
for m in data.get("data", []):
|
||||
if m["period"] == period:
|
||||
return float(m["customers"])
|
||||
return 0
|
||||
|
||||
elif kpi_code == "SALES_PROFIT_RATE":
|
||||
return float(data.get("gross_profit_rate", 0))
|
||||
|
||||
elif kpi_code == "TOP5_CUSTOMER_RATIO":
|
||||
top5 = data.get("data", [])
|
||||
top5_total = sum(c["amount"] for c in top5)
|
||||
# 同时获取全年总额
|
||||
total_url = f"{ERP_API_BASE}/stats/monthly?year={period_year}"
|
||||
req2 = urllib.request.Request(total_url, headers=headers)
|
||||
with urllib.request.urlopen(req2, timeout=15) as resp2:
|
||||
total_data = json.loads(resp2.read().decode())
|
||||
total_amount = sum(m["amount"] for m in total_data.get("data", []))
|
||||
if total_amount > 0:
|
||||
return round(top5_total / total_amount * 100, 2)
|
||||
return 0
|
||||
|
||||
raise ValueError(f"未实现的API映射: {kpi_code}")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Fallback 模式: 本地已有数据推算
|
||||
# ============================================================
|
||||
|
||||
def fetch_fallback(kpi: KPIDefinition, parsed: dict, db_session, period: str) -> float:
|
||||
"""Fallback: 从本地已有 kpi_values 推算或返回 None"""
|
||||
kpi_code = kpi.kpi_code
|
||||
|
||||
# 对于已有数据的KPI,沿用最近月份的值(标注为estimated)
|
||||
existing = db_session.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.source_type.in_(["erp", "manual"]),
|
||||
).order_by(KPIValue.period.desc()).first()
|
||||
|
||||
if existing and existing.actual_value is not None:
|
||||
logger.info(f" [{kpi_code}] Fallback: 沿用最近期 {existing.period}={existing.actual_value}")
|
||||
return existing.actual_value
|
||||
|
||||
# 特殊KPI的默认值
|
||||
DEFAULTS = {
|
||||
"SALES_TOTAL": 800000,
|
||||
"CUSTOMER_COUNT": 25,
|
||||
"SALES_PROFIT_RATE": 25.0,
|
||||
"TOP5_CUSTOMER_RATIO": 50.0,
|
||||
}
|
||||
if kpi_code in DEFAULTS:
|
||||
logger.info(f" [{kpi_code}] Fallback: 使用默认值 {DEFAULTS[kpi_code]}")
|
||||
return DEFAULTS[kpi_code]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# TOP5_CUSTOMER_RATIO 的公式补充处理
|
||||
# ============================================================
|
||||
|
||||
def compute_top5_ratio(db_session, period: str) -> float:
|
||||
"""从 ERP schema 采集数据计算:前5客户销售额/总销售额*100"""
|
||||
# 先检查 erp_schema 是否有 MasterBill 的完整数据
|
||||
# 如果有物化数据,可以在这里做本地计算
|
||||
# 目前 erp_schema 只有元数据没有数据,返回 None 表示需要 API
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 核心同步函数
|
||||
# ============================================================
|
||||
|
||||
def sync_kpi(kpi: KPIDefinition, db_session, dry_run: bool = False,
|
||||
use_api: bool = True, target_period: str = None) -> bool:
|
||||
"""同步单个KPI的ERP数据"""
|
||||
if kpi.data_source_type not in ("erp",):
|
||||
return False
|
||||
|
||||
formula = kpi.formula
|
||||
if not formula:
|
||||
logger.warning(f" [{kpi.kpi_code}] 无公式定义")
|
||||
return False
|
||||
|
||||
parsed = parse_formula(formula)
|
||||
logger.info(f" [{kpi.kpi_code}] 解析: table={parsed['table']}, agg={parsed['agg']}, "
|
||||
f"erp_direct={parsed.get('erp_direct',True)}")
|
||||
|
||||
current_period = target_period if target_period else datetime.now().strftime("%Y-%m")
|
||||
|
||||
# 尝试通过 API 获取
|
||||
value = None
|
||||
api_ok = False
|
||||
if use_api:
|
||||
try:
|
||||
value = fetch_via_api(kpi, parsed, current_period)
|
||||
if value is not None:
|
||||
api_ok = True
|
||||
logger.info(f" [{kpi.kpi_code}] API结果: {current_period}={value}")
|
||||
except Exception as e:
|
||||
logger.warning(f" [{kpi.kpi_code}] API失败: {e}")
|
||||
|
||||
# API 失败则 fallback
|
||||
if not api_ok:
|
||||
try:
|
||||
value = fetch_fallback(kpi, parsed, db_session, current_period)
|
||||
if value is not None:
|
||||
source_note = "estimated"
|
||||
logger.info(f" [{kpi.kpi_code}] Fallback结果: {current_period}={value}")
|
||||
else:
|
||||
logger.warning(f" [{kpi.kpi_code}] 无可用数据, 跳过")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f" [{kpi.kpi_code}] Fallback失败: {e}")
|
||||
return False
|
||||
|
||||
if dry_run:
|
||||
logger.info(f" [{kpi.kpi_code}] DRY RUN: 跳过写入 value={value}")
|
||||
return True
|
||||
|
||||
# 写入 kpi_values
|
||||
try:
|
||||
existing = db_session.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.period == current_period,
|
||||
KPIValue.source_type == "erp",
|
||||
).first()
|
||||
|
||||
remark = f"ERP自动同步{' (API)' if api_ok else ' (估算)'} {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
||||
|
||||
if existing:
|
||||
existing.actual_value = value
|
||||
existing.data_status = "verified" if api_ok else "estimated"
|
||||
existing.remark = remark
|
||||
existing.source_type = "erp"
|
||||
logger.info(f" [{kpi.kpi_code}] 更新 {current_period}: {value}")
|
||||
else:
|
||||
kv = KPIValue(
|
||||
kpi_id=kpi.id,
|
||||
period=current_period,
|
||||
actual_value=value,
|
||||
source_type="erp",
|
||||
source_batch=f"sync_{current_period}",
|
||||
data_status="verified" if api_ok else "estimated",
|
||||
remark=remark,
|
||||
)
|
||||
db_session.add(kv)
|
||||
logger.info(f" [{kpi.kpi_code}] 新增 {current_period}: {value}")
|
||||
|
||||
db_session.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
db_session.rollback()
|
||||
logger.error(f" [{kpi.kpi_code}] 写入失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def run_sync(dry_run: bool = False, kpi_codes: list = None, use_api: bool = True, period: str = None):
|
||||
"""执行全部ERP KPI同步"""
|
||||
db = get_session_local()()
|
||||
try:
|
||||
query = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.data_source_type == "erp",
|
||||
)
|
||||
if kpi_codes:
|
||||
query = query.filter(KPIDefinition.kpi_code.in_(kpi_codes))
|
||||
|
||||
kpis = query.all()
|
||||
target_period = period if period else datetime.now().strftime("%Y-%m")
|
||||
logger.info(f"开始同步ERP数据: {len(kpis)} 个KPI (API模式={use_api}, 期间={target_period})")
|
||||
|
||||
success = 0
|
||||
fail = 0
|
||||
for kpi in kpis:
|
||||
if sync_kpi(kpi, db, dry_run, use_api, target_period):
|
||||
success += 1
|
||||
else:
|
||||
fail += 1
|
||||
|
||||
if not dry_run:
|
||||
log = OperationLog(
|
||||
action="erp_sync",
|
||||
target_type="kpi",
|
||||
detail=json.dumps({
|
||||
"total": len(kpis), "success": success,
|
||||
"failed": fail, "api_mode": use_api,
|
||||
"period": datetime.now().strftime("%Y-%m"),
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"同步完成: {success}成功 / {fail}失败 / {len(kpis)}总计")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="ERP数据同步")
|
||||
parser.add_argument("--dry-run", action="store_true", help="仅预览,不写入数据库")
|
||||
parser.add_argument("--kpi", nargs="+", help="指定KPI编码")
|
||||
parser.add_argument("--no-api", action="store_true", help="禁用API模式,仅用本地fallback")
|
||||
parser.add_argument("--backfill", type=int, default=0,
|
||||
help="回填历史月份数(如 --backfill 6 回填最近6个月)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.backfill:
|
||||
from datetime import datetime, timedelta
|
||||
from app.database import get_session_local
|
||||
|
||||
today = datetime.now()
|
||||
months_backfilled = 0
|
||||
for i in range(1, args.backfill + 1):
|
||||
# 计算目标月份
|
||||
m = today.month - i
|
||||
y = today.year
|
||||
while m <= 0:
|
||||
m += 12
|
||||
y -= 1
|
||||
period = f"{y}-{m:02d}"
|
||||
|
||||
print(f"回填 {period}...")
|
||||
try:
|
||||
run_sync(dry_run=False, kpi_codes=args.kpi, use_api=not args.no_api, period=period)
|
||||
months_backfilled += 1
|
||||
except Exception as e:
|
||||
print(f" {period} 失败: {e}")
|
||||
|
||||
print(f"回填完成: {months_backfilled} 个月")
|
||||
else:
|
||||
run_sync(dry_run=args.dry_run, kpi_codes=args.kpi, use_api=not args.no_api)
|
||||
Reference in New Issue
Block a user