Files
cma-management/backend/app/api/cost.py
T
Hermes CI Fix 696f8bc934 feat(cost): 成本模型加研发归集维度 research_flag/project_type (cma-rd-dimension-20260905)
- StandardCost/ActualCost 增加 research_flag(int,idx)/project_type(varchar30,idx)
  project_type: platform_dev平台研发/client_delivery客户交付/internal内部管理
- 生产库两表 ALTER 完成, 存量 29+134 行默认 internal(保守防误归研发)
- API: create/update 支持写入维度, 未传默认 client_delivery(录入默认),
  research_flag 未显式传入按 project_type 联动(platform_dev=1);
  create/update/delete 绑定 entity_id(token优先, 防跨账套)
- API: standard/actual 列表支持 project_type/research_flag 过滤
- 前端 CostDashboard: 表单加归集类型下拉(默认客户交付+研发提示),
  列表加归集类型列(标签), 工具栏按归集类型过滤
- tests: test_cost.py +6 用例(默认值/联动/过滤/更新)
- scripts: verify_cost_research_dimension_e2e.py 生产E2E(标记+清理+回查)
2026-09-05 09:10:35 +08:00

352 lines
14 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.
"""成本分析API — 管理会计OS"""
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import get_entity_id
from app.models import StandardCost, ActualCost, AbcActivity, AbcAllocation
from app.models.cost_model import RESEARCH_PROJECT_TYPES
from app.utils.cost_engine import (
calc_product_variance, get_cost_overview, get_cost_breakdown,
calc_driver_rate, allocate_cost
)
logger = logging.getLogger("cma.cost")
router = APIRouter(prefix="/api/cma/cost", tags=["成本分析"])
def _resolve_research_dims(data: dict, current=None) -> tuple:
"""解析研发归集维度(research_flag/project_type)。
- project_type 缺省:新建=client_delivery(录入默认),编辑=保留现值
- research_flag 未显式传入 → 按 project_type 联动(platform_dev=研发1,其余=0)
- 显式传入 research_flag 尊重原值(可覆盖联动)
"""
if current is not None:
project_type = data.get("project_type") or current.project_type or "client_delivery"
else:
project_type = data.get("project_type") or "client_delivery"
if project_type not in RESEARCH_PROJECT_TYPES:
raise HTTPException(400, f"project_type 非法: {project_type},可选 {', '.join(RESEARCH_PROJECT_TYPES)}")
if data.get("research_flag") is not None:
research_flag = int(bool(data.get("research_flag")))
else:
research_flag = 1 if project_type == "platform_dev" else 0
return project_type, research_flag
# ============================================================
# 标准成本卡片 CRUD
# ============================================================
@router.get("/standard-costs")
def list_standard_costs(product_code: Optional[str] = Query(None),
cost_type: Optional[str] = Query(None),
project_type: Optional[str] = Query(None),
research_flag: Optional[int] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""查询标准成本卡片"""
query = db.query(StandardCost).filter(StandardCost.status == "active", StandardCost.entity_id == entity_id)
if product_code:
query = query.filter(StandardCost.product_code == product_code)
if cost_type:
query = query.filter(StandardCost.cost_type == cost_type)
if project_type:
query = query.filter(StandardCost.project_type == project_type)
if research_flag is not None:
query = query.filter(StandardCost.research_flag == research_flag)
items = query.order_by(StandardCost.product_code, StandardCost.cost_type).all()
return {"data": items}
@router.post("/standard-costs")
def create_standard_cost(data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""创建标准成本卡片"""
project_type, research_flag = _resolve_research_dims(data)
sc = StandardCost(
entity_id=entity_id,
project_type=project_type,
research_flag=research_flag,
product_code=data["product_code"],
product_name=data.get("product_name", ""),
cost_type=data["cost_type"],
item_name=data["item_name"],
standard_quantity=data["standard_quantity"],
unit=data.get("unit", ""),
standard_price=data["standard_price"],
standard_cost=round(data["standard_quantity"] * data["standard_price"], 2),
version=data.get("version", "v1.0"),
remark=data.get("remark"),
)
db.add(sc)
db.commit()
return {"message": "标准成本已创建", "id": sc.id}
@router.put("/standard-costs/{cost_id}")
def update_standard_cost(cost_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""修改标准成本卡片"""
sc = db.query(StandardCost).filter(StandardCost.id == cost_id,
StandardCost.entity_id == entity_id).first()
if not sc:
raise HTTPException(404, "标准成本记录不存在")
for k in ("product_code", "product_name", "cost_type", "item_name",
"standard_quantity", "unit", "standard_price", "version", "remark"):
if k in data:
setattr(sc, k, data[k])
project_type, research_flag = _resolve_research_dims(data, current=sc)
sc.project_type = project_type
sc.research_flag = research_flag
sc.standard_cost = round(sc.standard_quantity * sc.standard_price, 2)
db.commit()
return {"message": "已更新"}
@router.delete("/standard-costs/{cost_id}")
def delete_standard_cost(cost_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""删除标准成本卡片"""
sc = db.query(StandardCost).filter(StandardCost.id == cost_id,
StandardCost.entity_id == entity_id).first()
if not sc:
raise HTTPException(404, "标准成本记录不存在")
sc.status = "archived"
db.commit()
return {"message": "已归档"}
# ============================================================
# 实际成本 CRUD
# ============================================================
@router.get("/actual-costs")
def list_actual_costs(period: Optional[str] = Query(None),
product_code: Optional[str] = Query(None),
project_type: Optional[str] = Query(None),
research_flag: Optional[int] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""查询实际成本"""
query = db.query(ActualCost).filter(ActualCost.entity_id == entity_id)
if period:
query = query.filter(ActualCost.period == period)
if product_code:
query = query.filter(ActualCost.product_code == product_code)
if project_type:
query = query.filter(ActualCost.project_type == project_type)
if research_flag is not None:
query = query.filter(ActualCost.research_flag == research_flag)
items = query.order_by(ActualCost.period.desc(), ActualCost.product_code).all()
return {"data": items}
@router.post("/actual-costs")
def create_actual_cost(data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""录入实际成本"""
project_type, research_flag = _resolve_research_dims(data)
ac = ActualCost(
entity_id=entity_id,
project_type=project_type,
research_flag=research_flag,
period=data["period"],
product_code=data["product_code"],
product_name=data.get("product_name", ""),
cost_type=data["cost_type"],
item_name=data.get("item_name", ""),
actual_quantity=data["actual_quantity"],
actual_price=data["actual_price"],
actual_cost=round(data["actual_quantity"] * data["actual_price"], 2),
source=data.get("source", "manual"),
)
db.add(ac)
db.commit()
return {"message": "实际成本已录入", "id": ac.id}
# ============================================================
# ABC 作业成本
# ============================================================
@router.get("/abc/activities")
def list_abc_activities(db: Session = Depends(get_db)):
"""查询ABC作业中心列表"""
items = db.query(AbcActivity).order_by(AbcActivity.activity_code).all()
return {"data": items}
@router.post("/abc/activities")
def create_abc_activity(data: dict, db: Session = Depends(get_db)):
"""创建ABC作业中心"""
act = AbcActivity(
activity_code=data["activity_code"],
activity_name=data["activity_name"],
activity_desc=data.get("activity_desc"),
cost_driver=data["cost_driver"],
driver_unit=data.get("driver_unit"),
total_cost=data.get("total_cost", 0),
driver_volume=data.get("driver_volume", 0),
)
act.driver_rate = round(act.total_cost / act.driver_volume, 4) if act.driver_volume > 0 else 0
db.add(act)
db.commit()
return {"message": "作业中心已创建", "id": act.id}
@router.post("/abc/allocate")
def do_allocate(data: dict, db: Session = Depends(get_db)):
"""执行ABC成本分配"""
result = allocate_cost(
activity_id=data["activity_id"],
period=data.get("period", datetime.now().strftime("%Y-%m")),
product_code=data["product_code"],
product_name=data.get("product_name", ""),
driver_consumed=data["driver_consumed"],
)
return result
@router.get("/abc/allocations")
def list_allocations(period: Optional[str] = Query(None),
product_code: Optional[str] = Query(None),
db: Session = Depends(get_db)):
"""查询ABC分配记录"""
query = db.query(AbcAllocation)
if period:
query = query.filter(AbcAllocation.period == period)
if product_code:
query = query.filter(AbcAllocation.product_code == product_code)
items = query.order_by(AbcAllocation.period.desc()).all()
return {"data": items}
# ============================================================
# 分析看板
# ============================================================
@router.get("/overview")
def cost_overview(period: Optional[str] = Query(None)):
"""成本总览(总成本、结构占比、趋势)"""
if period is None:
period = datetime.now().strftime("%Y-%m")
return get_cost_overview(period)
@router.get("/variance")
def cost_variance(product_code: str = Query(...),
period: Optional[str] = Query(None)):
"""量差价差分析"""
if period is None:
period = datetime.now().strftime("%Y-%m")
return calc_product_variance(product_code, period)
@router.get("/breakdown")
def cost_breakdown(product_code: str = Query(...),
period: Optional[str] = Query(None)):
"""成本构成(料/工/费占比)"""
if period is None:
period = datetime.now().strftime("%Y-%m")
return get_cost_breakdown(product_code, period)
@router.get("/comparison")
def cost_method_comparison(entity: str = Query("hanke")):
"""三种成本法对比分析:传统/变动/作业成本法 (CMA P1)"""
if entity == "hanke":
gross_revenue = 713 # 万
channel_rebate_rate = 0.828
net_revenue = round(gross_revenue * (1 - channel_rebate_rate), 2)
weighted_cost_rate = 0.4862
book_cost = 717 # 万
total_expenses = 546 # 万
non_value_added = 26 # 万
traditional = {
"revenue": gross_revenue,
"cost": book_cost,
"gross_profit": round(gross_revenue - book_cost, 2),
"gross_margin": round((gross_revenue - book_cost) / gross_revenue * 100, 2),
"expenses": total_expenses,
"net_profit": round(gross_revenue - book_cost - total_expenses, 2),
}
var_cost = round(net_revenue * weighted_cost_rate, 2)
variable = {
"revenue": net_revenue,
"cost": var_cost,
"gross_profit": round(net_revenue - var_cost, 2),
"gross_margin": round((1 - weighted_cost_rate) * 100, 2),
"expenses": total_expenses,
"net_profit": round(net_revenue - var_cost - total_expenses, 2),
}
abc_cost = round(net_revenue * weighted_cost_rate, 2)
abc_exp = total_expenses - non_value_added
abc = {
"revenue": net_revenue,
"cost": abc_cost,
"gross_profit": round(net_revenue - abc_cost, 2),
"gross_margin": round((1 - weighted_cost_rate) * 100, 2),
"expenses": abc_exp,
"net_profit": round(net_revenue - abc_cost - abc_exp, 2),
}
return {
"entity": "hanke",
"entity_name": "陕西酣客(白酒经销)",
"traditional": traditional,
"variable": variable,
"abc": abc,
"insights": [
{"method": "传统成本法", "conclusion": "毛利率为负", "decision": "❌ 不赚钱,别卖了", "detail": "未剔除渠补,账面收入虚高"},
{"method": "变动成本法", "conclusion": "毛利率18.6%", "decision": "✅ 业务能赚钱→砍费用", "detail": "剔除渠补后净收入122万,成本率48.62%"},
{"method": "作业成本法", "conclusion": "识别非增值作业26万", "decision": "✅ 可再省", "detail": "剔除冗余招待费等非增值作业"},
],
"notes": {
"net_revenue": "毛收入713万 × (1-渠补率82.8%) = 净收入122万",
"variable_cost": "净收入122万 × 加权成本率48.62% = 成本59.3万",
"abc_expenses": "总费用546万 - 非增值作业26万 = 520万",
},
}
return {"error": "不支持的实体"}
@router.get("/dashboard")
def cost_dashboard(period: Optional[str] = Query(None)):
"""成本分析首页—汇总数据"""
if period is None:
period = datetime.now().strftime("%Y-%m")
overview = get_cost_overview(period)
# 获取所有产品列表
db = get_db().__next__()
try:
products = db.query(ActualCost.product_code, ActualCost.product_name).filter(
ActualCost.period == period
).distinct().all()
product_list = [{"code": p[0], "name": p[1]} for p in products]
# 各产品成本
product_costs = []
for code, name in products:
costs = db.query(ActualCost).filter(
ActualCost.product_code == code,
ActualCost.period == period,
).all()
total = round(sum(c.actual_cost for c in costs), 2)
product_costs.append({"product_code": code, "product_name": name, "total_cost": total})
finally:
db.close()
return {
"period": period,
"overview": overview,
"products": product_list,
"total_cost": round(sum(p["total_cost"] for p in product_list) + overview.get("erp_cost", 0), 2) if product_list else 0,
}