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}
+2 -1
View File
@@ -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):
+36
View File
@@ -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())
+87
View File
@@ -0,0 +1,87 @@
"""新30号准则适配 — 数据库迁移
创建 subjects 和 voucher_details 表,添加 new_standard_category 字段
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import get_engine, get_session_local
from sqlalchemy import text
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("migrate_new30")
def run():
engine = get_engine()
with engine.connect() as conn:
# 1. 创建 subjects 表(会计科目表)
conn.execute(text("""
CREATE TABLE IF NOT EXISTS subjects (
id INT AUTO_INCREMENT PRIMARY KEY,
subject_code VARCHAR(20) NOT NULL COMMENT '科目编码',
subject_name VARCHAR(200) NOT NULL COMMENT '科目名称',
parent_code VARCHAR(20) DEFAULT NULL COMMENT '上级科目编码',
level INT DEFAULT 1 COMMENT '科目级别 1-4',
category VARCHAR(50) DEFAULT NULL COMMENT '科目类别: asset/liability/equity/revenue/expense/profit_loss',
new_standard_category VARCHAR(20) DEFAULT NULL COMMENT '新30号准则分类: operating/investing/financing/tax/discontinued',
is_active TINYINT(1) DEFAULT 1 COMMENT '是否启用',
remark VARCHAR(500) DEFAULT NULL COMMENT '备注',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_subject_code (subject_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会计科目表'
"""))
logger.info("✅ 创建 subjects 表")
# 2. 创建 voucher_details 表(凭证明细表)
conn.execute(text("""
CREATE TABLE IF NOT EXISTS voucher_details (
id INT AUTO_INCREMENT PRIMARY KEY,
voucher_no VARCHAR(50) NOT NULL COMMENT '凭证编号',
voucher_date DATE NOT NULL COMMENT '凭证日期',
subject_code VARCHAR(20) NOT NULL COMMENT '科目编码',
subject_name VARCHAR(200) DEFAULT NULL COMMENT '科目名称',
debit_amount DECIMAL(18,2) DEFAULT 0 COMMENT '借方金额',
credit_amount DECIMAL(18,2) DEFAULT 0 COMMENT '贷方金额',
summary VARCHAR(500) DEFAULT NULL COMMENT '摘要',
new_standard_category VARCHAR(20) DEFAULT NULL COMMENT '新30号准则分类: operating/investing/financing/tax/discontinued',
period VARCHAR(20) DEFAULT NULL COMMENT '期间 YYYY-MM',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_voucher_no (voucher_no),
INDEX idx_period (period),
INDEX idx_subject_code (subject_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='凭证明细表'
"""))
logger.info("✅ 创建 voucher_details 表")
# 3. 插入初始科目数据(新30号准则相关科目)
seed_subjects = [
('6001', '主营业务收入', None, 1, 'revenue', 'operating'),
('6051', '其他业务收入', None, 1, 'revenue', 'operating'),
('6401', '主营业务成本', None, 1, 'expense', 'operating'),
('6402', '其他业务成本', None, 1, 'expense', 'operating'),
('6601', '销售费用', None, 1, 'expense', 'operating'),
('6602', '管理费用', None, 1, 'expense', 'operating'),
('660204', '研发费用', '6602', 2, 'expense', 'operating_rd'),
('6603', '财务费用', None, 1, 'expense', 'operating_fx'),
('660301', '利息支出(借款)', '6603', 2, 'expense', 'financing'),
('660302', '筹资汇兑损益', '6603', 2, 'expense', 'financing_fx'),
('6011', '利息收入(银行存款)', None, 1, 'revenue', 'investing'),
('6111', '投资收益', None, 1, 'revenue', 'investing'),
('611101', '股权投资', '6111', 2, 'revenue', 'investing'),
('6701', '资产减值损失', None, 1, 'expense', 'operating'),
('670101', '投资类资产减值', '6701', 2, 'expense', 'investing'),
('6801', '所得税费用', None, 1, 'expense', 'tax'),
('6901', '终止经营损益', None, 1, 'profit_loss', 'discontinued'),
]
for row in seed_subjects:
conn.execute(text("""
INSERT IGNORE INTO subjects (subject_code, subject_name, parent_code, level, category, new_standard_category, is_active)
VALUES (:code, :name, :parent, :level, :cat, :ns_cat, 1)
"""), {"code": row[0], "name": row[1], "parent": row[2], "level": row[3], "cat": row[4], "ns_cat": row[5]})
logger.info(f"✅ 插入 {len(seed_subjects)} 条初始科目数据")
conn.commit()
logger.info("🎉 新30号准则数据库迁移完成")
if __name__ == "__main__":
run()