ci/woodpecker/push/main Pipeline failed
Scope: backend/app/api/{alerts,action_plans,budget,cost}.py + tests
Task: 0-P1-project-20260918-entity-isolation-batch2
379 lines
16 KiB
Python
379 lines
16 KiB
Python
"""成本分析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.auth_middleware import require_auth_401
|
||
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")
|
||
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
|
||
# 本 router 原无任何鉴权依赖(端点仅 assert_entity_scope,无 token 走"Bot 服务通道"分支放行)
|
||
# → 无 token 可读成本数据、可写标准成本。收口为 require_auth_401(无 token → 401)。
|
||
# Bot 通道走 /api/cma/bot/cost/*(X-BOT-KEY)不受影响。
|
||
router = APIRouter(prefix="/api/cma/cost", tags=["成本分析"],
|
||
dependencies=[Depends(require_auth_401)])
|
||
|
||
|
||
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 作业成本
|
||
# 账套隔离(2026-09-18,0-P1-project-20260918-entity-isolation-batch2):
|
||
# GET /abc/activities 原为全表取(生产 10 行全属 entity2)→ 酣客会话可见博海 ABC 作业中心;
|
||
# 现按会话账套过滤(AbcActivity.entity_id)。
|
||
# OpenCode 多租户审查 2026-09-07 的「单实体演示数据暂不强制隔离」豁免就此撤销。
|
||
# ============================================================
|
||
|
||
@router.get("/abc/activities")
|
||
def list_abc_activities(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||
"""查询ABC作业中心列表(账套隔离:只返回本账套)"""
|
||
items = (db.query(AbcActivity)
|
||
.filter(AbcActivity.entity_id == entity_id)
|
||
.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),
|
||
db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id)):
|
||
"""成本总览(总成本、结构占比、趋势)— 多租户隔离 2026-09-07"""
|
||
if period is None:
|
||
period = datetime.now().strftime("%Y-%m")
|
||
return get_cost_overview(period, entity_id=entity_id)
|
||
|
||
|
||
@router.get("/variance")
|
||
def cost_variance(product_code: str = Query(...),
|
||
period: Optional[str] = Query(None),
|
||
db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id)):
|
||
"""量差价差分析 — 多租户隔离 2026-09-07"""
|
||
if period is None:
|
||
period = datetime.now().strftime("%Y-%m")
|
||
return calc_product_variance(product_code, period, entity_id=entity_id)
|
||
|
||
|
||
@router.get("/breakdown")
|
||
def cost_breakdown(product_code: str = Query(...),
|
||
period: Optional[str] = Query(None),
|
||
db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id)):
|
||
"""成本构成(料/工/费占比)— 多租户隔离 2026-09-07"""
|
||
if period is None:
|
||
period = datetime.now().strftime("%Y-%m")
|
||
return get_cost_breakdown(product_code, period, entity_id=entity_id)
|
||
|
||
|
||
@router.get("/comparison")
|
||
def cost_method_comparison(entity: str = Query("hanke")):
|
||
"""三种成本法对比分析:传统/变动/作业成本法 (CMA P1)
|
||
注:纯静态教学样例数据(无 DB 访问),不涉多租户泄漏——OpenCode 审查 2026-09-07 豁免标注"""
|
||
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),
|
||
db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id)):
|
||
"""成本分析首页—汇总数据(多租户隔离 2026-09-07: ActualCost 按账套过滤)"""
|
||
if period is None:
|
||
period = datetime.now().strftime("%Y-%m")
|
||
overview = get_cost_overview(period, entity_id=entity_id)
|
||
|
||
# 获取所有产品列表(本账套)
|
||
db_session = db if db is not None else get_db().__next__()
|
||
try:
|
||
products = db_session.query(ActualCost.product_code, ActualCost.product_name).filter(
|
||
ActualCost.entity_id == entity_id,
|
||
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_session.query(ActualCost).filter(
|
||
ActualCost.entity_id == entity_id,
|
||
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:
|
||
if db is None:
|
||
db_session.close()
|
||
|
||
return {
|
||
"period": period,
|
||
"overview": overview,
|
||
"products": product_list,
|
||
# 修复既有bug(2026-09-07测试实证): 原对 product_list 求 p["total_cost"] 必 KeyError(产品列表无total_cost键)
|
||
# → 改为 product_costs(含各产品总额),有产品数据时端点不再 500
|
||
"total_cost": round(sum(p["total_cost"] for p in product_costs) + overview.get("erp_cost", 0), 2) if product_costs else 0,
|
||
}
|