283 lines
11 KiB
Python
283 lines
11 KiB
Python
"""成本分析引擎 — 管理会计OS
|
|
标准成本vs实际成本差异分析(量差/价差/效率差异)
|
|
ABC作业成本法分配
|
|
"""
|
|
import logging, os
|
|
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 = os.environ.get("ERP_API_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()
|