From f33162b6c9ded59c2109e18ca006764d9b45b0e0 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Tue, 4 Aug 2026 15:35:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B4=A2=E5=8A=A1=E6=8A=A5=E8=A1=A8?= =?UTF-8?q?=E5=8A=A9=E6=89=8B=E2=80=94=E5=AF=B9=E5=A4=96=E6=B3=95=E5=AE=9A?= =?UTF-8?q?=E6=8A=A5=E8=A1=A8(=E4=B8=89=E8=A1=A8=E5=90=88=E4=B8=80+Excel?= =?UTF-8?q?=E5=AF=BC=E5=87=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/reports.py | 588 ++++++++++++++++++- backend/scripts/migrate_statutory_reports.py | 70 +++ frontend/src/views/ReportCenter.vue | 237 ++++++++ 3 files changed, 894 insertions(+), 1 deletion(-) create mode 100644 backend/scripts/migrate_statutory_reports.py diff --git a/backend/app/api/reports.py b/backend/app/api/reports.py index 6803cc9c..87f3e6b5 100644 --- a/backend/app/api/reports.py +++ b/backend/app/api/reports.py @@ -601,7 +601,7 @@ def get_profit_statement( def _build_new_format_profit(db: Session, period: str) -> dict: - """构建新30号准则五板块利润表""" + """构建新30号准则五板块利润表(含附注明细)""" blocks = [] total_net_profit = 0 all_items_have_data = True @@ -644,6 +644,9 @@ def _build_new_format_profit(db: Session, period: str) -> dict: blocks.append(block_result) total_net_profit += block_subtotal + # 附注明细(对外法定报表披露要求) + notes = _build_profit_notes(db, period, blocks, total_net_profit) + # 合计行:净利润 = 一二三+四+五 return { "period": period, @@ -653,10 +656,87 @@ def _build_new_format_profit(db: Session, period: str) -> dict: "net_profit": round(total_net_profit, 2), "net_profit_name": "净利润", "all_items_have_data": all_items_have_data, + "notes": notes, "prev_period": None, # TODO: P1追溯调整 } +def _build_profit_notes(db: Session, period: str, blocks: list, net_profit: float) -> dict: + """利润表附注明细 — 收入/费用/财务费用拆解 + 板块勾稽 + 关键比率""" + def amt(code): + return _get_subject_amount(db, code, period) + + revenue_main = amt("6001") + revenue_other = amt("6051") + revenue_total = None + if revenue_main is not None or revenue_other is not None: + revenue_total = round((revenue_main or 0) + (revenue_other or 0), 2) + + revenue_breakdown = [ + {"name": "主营业务收入", "code": "6001", "amount": round(revenue_main, 2) if revenue_main is not None else None}, + {"name": "其他业务收入", "code": "6051", "amount": round(revenue_other, 2) if revenue_other is not None else None}, + ] + + expense_items = [ + {"name": "营业成本", "code": "6401", "amount": amt("6401")}, + {"name": "其他业务成本", "code": "6402", "amount": amt("6402")}, + {"name": "销售费用", "code": "6601", "amount": amt("6601")}, + {"name": "管理费用", "code": "6602", "amount": amt("6602")}, + {"name": "研发费用", "code": "660204", "amount": amt("660204")}, + {"name": "经营资产减值损失", "code": "6701", "amount": amt("6701")}, + ] + expense_breakdown = [ + {"name": e["name"], "code": e["code"], "amount": round(e["amount"], 2) if e["amount"] is not None else None} + for e in expense_items + ] + + finance_breakdown = [ + {"name": "利息收入(投资类)", "code": "6011", "amount": amt("6011")}, + {"name": "投资收益(投资类)", "code": "6111", "amount": amt("6111")}, + {"name": "利息支出(筹资类)", "code": "660301", "amount": amt("660301")}, + {"name": "经营汇兑损益", "code": "6603", "amount": amt("6603")}, + {"name": "筹资汇兑损益", "code": "660302", "amount": amt("660302")}, + ] + finance_breakdown = [ + {"name": f["name"], "code": f["code"], "amount": round(f["amount"], 2) if f["amount"] is not None else None} + for f in finance_breakdown + ] + + # 板块勾稽(净利润 = 五板块之和) + block_reconciliation = [ + {"name": b["name"], "key": b["key"], "amount": b["subtotal"], "result_name": b["subtotal_name"]} + for b in blocks + ] + + # 关键比率 + key_ratios = [] + if revenue_total: + key_ratios.append({ + "name": "毛利率", + "value": round((revenue_total - (amt("6401") or 0) - (amt("6402") or 0)) / revenue_total * 100, 2) if (amt("6401") is not None or amt("6402") is not None) else None, + }) + key_ratios.append({ + "name": "净利率", + "value": round(net_profit / revenue_total * 100, 2), + }) + rd = amt("660204") + if rd is not None: + key_ratios.append({"name": "研发费用率", "value": round(rd / revenue_total * 100, 2)}) + else: + key_ratios.append({"name": "毛利率", "value": None}) + key_ratios.append({"name": "净利率", "value": None}) + + return { + "revenue_total": round(revenue_total, 2) if revenue_total is not None else None, + "revenue_breakdown": revenue_breakdown, + "expense_breakdown": expense_breakdown, + "finance_breakdown": finance_breakdown, + "block_reconciliation": block_reconciliation, + "net_profit": round(net_profit, 2), + "key_ratios": key_ratios, + } + + # ============================================================ # MPM管理层指标计算器 (P2) # ============================================================ @@ -1247,6 +1327,512 @@ def get_category_map( } +# ============================================================ +# 对外法定报表 — 新30号准则适配 (2027) +# 利润表(五板块+附注) / 资产负债表(新准则科目分类) / 现金流量表(三活动) +# ============================================================ + +# 资产负债表行项目: (编码列表[(code, sign)], 名称, 板块key, 新准则分类, 是否合计行) +# 新准则分类: operating经营 / investing投资 / financing筹资 / equity权益 +BALANCE_SHEET_SECTIONS = [ + { + "key": "current_assets", + "name": "流动资产", + "category_label": "经营资产", + "lines": [ + {"codes": [("1001", 1), ("1002", 1), ("1012", 1)], "name": "货币资金", "ns_category": "operating"}, + {"codes": [("1101", 1)], "name": "交易性金融资产", "ns_category": "investing"}, + {"codes": [("1122", 1)], "name": "应收账款", "ns_category": "operating"}, + {"codes": [("1123", 1)], "name": "预付账款", "ns_category": "operating"}, + {"codes": [("1221", 1)], "name": "其他应收款", "ns_category": "operating"}, + {"codes": [("1405", 1)], "name": "存货", "ns_category": "operating"}, + ], + }, + { + "key": "non_current_assets", + "name": "非流动资产", + "category_label": "投资资产", + "lines": [ + {"codes": [("1511", 1)], "name": "长期股权投资", "ns_category": "investing"}, + {"codes": [("1601", 1), ("1602", -1)], "name": "固定资产净额", "ns_category": "operating"}, + {"codes": [("1701", 1)], "name": "无形资产", "ns_category": "operating"}, + ], + }, + { + "key": "current_liabilities", + "name": "流动负债", + "category_label": "经营负债", + "lines": [ + {"codes": [("2001", 1)], "name": "短期借款", "ns_category": "financing"}, + {"codes": [("2202", 1)], "name": "应付账款", "ns_category": "operating"}, + {"codes": [("2203", 1)], "name": "预收账款", "ns_category": "operating"}, + {"codes": [("2211", 1)], "name": "应付职工薪酬", "ns_category": "operating"}, + {"codes": [("2221", 1)], "name": "应交税费", "ns_category": "operating"}, + {"codes": [("2241", 1)], "name": "其他应付款", "ns_category": "operating"}, + ], + }, + { + "key": "non_current_liabilities", + "name": "非流动负债", + "category_label": "筹资负债", + "lines": [ + {"codes": [("2501", 1)], "name": "长期借款", "ns_category": "financing"}, + {"codes": [("2502", 1)], "name": "应付债券", "ns_category": "financing"}, + ], + }, + { + "key": "equity", + "name": "所有者权益", + "category_label": "所有者权益", + "lines": [ + {"codes": [("4001", 1)], "name": "实收资本", "ns_category": "equity"}, + {"codes": [("4002", 1)], "name": "资本公积", "ns_category": "equity"}, + {"codes": [("4103", 1), ("4104", 1)], "name": "未分配利润", "ns_category": "equity"}, + ], + }, +] + +# 资产负债表示例数据(博海科技, 期末/期初, 单位: 千元) +BS_DEMO = { + "1001|1002|1012": {"end": 850, "begin": 780}, + "1101": {"end": 250, "begin": 220}, + "1122": {"end": 1100, "begin": 1050}, + "1123": {"end": 180, "begin": 160}, + "1221": {"end": 90, "begin": 80}, + "1405": {"end": 930, "begin": 900}, + "1511": {"end": 580, "begin": 550}, + "1601|1602": {"end": 1220, "begin": 1150}, + "1701": {"end": 130, "begin": 120}, + "2001": {"end": 1500, "begin": 1300}, + "2202": {"end": 1050, "begin": 980}, + "2203": {"end": 280, "begin": 250}, + "2211": {"end": 140, "begin": 130}, + "2221": {"end": 90, "begin": 85}, + "2241": {"end": 60, "begin": 50}, + "2501": {"end": 800, "begin": 750}, + "2502": {"end": 270, "begin": 250}, + "4001": {"end": 500, "begin": 500}, + "4002": {"end": 180, "begin": 175}, + "4103|4104": {"end": 460, "begin": 540}, +} + +BS_CATEGORY_CN = { + "operating": "经营类", + "investing": "投资类", + "financing": "筹资类", + "equity": "权益类", +} + + +def _prev_period_str(period: str) -> str: + """上一期间 YYYY-MM → YYYY-(MM-1)""" + try: + y, m = period.split("-") + y, m = int(y), int(m) + m -= 1 + if m <= 0: + m += 12 + y -= 1 + return f"{y}-{m:02d}" + except Exception: + return period + + +def _get_bs_amount(db: Session, codes: list, period: str) -> Optional[float]: + """资产负债表科目余额 — 优先凭证明细,无数据返回 None""" + total = 0.0 + has_data = False + try: + from app.models import VoucherDetail + for code, sign in codes: + 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: + total += float(result) * sign + has_data = True + except Exception: + pass + return round(total, 2) if has_data else None + + +def _bs_line_amount(db: Session, line: dict, period: str, column: str = "end") -> dict: + """单行:凭证数据优先,否则示例数据(column: end期末 / begin期初)""" + real = _get_bs_amount(db, line["codes"], period) + if real is not None: + return {"value": real, "is_demo": False} + key = "|".join(c for c, _ in line["codes"]) + demo = BS_DEMO.get(key) + if demo: + return {"value": demo.get(column, demo["end"]), "is_demo": True} + return {"value": None, "is_demo": True} + + +@router.get("/balance-sheet") +def get_balance_sheet( + period: str = Query(None, description="格式 YYYY-MM"), + db: Session = Depends(get_db), +): + """资产负债表 — 新30号准则科目分类(经营/投资/筹资),期末vs期初""" + if period is None: + period = datetime.now().strftime("%Y-%m") + prev_period = _prev_period_str(period) + + sections = [] + total_assets_end = total_assets_begin = 0 + total_liab_end = total_liab_begin = 0 + total_equity_end = total_equity_begin = 0 + all_real = True + + for sec in BALANCE_SHEET_SECTIONS: + lines = [] + sec_end = sec_begin = 0.0 + sec_real = False + for line in sec["lines"]: + end = _bs_line_amount(db, line, period, column="end") + begin = _bs_line_amount(db, line, prev_period, column="begin") + if end["is_demo"] or begin["is_demo"]: + all_real = False + if end["value"] is not None: + sec_end += end["value"] + sec_real = True + if begin["value"] is not None: + sec_begin += begin["value"] + lines.append({ + "name": line["name"], + "ns_category": line["ns_category"], + "ns_category_label": BS_CATEGORY_CN.get(line["ns_category"], line["ns_category"]), + "end_value": round(end["value"], 2) if end["value"] is not None else None, + "begin_value": round(begin["value"], 2) if begin["value"] is not None else None, + "is_demo": end["is_demo"] or begin["is_demo"], + }) + + # 归属汇总 + if sec["key"] in ("current_assets", "non_current_assets"): + total_assets_end += sec_end + total_assets_begin += sec_begin + elif sec["key"] in ("current_liabilities", "non_current_liabilities"): + total_liab_end += sec_end + total_liab_begin += sec_begin + else: + total_equity_end += sec_end + total_equity_begin += sec_begin + + sections.append({ + "key": sec["key"], + "name": sec["name"], + "category_label": sec["category_label"], + "subtotal_end": round(sec_end, 2), + "subtotal_begin": round(sec_begin, 2), + "lines": lines, + "has_real_data": sec_real, + }) + + return { + "period": period, + "prev_period": prev_period, + "title": f"资产负债表 — 新30号准则({period})", + "sections": sections, + "totals": { + "assets": {"end": round(total_assets_end, 2), "begin": round(total_assets_begin, 2)}, + "liabilities": {"end": round(total_liab_end, 2), "begin": round(total_liab_begin, 2)}, + "equity": {"end": round(total_equity_end, 2), "begin": round(total_equity_begin, 2)}, + "liab_equity": {"end": round(total_liab_end + total_equity_end, 2), + "begin": round(total_liab_begin + total_equity_begin, 2)}, + "balanced": abs(total_assets_end - total_liab_end - total_equity_end) < 0.01 + and abs(total_assets_begin - total_liab_begin - total_equity_begin) < 0.01, + }, + "all_items_have_data": all_real, + } + + +# 现金流量表行项目: (code, 名称, 板块, 方向, kpi_code可选) +CASH_FLOW_LINES = [ + # 经营活动 + {"code": "CF01", "name": "销售商品、提供劳务收到的现金", "section": "operating", "sign": 1, "kpi_code": None}, + {"code": "CF02", "name": "收到的税费返还", "section": "operating", "sign": 1, "kpi_code": None}, + {"code": "CF03", "name": "收到其他与经营活动有关的现金", "section": "operating", "sign": 1, "kpi_code": None}, + {"code": "CF04", "name": "购买商品、接受劳务支付的现金", "section": "operating", "sign": -1, "kpi_code": None}, + {"code": "CF05", "name": "支付给职工以及为职工支付的现金", "section": "operating", "sign": -1, "kpi_code": None}, + {"code": "CF06", "name": "支付的各项税费", "section": "operating", "sign": -1, "kpi_code": None}, + {"code": "CF07", "name": "支付其他与经营活动有关的现金", "section": "operating", "sign": -1, "kpi_code": None}, + # 投资活动 + {"code": "CF08", "name": "收回投资收到的现金", "section": "investing", "sign": 1, "kpi_code": None}, + {"code": "CF09", "name": "取得投资收益收到的现金", "section": "investing", "sign": 1, "kpi_code": None}, + {"code": "CF10", "name": "处置固定资产、无形资产等收回的现金", "section": "investing", "sign": 1, "kpi_code": None}, + {"code": "CF11", "name": "购建固定资产、无形资产等支付的现金", "section": "investing", "sign": -1, "kpi_code": None}, + {"code": "CF12", "name": "投资支付的现金", "section": "investing", "sign": -1, "kpi_code": None}, + # 筹资活动 + {"code": "CF13", "name": "吸收投资收到的现金", "section": "financing", "sign": 1, "kpi_code": None}, + {"code": "CF14", "name": "取得借款收到的现金", "section": "financing", "sign": 1, "kpi_code": None}, + {"code": "CF15", "name": "偿还债务支付的现金", "section": "financing", "sign": -1, "kpi_code": None}, + {"code": "CF16", "name": "分配股利、利润或偿付利息支付的现金", "section": "financing", "sign": -1, "kpi_code": None}, +] + +# 现金流量表示例数据(2026-06, 单位: 千元, 与利润表/KPI口径一致) +CF_DEMO = { + "CF01": 5200, "CF02": 0, "CF03": 120, "CF04": -3150, "CF05": -820, + "CF06": -360, "CF07": -140, + "CF08": 200, "CF09": 50, "CF10": 30, "CF11": -330, "CF12": -100, + "CF13": 0, "CF14": 500, "CF15": -200, "CF16": -150, +} +CF_DEMO_FX = 0 # 汇率变动对现金的影响 +CF_DEMO_BEGIN = 1200 # 期初现金及现金等价物余额 + + +def _get_cf_amount(db: Session, line: dict, period: str) -> dict: + """现金流量表行项目 — 优先KPI/凭证,否则示例数据""" + # 经营净额行特殊处理:优先取 F_OP_CFLOW + if line.get("kpi_code"): + kpi_val = _get_kpi_val(db, line["kpi_code"], period) + if kpi_val is not None: + return {"value": round(kpi_val, 2), "is_demo": False} + real = _get_bs_amount(db, [(line["code"], line["sign"])], period) + if real is not None: + return {"value": real, "is_demo": False} + demo = CF_DEMO.get(line["code"]) + if demo is not None: + return {"value": float(demo), "is_demo": True} + return {"value": None, "is_demo": True} + + +@router.get("/cash-flow") +def get_cash_flow_statement( + period: str = Query(None, description="格式 YYYY-MM"), + db: Session = Depends(get_db), +): + """现金流量表 — 经营/投资/筹资三活动(新30号准则直接法)""" + if period is None: + period = datetime.now().strftime("%Y-%m") + + section_cfg = [ + {"key": "operating", "name": "一、经营活动产生的现金流量", "short": "经营活动"}, + {"key": "investing", "name": "二、投资活动产生的现金流量", "short": "投资活动"}, + {"key": "financing", "name": "三、筹资活动产生的现金流量", "short": "筹资活动"}, + ] + + sections = [] + net_by_section = {} + all_real = True + + for sc in section_cfg: + lines = [] + subtotal = 0.0 + sec_real = False + for line in CASH_FLOW_LINES: + if line["section"] != sc["key"]: + continue + v = _get_cf_amount(db, line, period) + if v["is_demo"]: + all_real = False + if v["value"] is not None: + subtotal += v["value"] + sec_real = True + lines.append({ + "code": line["code"], + "name": line["name"], + "value": round(v["value"], 2) if v["value"] is not None else None, + "is_demo": v["is_demo"], + }) + net_by_section[sc["key"]] = round(subtotal, 2) + sections.append({ + "key": sc["key"], + "name": sc["name"], + "short": sc["short"], + "net": round(subtotal, 2), + "lines": lines, + "has_real_data": sec_real, + }) + + # 经营净额行优先取 KPI F_OP_CFLOW(真实数据优先) + op_kpi = _get_kpi_val(db, "F_OP_CFLOW", period) + if op_kpi is not None: + sections[0]["net"] = round(op_kpi, 2) + net_by_section["operating"] = round(op_kpi, 2) + + fx = _get_kpi_val(db, "F_FX_LOSS", period) + if fx is None: + fx = CF_DEMO_FX + fx_demo = True + else: + fx_demo = False + + begin_cash = CF_DEMO_BEGIN + net_increase = round(net_by_section.get("operating", 0) + + net_by_section.get("investing", 0) + + net_by_section.get("financing", 0) + + float(fx or 0), 2) + end_cash = round(begin_cash + net_increase, 2) + + return { + "period": period, + "title": f"现金流量表 — 新30号准则({period})", + "sections": sections, + "fx_effect": {"name": "四、汇率变动对现金及现金等价物的影响", "value": round(float(fx), 2), "is_demo": fx_demo}, + "summary": { + "net_increase": net_increase, + "begin_cash": begin_cash, + "end_cash": end_cash, + }, + "all_items_have_data": all_real, + } + + +@router.get("/statutory") +def get_statutory_reports( + period: str = Query(None, description="格式 YYYY-MM"), + db: Session = Depends(get_db), +): + """对外法定报表(新30号准则)— 利润表+资产负债表+现金流量表 组合视图""" + if period is None: + period = datetime.now().strftime("%Y-%m") + return { + "period": period, + "title": f"对外法定报表 — 新30号准则({period})", + "profit": _build_new_format_profit(db, period), + "balance_sheet": get_balance_sheet(period=period, db=db), + "cash_flow": get_cash_flow_statement(period=period, db=db), + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + } + + +@router.get("/statutory/export") +def export_statutory_reports( + period: str = Query(None, description="格式 YYYY-MM"), + db: Session = Depends(get_db), +): + """导出对外法定报表(新30号准则)— Excel 三表合一""" + if period is None: + period = datetime.now().strftime("%Y-%m") + data = get_statutory_reports(period=period, db=db) + + from io import BytesIO + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + from fastapi.responses import StreamingResponse + + wb = Workbook() + + head_fill = PatternFill("solid", fgColor="305496") + head_font = Font(color="FFFFFF", bold=True, size=12) + section_fill = PatternFill("solid", fgColor="D9E1F2") + section_font = Font(bold=True, size=11) + total_fill = PatternFill("solid", fgColor="FCE4D6") + total_font = Font(bold=True, size=11) + thin = Side(style="thin", color="BFBFBF") + border = Border(left=thin, right=thin, top=thin, bottom=thin) + demo_font = Font(color="D46B08", size=9, italic=True) + + def style_header(ws, row, ncols, title): + ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=ncols) + c = ws.cell(row=row, column=1, value=title) + c.font = Font(bold=True, size=14, color="305496") + c.alignment = Alignment(horizontal="center", vertical="center") + ws.row_dimensions[row].height = 26 + + def write_row(ws, r, values, font=None, fill=None, use_border=True): + for ci, v in enumerate(values, start=1): + c = ws.cell(row=r, column=ci, value=v) + if font: + c.font = font + if fill: + c.fill = fill + if use_border: + c.border = border + return r + 1 + + # ── Sheet 1: 利润表(五板块+附注) ── + ws1 = wb.active + ws1.title = "利润表-新30号准则" + style_header(ws1, 1, 4, f"利润表(新30号准则五板块) {period} 单位: 元") + r = write_row(ws1, 2, ["板块", "项目", "科目编码", "本期金额"], head_font, head_fill) + profit = data["profit"] + for block in profit.get("blocks", []): + r = write_row(ws1, r, [block["name"], block.get("subtotal_name", ""), "", block["subtotal"]], + section_font, section_fill) + for item in block.get("items", []): + r = write_row(ws1, r, ["", item["name"], item["code"], item["effective"]]) + r = write_row(ws1, r, ["合计", "净利润", "", profit.get("net_profit")], total_font, total_fill) + + # 附注明细 + notes = profit.get("notes") or {} + r += 1 + r = write_row(ws1, r, ["附注一、收入构成", "", "", ""], section_font, section_fill) + for n in notes.get("revenue_breakdown", []): + r = write_row(ws1, r, ["", n["name"], n["code"], n["amount"]]) + r = write_row(ws1, r, ["", "营业收入合计", "", notes.get("revenue_total")], total_font) + r = write_row(ws1, r, ["附注二、费用构成", "", "", ""], section_font, section_fill) + for n in notes.get("expense_breakdown", []): + r = write_row(ws1, r, ["", n["name"], n["code"], n["amount"]]) + r = write_row(ws1, r, ["附注三、财务费用拆解", "", "", ""], section_font, section_fill) + for n in notes.get("finance_breakdown", []): + r = write_row(ws1, r, ["", n["name"], n["code"], n["amount"]]) + r = write_row(ws1, r, ["附注四、板块勾稽(净利润=五板块之和)", "", "", ""], section_font, section_fill) + for n in notes.get("block_reconciliation", []): + r = write_row(ws1, r, ["", n["name"], "", n["amount"]]) + r = write_row(ws1, r, ["", "净利润", "", notes.get("net_profit")], total_font, total_fill) + r = write_row(ws1, r, ["附注五、关键比率", "", "", ""], section_font, section_fill) + for n in notes.get("key_ratios", []): + r = write_row(ws1, r, ["", n["name"], "", n["value"]]) + for col, w in zip("ABCD", [28, 40, 14, 18]): + ws1.column_dimensions[col].width = w + + # ── Sheet 2: 资产负债表 ── + ws2 = wb.create_sheet("资产负债表-新30号准则") + bs = data["balance_sheet"] + style_header(ws2, 1, 5, f"资产负债表(新30号准则科目分类) {period} 单位: 元") + r = write_row(ws2, 2, ["项目", "新准则分类", "期末余额", "期初余额", "数据来源"], head_font, head_fill) + for sec in bs.get("sections", []): + r = write_row(ws2, r, [sec["name"], sec.get("category_label", ""), sec["subtotal_end"], sec["subtotal_begin"], "小计"], + section_font, section_fill) + for line in sec.get("lines", []): + src = "示例" if line.get("is_demo") else "凭证" + r = write_row(ws2, r, [line["name"], line.get("ns_category_label", ""), + line["end_value"], line["begin_value"], src]) + t = bs.get("totals", {}) + r = write_row(ws2, r, ["资产总计", "", t["assets"]["end"], t["assets"]["begin"], ""], total_font, total_fill) + r = write_row(ws2, r, ["负债合计", "", t["liabilities"]["end"], t["liabilities"]["begin"], ""], total_font, total_fill) + r = write_row(ws2, r, ["所有者权益合计", "", t["equity"]["end"], t["equity"]["begin"], ""], total_font, total_fill) + r = write_row(ws2, r, ["负债和所有者权益总计", "", t["liab_equity"]["end"], t["liab_equity"]["begin"], ""], total_font, total_fill) + r = write_row(ws2, r, ["勾稽校验(资产=负债+权益)", "", "✓ 平衡" if t.get("balanced") else "✗ 不平", "", ""], total_font, total_fill) + for col, w in zip("ABCDE", [36, 16, 18, 18, 12]): + ws2.column_dimensions[col].width = w + + # ── Sheet 3: 现金流量表 ── + ws3 = wb.create_sheet("现金流量表-新30号准则") + cf = data["cash_flow"] + style_header(ws3, 1, 4, f"现金流量表(新30号准则三活动) {period} 单位: 元") + r = write_row(ws3, 2, ["项目", "行次", "本期金额", "数据来源"], head_font, head_fill) + for sec in cf.get("sections", []): + r = write_row(ws3, r, [sec["name"], "", "", "小计"], section_font, section_fill) + for line in sec.get("lines", []): + src = "示例" if line.get("is_demo") else "凭证" + r = write_row(ws3, r, [line["name"], line["code"], line["value"], src]) + r = write_row(ws3, r, [f"{sec['name']}净额", "", sec["net"], ""], total_font, total_fill) + fx = cf.get("fx_effect", {}) + r = write_row(ws3, r, [fx.get("name", ""), "", fx.get("value"), "示例" if fx.get("is_demo") else "凭证"], section_font, section_fill) + s = cf.get("summary", {}) + r = write_row(ws3, r, ["现金及现金等价物净增加额", "", s.get("net_increase"), ""], total_font, total_fill) + r = write_row(ws3, r, ["加:期初现金及现金等价物余额", "", s.get("begin_cash"), ""], total_font, total_fill) + r = write_row(ws3, r, ["期末现金及现金等价物余额", "", s.get("end_cash"), ""], total_font, total_fill) + for col, w in zip("ABCD", [46, 10, 18, 12]): + ws3.column_dimensions[col].width = w + + buf = BytesIO() + wb.save(buf) + buf.seek(0) + from urllib.parse import quote + filename = f"对外法定报表_新30号准则_{period}.xlsx" + return StreamingResponse( + buf, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"}, + ) + + @router.get("/dupont") def get_dupont_analysis( entity: str = Query("bohai"), diff --git a/backend/scripts/migrate_statutory_reports.py b/backend/scripts/migrate_statutory_reports.py new file mode 100644 index 00000000..f37e997f --- /dev/null +++ b/backend/scripts/migrate_statutory_reports.py @@ -0,0 +1,70 @@ +"""对外法定报表适配 — 数据库迁移 +补充资产负债表科目(资产/负债/所有者权益),按新30号准则经营/投资/筹资分类打标 +""" +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from app.database import get_engine +from sqlalchemy import text +import logging + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger("migrate_statutory") + +# (科目编码, 科目名称, 上级, 级别, 类别, 新30号准则分类) +# 新30号准则分类: operating经营类 / investing投资类 / financing筹资类 / equity权益 +BALANCE_SHEET_SUBJECTS = [ + # ── 资产类 (category=asset) ── + ('1001', '库存现金', None, 1, 'asset', 'operating'), + ('1002', '银行存款', None, 1, 'asset', 'operating'), + ('1012', '其他货币资金', None, 1, 'asset', 'operating'), + ('1101', '交易性金融资产', None, 1, 'asset', 'investing'), + ('1122', '应收账款', None, 1, 'asset', 'operating'), + ('1123', '预付账款', None, 1, 'asset', 'operating'), + ('1131', '应收股利', None, 1, 'asset', 'investing'), + ('1221', '其他应收款', None, 1, 'asset', 'operating'), + ('1405', '库存商品', None, 1, 'asset', 'operating'), + ('1501', '持有至到期投资', None, 1, 'asset', 'investing'), + ('1511', '长期股权投资', None, 1, 'asset', 'investing'), + ('1601', '固定资产', None, 1, 'asset', 'operating'), + ('1602', '累计折旧', '1601', 2, 'asset', 'operating'), + ('1701', '无形资产', None, 1, 'asset', 'operating'), + # ── 负债类 (category=liability) ── + ('2001', '短期借款', None, 1, 'liability', 'financing'), + ('2202', '应付账款', None, 1, 'liability', 'operating'), + ('2203', '预收账款', None, 1, 'liability', 'operating'), + ('2211', '应付职工薪酬', None, 1, 'liability', 'operating'), + ('2221', '应交税费', None, 1, 'liability', 'operating'), + ('2241', '其他应付款', None, 1, 'liability', 'operating'), + ('2501', '长期借款', None, 1, 'liability', 'financing'), + ('2502', '应付债券', None, 1, 'liability', 'financing'), + # ── 所有者权益类 (category=equity) ── + ('4001', '实收资本', None, 1, 'equity', 'equity'), + ('4002', '资本公积', None, 1, 'equity', 'equity'), + ('4103', '本年利润', None, 1, 'equity', 'equity'), + ('4104', '利润分配', None, 1, 'equity', 'equity'), +] + + +def run(): + engine = get_engine() + with engine.connect() as conn: + inserted = 0 + for row in BALANCE_SHEET_SUBJECTS: + res = 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]}) + inserted += res.rowcount + conn.commit() + logger.info(f"✅ 插入 {inserted} 条资产负债表科目(已有则跳过)") + total = conn.execute(text("SELECT count(*) FROM subjects")).scalar() + by_cat = conn.execute(text( + "SELECT category, count(*) FROM subjects GROUP BY category ORDER BY category" + )).fetchall() + dist = {r[0]: r[1] for r in by_cat} + logger.info(f"✅ subjects 表当前共 {total} 条,分类分布: {dist}") + + +if __name__ == "__main__": + run() diff --git a/frontend/src/views/ReportCenter.vue b/frontend/src/views/ReportCenter.vue index 07453135..bec743c7 100644 --- a/frontend/src/views/ReportCenter.vue +++ b/frontend/src/views/ReportCenter.vue @@ -360,6 +360,200 @@ + + + +
+
对外法定报表(新30号准则2027)— 利润表五板块+附注明细 / 资产负债表新准则科目分类 / 现金流量表三活动,支持导出Excel。
+ +
+ + 利润表 + 资产负债表 + 现金流量表 + + 📥 导出Excel(三表合一) + ⚠️ 部分数据为示例数据,导入凭证/打标后显示实际数据 +
+ + +
+
利润表 — 新30号准则五板块({{ statutoryData.period }})
+
+
+
+ {{ block.expanded ? '▼' : '▶' }} + {{ block.name }} + {{ formatMoney(block.subtotal) }} + {{ block.subtotal_name }} + 示例数据 +
+
+
+ {{ item.name }} + + {{ item.effective != null ? formatMoney(item.effective) : '-' }} + +
+
+
+
+
+
+ 净利润: + {{ formatMoney(statutoryData.profit.net_profit) }} + = 经营 + 投资 + 筹资 + 所得税 + 终止经营 +
+
+ + +
+
附注明细
+
+ +
+
附注一、收入构成
+
+ {{ n.name }}({{ n.code }}) + {{ n.amount != null ? formatMoney(n.amount) : '-' }} +
+
+ 营业收入合计 + {{ statutoryData.profit.notes.revenue_total != null ? formatMoney(statutoryData.profit.notes.revenue_total) : '-' }} +
+
+ +
+
附注二、费用构成
+
+ {{ n.name }}({{ n.code }}) + {{ n.amount != null ? formatMoney(n.amount) : '-' }} +
+
+ +
+
附注三、财务费用拆解
+
+ {{ n.name }}({{ n.code }}) + {{ n.amount != null ? formatMoney(n.amount) : '-' }} +
+
+ +
+
附注四、板块勾稽
+
+ {{ n.name }} + {{ formatMoney(n.amount) }} +
+
+ 净利润 + {{ formatMoney(statutoryData.profit.notes.net_profit) }} +
+
+
+
附注五、关键比率
+
+ {{ n.name }} + {{ n.value != null ? n.value.toFixed(2) + '%' : '-' }} +
+
+
+
+
+ + +
+
资产负债表 — 新30号准则科目分类({{ statutoryData.period }})
+ + + + + + + + + + + + + + + + + + +
+
+
资产总计
+
{{ formatMoney(statutoryData.balance_sheet.totals?.assets?.end) }}
+
+
+
负债合计
+
{{ formatMoney(statutoryData.balance_sheet.totals?.liabilities?.end) }}
+
+
+
所有者权益合计
+
{{ formatMoney(statutoryData.balance_sheet.totals?.equity?.end) }}
+
+
+
勾稽校验(资产=负债+权益)
+
{{ statutoryData.balance_sheet.totals?.balanced ? '✅ 平衡' : '⚠️ 不平' }}
+
+
+
+ + +
+
现金流量表 — 新30号准则三活动({{ statutoryData.period }})
+
+
+ {{ sec.name }} + {{ formatMoney(sec.net) }} +
+
+ {{ line.name }} + {{ line.code }} + {{ line.value != null ? formatMoney(line.value) : '-' }} + 示例 +
+
+
+
+ {{ statutoryData.cash_flow.fx_effect?.name }} + {{ formatMoney(statutoryData.cash_flow.fx_effect?.value) }} +
+
+
+
+ 现金及现金等价物净增加额 + {{ formatMoney(statutoryData.cash_flow.summary?.net_increase) }} +
+
+ 加:期初现金及现金等价物余额 + {{ formatMoney(statutoryData.cash_flow.summary?.begin_cash) }} +
+
+ 期末现金及现金等价物余额 + {{ formatMoney(statutoryData.cash_flow.summary?.end_cash) }} +
+
+
+
+
@@ -471,6 +665,48 @@ function openSubjectManage() { window.open('/subjects', '_blank') } +// ════ 对外法定报表(新30号准则三表合一)════ +const statutoryData = ref({}) +const statutoryView = ref('profit') +const statutoryDemoMode = ref(false) +const statutoryLoading = ref(false) +const exporting = ref(false) + +async function loadStatutory() { + statutoryLoading.value = true + try { + const r = await api.get('/reports/statutory', { params: { period: reportPeriod.value, entity_id: getEntityId() } }) + const d = (r as any).data || {} + statutoryData.value = d + statutoryDemoMode.value = !d.cash_flow?.all_items_have_data + } catch (e) { + ElMessage.error('加载法定报表失败') + } finally { + statutoryLoading.value = false + } +} + +async function exportStatutory() { + exporting.value = true + try { + const token = localStorage.getItem('cma_token') || '' + const url = `/api/cma/reports/statutory/export?period=${reportPeriod.value}&entity_id=${getEntityId()}` + const resp = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }) + if (!resp.ok) throw new Error('导出失败') + const blob = await resp.blob() + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = `对外法定报表_${reportPeriod.value}.xlsx` + a.click() + URL.revokeObjectURL(a.href) + ElMessage.success('导出成功') + } catch (e) { + ElMessage.error('导出失败') + } finally { + exporting.value = false + } +} + // ── 报表2:预算执行报告 ── const budgetSummary = ref({ total: 0, with_budget: 0, over_budget: 0, under_budget: 0, normal: 0 }) const budgetItems = ref([]) @@ -654,6 +890,7 @@ function onBudgetSort(sort: any) { onMounted(() => { loadKpiOptions() loadAll() + loadStatutory() })