feat: 新30号准则P0 — 利润表五板块重构+费用分类打标
This commit is contained in:
+273
-2
@@ -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"),
|
||||
|
||||
@@ -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}
|
||||
+2
-1
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from dotenv import load_dotenv
|
||||
from app.database import init_db
|
||||
from app.api import auth, kpis, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates
|
||||
from app.api import auth, kpis, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects
|
||||
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
||||
from scripts.erp_sync import run_sync as run_erp_sync
|
||||
from app.auth_middleware import require_auth
|
||||
@@ -66,6 +66,7 @@ app.include_router(entities.router)
|
||||
app.include_router(bsc_layers.router)
|
||||
app.include_router(okr.router)
|
||||
app.include_router(okr_templates.router)
|
||||
app.include_router(subjects.router)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
|
||||
@@ -404,3 +404,39 @@ class ScenarioSuggestion(Base):
|
||||
priority = Column(String(20), default="medium", comment="high/medium/low")
|
||||
sort_order = Column(Integer, default=0, comment="排序")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 新30号准则模型 (2027)
|
||||
# ============================================================
|
||||
|
||||
class Subject(Base):
|
||||
"""会计科目 — 新30号准则分类"""
|
||||
__tablename__ = "subjects"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
subject_code = Column(String(20), nullable=False, unique=True, comment="科目编码")
|
||||
subject_name = Column(String(200), nullable=False, comment="科目名称")
|
||||
parent_code = Column(String(20), nullable=True, comment="上级科目编码")
|
||||
level = Column(Integer, default=1, comment="科目级别 1-4")
|
||||
category = Column(String(50), nullable=True, comment="科目类别")
|
||||
new_standard_category = Column(String(20), nullable=True, comment="新30号准则分类: operating/investing/financing/tax/discontinued")
|
||||
is_active = Column(Integer, default=1, comment="是否启用")
|
||||
remark = Column(String(500), nullable=True, comment="备注")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class VoucherDetail(Base):
|
||||
"""凭证明细 — 新30号准则分类"""
|
||||
__tablename__ = "voucher_details"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
voucher_no = Column(String(50), nullable=False, comment="凭证编号")
|
||||
voucher_date = Column(DateTime, nullable=False, comment="凭证日期")
|
||||
subject_code = Column(String(20), nullable=False, comment="科目编码")
|
||||
subject_name = Column(String(200), nullable=True, comment="科目名称")
|
||||
debit_amount = Column(Float, default=0, comment="借方金额")
|
||||
credit_amount = Column(Float, default=0, comment="贷方金额")
|
||||
summary = Column(String(500), nullable=True, comment="摘要")
|
||||
new_standard_category = Column(String(20), nullable=True, comment="新30号准则分类")
|
||||
period = Column(String(20), nullable=True, comment="期间 YYYY-MM")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
Reference in New Issue
Block a user