diff --git a/backend/app/api/reports.py b/backend/app/api/reports.py index 1dbb3dfd..5d91d350 100644 --- a/backend/app/api/reports.py +++ b/backend/app/api/reports.py @@ -551,11 +551,15 @@ def get_profit_statement( return _build_new_format_profit(db, period, entity_id) -def _build_new_format_profit(db: Session, period: str, entity_id: int = 1) -> dict: - """构建新30号准则五板块利润表(含附注明细)(账套隔离 2026-08-31)""" +def _new_format_blocks(db: Session, period: str, entity_id: int = 1) -> dict: + """新30号准则五板块取数核心(真实科目;无数据板块 subtotal=None) + + 批① 2026-10-15:`_get_demo_block_total` 示例兜底已摘除,示例值不进对外数字。 + """ blocks = [] total_net_profit = 0 all_items_have_data = True + blocks_with_data = 0 for block_key in ["operating", "investing", "financing", "tax", "discontinued"]: block_cfg = BLOCK_INFO[block_key] @@ -577,28 +581,86 @@ def _build_new_format_profit(db: Session, period: str, entity_id: int = 1) -> di "effective": round(amount * item_cfg["sign"], 2) if amount is not None else None, }) - # Fallback: 使用PRD示例数据 + # 批① 2026-10-15:去 PRD 示例兜底 —— 无真实数据 ⇒ 小计 null(显式「无数据」), + # 严禁示例值进入对外数字(原 `_get_demo_block_total(block_key)` 调用已摘除)。 if not block_has_data: all_items_have_data = False - block_subtotal = _get_demo_block_total(block_key) + block_subtotal = None block_result = { "key": block_key, "name": block_cfg["name"], "short_name": block_cfg["short_name"], - "subtotal": round(block_subtotal, 2), + "subtotal": round(block_subtotal, 2) if block_subtotal is not None else None, "subtotal_name": block_cfg["result_name"], "items": items, "expanded": True, "has_real_data": block_has_data, + "data_state": "voucher" if block_has_data else "no_data", } blocks.append(block_result) - total_net_profit += block_subtotal + if block_subtotal is not None: + total_net_profit += block_subtotal + else: + pass # 无真实数据板块:小计为 None,不计入合计(见 reconciliation.blocks_missing) + if block_has_data: + blocks_with_data += 1 + + return { + "period": period, + "blocks": blocks, + "net_profit": round(total_net_profit, 2), + "all_items_have_data": all_items_have_data, + "blocks_with_data": blocks_with_data, + } + + +def _build_new_format_profit(db: Session, period: str, entity_id: int = 1) -> dict: + """构建新30号准则五板块利润表(含附注明细 + 上期/同比;账套隔离 2026-08-31) + + 批①② 2026-10-15(cma-reports-real-data-all): + ① 去 PRD 示例兜底:无数据板块 subtotal=null + data_state=no_data(示例值零注入); + ② 上期/同比照本期取数口径实取:无上期数据 ⇒ 显式 null(不顶替、不编造); + ③ 勾稽显式:net_profit_complete / reconciliation(净利润 = 有数据板块小计之和)。 + """ + cur = _new_format_blocks(db, period, entity_id) + blocks = cur["blocks"] + total_net_profit = cur["net_profit"] + all_items_have_data = cur["all_items_have_data"] + + # ② 上期(月):同口径实取;无上期 ⇒ None(显式 null) + prev_period = prev_period_of(period) + prev_core = _new_format_blocks(db, prev_period, entity_id) + prev_by_key = {b["key"]: b for b in prev_core["blocks"]} + for b in blocks: + pb = prev_by_key.get(b["key"]) + prev_sub = pb["subtotal"] if (pb and pb.get("has_real_data")) else None + b["prev_subtotal"] = prev_sub + if b["subtotal"] is not None and prev_sub is not None: + b["variance"] = round(b["subtotal"] - prev_sub, 2) + b["variance_pct"] = (round((b["subtotal"] - prev_sub) / abs(prev_sub) * 100, 2) + if prev_sub else None) + else: + b["variance"] = None + b["variance_pct"] = None + b["prev_data_state"] = "voucher" if prev_sub is not None else "no_data" + + prev_net = prev_core["net_profit"] if prev_core["blocks_with_data"] else None + cur_sum = round(sum(b["subtotal"] for b in blocks if b["subtotal"] is not None), 2) + _assessable = any(b["subtotal"] is not None for b in blocks) + reconciliation = { + "formula": "净利润 = 五板块小计之和(缺数据板块不计入并在 blocks_missing 列出)", + "blocks_sum": cur_sum, + "net_profit": round(total_net_profit, 2), + # 全板块无真实数据 ⇒ 0==0 不是「勾稽成立」,显式不可判定(禁假信号) + "assessable": _assessable, + "balanced": (abs(cur_sum - round(total_net_profit, 2)) < 0.01) if _assessable else None, + "blocks_missing": [b["key"] for b in blocks if b["subtotal"] is None], + } # 附注明细(对外法定报表披露要求) notes = _build_profit_notes(db, period, blocks, total_net_profit, entity_id) - # 合计行:净利润 = 一二三+四+五 return { "period": period, "format": "new", @@ -606,9 +668,21 @@ def _build_new_format_profit(db: Session, period: str, entity_id: int = 1) -> di "blocks": blocks, "net_profit": round(total_net_profit, 2), "net_profit_name": "净利润", + "net_profit_complete": all_items_have_data, "all_items_have_data": all_items_have_data, + "prev_period": prev_period, + "prev": { + "period": prev_period, + "net_profit": prev_net, + "blocks": {k: (v["subtotal"] if v.get("has_real_data") else None) + for k, v in prev_by_key.items()}, + "has_real_data": bool(prev_core["blocks_with_data"]), + }, + "reconciliation": reconciliation, + "data_state": "voucher" if all_items_have_data else "partial_no_data", + "demo_free": True, + "source": "subjects/kpi_values + voucher_details 真实发生额(按 entity 隔离;本报表零示例值)", "notes": notes, - "prev_period": None, # TODO: P1追溯调整 } @@ -781,18 +855,29 @@ def mpm_calculate( # 获取基准值:净利润 net_profit = _calc_new_net_profit(db, req.period, entity_id) - if net_profit is None: - net_profit = 0 # 经营现金流(自由现金流的基准) operating_cf = _get_kpi_val(db, "F_OPERATING_CF", req.period, entity_id) - # 确定基准值 + # 确定基准值(批① 2026-10-15:原 `operating_cf or net_profit` 静默 fallback 改为显式标注) + base_fallback = False if req.indicator_type == "free_cash_flow": - base_value = operating_cf or net_profit # fallback - base_label = "经营现金流" + if operating_cf is not None: + base_value = operating_cf + base_source = "F_OPERATING_CF(真实)" + base_label = "经营现金流" + elif net_profit is not None: + base_value = net_profit + base_source = "净利润(经营现金流未采集 ⇒ 退回,显式标注)" + base_label = "经营现金流(缺 ⇒ 退回净利润)" + base_fallback = True + else: + base_value = None + base_source = "无数据(经营现金流与净利润均未采集)" + base_label = "经营现金流" else: base_value = net_profit + base_source = indicator_cfg["base_label"] base_label = indicator_cfg["base_label"] # 获取调整项(来自请求或默认) @@ -800,7 +885,7 @@ def mpm_calculate( # 自动填充调整项金额 reconciliation_items = [] - running_total = base_value + running_total = 0.0 if base_value is None else base_value # 第一步:基准值 reconciliation_items.append({ @@ -808,10 +893,10 @@ def mpm_calculate( "code": "_base", "name": base_label, "sign": 1, - "amount": round(base_value, 2), - "effective": round(base_value, 2), + "amount": round(base_value, 2) if base_value is not None else None, + "effective": round(base_value, 2) if base_value is not None else None, "is_base": True, - "running_total": round(base_value, 2), + "running_total": round(running_total, 2), }) for adj in adjustments: @@ -845,22 +930,25 @@ def mpm_calculate( reconciliation_items.append(item) - # 最终结果 - final_value = round(running_total, 2) + # 最终结果(基准缺失 ⇒ 不给出伪合计) + final_value = round(running_total, 2) if base_value is not None else None return { "indicator_type": req.indicator_type, "indicator_name": indicator_cfg["name"], "indicator_desc": indicator_cfg["description"], "period": req.period, - "base_value": round(base_value, 2), + "base_value": round(base_value, 2) if base_value is not None else None, "base_label": base_label, + "base_source": base_source, + "base_fallback": base_fallback, + "base_missing": base_value is None, "final_value": final_value, "final_label": indicator_cfg["name"], "adjustment_count": sum(1 for a in adjustments if a.get("checked", False)), "total_adjustments": len(adjustments), "reconciliation_items": reconciliation_items, - "has_real_data": net_profit != 0, + "has_real_data": net_profit is not None, } @@ -908,17 +996,6 @@ def _get_adjustment_value(db: Session, adj_code: str, period: str, entity_id: in return abs(amount) if mapping.get("sign_inverse", False) else amount -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) - # ============================================================ # 追溯调整: 2026年数据按新30号准则重述 (P1) @@ -1363,32 +1440,6 @@ BALANCE_SHEET_SECTIONS = [ }, ] -# 资产负债表示例数据(博海科技, 期末/期初, 单位: 千元) -BS_DEMO = { - "1001|1002|1012": {"end": 850, "begin": 780}, - "1101": {"end": 250, "begin": 220}, - "1122": {"end": 1100, "begin": 1050}, - "1123": {"end": 180, "begin": 160}, - "1131": {"end": 120, "begin": 100}, - "1221": {"end": 90, "begin": 80}, - "1405": {"end": 930, "begin": 900}, - "1511": {"end": 580, "begin": 550}, - "1501": {"end": 200, "begin": 180}, - "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": 780, "begin": 820}, -} - BS_CATEGORY_CN = { "operating": "经营类", "investing": "投资类", @@ -1438,16 +1489,28 @@ def _get_bs_amount(db: Session, codes: list, period: str, entity_id: int = 1) -> return round(total, 2) if has_data else None +def _src_label(v: dict) -> str: + """Excel 数据来源标签(批① 2026-10-15:示例值已清零 ⇒ 只剩 凭证 / 无数据 / KPI)""" + if v is None: + return "" + if v.get("value") is None or v.get("no_data"): + return "无数据" + src = v.get("source") or "" + if "kpi" in src.lower() or src.startswith("KPI"): + return "KPI" + return "凭证" + + def _bs_line_amount(db: Session, line: dict, period: str, column: str = "end", entity_id: int = 1) -> dict: - """单行:凭证数据优先,否则示例数据(column: end期末 / begin期初)(账套隔离 2026-08-31)""" + """单行:凭证明细取数;无真实数据 ⇒ value=None + no_data=True(**不注入示例值**) + + 批① 2026-10-15(cma-reports-real-data-all):原「凭证优先,否则 BS_DEMO 示例」已摘除, + 示例值一律不得进入对外数字;`is_demo` 保留为恒 False 以兼容既有前端契约。 + """ real = _get_bs_amount(db, line["codes"], period, entity_id) 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} + return {"value": real, "is_demo": False, "no_data": False, "source": "voucher_details"} + return {"value": None, "is_demo": False, "no_data": True, "source": "none"} # ── 资产负债表取数口径(2026-09-16 重分类落地) ── @@ -1532,25 +1595,31 @@ def get_balance_sheet( total_equity_end = total_equity_begin = 0 all_real = has_real + has_no_data = False for sec in BALANCE_SHEET_SECTIONS: lines = [] sec_end = sec_begin = 0.0 sec_real = False + sec_begin_real = False for line in sec["lines"]: key = "|".join(c for c, _ in line["codes"]) if has_real: - end = {"value": _line_value(plan, key, "end"), "is_demo": False} - begin = {"value": _line_value(plan_prev, key, "begin"), "is_demo": False} + _end_v = _line_value(plan, key, "end") + _beg_v = _line_value(plan_prev, key, "begin") + end = {"value": _end_v, "is_demo": False, "no_data": _end_v is None} + begin = {"value": _beg_v, "is_demo": False, "no_data": _beg_v is None} else: end = _bs_line_amount(db, line, period, column="end", entity_id=entity_id) begin = _bs_line_amount(db, line, prev_period, column="begin", entity_id=entity_id) - if end["is_demo"] or begin["is_demo"]: - all_real = False + # 批① 2026-10-15:示例注入已摘除;此处改为统计「无数据」行 + if end.get("no_data") and begin.get("no_data"): + has_no_data = True if end["value"] is not None: sec_end += end["value"] sec_real = True if begin["value"] is not None: sec_begin += begin["value"] + sec_begin_real = True raw_key = plan["raw"].get(key) diag_adj = None if raw_key is None else round( float(plan["after"].get(key) or 0) - float(raw_key), 2) @@ -1561,7 +1630,9 @@ def get_balance_sheet( "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"], + "is_demo": False, + "no_data": bool(end.get("no_data")) and bool(begin.get("no_data")), + "data_state": "voucher" if (end["value"] is not None or begin["value"] is not None) else "no_data", "is_reclass_line": False, "template_gap": False, "raw_value": None if raw_key is None else round(float(raw_key), 2), @@ -1597,18 +1668,24 @@ def get_balance_sheet( "key": sec["key"], "name": sec["name"], "category_label": sec["category_label"], - "subtotal_end": round(sec_end, 2), - "subtotal_begin": round(sec_begin, 2), + "subtotal_end": round(sec_end, 2) if sec_real else None, + "subtotal_begin": round(sec_begin, 2) if sec_begin_real else None, "lines": lines, "has_real_data": sec_real, + "data_state": "voucher" if sec_real else "no_data", }) inv = plan["invariants"] if has_real: 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 + balanced_note = ("期末/期初均满足:资产 = 负债 + 所有者权益" if balanced + else "不平:资产 ≠ 负债 + 所有者权益(差异见 totals.identity_gap;" + "改前既有真账差异,本报表如实暴露、不掩盖)") else: - balanced = abs(total_assets_end - total_liab_end - total_equity_end) < 0.01 + # 批① 2026-10-15:无真实凭证数据 ⇒ 不注入示例、勾稽不可判定 ⇒ 显式 null(禁 0==0 假平衡) + balanced = None + balanced_note = "无真实凭证数据(no_data)⇒ 勾稽不可判定(不再以示例值凑平)" return { "period": period, @@ -1616,6 +1693,10 @@ def get_balance_sheet( "basis": use_basis, "title": f"资产负债表 — 新30号准则({period})", "sections": sections, + "data_state": "voucher" if has_real else "no_data", + "no_data_lines": has_no_data, + "demo_free": True, + "source": "report_reclass 取数层(凭证明细 + 期末余额口径;本报表零示例值)", "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)}, @@ -1623,6 +1704,7 @@ def get_balance_sheet( "liab_equity": {"end": round(total_liab_end + total_equity_end, 2), "begin": round(total_liab_begin + total_equity_begin, 2)}, "balanced": balanced, + "balanced_note": balanced_note, "identity_gap": round(total_assets_end - total_liab_end - total_equity_end, 2), }, # ── 重分类(调表不调账)── @@ -1777,31 +1859,70 @@ CASH_FLOW_LINES = [ {"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, entity_id: int = 1) -> dict: - """现金流量表行项目 — 优先KPI/凭证,否则示例数据(账套隔离 2026-08-31)""" + """现金流量表行项目 — KPI/凭证明细取数;无真实数据 ⇒ value=None + no_data=True + + 批① 2026-10-15:原「否则 CF_DEMO 示例数据」已摘除,示例值不进对外数字。 + """ # 经营净额行特殊处理:优先取 F_OP_CFLOW if line.get("kpi_code"): kpi_val = _get_kpi_val(db, line["kpi_code"], period, entity_id) if kpi_val is not None: - return {"value": round(kpi_val, 2), "is_demo": False} + return {"value": round(kpi_val, 2), "is_demo": False, "no_data": False, + "source": "KPI %s" % line["kpi_code"]} real = _get_bs_amount(db, [(line["code"], line["sign"])], period, entity_id) 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} + return {"value": real, "is_demo": False, "no_data": False, "source": "voucher_details"} + return {"value": None, "is_demo": False, "no_data": True, "source": "none"} + + +def _kpi_to_yuan(db: Session, kpi_code: str, value: float, entity_id: int = 1) -> float: + """按 `kpi_definitions.unit` 把 KPI 值折算为**元**(unit 含「万」⇒ ×10000)。 + + 2026-10-15(批①):现金流量表内部一律以元为口径(凭证明细亦为元); + 原实现把 KPI(万元)与凭证(元)混排 ⇒ 单位错配,本函数收口折算。 + """ + try: + kpi = db.query(KPIDefinition).filter( + KPIDefinition.kpi_code == kpi_code, KPIDefinition.entity_id == entity_id).first() + unit = (kpi.unit if kpi else None) or "" + except Exception: + unit = "" + return round(float(value) * 10000.0, 2) if "万" in unit else round(float(value), 2) + + +def _get_official_cf(db: Session, entity_id: int, period: str) -> Optional[dict]: + """官方现金流量表口径(`cashflow_statements`,库内**万元**)→ 元;无该期行 ⇒ None。 + + 2026-10-15(cma-reports-real-data-all 批①):现金流量表**行明细**目前未映射到科目码 + (CASH_FLOW_LINES 的 code 是 CF01… 报告行号,不是 subjects.subject_code)⇒ 行级取数 + 结构性缺口(回执点名,不在本单内擅改口径);但三大活动净额 / 期初·期末现金有官方 + 注入真值可用,故此处按真实来源取,并逐项标注来源与「行明细不可用」。 + """ + try: + from app.models import CashFlowStructureValue + row = db.query(CashFlowStructureValue).filter( + CashFlowStructureValue.entity_id == entity_id, + CashFlowStructureValue.period == period).first() + except Exception: + return None + if row is None: + return None + + def _y(v): + return None if v is None else round(float(v) * 10000.0, 2) + + return { + "period": row.period, + "operating": _y(row.operating_net), + "investing": _y(row.investing_net), + "financing": _y(row.financing_net), + "begin_cash": _y(row.begin_cash), + "end_cash": _y(row.end_cash), + "source": "cashflow_statements(id=%s, source=%s) 万元→元" % (row.id, row.source or ""), + "line_detail_available": False, + } @router.get("/cash-flow") @@ -1823,6 +1944,8 @@ def get_cash_flow_statement( sections = [] net_by_section = {} all_real = True + has_no_data = False + _off = _get_official_cf(db, entity_id, period) for sc in section_cfg: lines = [] @@ -1832,8 +1955,9 @@ def get_cash_flow_statement( if line["section"] != sc["key"]: continue v = _get_cf_amount(db, line, period, entity_id) - if v["is_demo"]: + if v.get("no_data"): all_real = False + has_no_data = True if v["value"] is not None: subtotal += v["value"] sec_real = True @@ -1841,49 +1965,113 @@ def get_cash_flow_statement( "code": line["code"], "name": line["name"], "value": round(v["value"], 2) if v["value"] is not None else None, - "is_demo": v["is_demo"], + "is_demo": False, + "no_data": bool(v.get("no_data")), + "source": v.get("source", "none"), }) - net_by_section[sc["key"]] = round(subtotal, 2) + net_by_section[sc["key"]] = round(subtotal, 2) if sec_real else None + _official_net = None + if not sec_real and _off and _off.get(sc["key"]) is not None: + _official_net = _off[sc["key"]] + net_by_section[sc["key"]] = _official_net sections.append({ "key": sc["key"], "name": sc["name"], "short": sc["short"], - "net": round(subtotal, 2), + "net": round(subtotal, 2) if sec_real else _official_net, "lines": lines, - "has_real_data": sec_real, + "has_real_data": sec_real or (_official_net is not None), + "data_state": ("voucher" if sec_real else + ("official_statement" if _official_net is not None else "no_data")), + "net_source": ("voucher_details 行明细合计" if sec_real else + ("cashflow_statements 官方口径(万元→元);行明细未映射到科目码 ⇒ 保持 null" + if _official_net is not None else "无数据(行明细未映射 + 无官方注入)")), + "line_detail_available": bool(sec_real), }) - # 经营净额行优先取 KPI F_OP_CFLOW(真实数据优先) - op_kpi = _get_kpi_val(db, "F_OP_CFLOW", period, entity_id) - 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, entity_id) - if fx is None: - fx = CF_DEMO_FX - fx_demo = True + # 经营净额行:① 官方口径(cashflow_statements,万元→元)优先; + # ② 无官方行时才退回 KPI F_OP_CFLOW,并按 kpi_definitions.unit 折算为元 + # (批① 2026-10-15:原实现直接写入 KPI 原值 ⇒ 万元/元 混排;且 4dp KPI 会与 + # 2dp 官方值互踩造成勾稽假不平) + if _off and _off.get("operating") is not None: + sections[0]["net"] = _off["operating"] + sections[0]["net_source"] = _off["source"] + " · operating_net" + sections[0]["data_state"] = "official_statement" + sections[0]["has_real_data"] = True + net_by_section["operating"] = _off["operating"] else: - fx_demo = False + op_kpi = _get_kpi_val(db, "F_OP_CFLOW", period, entity_id) + if op_kpi is not None: + _op_yuan = _kpi_to_yuan(db, "F_OP_CFLOW", op_kpi, entity_id) + sections[0]["net"] = _op_yuan + sections[0]["net_source"] = "KPI F_OP_CFLOW(按 kpi_definitions.unit 折算为元)" + sections[0]["data_state"] = "kpi" + sections[0]["has_real_data"] = True + net_by_section["operating"] = _op_yuan - 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) + # 批①:fx 无真实来源 ⇒ 显式 null(原 CF_DEMO_FX=0 注入已摘除) + fx = _get_kpi_val(db, "F_FX_LOSS", period, entity_id) + + # 批①:期初现金取真实来源(cashflow_statements 官方口径,万元 → 元);无则 null + begin_cash = _off.get("begin_cash") if _off else None + official_end_cash = _off.get("end_cash") if _off else None + if _off and begin_cash is not None: + begin_cash_source = _off["source"] + " · begin_cash" + else: + begin_cash_source = "cashflow_statements 无该期期初现金(未采集)" + if not _off: + has_no_data = True + + parts = [net_by_section.get("operating"), net_by_section.get("investing"), + net_by_section.get("financing")] + if all(p is not None for p in parts): + net_increase = round(sum(parts) + float(fx or 0), 2) + _by_lines = any(s.get("line_detail_available") for s in sections) + net_increase_source = ("voucher_details 行明细合计(含 fx=%s)" % fx + if _by_lines + else "cashflow_statements 官方口径(三大活动净额合计;fx=%s)" % fx) + else: + net_increase = None + net_increase_source = "无数据(存在净额缺失活动)" + end_cash = (round(begin_cash + net_increase, 2) + if (begin_cash is not None and net_increase is not None) else None) + # 勾稽:期初 + 净增加 = 期末(官方口径给定期末时显式对拍) + if official_end_cash is not None and end_cash is not None: + reconciliation = {"formula": "期初现金 + 现金净增加额 = 期末现金", + "begin_cash": begin_cash, "net_increase": net_increase, + "end_cash_computed": end_cash, "end_cash_official": official_end_cash, + "balanced": abs(end_cash - official_end_cash) < 0.01} + else: + reconciliation = {"formula": "期初现金 + 现金净增加额 = 期末现金", + "begin_cash": begin_cash, "net_increase": net_increase, + "end_cash_computed": end_cash, "end_cash_official": official_end_cash, + "balanced": None, + "note": "官方口径缺期初/净额或期末 ⇒ 勾稽不可判定(显式 null,不用示例值凑平)"} return { "period": period, "title": f"现金流量表 — 新30号准则({period})", "sections": sections, - "fx_effect": {"name": "四、汇率变动对现金及现金等价物的影响", "value": round(float(fx), 2), "is_demo": fx_demo}, + "fx_effect": {"name": "四、汇率变动对现金及现金等价物的影响", + "value": round(float(fx), 2) if fx is not None else None, + "is_demo": False, + "no_data": fx is None, + "source": "KPI F_FX_LOSS" if fx is not None else "无数据(未采集)"}, "summary": { "net_increase": net_increase, + "net_increase_source": net_increase_source, + "fx_available": fx is not None, "begin_cash": begin_cash, + "begin_cash_source": begin_cash_source, "end_cash": end_cash, + "end_cash_official": official_end_cash, }, + "reconciliation": reconciliation, "all_items_have_data": all_real, + "data_state": "voucher" if all_real else "partial_no_data", + "no_data_present": has_no_data, + "demo_free": True, + "source": "KPI + voucher_details 真实发生额 / cashflow_statements 官方口径(按 entity 隔离;本报表零示例值)", } @@ -1997,7 +2185,7 @@ def export_statutory_reports( 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 "凭证" + src = _src_label(line) r = write_row(ws2, r, [line["name"], line.get("ns_category_label", ""), line["end_value"], line["begin_value"], src]) t = bs.get("totals", {}) @@ -2028,11 +2216,11 @@ def export_statutory_reports( 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 "凭证" + src = _src_label(line) 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) + r = write_row(ws3, r, [fx.get("name", ""), "", fx.get("value"), _src_label(fx)], 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) @@ -3375,7 +3563,9 @@ def get_proforma_profit_statement( "period": period, "budget_version": _proforma_versions(found_versions), "requested_version": version, - "title": f"预算版利润表 — 新30号准则({period})", + "title": f"【预测】预算版利润表 — 新30号准则({period})", + "is_forecast": True, + "forecast_notice": "【预测/预算版】非实际发生数;实际数见 /reports/profit-statement、/reports/balance-sheet、/reports/cash-flow,两者不得混排", "blocks": blocks, "net_profit_actual": round(net_actual, 2), "net_profit_budget": round(net_budget, 2), @@ -3451,7 +3641,9 @@ def get_proforma_balance_sheet( "period": period, "budget_version": _proforma_versions(found_versions), "requested_version": version, - "title": f"预算版资产负债表({period})", + "title": f"【预测】预算版资产负债表({period})", + "is_forecast": True, + "forecast_notice": "【预测/预算版】非实际发生数;实际数见 /reports/profit-statement、/reports/balance-sheet、/reports/cash-flow,两者不得混排", "sections": sections, "budget_source_hint": "budget_plan=预算方案 / target_split=KPI目标值按月分摊 / none=无预算", } @@ -3547,7 +3739,9 @@ def get_proforma_cash_flow( "period": period, "budget_version": _proforma_versions(found_versions), "requested_version": version, - "title": f"预算版现金流量表({period})", + "title": f"【预测】预算版现金流量表({period})", + "is_forecast": True, + "forecast_notice": "【预测/预算版】非实际发生数;实际数见 /reports/profit-statement、/reports/balance-sheet、/reports/cash-flow,两者不得混排", "sections": sections, "summary": { "net_increase_actual": net_increase_actual,