Files
cma-management/backend/app/utils/cost_engine.py
T
Hermes CI Fix 9fb9d14a58 fix(cma): entity isolation batch3 (cost abc write endpoints + allocations/stats reads + plan kr_id scope)
- cost.py: POST /abc/activities 按会话账套写入 entity_id;POST /abc/allocate 校验 activity_id 归属本账套(跨账套 404)并透传 entity_id;GET /abc/allocations 加账套过滤
- cost_engine.py: allocate_cost(entity_id=None) 按账套过滤作业中心 + 显式写入 AbcAllocation.entity_id(不传维持原语义)
- action_plans.py: update_plan 的 kr_id 加账套校验(跨账套 404,对齐 create_plan 既有写法);plan_stats join KPIDefinition 按账套过滤(口径与 list_plans 一致)
- tests: 新增 tests/test_cross_entity_isolation_batch3_20260918.py(10 例;改前 7 failed/3 passed → 改后 10 passed)
- 回归: 1184 passed,1 xfailed → 1194 passed,1 xfailed(无新增失败)
2026-09-18 18:42:38 +08:00

311 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""成本分析引擎 — 管理会计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", "")
# ============================================================
# 差异计算
# ============================================================
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, entity_id: Optional[int] = None) -> dict:
"""计算指定产品在指定期间的成本差异(entity_id=None 时不过滤,API 调用必传——多租户隔离 2026-09-07)"""
db = get_session_local()()
try:
q_std = db.query(StandardCost).filter(
StandardCost.product_code == product_code,
StandardCost.status == "active",
)
q_act = db.query(ActualCost).filter(
ActualCost.product_code == product_code,
ActualCost.period == period,
)
if entity_id is not None:
q_std = q_std.filter(StandardCost.entity_id == entity_id)
q_act = q_act.filter(ActualCost.entity_id == entity_id)
standards = q_std.all()
actuals = q_act.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,
entity_id: Optional[int] = None) -> dict:
"""按动因分配成本到产品
账套隔离(2026-09-18,project-20260918-entity-isolation-batch3):
- entity_id 非 None 时,作业中心查询按本账套过滤(跨账套 activity_id 查不到 → 返回错误字典,
端点侧已先行 404);
- 新建 AbcAllocation 显式写入 entity_id(不传则维持原语义:不写归属,落模型默认)。
"""
db = get_session_local()()
try:
q_act = db.query(AbcActivity).filter(AbcActivity.id == activity_id)
if entity_id is not None:
q_act = q_act.filter(AbcActivity.entity_id == entity_id)
act = q_act.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_kwargs = dict(
period=period,
activity_id=activity_id,
product_code=product_code,
product_name=product_name,
driver_consumed=driver_consumed,
allocated_cost=allocated,
)
if entity_id is not None:
alloc_kwargs["entity_id"] = entity_id
alloc = AbcAllocation(**alloc_kwargs)
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, entity_id: Optional[int] = None) -> dict:
"""获取成本总览数据(总成本、结构占比、趋势)
entity_id=None 时不过滤(兼容存量调用);API 调用必须传 entity_id —— 多租户隔离 2026-09-07"""
db = get_session_local()()
try:
# 从实际成本表汇总(按账套过滤)
q_costs = db.query(ActualCost).filter(ActualCost.period == period)
if entity_id is not None:
q_costs = q_costs.filter(ActualCost.entity_id == entity_id)
actual_costs = q_costs.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):
q_trend = db.query(ActualCost).filter(ActualCost.period == p)
if entity_id is not None:
q_trend = q_trend.filter(ActualCost.entity_id == entity_id)
costs = q_trend.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, entity_id: Optional[int] = None) -> dict:
"""获取成本构成(料/工/费占比)(entity_id=None 不过滤——多租户隔离 2026-09-07,API 必传)"""
db = get_session_local()()
try:
q_actuals = db.query(ActualCost).filter(
ActualCost.product_code == product_code,
ActualCost.period == period,
)
if entity_id is not None:
q_actuals = q_actuals.filter(ActualCost.entity_id == entity_id)
actuals = q_actuals.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()