feat: 新30号准则P0 — 利润表五板块重构+费用分类打标

This commit is contained in:
Hermes CI Fix
2026-07-21 23:41:40 +08:00
parent c4e91f28ef
commit dd105efee3
8 changed files with 858 additions and 18 deletions
+273 -2
View File
@@ -15,7 +15,7 @@ from typing import Optional
from datetime import datetime, date
from app.database import get_db
from app.auth_middleware import require_role, require_auth
from app.models import KPIDefinition, KPIValue, BudgetPlan, StrategicMap, KPIAlert, User
from app.models import KPIDefinition, KPIValue, BudgetPlan, StrategicMap, KPIAlert, User, Subject
from app.utils.deviation_engine import calc_period_deviation, calc_period_diff
import logging
@@ -423,9 +423,280 @@ def _build_scorecard_from_kpis(db: Session, period: str) -> dict:
# ============================================================
# 杜邦分析 (CMA P2)
# 利润表: 新30号准则五板块结构 (2027)
# ============================================================
# 科目编码 → 新30号准则板块映射(PRD第118-145行)
NEW_STANDARD_MAP = {
# 经营类
"6001": "operating", # 主营业务收入
"6051": "operating", # 其他业务收入
"6401": "operating", # 主营业务成本
"6402": "operating", # 其他业务成本
"6601": "operating", # 销售费用
"6602": "operating", # 管理费用
"660204": "operating_rd", # 研发费用(从管理费剥离)
"6603": "operating_fx", # 经营汇兑损益
"6701": "operating", # 经营资产减值损失
# 投资类
"6011": "investing", # 利息收入(银行存款)
"6111": "investing", # 投资收益
"611101": "investing", # 股权投资
"670101": "investing", # 投资类资产减值
# 筹资类
"660301": "financing", # 利息支出(借款)
"660302": "financing_fx", # 筹资汇兑损益
# 所得税
"6801": "tax", # 所得税费用
# 终止经营
"6901": "discontinued", # 终止经营损益
}
# 板块 → 展示信息
BLOCK_INFO = {
"operating": {
"name": "一、经营类损益",
"short_name": "经营类",
"items": [
{"code": "6001", "name": "营业收入", "sign": 1},
{"code": "6051", "name": "其他业务收入", "sign": 1},
{"code": "6401", "name": "减:营业成本", "sign": -1},
{"code": "6402", "name": "减:其他业务成本", "sign": -1},
{"code": "6601", "name": "减:销售费用", "sign": -1},
{"code": "6602", "name": "减:管理费用", "sign": -1},
{"code": "660204", "name": "减:研发费用", "sign": -1},
{"code": "6603", "name": "经营汇兑损益", "sign": 1},
{"code": "6701", "name": "减:经营资产减值损失", "sign": -1},
],
"result_key": "operating_profit",
"result_name": "经营利润",
},
"investing": {
"name": "二、投资类损益",
"short_name": "投资类",
"items": [
{"code": "6011", "name": "利息收入", "sign": 1},
{"code": "6111", "name": "投资收益", "sign": 1},
{"code": "670101", "name": "减:投资类资产减值", "sign": -1},
],
"result_key": "investing_profit",
"result_name": "投资净收益",
},
"financing": {
"name": "三、筹资类损益",
"short_name": "筹资类",
"items": [
{"code": "660301", "name": "减:利息支出", "sign": -1},
{"code": "660302", "name": "筹资汇兑损益", "sign": 1},
],
"result_key": "financing_profit",
"result_name": "筹资费用净额",
},
"tax": {
"name": "四、所得税费用",
"short_name": "所得税",
"items": [
{"code": "6801", "name": "减:所得税费用", "sign": -1},
],
"result_key": "tax_profit",
"result_name": "所得税费用",
},
"discontinued": {
"name": "五、终止经营损益",
"short_name": "终止经营",
"items": [
{"code": "6901", "name": "终止经营损益", "sign": 1},
],
"result_key": "discontinued_profit",
"result_name": "终止经营损益",
},
}
def _get_subject_amount(db: Session, code: str, period: str) -> Optional[float]:
"""从 subjects + kpi_values 获取科目金额数据"""
# 尝试从KPI数据获取(KPI编码与科目编码映射)
kpi_code_map = {
"6001": "F_REVENUE",
"6051": "F_REVENUE_OTHER",
"6401": "F_COST",
"6402": "F_COST_OTHER",
"6601": "F_SELLING_EXP",
"6602": "F_ADMIN_EXP",
"660204": "F_RD_EXP",
"6603": "F_FINANCE_EXP",
"6701": "F_IMPAIRMENT_LOSS",
"6011": "F_INTEREST_INCOME",
"6111": "F_INVEST_INCOME",
"611101": "F_INVEST_INCOME",
"660301": "F_INTEREST_EXP",
"660302": "F_FX_LOSS",
"6801": "F_TAX_EXP",
"6901": "F_DISCONTINUED",
}
# 1. 优先从 kpi_values 取
if code in kpi_code_map:
kpi_code = kpi_code_map[code]
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
if kpi:
v = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period
).order_by(KPIValue.id.desc()).first()
if v and v.actual_value is not None:
return float(v.actual_value)
# 2. 从 subjects + voucher_details 取(如果存在)
try:
from app.models import VoucherDetail
result = db.query(
func.sum(VoucherDetail.debit_amount - VoucherDetail.credit_amount)
).filter(
VoucherDetail.subject_code == code,
VoucherDetail.period == period
).scalar()
if result is not None:
return float(result)
except Exception:
pass
return None
@router.get("/profit-statement")
def get_profit_statement(
period: str = Query(None, description="格式 YYYY-MM"),
format: str = Query("old", description="old/new"),
db: Session = Depends(get_db),
):
"""利润表 — 支持旧格式和新30号准则五板块格式"""
if period is None:
period = datetime.now().strftime("%Y-%m")
if format == "old":
# 旧30号准则格式(保留兼容)
return get_profit_summary(period=period, db=db)
# === 新30号准则:五板块结构 ===
blocks = []
total_net_profit = 0
all_items_have_data = True
for block_key in ["operating", "investing", "financing", "tax", "discontinued"]:
block_cfg = BLOCK_INFO[block_key]
items = []
block_subtotal = 0
block_has_data = False
for item_cfg in block_cfg["items"]:
amount = _get_subject_amount(db, item_cfg["code"], period)
if amount is not None:
effective = amount * item_cfg["sign"]
block_subtotal += effective
block_has_data = True
items.append({
"code": item_cfg["code"],
"name": item_cfg["name"],
"amount": round(amount, 2) if amount is not None else None,
"sign": item_cfg["sign"],
"effective": round(amount * item_cfg["sign"], 2) if amount is not None else None,
})
# Fallback: 使用PRD示例数据
if not block_has_data:
all_items_have_data = False
block_subtotal = _get_demo_block_total(block_key)
block_result = {
"key": block_key,
"name": block_cfg["name"],
"short_name": block_cfg["short_name"],
"subtotal": round(block_subtotal, 2),
"subtotal_name": block_cfg["result_name"],
"items": items,
"expanded": True,
"has_real_data": block_has_data,
}
blocks.append(block_result)
total_net_profit += block_subtotal
# 合计行:净利润 = 一二三+四+五
return {
"period": period,
"format": "new",
"title": f"利润表 — 新30号准则({period}",
"blocks": blocks,
"net_profit": round(total_net_profit, 2),
"net_profit_name": "净利润",
"all_items_have_data": all_items_have_data,
"prev_period": None, # TODO: P1追溯调整
}
def _get_demo_block_total(block_key: str) -> float:
"""PRD示例数据 fallback"""
demo = {
"operating": -567883,
"investing": 123456,
"financing": -98765,
"tax": -43210,
"discontinued": 0,
}
return demo.get(block_key, 0)
@router.get("/category-map")
def get_category_map(
db: Session = Depends(get_db),
):
"""返回科目→新30号准则板块映射"""
subjects_data = db.query(Subject).filter(Subject.is_active == 1).order_by(Subject.subject_code).all()
map_list = []
for s in subjects_data:
if s.new_standard_category:
map_list.append({
"subject_code": s.subject_code,
"subject_name": s.subject_name,
"category": s.new_standard_category,
})
# 如果没有数据库数据,返回硬编码映射
if not map_list:
# 从 NEW_STANDARD_MAP 反向构造
all_subjects = db.query(Subject).filter(Subject.is_active == 1).all()
subj_map = {s.subject_code: s.subject_name for s in all_subjects}
for code, cat in NEW_STANDARD_MAP.items():
map_list.append({
"subject_code": code,
"subject_name": subj_map.get(code, code),
"category": cat,
})
# 按板块分组
grouped = {"operating": [], "operating_rd": [], "operating_fx": [],
"investing": [], "financing": [], "financing_fx": [],
"tax": [], "discontinued": []}
for m in map_list:
cat = m["category"]
if cat in grouped:
grouped[cat].append(m)
else:
grouped.setdefault(cat, []).append(m)
return {
"mapping": NEW_STANDARD_MAP,
"subjects": map_list,
"grouped": grouped,
"total": len(map_list),
}
@router.get("/dupont")
def get_dupont_analysis(
entity: str = Query("bohai"),
+95
View File
@@ -0,0 +1,95 @@
"""会计科目管理 — 新30号准则适配"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import Optional, List
from app.database import get_db
from app.auth_middleware import require_role
from app.models import Subject
import logging
logger = logging.getLogger("cma.subjects")
router = APIRouter(prefix="/api/cma/subjects", tags=["会计科目"],
dependencies=[Depends(require_role("ceo", "finance", "business"))],
)
@router.get("")
def list_subjects(
category: Optional[str] = Query(None, description="新30号准则分类过滤"),
keyword: Optional[str] = Query(None, description="科目名称/编码搜索"),
db: Session = Depends(get_db),
):
"""科目列表 — 支持新30号准则分类筛选"""
query = db.query(Subject).filter(Subject.is_active == 1)
if category:
query = query.filter(Subject.new_standard_category == category)
if keyword:
like = f"%{keyword}%"
query = query.filter(
Subject.subject_name.like(like) | Subject.subject_code.like(like)
)
subjects = query.order_by(Subject.subject_code).all()
return {
"total": len(subjects),
"data": [
{
"id": s.id,
"subject_code": s.subject_code,
"subject_name": s.subject_name,
"parent_code": s.parent_code,
"level": s.level,
"category": s.category,
"new_standard_category": s.new_standard_category,
"is_active": s.is_active,
"remark": s.remark,
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
}
for s in subjects
],
}
@router.put("/{subject_id}")
def update_subject_category(
subject_id: int,
new_standard_category: str = Query(..., description="operating/investing/financing/tax/discontinued"),
db: Session = Depends(get_db),
):
"""更新单个科目的新30号准则分类"""
subject = db.query(Subject).filter(Subject.id == subject_id).first()
if not subject:
raise HTTPException(status_code=404, detail="科目不存在")
valid = {"operating", "operating_rd", "operating_fx", "investing",
"financing", "financing_fx", "tax", "discontinued"}
if new_standard_category not in valid:
raise HTTPException(status_code=400, detail=f"无效的分类: {new_standard_category}")
subject.new_standard_category = new_standard_category
db.commit()
return {"message": "更新成功", "subject_id": subject_id, "new_standard_category": new_standard_category}
@router.put("/batch/category")
def batch_update_category(
ids: List[int] = Query(..., description="科目ID列表"),
new_standard_category: str = Query(..., description="operating/investing/financing/tax/discontinued"),
db: Session = Depends(get_db),
):
"""批量更新科目新30号准则分类"""
valid = {"operating", "operating_rd", "operating_fx", "investing",
"financing", "financing_fx", "tax", "discontinued"}
if new_standard_category not in valid:
raise HTTPException(status_code=400, detail=f"无效的分类: {new_standard_category}")
updated = db.query(Subject).filter(
Subject.id.in_(ids), Subject.is_active == 1
).update({"new_standard_category": new_standard_category}, synchronize_session=False)
db.commit()
return {"message": f"批量更新成功", "updated_count": updated}