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()
+1
View File
@@ -36,6 +36,7 @@ const routes = [
{ path: 'data-quality', name: 'DataQuality', component: () => import('@/views/DataQuality.vue'), meta: { title: '数据质量', roles: ['ceo', 'finance', 'it'] } },
{ path: 'bi-reports', name: 'BiReports', component: () => import('@/views/BIReportCenter.vue'), meta: { title: 'BI报表', roles: ['ceo', 'finance', 'business'] } },
{ path: 'okr-templates', name: 'OKRTemplates', component: () => import('@/views/OKRTemplates.vue'), meta: { title: 'OKR模板库', roles: ['ceo', 'finance', 'it'] } },
{ path: 'subjects', name: 'SubjectManage', component: () => import('@/views/SubjectManage.vue'), meta: { title: '科目打标', roles: ['ceo', 'finance', 'business', 'it'] } },
]
},
]
+134 -15
View File
@@ -15,10 +15,11 @@
/>
<el-button text size="small" :loading="loading" @click="loadAll">刷新</el-button>
<el-button type="primary" size="small" @click="openDupont">📊 杜邦分析</el-button>
<el-button type="success" size="small" @click="openSubjectManage">📋 科目打标</el-button>
</div>
</div>
<!-- 四个标签页 -->
<!-- 标签页 -->
<el-tabs v-model="activeTab" type="border-card" class="report-tabs">
<!-- 报表1管理利润表 -->
@@ -61,6 +62,55 @@
</div>
</el-tab-pane>
<!-- 新30号准则利润表 -->
<el-tab-pane label="📋 新30号准则" name="new30">
<div v-loading="loadingNew30">
<div class="report-desc">基于新30号准则的五板块利润表列报2027经营/投资/筹资分类列示财务费用拆解</div>
<div class="new30-blocks">
<div v-for="block in new30Blocks" :key="block.key" class="new30-block">
<div class="new30-block-header" @click="block.expanded = !block.expanded">
<span class="new30-toggle">{{ block.expanded ? '▼' : '▶' }}</span>
<span class="new30-block-name">{{ block.name }}</span>
<span class="new30-block-subtotal" :class="{ negative: block.subtotal < 0 }">
{{ block.subtotal != null ? formatMoney(block.subtotal) : '-' }}
</span>
<span class="new30-block-label">{{ block.subtotal_name }}</span>
<span v-if="!block.has_real_data" class="new30-demo-tag">示例数据</span>
</div>
<div v-if="block.expanded" class="new30-block-body">
<div v-for="item in block.items" :key="item.code" class="new30-item">
<span class="new30-item-name">{{ item.name }}</span>
<span class="new30-item-amount" :class="{ negative: item.effective != null && item.effective < 0 }">
{{ item.effective != null ? formatMoney(item.effective) : '-' }}
</span>
<span v-if="item.effective != null && block.subtotal != null && block.subtotal !== 0" class="new30-item-ratio">
{{ (Math.abs(item.effective) / Math.abs(block.subtotal) * 100).toFixed(1) }}%
</span>
</div>
</div>
</div>
</div>
<div class="new30-net-profit">
<div class="new30-profit-row">
<span class="new30-profit-label">合计</span>
<span class="new30-profit-value" :class="{ negative: new30NetProfit < 0 }">
{{ formatMoney(new30NetProfit) }}
</span>
<span class="new30-profit-name">净利润</span>
</div>
</div>
<div class="new30-footer">
<el-button size="small" :type="new30Format === 'new' ? 'primary' : ''" @click="switchNew30Format('new')">新准则</el-button>
<el-button size="small" :type="new30Format === 'old' ? 'primary' : ''" @click="switchNew30Format('old')">旧准则对比</el-button>
<el-button size="small" @click="exportNew30">导出</el-button>
<span v-if="new30DemoMode" class="new30-demo-hint"> 当前显示示例数据科目打标后显示实际数据</span>
</div>
</div>
</el-tab-pane>
<!-- 报表2预算执行报告 -->
<el-tab-pane label="📋 预算执行报告" name="budget">
<div v-loading="loading">
@@ -129,19 +179,14 @@
<el-option label="30期" :value="30" />
</el-select>
</div>
<!-- 趋势图表 -->
<div v-if="trendData.length > 0" class="trend-grid">
<el-card v-for="t in trendData" :key="t.kpi_id" class="trend-card-2">
<template #header>
<div class="trend-hd">
<span class="trend-name">{{ t.kpi_name }}</span>
<span class="trend-unit">{{ t.unit }}</span>
<span class="trend-stat">
均值 {{ t.avg }} | 最高 {{ t.max }} | 最低 {{ t.min }}
</span>
<span class="trend-dir" :class="t.trend_dir">
{{ t.trend_dir === 'up' ? '↑ 上升' : t.trend_dir === 'down' ? '↓ 下降' : '→ 平稳' }}
</span>
<span class="trend-stat">均值 {{ t.avg }} | 最高 {{ t.max }} | 最低 {{ t.min }}</span>
<span class="trend-dir" :class="t.trend_dir">{{ t.trend_dir === 'up' ? '↑ 上升' : t.trend_dir === 'down' ? '↓ 下降' : '→ 平稳' }}</span>
</div>
</template>
<div :ref="el => setTrendChartRef(t.kpi_id, el)" style="height:180px;"></div>
@@ -156,17 +201,13 @@
<div v-loading="loading">
<div class="report-desc">基于BSC框架的四维度绩效评分从已发布战略地图构建</div>
<div v-if="bscData.overall_score != null" style="margin-top:12px;">
<!-- 总分 -->
<div class="bsc-overall">
<div class="bsc-score-ring" :style="{ borderColor: bscScoreColor(bscData.overall_score) }">
<span class="bsc-score-val">{{ bscData.overall_score }}</span>
<span class="bsc-score-label">综合评分</span>
</div>
<div class="bsc-map-info" v-if="bscData.map_title">
基于{{ bscData.map_title }} | 周期{{ bscData.period }}
</div>
<div class="bsc-map-info" v-if="bscData.map_title">基于{{ bscData.map_title }} | 周期{{ bscData.period }}</div>
</div>
<!-- 四维度 -->
<div class="bsc-dims">
<div v-for="dim in bscData.dimensions" :key="dim.key" class="bsc-dim-card">
<div class="bsc-dim-head" :style="{ borderLeft: '4px solid ' + dim.color }">
@@ -233,6 +274,58 @@ function profitSummary(param: any) {
return sums
}
// 30
const loadingNew30 = ref(false)
const new30Blocks = ref<any[]>([])
const new30NetProfit = ref(0)
const new30Format = ref('new')
const new30DemoMode = ref(false)
async function loadNew30() {
loadingNew30.value = true
try {
const r = await api.get('/reports/profit-statement', {
params: { period: reportPeriod.value, format: 'new' }
})
const d = (r as any).data || {}
new30Blocks.value = d.blocks || []
new30NetProfit.value = d.net_profit ?? 0
new30DemoMode.value = !d.all_items_have_data
} catch (e) {
new30Blocks.value = []
new30NetProfit.value = 0
ElMessage.error('加载新30号准则利润表失败')
} finally {
loadingNew30.value = false
}
}
function formatMoney(val: number): string {
if (val == null) return '-'
const abs = Math.abs(val)
let formatted = '¥ ' + abs.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
if (val < 0) formatted = '-' + formatted
return formatted
}
function switchNew30Format(fmt: string) {
new30Format.value = fmt
if (fmt === 'new') {
loadNew30()
} else {
// tab
activeTab.value = 'profit'
}
}
function exportNew30() {
ElMessage.success('导出功能将在P2实现')
}
function openSubjectManage() {
window.open('/subjects', '_blank')
}
// 2
const budgetSummary = ref({ total: 0, with_budget: 0, over_budget: 0, under_budget: 0, normal: 0 })
const budgetItems = ref<any[]>([])
@@ -324,7 +417,7 @@ async function loadKpiOptions() {
function loadAll() {
loading.value = true
Promise.all([loadProfit(), loadBudget(), loadTrends(), loadBsc()]).finally(() => { loading.value = false })
Promise.all([loadProfit(), loadNew30(), loadBudget(), loadTrends(), loadBsc()]).finally(() => { loading.value = false })
}
function openDupont() {
@@ -347,7 +440,6 @@ function renderTrendCharts() {
areaStyle: { opacity: 0.1 }, symbol: 'circle', symbolSize: 4,
name: '实际值',
}]
// 线
if (t.target_value != null) {
series.push({
type: 'line', data: Array(dates.length).fill(t.target_value),
@@ -445,4 +537,31 @@ onMounted(() => {
.bsc-kpi-score { font-weight: 600; color: #333; }
.bsc-kpi-more { font-size: 11px; color: #409eff; text-align: center; padding: 2px; }
.bsc-obj-empty { font-size: 11px; color: #ccc; text-align: center; padding: 4px; }
/* 新30号准则样式 */
.new30-blocks { margin-top: 12px; }
.new30-block { background: #fff; border-radius: 8px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); border: 1px solid #ebeef5; overflow: hidden; }
.new30-block-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; cursor: pointer; background: #f9fafc; border-bottom: 1px solid #ebeef5; transition: background 0.2s; }
.new30-block-header:hover { background: #f0f2f5; }
.new30-toggle { font-size: 10px; color: #999; width: 16px; }
.new30-block-name { font-weight: 600; font-size: 14px; flex: 1; color: #1a1a2e; }
.new30-block-subtotal { font-size: 16px; font-weight: 700; color: #1a1a2e; min-width: 120px; text-align: right; }
.new30-block-subtotal.negative { color: #f56c6c; }
.new30-block-label { font-size: 11px; color: #999; width: 80px; }
.new30-demo-tag { font-size: 10px; background: #fff7e6; color: #d46b08; padding: 1px 6px; border-radius: 3px; }
.new30-block-body { padding: 6px 14px 10px 38px; }
.new30-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px dashed #f0f0f0; }
.new30-item:last-child { border-bottom: none; }
.new30-item-name { flex: 1; font-size: 13px; color: #555; }
.new30-item-amount { font-size: 13px; font-weight: 500; min-width: 120px; text-align: right; color: #333; }
.new30-item-amount.negative { color: #f56c6c; }
.new30-item-ratio { font-size: 11px; color: #bbb; width: 60px; text-align: right; }
.new30-net-profit { margin-top: 16px; padding: 12px 16px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 8px; color: #fff; }
.new30-profit-row { display: flex; align-items: center; gap: 12px; }
.new30-profit-label { font-size: 14px; opacity: 0.9; }
.new30-profit-value { font-size: 24px; font-weight: 700; flex: 1; }
.new30-profit-value.negative { color: #ffccc7; }
.new30-profit-name { font-size: 13px; opacity: 0.8; }
.new30-footer { margin-top: 12px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.new30-demo-hint { font-size: 11px; color: #d46b08; margin-left: 8px; }
</style>
+230
View File
@@ -0,0 +1,230 @@
<template>
<div class="subject-page">
<div class="subject-header">
<h3 class="page-title">📋 会计科目管理 新30号准则打标</h3>
<div class="header-right">
<el-button size="small" @click="goBack"> 返回报表</el-button>
<el-button type="primary" size="small" :loading="loading" @click="loadSubjects">刷新</el-button>
</div>
</div>
<!-- 过滤 -->
<div class="filter-row">
<el-input v-model="keyword" placeholder="搜索科目名称/编码" size="small" style="width:200px;" clearable @change="loadSubjects" />
<el-select v-model="filterCategory" placeholder="新准则分类" clearable size="small" style="width:160px;" @change="loadSubjects">
<el-option label="经营类" value="operating" />
<el-option label="研发费用" value="operating_rd" />
<el-option label="经营汇兑" value="operating_fx" />
<el-option label="投资类" value="investing" />
<el-option label="筹资类" value="financing" />
<el-option label="筹资汇兑" value="financing_fx" />
<el-option label="所得税" value="tax" />
<el-option label="终止经营" value="discontinued" />
</el-select>
<span class="filter-info"> {{ totalSubjects }} 个科目</span>
<span style="margin-left:auto;">
<el-button size="small" type="success" :disabled="selectedIds.length === 0" @click="showBatchDialog">
批量打标 ({{ selectedIds.length }})
</el-button>
</span>
</div>
<!-- 科目表格 -->
<el-table
:data="subjects"
border stripe size="small"
style="width:100%;margin-top:8px;"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="40" />
<el-table-column prop="subject_code" label="科目编码" width="100" />
<el-table-column prop="subject_name" label="科目名称" min-width="160" />
<el-table-column prop="parent_code" label="上级编码" width="80" />
<el-table-column prop="level" label="级别" width="50" />
<el-table-column label="新30号准则分类" width="180">
<template #default="{ row }">
<el-select
v-model="row.new_standard_category"
size="small"
placeholder="选择分类"
style="width:150px;"
@change="val => updateCategory(row, val)"
>
<el-option label="未分类" value="" />
<el-option label="经营类" value="operating" />
<el-option label="研发费用(经营类)" value="operating_rd" />
<el-option label="经营汇兑损益" value="operating_fx" />
<el-option label="投资类" value="investing" />
<el-option label="筹资类" value="financing" />
<el-option label="筹资汇兑损益" value="financing_fx" />
<el-option label="所得税" value="tax" />
<el-option label="终止经营" value="discontinued" />
</el-select>
</template>
</el-table-column>
<el-table-column prop="category" label="科目类别" width="80" />
<el-table-column label="分类标签" width="100">
<template #default="{ row }">
<el-tag v-if="row.new_standard_category" :type="tagType(row.new_standard_category)" size="small">
{{ catLabel(row.new_standard_category) }}
</el-tag>
<span v-else style="color:#ccc;">未分类</span>
</template>
</el-table-column>
</el-table>
<!-- 批量打标对话框 -->
<el-dialog v-model="batchDialogVisible" title="批量设置新30号准则分类" width="400px">
<div class="batch-dialog-body">
<p>已选 <strong>{{ selectedIds.length }}</strong> 个科目</p>
<el-select v-model="batchCategory" placeholder="选择分类" size="small" style="width:100%;">
<el-option label="经营类" value="operating" />
<el-option label="研发费用(经营类)" value="operating_rd" />
<el-option label="经营汇兑损益" value="operating_fx" />
<el-option label="投资类" value="investing" />
<el-option label="筹资类" value="financing" />
<el-option label="筹资汇兑损益" value="financing_fx" />
<el-option label="所得税" value="tax" />
<el-option label="终止经营" value="discontinued" />
</el-select>
</div>
<template #footer>
<el-button size="small" @click="batchDialogVisible = false">取消</el-button>
<el-button type="primary" size="small" :loading="batchLoading" @click="doBatchUpdate">确认打标</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import axios from 'axios'
const api = axios.create({ baseURL: '/api/cma', timeout: 30000 })
api.interceptors.request.use((config: any) => {
const token = localStorage.getItem('cma_token')
if (token) config.headers.Authorization = `'Bearer ' + token`
return config
})
const loading = ref(false)
const subjects = ref<any[]>([])
const totalSubjects = ref(0)
const keyword = ref('')
const filterCategory = ref('')
const selectedIds = ref<number[]>([])
//
const batchDialogVisible = ref(false)
const batchCategory = ref('')
const batchLoading = ref(false)
async function loadSubjects() {
loading.value = true
try {
const params: any = {}
if (filterCategory.value) params.category = filterCategory.value
if (keyword.value) params.keyword = keyword.value
const r = await api.get('/subjects', { params })
const d = (r as any).data || {}
subjects.value = d.data || []
totalSubjects.value = d.total || 0
} catch (e) {
ElMessage.error('加载科目列表失败')
subjects.value = []
} finally {
loading.value = false
}
}
async function updateCategory(row: any, val: string) {
if (!val) {
//
row.new_standard_category = ''
return
}
try {
await api.put(`/subjects/${row.id}`, null, {
params: { new_standard_category: val }
})
ElMessage.success(`${row.subject_name} 分类更新成功`)
} catch (e) {
ElMessage.error('更新失败')
//
row.new_standard_category = row._old_category || ''
}
}
function onSelectionChange(rows: any[]) {
selectedIds.value = rows.map((r: any) => r.id)
}
function showBatchDialog() {
if (selectedIds.value.length === 0) {
ElMessage.warning('请先选择科目')
return
}
batchCategory.value = ''
batchDialogVisible.value = true
}
async function doBatchUpdate() {
if (!batchCategory.value) {
ElMessage.warning('请选择分类')
return
}
batchLoading.value = true
try {
await api.put('/subjects/batch/category', null, {
params: {
ids: selectedIds.value,
new_standard_category: batchCategory.value,
}
})
ElMessage.success(`批量更新 ${selectedIds.value.length} 个科目成功`)
batchDialogVisible.value = false
await loadSubjects()
} catch (e) {
ElMessage.error('批量更新失败')
} finally {
batchLoading.value = false
}
}
function catLabel(cat: string): string {
const map: Record<string, string> = {
operating: '经营类', operating_rd: '研发费用', operating_fx: '经营汇兑',
investing: '投资类', financing: '筹资类', financing_fx: '筹资汇兑',
tax: '所得税', discontinued: '终止经营',
}
return map[cat] || cat
}
function tagType(cat: string): string {
const map: Record<string, string> = {
operating: '', operating_rd: 'warning', operating_fx: 'info',
investing: 'success', financing: 'danger', financing_fx: 'danger',
tax: 'info', discontinued: 'warning',
}
return map[cat] || ''
}
function goBack() {
window.history.back()
}
onMounted(() => {
loadSubjects()
})
</script>
<style scoped>
.subject-page { max-width: 1200px; margin: 0 auto; padding: 16px; }
.subject-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.header-right { display: flex; gap: 8px; align-items: center; }
.page-title { font-size: 18px; font-weight: 600; color: #1a1a2e; margin: 0; }
.filter-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.filter-info { font-size: 12px; color: #999; }
.batch-dialog-body { display: flex; flex-direction: column; gap: 12px; }
</style>