diff --git a/backend/app/api/reports.py b/backend/app/api/reports.py index d85cf301..7507fdb1 100644 --- a/backend/app/api/reports.py +++ b/backend/app/api/reports.py @@ -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"), diff --git a/backend/app/api/subjects.py b/backend/app/api/subjects.py new file mode 100644 index 00000000..9d0b53ae --- /dev/null +++ b/backend/app/api/subjects.py @@ -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} diff --git a/backend/app/main.py b/backend/app/main.py index 5640d682..4543ffed 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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): diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index db246fd2..510f02e3 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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()) diff --git a/backend/scripts/migrate_new30_standard.py b/backend/scripts/migrate_new30_standard.py new file mode 100644 index 00000000..82e37605 --- /dev/null +++ b/backend/scripts/migrate_new30_standard.py @@ -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() diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index da85022b..9192210d 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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'] } }, ] }, ] diff --git a/frontend/src/views/ReportCenter.vue b/frontend/src/views/ReportCenter.vue index a1e2ffb6..3df7c980 100644 --- a/frontend/src/views/ReportCenter.vue +++ b/frontend/src/views/ReportCenter.vue @@ -15,10 +15,11 @@ /> 刷新 📊 杜邦分析 + 📋 科目打标 - + @@ -61,6 +62,55 @@ + + + + 基于新30号准则的五板块利润表列报(2027版)。经营/投资/筹资分类列示,财务费用拆解。 + + + + + {{ block.expanded ? '▼' : '▶' }} + {{ block.name }} + + {{ block.subtotal != null ? formatMoney(block.subtotal) : '-' }} + + {{ block.subtotal_name }} + 示例数据 + + + + {{ item.name }} + + {{ item.effective != null ? formatMoney(item.effective) : '-' }} + + + {{ (Math.abs(item.effective) / Math.abs(block.subtotal) * 100).toFixed(1) }}% + + + + + + + + + 合计: + + {{ formatMoney(new30NetProfit) }} + + 净利润 + + + + + + + @@ -129,19 +179,14 @@ - {{ t.kpi_name }} {{ t.unit }} - - 均值 {{ t.avg }} | 最高 {{ t.max }} | 最低 {{ t.min }} - - - {{ t.trend_dir === 'up' ? '↑ 上升' : t.trend_dir === 'down' ? '↓ 下降' : '→ 平稳' }} - + 均值 {{ t.avg }} | 最高 {{ t.max }} | 最低 {{ t.min }} + {{ t.trend_dir === 'up' ? '↑ 上升' : t.trend_dir === 'down' ? '↓ 下降' : '→ 平稳' }} @@ -156,17 +201,13 @@ 基于BSC框架的四维度绩效评分,从已发布战略地图构建。 - {{ bscData.overall_score }} 综合评分 - - 基于:{{ bscData.map_title }} | 周期:{{ bscData.period }} - + 基于:{{ bscData.map_title }} | 周期:{{ bscData.period }} - @@ -233,6 +274,58 @@ function profitSummary(param: any) { return sums } +// ── 新30号准则利润表 ── +const loadingNew30 = ref(false) +const new30Blocks = ref([]) +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([]) @@ -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; } diff --git a/frontend/src/views/SubjectManage.vue b/frontend/src/views/SubjectManage.vue new file mode 100644 index 00000000..1f1778d7 --- /dev/null +++ b/frontend/src/views/SubjectManage.vue @@ -0,0 +1,230 @@ + + + + 📋 会计科目管理 — 新30号准则打标 + + ← 返回报表 + 刷新 + + + + + + + + + + + + + + + + + 共 {{ totalSubjects }} 个科目 + + + 批量打标 ({{ selectedIds.length }}) + + + + + + + + + + + + + + updateCategory(row, val)" + > + + + + + + + + + + + + + + + + + {{ catLabel(row.new_standard_category) }} + + 未分类 + + + + + + + + 已选 {{ selectedIds.length }} 个科目 + + + + + + + + + + + + + 取消 + 确认打标 + + + + + + + +
已选 {{ selectedIds.length }} 个科目