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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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],
|
||||
}
|
||||
Reference in New Issue
Block a user