init: 管理会计OS初始代码

包含前后端完整代码:
- 前端:Vue3+Vite+ElementPlus
- 后端:FastAPI+SQLAlchemy
- 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动
- 当前版本:v1.0.0
This commit is contained in:
Hermes CI Fix
2026-05-28 17:32:22 +08:00
commit 3dddd36866
142 changed files with 18533 additions and 0 deletions
View File
Binary file not shown.
+100
View File
@@ -0,0 +1,100 @@
"""Redis 缓存工具类 — 管理会计OS"""
import json
import hashlib
import logging
from typing import Any, Optional
logger = logging.getLogger("cma.cache")
try:
import redis as redis_lib
_client = redis_lib.Redis(
host="127.0.0.1",
port=6379,
db=1,
decode_responses=True,
socket_connect_timeout=2,
socket_timeout=3,
)
_client.ping()
_available = True
logger.info("Redis 缓存已连接 (db=1)")
except Exception as e:
_client = None
_available = False
logger.warning(f"Redis 不可用,回退到无缓存模式: {e}")
def _make_key(module: str, key: str) -> str:
"""生成统一格式的缓存key: cma:cache:{module}:{hash}"""
h = hashlib.md5(key.encode()).hexdigest()[:16]
return f"cma:cache:{module}:{h}"
def get(module: str, key: str) -> Optional[Any]:
"""获取缓存"""
if not _available:
return None
try:
full_key = _make_key(module, key)
data = _client.get(full_key)
if data:
return json.loads(data)
return None
except Exception as e:
logger.warning(f"缓存读取失败 [{module}]: {e}")
return None
def set(module: str, key: str, value: Any, ttl_seconds: int = 300) -> bool:
"""写入缓存,默认5分钟"""
if not _available:
return False
try:
full_key = _make_key(module, key)
_client.setex(full_key, ttl_seconds, json.dumps(value, ensure_ascii=False))
return True
except Exception as e:
logger.warning(f"缓存写入失败 [{module}]: {e}")
return False
def delete(module: str, key: str = None) -> bool:
"""删除缓存。不传key则清空该模块所有缓存"""
if not _available:
return False
try:
if key:
full_key = _make_key(module, key)
_client.delete(full_key)
else:
pattern = f"cma:cache:{module}:*"
cursor = 0
while True:
cursor, keys = _client.scan(cursor=cursor, match=pattern, count=100)
if keys:
_client.delete(*keys)
if cursor == 0:
break
return True
except Exception as e:
logger.warning(f"缓存删除失败 [{module}]: {e}")
return False
def clear_all() -> bool:
"""清空所有CMA缓存"""
if not _available:
return False
try:
cursor = 0
while True:
cursor, keys = _client.scan(cursor=cursor, match="cma:cache:*", count=200)
if keys:
_client.delete(*keys)
if cursor == 0:
break
return True
except Exception as e:
logger.warning(f"缓存清空失败: {e}")
return False
+93
View File
@@ -0,0 +1,93 @@
"""KPI计算引擎 v4 — 基于会计科目余额和销售报表"""
import httpx, asyncio
from datetime import datetime
from app.database import get_session_local
from app.models import KPIDefinition, KPIValue
ERP_API = "http://127.0.0.1:8300"
ERP_KEY = "erp-gateway-key-bhwl-2026"
async def _get(url: str, params: dict = None):
async with httpx.AsyncClient(timeout=20) as c:
r = await c.get(url, headers={"X-API-Key": ERP_KEY}, params=params)
return r.json()
async def calculate_all():
db = get_session_local()()
try:
now = datetime.now()
period = f"{now.year}-{now.month:02d}"
# 1. 从科目余额表取数据(BalanceInfo
balance_data = await _get(f"{ERP_API}/api/v1/query", {"table": "BalanceInfo", "limit": 200})
balances = balance_data.get("data", [])
# 按科目和期间汇总
revenue = 0 # 营业收入 (Act_ID=4)
cost = 0 # 营业成本 (Act_ID=5)
ar_balance = 0 # 应收账款 (Act_ID=3)
inv_balance = 0 # 库存商品 (Act_ID=2)
for b in balances:
aid = b.get("Act_ID")
tot = float(b.get("Act_Tot", 0) or 0)
hap = float(b.get("Act_Hap", 0) or 0)
if aid == 4: # 营业收入(本期发生额更准确)
revenue += hap if hap > 0 else tot
elif aid == 5: # 营业成本
cost += hap if hap > 0 else tot
elif aid == 3: # 应收账款余额
ar_balance = tot
elif aid == 2: # 存货余额
inv_balance = tot
# 2. 从销售总览取数据
summary = await _get(f"{ERP_API}/api/v1/stats/sales-summary", {"year": now.year})
s = summary.get("summary", {})
total_sales = s.get("total_amount", 0)
total_customers = s.get("customer_count", 0)
# 3. 前5客户集中度
top_customers = await _get(f"{ERP_API}/api/v1/stats/customer-top", {"year": now.year, "limit": 5})
top5_amt = sum(c["amount"] for c in top_customers.get("data", []))
top5_ratio = round(top5_amt / total_sales * 100, 1) if total_sales > 0 else 0
# 4. 计算KPI
gross_margin = round((revenue - cost) / revenue * 100, 2) if revenue > 0 else 0
ar_turnover = round(revenue / ar_balance, 2) if ar_balance > 0 else 0
inv_turnover = round(cost / inv_balance, 2) if inv_balance > 0 else 0
kpi_values = {
"SALES_TOTAL": total_sales,
"CUSTOMER_COUNT": total_customers,
"SALES_PROFIT_RATE": gross_margin,
"RECEIVABLE_TURNOVER": ar_turnover,
"TURNOVER_RATE": inv_turnover,
"TOP5_CUSTOMER_RATIO": top5_ratio,
}
for code, value in kpi_values.items():
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
if kpi and (value > 0 or kpi.kpi_code in ("SALES_PROFIT_RATE","RECEIVABLE_TURNOVER","TURNOVER_RATE")):
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
KPIValue.source_type == "erp",
).first()
if not existing:
kv = KPIValue(kpi_id=kpi.id, period=period, actual_value=round(value, 2), source_type="erp", data_status="verified")
db.add(kv)
db.commit()
print(f"✅ KPI计算完成: {period}")
for code, value in kpi_values.items():
kpi_n = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
n = kpi_n.kpi_name if kpi_n else code
print(f" {n}: {round(value,2) if value else '-'}")
except Exception as e:
print(f"❌ KPI计算失败: {e}")
finally:
db.close()
if __name__ == "__main__":
asyncio.run(calculate_all())
+282
View File
@@ -0,0 +1,282 @@
"""成本分析引擎 — 管理会计OS
标准成本vs实际成本差异分析(量差/价差/效率差异)
ABC作业成本法分配
"""
import logging
from datetime import datetime
from typing import Optional, List, Dict
from app.database import get_session_local
from app.models import StandardCost, ActualCost, AbcActivity, AbcAllocation, KPIDefinition, KPIValue
logger = logging.getLogger("cma.cost")
ERP_API = "http://127.0.0.1:8300"
ERP_KEY = "erp-gateway-key-bhwl-2026"
# ============================================================
# 差异计算
# ============================================================
def calc_variance(standard_qty: float, actual_qty: float,
standard_price: float, actual_price: float) -> dict:
"""计算量差和价差
量差 = (实际用量 - 标准用量) × 标准价格
价差 = (实际价格 - 标准价格) × 实际用量
总差异 = 量差 + 价差
"""
qty_variance = round((actual_qty - standard_qty) * standard_price, 2)
price_variance = round((actual_price - standard_price) * actual_qty, 2)
total_variance = round(qty_variance + price_variance, 2)
return {
"qty_variance": qty_variance, # 量差
"price_variance": price_variance, # 价差
"total_variance": total_variance, # 总差异
"standard_cost": round(standard_qty * standard_price, 2),
"actual_cost": round(actual_qty * actual_price, 2),
}
def calc_efficiency_variance(standard_hours: float, actual_hours: float,
standard_rate: float) -> dict:
"""计算效率差异(人工/制造费用)
效率差异 = (实际工时 - 标准工时) × 标准分配率
分配率差异 = (实际分配率 - 标准分配率) × 实际工时
"""
eff = round((actual_hours - standard_hours) * standard_rate, 2)
# 假设实际分配率从外面传入
return {
"efficiency_variance": eff,
}
# ============================================================
# 产品级差异分析
# ============================================================
def calc_product_variance(product_code: str, period: str) -> dict:
"""计算指定产品在指定期间的成本差异"""
db = get_session_local()()
try:
standards = db.query(StandardCost).filter(
StandardCost.product_code == product_code,
StandardCost.status == "active",
).all()
actuals = db.query(ActualCost).filter(
ActualCost.product_code == product_code,
ActualCost.period == period,
).all()
if not standards or not actuals:
return {"product_code": product_code, "error": "标准成本或实际成本数据不足", "items": [], "summary": {}}
# 按 cost_type 分组
cost_types = set()
for s in standards: cost_types.add(s.cost_type)
for a in actuals: cost_types.add(a.cost_type)
items = []
total_std = 0
total_act = 0
total_qty_var = 0
total_price_var = 0
for ct in sorted(cost_types):
std_items = [s for s in standards if s.cost_type == ct]
act_items = [a for a in actuals if a.cost_type == ct]
if std_items and act_items:
s = std_items[0]
a = act_items[0]
var = calc_variance(s.standard_quantity, a.actual_quantity,
s.standard_price, a.actual_price)
items.append({
"cost_type": ct,
"item_name": s.item_name,
"standard_quantity": s.standard_quantity,
"actual_quantity": a.actual_quantity,
"standard_price": s.standard_price,
"actual_price": a.actual_price,
"standard_cost": var["standard_cost"],
"actual_cost": var["actual_cost"],
"qty_variance": var["qty_variance"],
"price_variance": var["price_variance"],
"total_variance": var["total_variance"],
})
total_std += var["standard_cost"]
total_act += var["actual_cost"]
total_qty_var += var["qty_variance"]
total_price_var += var["price_variance"]
return {
"product_code": product_code,
"period": period,
"items": items,
"summary": {
"total_standard_cost": round(total_std, 2),
"total_actual_cost": round(total_act, 2),
"total_variance": round(total_act - total_std, 2),
"total_qty_variance": round(total_qty_var, 2),
"total_price_variance": round(total_price_var, 2),
"variance_rate": round((total_act - total_std) / total_std * 100, 2) if total_std else 0,
}
}
finally:
db.close()
# ============================================================
# ABC 作业成本分配
# ============================================================
def calc_driver_rate(activity_id: int) -> dict:
"""计算作业动因分配率 = 总成本 / 动因总量"""
db = get_session_local()()
try:
act = db.query(AbcActivity).filter(AbcActivity.id == activity_id).first()
if not act or not act.driver_volume:
return {"error": "作业中心不存在或动因总量为0"}
rate = round(act.total_cost / act.driver_volume, 4) if act.driver_volume > 0 else 0
act.driver_rate = rate
db.commit()
return {
"activity_code": act.activity_code,
"activity_name": act.activity_name,
"total_cost": act.total_cost,
"driver_volume": act.driver_volume,
"driver_rate": rate,
}
finally:
db.close()
def allocate_cost(activity_id: int, period: str, product_code: str,
product_name: str, driver_consumed: float) -> dict:
"""按动因分配成本到产品"""
db = get_session_local()()
try:
act = db.query(AbcActivity).filter(AbcActivity.id == activity_id).first()
if not act or act.driver_rate == 0:
# 自动计算分配率
if act and act.driver_volume > 0:
act.driver_rate = round(act.total_cost / act.driver_volume, 4)
db.commit()
if not act or act.driver_rate == 0:
return {"error": "分配率未设置"}
allocated = round(driver_consumed * act.driver_rate, 2)
alloc = AbcAllocation(
period=period,
activity_id=activity_id,
product_code=product_code,
product_name=product_name,
driver_consumed=driver_consumed,
allocated_cost=allocated,
)
db.add(alloc)
db.commit()
return {
"activity_code": act.activity_code,
"product_code": product_code,
"driver_consumed": driver_consumed,
"driver_rate": act.driver_rate,
"allocated_cost": allocated,
}
finally:
db.close()
# ============================================================
# 成本总览数据
# ============================================================
def get_cost_overview(period: str) -> dict:
"""获取成本总览数据(总成本、结构占比、趋势)"""
db = get_session_local()()
try:
# 从实际成本表汇总
actual_costs = db.query(ActualCost).filter(ActualCost.period == period).all()
total_cost = sum(a.actual_cost for a in actual_costs)
# 按成本类型分组
by_type: Dict[str, float] = {}
for a in actual_costs:
by_type[a.cost_type] = by_type.get(a.cost_type, 0) + a.actual_cost
structure = [
{"cost_type": k, "amount": round(v, 2), "ratio": round(v / total_cost * 100, 1) if total_cost else 0}
for k, v in sorted(by_type.items())
]
# 从ERP获取成本数据做补充
import httpx
try:
resp = httpx.get(f"{ERP_API}/api/v1/query", params={"table": "BalanceInfo", "limit": 100},
headers={"X-API-Key": ERP_KEY}, timeout=10)
balance_data = resp.json().get("data", [])
erp_cost = 0
for b in balance_data:
if b.get("Act_ID") == 5: # 营业成本
erp_cost += float(b.get("Act_Hap", 0) or 0) + float(b.get("Act_Tot", 0) or 0)
except Exception:
erp_cost = 0
# 历史趋势(近6个月)
from sqlalchemy import text
year = period[:4]
months_texts = []
try:
m = int(period.split("-")[1])
for i in range(6):
pm = m - i
py = int(year)
while pm <= 0:
pm += 12
py -= 1
months_texts.append(f"{py}-{pm:02d}")
trend = []
for p in reversed(months_texts):
costs = db.query(ActualCost).filter(ActualCost.period == p).all()
total = round(sum(c.actual_cost for c in costs), 2)
trend.append({"period": p, "total_cost": total})
except Exception:
trend = []
return {
"period": period,
"total_cost": round(total_cost + erp_cost, 2),
"erp_cost": round(erp_cost, 2),
"manual_cost": round(total_cost, 2),
"structure": structure,
"trend": trend,
}
finally:
db.close()
def get_cost_breakdown(product_code: str, period: str) -> dict:
"""获取成本构成(料/工/费占比)"""
db = get_session_local()()
try:
actuals = db.query(ActualCost).filter(
ActualCost.product_code == product_code,
ActualCost.period == period,
).all()
material = sum(a.actual_cost for a in actuals if a.cost_type == "material")
labor = sum(a.actual_cost for a in actuals if a.cost_type == "labor")
overhead = sum(a.actual_cost for a in actuals if a.cost_type == "overhead")
total = material + labor + overhead
return {
"product_code": product_code,
"period": period,
"material": {"amount": round(material, 2), "ratio": round(material / total * 100, 1) if total else 0},
"labor": {"amount": round(labor, 2), "ratio": round(labor / total * 100, 1) if total else 0},
"overhead": {"amount": round(overhead, 2), "ratio": round(overhead / total * 100, 1) if total else 0},
"total": round(total, 2),
}
finally:
db.close()
+311
View File
@@ -0,0 +1,311 @@
"""差异预警引擎 — 管理会计OS
实际 vs 预算/目标对比,超阈值自动推送预警
功能:
1. 实际 vs 预算差异计算(差异额/差异率)
2. 同比/环比差异计算
3. 趋势异常检测(连续N期下滑/上升)
4. 差异预警触发(集成到现有预警系统)
"""
import logging
from datetime import datetime
from typing import Optional
from app.database import get_session_local
from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan
logger = logging.getLogger("cma.deviation")
# ============================================================
# 差异计算
# ============================================================
def calc_deviation(actual: float, budget: float) -> dict:
"""计算差异额和差异率"""
if budget is None or budget == 0:
return {
"deviation_amount": None,
"deviation_rate": None,
"is_over_budget": None,
}
amount = round(actual - budget, 2)
rate = round(amount / budget * 100, 2)
return {
"deviation_amount": amount,
"deviation_rate": rate,
"is_over_budget": amount > 0,
}
def get_budget_for_kpi(db, kpi_id: int, period: str, version: str = None) -> Optional[float]:
"""获取指定KPI在指定期间的预算值"""
query = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.period == period,
BudgetPlan.status == "active",
)
if version:
query = query.filter(BudgetPlan.version == version)
plan = query.order_by(BudgetPlan.updated_at.desc()).first()
return plan.budget_value if plan else None
def get_actual_for_kpi(db, kpi_id: int, period: str) -> Optional[float]:
"""获取指定KPI在指定期间的实际值"""
val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period == period,
).order_by(KPIValue.calculated_at.desc()).first()
return val.actual_value if val else None
def calc_period_deviation(db, kpi_id: int, period: str) -> dict:
"""单KPI单期的差异计算"""
actual = get_actual_for_kpi(db, kpi_id, period)
budget = get_budget_for_kpi(db, kpi_id, period)
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
kpi_code = kpi.kpi_code if kpi else "unknown"
# 如果没预算值,用 target_value 作为替代
if budget is None and kpi:
# 尝试把年度目标按月均分
month = int(period.split("-")[1])
target = kpi.target_value
if target and target > 0 and kpi.frequency == "monthly":
budget = round(target / 12, 2)
deviation = calc_deviation(actual, budget) if actual is not None else None
result = {
"kpi_id": kpi_id,
"kpi_code": kpi_code,
"period": period,
"actual_value": actual,
"budget_value": budget,
}
if deviation:
result.update(deviation)
return result
# ============================================================
# 同比/环比差异
# ============================================================
def calc_period_diff(db, kpi_id: int, current_period: str, diff_type: str = "yoy") -> dict:
"""计算同比(上年同期)或环比(上期)差异"""
year, month = current_period.split("-")
y, m = int(year), int(month)
if diff_type == "yoy":
# 同比:上年同期
prev_period = f"{y-1}-{m:02d}"
label = "同比"
elif diff_type == "mom":
# 环比:上个月
prev_m = m - 1
prev_y = y
if prev_m <= 0:
prev_m += 12
prev_y -= 1
prev_period = f"{prev_y}-{prev_m:02d}"
label = "环比"
else:
return {"error": f"未知比较类型: {diff_type}"}
current = get_actual_for_kpi(db, kpi_id, current_period)
previous = get_actual_for_kpi(db, kpi_id, prev_period)
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
kpi_code = kpi.kpi_code if kpi else "unknown"
if current is None or previous is None or previous == 0:
return {
"kpi_id": kpi_id,
"kpi_code": kpi_code,
"type": diff_type,
"label": label,
"current_period": current_period,
"prev_period": prev_period,
"current_value": current,
"prev_value": previous,
"diff_amount": None,
"diff_rate": None,
}
diff_amount = round(current - previous, 2)
diff_rate = round(diff_amount / previous * 100, 2)
return {
"kpi_id": kpi_id,
"kpi_code": kpi_code,
"type": diff_type,
"label": label,
"current_period": current_period,
"prev_period": prev_period,
"current_value": current,
"prev_value": previous,
"diff_amount": diff_amount,
"diff_rate": diff_rate,
}
# ============================================================
# 趋势检测
# ============================================================
def check_trend_anomaly(db, kpi_id: int, period: str, consecutive: int = 3) -> dict:
"""检测连续N期下滑或上升的趋势异常"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
return {"anomaly": False}
# 获取包括当前期在内的近期数据
year, month = period.split("-")
y, m = int(year), int(month)
values = []
for i in range(consecutive + 2): # 多取2期做参考
p = f"{y}-{m:02d}"
v = get_actual_for_kpi(db, kpi_id, p)
if v is not None:
values.append({"period": p, "value": v})
m -= 1
if m <= 0:
m += 12
y -= 1
values.reverse() # 按时间正序
if len(values) < consecutive:
return {"anomaly": False, "reason": "数据不足"}
last_n = values[-consecutive:]
all_decreasing = all(last_n[i]["value"] > last_n[i + 1]["value"] for i in range(len(last_n) - 1))
all_increasing = all(last_n[i]["value"] < last_n[i + 1]["value"] for i in range(len(last_n) - 1))
if all_decreasing:
return {
"anomaly": True,
"type": "continuous_decline",
"level": "yellow" if consecutive >= 3 else "green",
"periods": [v["period"] for v in last_n],
"values": [v["value"] for v in last_n],
"message": f"{kpi.kpi_name} 连续{consecutive}期下滑",
}
if all_increasing:
return {
"anomaly": True,
"type": "continuous_rise",
"level": "yellow" if consecutive >= 3 else "green",
"periods": [v["period"] for v in last_n],
"values": [v["value"] for v in last_n],
"message": f"{kpi.kpi_name} 连续{consecutive}期上升(可能过热)",
}
return {"anomaly": False}
# ============================================================
# 差异预警触发
# ============================================================
def run_deviation_check(db_session, period: str = None) -> int:
"""运行差异预警检查,返回新增预警数"""
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:
# 1. 差异预警:实际 vs 预算
deviation = calc_period_deviation(db_session, kpi.id, period)
if deviation.get("deviation_rate") is not None:
rate = abs(deviation["deviation_rate"])
# 差异化阈值:越高越好型 vs 越低越好型
higher_better = kpi.kpi_code in [
"SALES_TOTAL", "CUSTOMER_COUNT", "SALES_PROFIT_RATE",
"RECEIVABLE_TURNOVER", "TURNOVER_RATE",
"CUSTOMER_SATISFACTION", "ORDER_DELIVERY_RATE",
]
if higher_better:
# 实际低于预算才是问题
if deviation["actual_value"] < deviation["budget_value"] and rate >= 10:
level = "yellow" if rate >= 10 else "green"
level = "red" if rate >= 30 else level
else:
continue
else:
# 实际高于预算才是问题(成本型)
if deviation["actual_value"] > deviation["budget_value"] and rate >= 10:
level = "yellow" if rate >= 10 else "green"
level = "red" if rate >= 30 else level
else:
continue
alert_msg = (
f"{kpi.kpi_name}[{period}] 差异预警: 实际{deviation['actual_value']} "
f"vs 预算{deviation['budget_value']}"
f"差异率{deviation['deviation_rate']}%"
)
# 检查是否已有同KPI同期间的差异预警
existing = db_session.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi.id,
KPIAlert.alert_message.contains("[差异预警]"),
KPIAlert.alert_message.contains(period),
KPIAlert.status.in_(["pending", "processing"]),
).first()
if not existing:
alert = KPIAlert(
kpi_id=kpi.id,
alert_level=level,
alert_message=f"[差异预警] {alert_msg}",
status="pending",
)
db_session.add(alert)
new_count += 1
logger.info(f" 新增差异预警 [{level}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
# 2. 趋势异常检测(每期检查连续3期)
trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3)
if trend.get("anomaly") and trend.get("level") in ("yellow", "red"):
trend_alert_msg = trend["message"]
existing_trend = db_session.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi.id,
KPIAlert.alert_message.contains("[趋势预警]"),
KPIAlert.status.in_(["pending", "processing"]),
).first()
if not existing_trend:
alert = KPIAlert(
kpi_id=kpi.id,
alert_level=trend["level"],
alert_message=f"[趋势预警] {trend_alert_msg}",
status="pending",
)
db_session.add(alert)
new_count += 1
logger.info(f" 新增趋势预警 [{trend['level']}] {trend_alert_msg}")
db_session.commit()
return new_count
if __name__ == "__main__":
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
db = get_session_local()()
try:
n = run_deviation_check(db)
print(f"差异预警检查完成: 新增 {n}")
finally:
db.close()
+251
View File
@@ -0,0 +1,251 @@
"""
预警通知推送模块 — 管理会计OS
支持渠道:企业微信 (群机器人/应用消息)、邮件
"""
import json
import logging
import urllib.request
import urllib.error
import smtplib
from email.mime.text import MIMEText
from email.header import Header
from datetime import datetime
from typing import Optional
logger = logging.getLogger("cma.notifier")
# ============================================================
# 企业微信机器人推送
# ============================================================
def send_wecom_robot(webhook_url: str, title: str, content: str, alert_level: str = "yellow") -> dict:
"""通过企业微信群机器人发送告警"""
color_tag = {"red": "🔴", "yellow": "🟡", "green": "🟢"}.get(alert_level, "")
msg = {
"msgtype": "markdown",
"markdown": {
"content": f"## {color_tag} 管理会计OS预警通知\n"
f"**{title}**\n\n"
f"{content}\n\n"
f"---\n"
f"{datetime.now().strftime('%Y-%m-%d %H:%M')}"
}
}
data = json.dumps(msg).encode("utf-8")
req = urllib.request.Request(webhook_url, data=data,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read().decode())
if result.get("errcode") == 0:
return {"success": True, "message": "已推送至企业微信群"}
else:
return {"success": False, "message": f"推送失败: {result.get('errmsg', '未知错误')}"}
except Exception as e:
return {"success": False, "message": f"推送异常: {str(e)}"}
# ============================================================
# 企业微信应用消息推送(通过自建应用 message/send API
# ============================================================
def send_wecom_app(corp_id: str, corp_secret: str, agent_id: str,
touser: str, title: str, content: str, alert_level: str = "yellow") -> dict:
"""通过企微自建应用发送应用消息"""
import requests
try:
token_url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={corp_id}&corpsecret={corp_secret}"
r = requests.get(token_url, timeout=10)
token_data = r.json()
if token_data.get("errcode") != 0:
return {"success": False, "message": f"获取token失败: {token_data.get('errmsg', '')}"}
access_token = token_data["access_token"]
color_tag = {"red": "🔴", "yellow": "🟡", "green": "🟢"}.get(alert_level, "")
md = "## " + color_tag + " 管理会计OS预警通知\n\n"
md += "**" + title + "**\n\n"
md += content + "\n\n---\n"
md += "" + datetime.now().strftime("%Y-%m-%d %H:%M")
payload = {
"touser": touser,
"msgtype": "markdown",
"agentid": int(agent_id),
"markdown": {"content": md},
"safe": 0,
}
send_url = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + access_token
r2 = requests.post(send_url, json=payload, timeout=10)
send_data = r2.json()
if send_data.get("errcode") == 0:
return {"success": True, "message": f"已推送到企微用户 {touser}"}
else:
return {"success": False, "message": f"推送失败: {send_data.get('errmsg', '')}"}
except Exception as e:
return {"success": False, "message": f"推送异常: {str(e)}"}
# ============================================================
# 邮件推送
# ============================================================
def send_mail(smtp_config: dict, to_addrs: list, title: str, content: str) -> dict:
"""通过 SMTP 发送邮件告警"""
try:
msg = MIMEText(content, "plain", "utf-8")
msg["Subject"] = Header(f"[管理会计OS预警] {title}", "utf-8")
msg["From"] = smtp_config.get("from_addr", "")
msg["To"] = ", ".join(to_addrs)
host = smtp_config.get("host", "smtp.qq.com")
port = int(smtp_config.get("port", 465))
user = smtp_config.get("user", "")
password = smtp_config.get("password", "")
use_ssl = smtp_config.get("use_ssl", True)
if use_ssl:
server = smtplib.SMTP_SSL(host, port, timeout=10)
else:
server = smtplib.SMTP(host, port, timeout=10)
server.starttls()
server.login(user, password)
server.sendmail(user, to_addrs, msg.as_string())
server.quit()
return {"success": True, "message": f"已发送邮件至 {', '.join(to_addrs)}"}
except Exception as e:
return {"success": False, "message": f"邮件发送失败: {str(e)}"}
# ============================================================
# 主推送函数
# ============================================================
def push_alert(alert: dict, channels: list[dict]) -> list[dict]:
"""向所有已启用渠道推送一条预警"""
results = []
alert_level = alert.get("alert_level", "yellow")
title = alert.get("alert_message", "预警通知")
content = _build_content(alert)
for ch in channels:
if not ch.get("enabled", True):
continue
ch_type = ch.get("channel_type", "")
config = ch.get("config", {})
result = {"channel": ch_type, "channel_name": ch.get("name", ""), "success": False}
if ch_type == "wecom":
webhook = config.get("webhook_url", "")
if webhook:
result = send_wecom_robot(webhook, title, content, alert_level)
result["channel"] = "wecom"
elif ch_type == "wecom_app":
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", ""),
title=title, content=content, alert_level=alert_level,
)
result["channel"] = "wecom_app"
elif ch_type == "mail":
to_list = config.get("to", [])
if to_list:
result = send_mail(config, to_list, title, content)
result["channel"] = "mail"
results.append({**result, "channel_name": ch.get("name", "")})
return results
def _build_content(alert: dict) -> str:
"""构建预警详情内容"""
parts = [
f"KPI: {alert.get('kpi_name', '未知')}",
f"期间: {alert.get('period', '')}",
f"实际值: {alert.get('actual_value', '-')}",
f"目标值: {alert.get('target_value', '-')}",
f"预警级别: {'🔴 紧急' if alert.get('alert_level') == 'red' else '🟡 警告'}",
]
if alert.get("resolution"):
parts.append(f"处理建议: {alert['resolution']}")
return "\n".join(parts)
# ============================================================
# 从数据库加载渠道配置并推送待处理预警
# ============================================================
def push_pending_alerts(db_session) -> int:
"""推送所有待处理预警"""
from app.models import NotificationChannel, NotificationLog, KPIAlert, KPIDefinition, KPIValue
# 加载已启用的通知渠道
channels = db_session.query(NotificationChannel).filter(
NotificationChannel.enabled == True
).all()
if not channels:
logger.info("无已启用的通知渠道,跳过推送")
return 0
# 查待处理的预警
alerts = db_session.query(KPIAlert).filter(
KPIAlert.status == "pending"
).all()
if not alerts:
logger.info("无待处理预警")
return 0
channel_configs = [json.loads(json.dumps({
"name": c.name, "channel_type": c.channel_type,
"config": c.config, "enabled": c.enabled
})) for c in channels]
pushed = 0
for alert in alerts:
# 获取预警详情
kpi = db_session.query(KPIDefinition).filter(
KPIDefinition.id == alert.kpi_id
).first()
kpi_value = db_session.query(KPIValue).filter(
KPIValue.id == alert.kpi_value_id
).first()
alert_data = {
"alert_level": alert.alert_level,
"alert_message": alert.alert_message,
"kpi_name": kpi.kpi_name if kpi else "未知",
"period": kpi_value.period if kpi_value else "",
"actual_value": kpi_value.actual_value if kpi_value else None,
"target_value": kpi.target_value if kpi else None,
}
results = push_alert(alert_data, channel_configs)
# 记录推送日志
for r in results:
log = NotificationLog(
alert_id=alert.id,
channel=r.get("channel", ""),
recipient=r.get("channel_name", ""),
title=alert.alert_message[:200],
content=alert.alert_message,
status="sent" if r.get("success") else "failed",
error_msg=r.get("message") if not r.get("success") else None,
sent_at=datetime.now(),
)
db_session.add(log)
if r.get("success"):
pushed += 1
logger.info(f" 已推送预警 #{alert.id} -> {r.get('channel_name')}")
db_session.commit()
return pushed
+260
View File
@@ -0,0 +1,260 @@
"""预测模拟引擎 — 管理会计OS
CVP本量利分析、投资决策(NPV/IRR)、敏感性分析、情景模拟
"""
import math
import logging
from typing import List, Dict, Optional, Tuple
logger = logging.getLogger("cma.predict")
# ============================================================
# CVP 本量利分析
# ============================================================
def cvp_analysis(
unit_price: float, # 单价
unit_variable_cost: float, # 单位变动成本
fixed_cost: float, # 固定成本
target_profit: float = None, # 目标利润(可选)
actual_volume: float = None, # 实际销量(可选)
) -> dict:
"""CVP本量利分析
返回:盈亏平衡点、安全边际、目标利润所需销量
"""
if unit_price <= unit_variable_cost:
return {"error": "单价必须大于单位变动成本"}
contribution_margin = unit_price - unit_variable_cost # 单位边际贡献
contribution_ratio = round(contribution_margin / unit_price * 100, 2) # 边际贡献率
# 盈亏平衡点(保本点)
bep_units = round(fixed_cost / contribution_margin, 2) # 保本销量
bep_revenue = round(bep_units * unit_price, 2) # 保本销售额
result = {
"unit_price": unit_price,
"unit_variable_cost": unit_variable_cost,
"fixed_cost": fixed_cost,
"contribution_margin": round(contribution_margin, 2),
"contribution_ratio": contribution_ratio,
"bep_units": bep_units,
"bep_revenue": bep_revenue,
}
# 安全边际
if actual_volume is not None:
safety_margin_units = actual_volume - bep_units
safety_margin_ratio = round(safety_margin_units / actual_volume * 100, 2) if actual_volume > 0 else 0
actual_profit = round((unit_price - unit_variable_cost) * actual_volume - fixed_cost, 2)
result["safety_margin_units"] = round(safety_margin_units, 2)
result["safety_margin_revenue"] = round(safety_margin_units * unit_price, 2)
result["safety_margin_ratio"] = safety_margin_ratio
result["actual_profit"] = actual_profit
# 目标利润
if target_profit is not None:
target_units = round((fixed_cost + target_profit) / contribution_margin, 2)
target_revenue = round(target_units * unit_price, 2)
result["target_profit"] = target_profit
result["target_units"] = target_units
result["target_revenue"] = target_revenue
return result
# ============================================================
# 投资决策模型
# ============================================================
def npv(initial_investment: float, cash_flows: List[float], discount_rate: float) -> dict:
"""计算净现值 NPV = Σ CFt / (1+r)^t - I0"""
if not cash_flows:
return {"error": "现金流列表不能为空"}
r = discount_rate / 100
pv = 0
for t, cf in enumerate(cash_flows, 1):
pv += cf / ((1 + r) ** t)
npv_value = round(pv - initial_investment, 2)
# 盈利能力指数 PI = PV / I0
pi = round(pv / initial_investment, 4) if initial_investment > 0 else 0
return {
"initial_investment": initial_investment,
"discount_rate": discount_rate,
"pv_of_cash_flows": round(pv, 2),
"npv": npv_value,
"profitability_index": pi,
"is_viable": npv_value > 0,
}
def irr(initial_investment: float, cash_flows: List[float], max_iter: int = 1000, tolerance: float = 1e-6) -> dict:
"""计算内部收益率 IRR(迭代法)"""
if not cash_flows:
return {"error": "现金流列表不能为空"}
# 确保现金流总和 > 初始投资(否则 IRR 可能为负)
total_cf = sum(cash_flows)
if total_cf <= initial_investment:
# 用牛顿法尝试求负IRR
pass
def _npv_at(rate: float) -> float:
return sum(cf / ((1 + rate) ** (t + 1)) for t, cf in enumerate(cash_flows)) - initial_investment
# 牛顿法求根
rate = 0.1 # 初始猜测 10%
for _ in range(max_iter):
f = _npv_at(rate)
if abs(f) < tolerance:
break
# 导数近似
h = 1e-4
df = (_npv_at(rate + h) - _npv_at(rate - h)) / (2 * h)
if abs(df) < tolerance:
break
rate -= f / df
if rate < -0.99: # IRR 不能低于 -99%
rate = -0.99
break
irr_value = round(rate * 100, 2)
# 回收期
cumulative = 0
payback_period = None
for t, cf in enumerate(cash_flows, 1):
cumulative += cf
if cumulative >= initial_investment:
payback_period = t
break
# 动态回收期(折现)
r = irr_value / 100 if irr_value > 0 else 0.1
discounted_cumulative = 0
discounted_payback = None
for t, cf in enumerate(cash_flows, 1):
discounted_cumulative += cf / ((1 + r) ** t)
if discounted_cumulative >= initial_investment:
discounted_payback = t
break
return {
"initial_investment": initial_investment,
"cash_flows": cash_flows,
"irr": irr_value,
"payback_period": payback_period, # 静态回收期(年)
"discounted_payback_period": discounted_payback, # 动态回收期
"is_viable": irr_value > 0,
}
# ============================================================
# 敏感性分析
# ============================================================
def sensitivity_analysis(
base_revenue: float, # 基准收入
base_cost: float, # 基准成本
base_profit: float = None, # 基准利润(若为None则自动 = 收入-成本)
step: float = 5, # 步长 %
max_step: float = 20, # 最大变动 %
) -> dict:
"""单因素敏感性分析
分析销量、单价、成本变动对利润的影响
"""
if base_profit is None:
base_profit = base_revenue - base_cost
factors = []
steps = [s for s in range(-max_step, max_step + 1, step)] or [0]
for pct in steps:
factor = pct / 100
# 收入变动(销量变动)
revenue_change_profit = base_profit * (1 + factor)
rev_sensitivity = round((revenue_change_profit - base_profit) / base_profit * 100, 2) if base_profit else 0
# 成本变动
cost_change_profit = base_profit - base_cost * factor
cost_sensitivity = round((cost_change_profit - base_profit) / base_profit * 100, 2) if base_profit else 0
# 同时变动(收入+5%同时成本+5%)
both_profit = (base_revenue * (1 + factor)) - (base_cost * (1 + factor))
both_sensitivity = round((both_profit - base_profit) / base_profit * 100, 2) if base_profit else 0
factors.append({
"change_pct": pct,
"revenue_change_profit": round(revenue_change_profit, 2),
"revenue_sensitivity": rev_sensitivity,
"cost_change_profit": round(cost_change_profit, 2),
"cost_sensitivity": cost_sensitivity,
"both_change_profit": round(both_profit, 2),
"both_sensitivity": both_sensitivity,
})
return {
"base_revenue": base_revenue,
"base_cost": base_cost,
"base_profit": round(base_profit, 2),
"step": step,
"max_step": max_step,
"factors": factors,
}
# ============================================================
# 情景模拟
# ============================================================
def scenario_analysis(
optimistic: dict, # {"revenue": 130, "cost": 90}
pessimistic: dict, # {"revenue": 80, "cost": 110}
base: dict, # {"revenue": 100, "cost": 100}
) -> dict:
"""三情景模拟(乐观/中性/悲观)
每个情景包含 revenue(收入) 和 cost(成本)
计算各情景下的利润和偏差
"""
scenarios = []
for label, data in [("乐观", optimistic), ("中性", base), ("悲观", pessimistic)]:
revenue = data.get("revenue", 0)
cost = data.get("cost", 0)
profit = round(revenue - cost, 2)
scenarios.append({
"scenario": label,
"revenue": revenue,
"cost": cost,
"profit": profit,
"profit_margin": round(profit / revenue * 100, 2) if revenue else 0,
})
base_profit = scenarios[1]["profit"] # 中性情景利润
for s in scenarios:
if base_profit:
s["deviation_from_base"] = round(s["profit"] - base_profit, 2)
s["deviation_pct"] = round((s["profit"] - base_profit) / base_profit * 100, 2)
else:
s["deviation_from_base"] = s["profit"]
s["deviation_pct"] = 0
# 最好/最坏/期望值(假设各1/3概率)
expected_profit = round(
(scenarios[0]["profit"] + scenarios[1]["profit"] + scenarios[2]["profit"]) / 3, 2
)
variance = sum((s["profit"] - expected_profit) ** 2 for s in scenarios) / 3
std_dev = round(math.sqrt(variance), 2)
return {
"scenarios": scenarios,
"expected_profit": expected_profit,
"std_deviation": std_dev,
"best_case": scenarios[0],
"worst_case": scenarios[2],
}