# -*- coding: utf-8 -*- """管理利润表(贡献式)取数层 —— 真实科目发生额,按账套(entity)隔离。 **口径唯一源**:`app/utils/bep_caliber.py::ENTITY_CALIBERS` · 收入 :`caliber['revenue_field']` + `caliber['revenue_filter']`(e1=5001 贷方 / e2=6001 贷方) · 变动成本 :`caliber['varcost_terms']`(带号科目项;e1 = 5401借 + 5403借 + 571103借 − 530103贷) · 固定成本 :`caliber['fixed_terms']`(带号科目项,**优先**)否则 `caliber['fixed_filters']` (前缀元组 → 派生为 `('+', '<前缀>%', 'net')` 借−贷净额);两者一律经 `bep_caliber.fixed_cost_spec()` **唯一解析口**解析(报表/卡面/派生器同源)。 §4-1 批后:博海走 terms(`6601/6602/6603` **仅借方**),酣客仍走 filters(净额)。 · 财务收益 :`caliber['finance_income_terms']`(§4-1:博海 `6603` **贷方**)—— **单列备查**, **不进**固定成本、不进末行、不参与勾稽(纯展示项)。 · 期间行域 :`carry_forward == 0` + `caliber['exclude_closing']`(结转腿未标账套须显式剔 `summary LIKE '%结转%'`) · 作废行域 :`apply_voucher_guard`(`voucher_void_marks`,全库唯一实现) 铁律(2026-10-14 管理利润表重建单) 1. 本模块**不定义任何科目集、不含任何比例常量**;科目构成一律从 ENTITY_CALIBERS 派生。 口径要改 → 只改 `bep_caliber.py` 一处(本层与报表层零改动自动跟随),**禁第二套口径**。 2. **禁** KPI 比率 × 收入倒推(旧实现 `F_PROFIT_RATE` / `F_NET_PROFIT_RATE` / `F_COST_RATIO` 路线已废); **禁**硬编码比例(旧实现:变动成本 = 营收 × 写死的五成比例)。 3. 舍入一律「**汇总后舍入**」:先按元聚合相减,末步 ÷10000 取 2 位;勿逐分量舍入后相加。 4. 查不到的数据**如实标缺**(`None` + `missing` 列表),不倒推、不回落政策常量。 5. 末行标签必须与算法一致:本层算的是「收入 − 变动成本 − 固定成本」⇒ 标签 `营业利润` (常量 `BOTTOM_LABEL`)。**不称净利润**(未扣所得税/营业外收支/投资收益); **不称息税前利润**(已扣 6603 财务费用,EBIT 应在其之上)。 """ from __future__ import annotations import hashlib import json from typing import Dict, List, Optional, Tuple from sqlalchemy import func, or_ from app.models import ImportLog, VoucherDetail from app.utils.bep_caliber import (CLOSING_SUMMARY_LIKE, caliber_or_none, finance_income_terms as cal_finance_income_terms, fixed_cost_spec) from app.utils.voucher_guard import apply_voucher_guard # ── 展示口径 ──────────────────────────────────────────────────────────────── UNIT = "万元" # 库内为元,展示 ÷10,000(与 bep_caliber 头注一致) YUAN_PER_WAN = 10000.0 ROUND_DP = 2 # 勾稽容差(万元):三分量各 2 位舍入,「汇总后舍入」口径下最大漂移 < 0.015 ⇒ 0.02 RECON_TOL_WAN = 0.02 # 末行标签(与算法一致:收入 − 变动成本 − 固定成本) BOTTOM_LABEL = "营业利润" BOTTOM_LABEL_NOTE = ("= 营业收入 − 变动成本 − 固定成本;不含营业外收支、投资收益、所得税 " "⇒ 不标「净利润」;已扣 6603 财务费用 ⇒ 不标「息税前利润」") RECON_FORMULA = "营业收入 − 变动成本 − 固定成本 = 末行" FIELD_MAP = { "debit": lambda: VoucherDetail.debit_amount, "credit": lambda: VoucherDetail.credit_amount, "net": lambda: VoucherDetail.debit_amount - VoucherDetail.credit_amount, # 借−贷 # 列名别名(与 dashboard._bep_sum_field / kpi_bep_deriver._sum_field 的调用风格互通,防两套拼写) "debit_amount": lambda: VoucherDetail.debit_amount, "credit_amount": lambda: VoucherDetail.credit_amount, } _FIELD_FROM_COLUMN = {"debit_amount": "debit", "credit_amount": "credit", "net": "net"} # 口径指纹涉及的键(变更即变;供报表/回执标注取证) _DIGEST_KEYS = ("name", "varcost_terms", "fixed_terms", "fixed_filters", "revenue_filter", "revenue_field", "exclude_closing", "closing_subject") # ── 通用聚合原语(机制层;与 dashboard._bep_sum_field / kpi_bep_deriver._sum_field 同式同参)── def subject_clause(kind: str, code: str): """科目过滤谓词:('eq', code) 精确 / ('like', prefix) 前缀。""" if kind == "eq": return VoucherDetail.subject_code == code if kind == "like": return VoucherDetail.subject_code.like(str(code) + "%") raise ValueError(f"未知科目过滤类型: {kind}") def sum_by_subject(db, entity_id: int, period_from: str, period_to: str, field: str, subject_filter: Optional[Tuple[str, str]] = None, *, subject_filters: Optional[List[Tuple[str, str]]] = None, exclude_closing: bool = False) -> Dict[str, float]: """按科目分组的金额聚合(**元**)→ {subject_code: 金额}。 · field ∈ {debit, credit, net(=借−贷)};`net` 只在 `carry_forward==0` 行域成立 · subject_filter = ('eq', code) / ('like', prefix);subject_filters = [(kind, code), ...] 多科目 OR · 作废/撤单行经 apply_voucher_guard 剔除(标记表缺失时原样放行并告警一次) · 返回按科目明细 ⇒ 报表每行可逐行对到真实科目(取数留痕) """ expr = FIELD_MAP[field]() q = db.query(VoucherDetail.subject_code, func.sum(expr)).filter( VoucherDetail.entity_id == entity_id, VoucherDetail.carry_forward == 0, VoucherDetail.period >= period_from, VoucherDetail.period <= period_to, ) if subject_filters: q = q.filter(or_(*[subject_clause(k, c) for k, c in subject_filters])) elif subject_filter: q = q.filter(subject_clause(*subject_filter)) if exclude_closing: q = q.filter(or_(VoucherDetail.summary.is_(None), ~VoucherDetail.summary.like(CLOSING_SUMMARY_LIKE))) q = q.group_by(VoucherDetail.subject_code).order_by(VoucherDetail.subject_code) q = apply_voucher_guard(db, q, VoucherDetail) return {str(code): float(amount or 0.0) for code, amount in q.all()} def sum_field(db, entity_id: int, period_from: str, period_to: str, field: str, subject_filter: Optional[Tuple[str, str]] = None, *, subject_filters: Optional[List[Tuple[str, str]]] = None, exclude_closing: bool = False) -> float: """单科目/多科目金额合计(元)—— `sum_by_subject` 的标量包装(供口径对拍)。""" return float(sum(sum_by_subject(db, entity_id, period_from, period_to, field, subject_filter, subject_filters=subject_filters, exclude_closing=exclude_closing).values())) def sum_terms(db, entity_id: int, period_from: str, period_to: str, terms, *, exclude_closing: bool = False) -> dict: """「带号科目项」合计(元)—— terms = [(sign, code, field), ...]。 · sign='+' 加该科目项发生额;'-' 减之 · code 以 '%' 结尾 ⇒ 前缀匹配(like),否则精确匹配(eq) · 返回 {'total_yuan': float, 'parts': [{'sign','code','field','match','total_yuan','by_subject'}]} `parts` 即逐行取数留痕(可逐项对到真实科目与金额) """ total = 0.0 parts: List[dict] = [] for sign, code, field in terms: if sign not in ("+", "-"): raise ValueError(f"未知符号: {sign}(只支持 +/-)") raw = str(code) kind = "like" if raw.endswith("%") else "eq" code_arg = raw[:-1] if kind == "like" else raw by = sum_by_subject(db, entity_id, period_from, period_to, field, (kind, code_arg), exclude_closing=exclude_closing) sub = float(sum(by.values())) total += sub if sign == "+" else -sub parts.append({ "sign": sign, "code": raw, "field": field, "match": kind, "total_yuan": round(sub, ROUND_DP), "by_subject": {k: round(v, 2) for k, v in by.items()}, }) return {"total_yuan": total, "parts": parts} def has_voucher_rows(db, entity_id: int, period_from: str, period_to: str, exclude_closing: bool = False) -> bool: """该账套/期间行域内是否有明细行(无 ⇒ 报表如实标缺,而不是当作 0)。""" q = db.query(func.count(VoucherDetail.id)).filter( VoucherDetail.entity_id == entity_id, VoucherDetail.carry_forward == 0, VoucherDetail.period >= period_from, VoucherDetail.period <= period_to, ) if exclude_closing: q = q.filter(or_(VoucherDetail.summary.is_(None), ~VoucherDetail.summary.like(CLOSING_SUMMARY_LIKE))) q = apply_voucher_guard(db, q, VoucherDetail) return bool(q.scalar() or 0) # ── 口径派生(全部来自 ENTITY_CALIBERS,本层零科目集) ─────────────────────── def revenue_terms(cal: dict) -> list: """收入项(带号科目项):由 caliber['revenue_field'/'revenue_filter'] 派生。""" kind, code = cal["revenue_filter"] field = _FIELD_FROM_COLUMN.get(cal.get("revenue_field") or "credit_amount", "credit") return [("+", (str(code) + "%") if kind == "like" else str(code), field)] def fixed_cost_terms(cal: dict) -> Tuple[list, str]: """固定成本科目项:**经 `bep_caliber.fixed_cost_spec()` 唯一解析口**。 优先 `fixed_terms`(带号项,§4-1 后博海形态:仅借方),否则由 `fixed_filters` 派生 (前缀 + 借−贷净额,酣客形态)。 返回 (terms, mode);mode 供报表标注当前口径形态(`fixed_terms` / `fixed_filters(net)`)。 """ mode, spec = fixed_cost_spec(cal) if mode == "terms": return [tuple(t) for t in spec], "fixed_terms" derived = [("+", str(code) + "%", "net") for _kind, code in spec] return derived, "fixed_filters(net)" def caliber_digest(cal: dict) -> str: """口径指纹(md5 12 位)—— 口径表变更即变,供报表/回执标注取证。""" payload = json.dumps({k: cal.get(k) for k in _DIGEST_KEYS}, ensure_ascii=False, sort_keys=True, default=str) return hashlib.md5(payload.encode("utf-8")).hexdigest()[:12] def prev_period_of(period: str) -> str: """上期(月)—— '2026-01' → '2025-12'。""" year, month = str(period).split("-")[:2] y, m = int(year), int(month) m -= 1 if m <= 0: m += 12 y -= 1 return f"{y}-{m:02d}" # ── 取数主入口 ────────────────────────────────────────────────────────────── def compute_lines(db, entity_id: int, period_from: str, period_to: str) -> dict: """按 entity 口径从**真实科目发生额**算管理利润表各行(元 + 万元)。 返回: available 口径已注册且有明细行(否则 False + missing 说明,禁倒推) caliber 口径快照(含 digest / 各科目项 ⇒ 报表可逐行对到科目) raw_yuan 元级原值(revenue/variable_cost/contribution/fixed_cost/result) values 万元(2 位,**汇总后舍入**:先元级相减,末步落位) rows 各行的带号科目项明细(parts/by_subject,取数留痕) """ out = { "entity_id": entity_id, "period_from": period_from, "period_to": period_to, "available": False, "missing": [], "raw_yuan": {}, "values": {k: None for k in ("revenue", "variable_cost", "contribution", "fixed_cost", "result")}, "rows": {}, "caliber": None, } cal = caliber_or_none(entity_id) if cal is None: out["missing"].append( f"entity={entity_id} 未在 ENTITY_CALIBERS 注册口径(禁编造/禁跨账套套用:" f"固定成本与收入基数按账套而异,见 app/utils/bep_caliber.py)") return out exclude_closing = bool(cal.get("exclude_closing")) fix_terms, fix_mode = fixed_cost_terms(cal) rev_terms = revenue_terms(cal) fin_terms = cal_finance_income_terms(cal) out["caliber"] = { "entity_id": entity_id, "name": cal.get("name"), "margin_label": cal.get("margin_label"), "margin_desc": cal.get("margin_desc"), "revenue_terms": [list(t) for t in rev_terms], "varcost_terms": [list(t) for t in (cal.get("varcost_terms") or [])], "fixed_cost_terms": [list(t) for t in fix_terms], "fixed_mode": fix_mode, "finance_income_terms": [list(t) for t in fin_terms], "exclude_closing": exclude_closing, "closing_subject": cal.get("closing_subject"), "digest": caliber_digest(cal), "unit": UNIT, } if not has_voucher_rows(db, entity_id, period_from, period_to, exclude_closing): out["missing"].append( f"entity={entity_id} 期间 {period_from}..{period_to} 无凭证明细行(如实标缺,不倒推)") return out rev = sum_terms(db, entity_id, period_from, period_to, rev_terms, exclude_closing=exclude_closing) var = sum_terms(db, entity_id, period_from, period_to, cal.get("varcost_terms") or [], exclude_closing=exclude_closing) fix = sum_terms(db, entity_id, period_from, period_to, fix_terms, exclude_closing=exclude_closing) # 财务收益(§4-1:博海 6603 贷方)—— 单列备查,**不进**固定成本/末行,不参与勾稽 fin = (sum_terms(db, entity_id, period_from, period_to, fin_terms, exclude_closing=exclude_closing) if fin_terms else {"total_yuan": 0.0, "parts": []}) rev_y = rev["total_yuan"] var_y = var["total_yuan"] fix_y = fix["total_yuan"] con_y = rev_y - var_y # 边际贡献 res_y = con_y - fix_y # 末行(营业利润) out["rows"] = {"revenue": rev, "variable_cost": var, "fixed_cost": fix, "finance_income": fin} out["raw_yuan"] = {"revenue": round(rev_y, 2), "variable_cost": round(var_y, 2), "contribution": round(con_y, 2), "fixed_cost": round(fix_y, 2), "result": round(res_y, 2)} # 汇总后舍入:元级相减后一次性落万元,不逐分量舍入相加 out["values"] = {"revenue": round(rev_y / YUAN_PER_WAN, ROUND_DP), "variable_cost": round(var_y / YUAN_PER_WAN, ROUND_DP), "contribution": round(con_y / YUAN_PER_WAN, ROUND_DP), "fixed_cost": round(fix_y / YUAN_PER_WAN, ROUND_DP), "result": round(res_y / YUAN_PER_WAN, ROUND_DP)} out["finance_income"] = { "value": round(fin["total_yuan"] / YUAN_PER_WAN, ROUND_DP), "raw_yuan": round(fin["total_yuan"], ROUND_DP), "terms": fin["parts"], "unit": UNIT, "name": "财务收益(单列备查)", "basis": ("ENTITY_CALIBERS[entity]['finance_income_terms'](§4-1:6603 贷方 = 现金折扣收益等)" ";单列备查,不进固定成本、不参与勾稽"), } out["available"] = True return out def import_batches(db, entity_id: int, period: str, limit: int = 5) -> list: """数据批次 / 导入时刻(**纯展示备注**,4-5)—— 不参与任何计算。 取自 `import_logs`(该账套 + 该期间),倒序最多 `limit` 条:批次号 / 行数 / 导入时刻。 无记录 ⇒ 空列表(页面不显示备注,不编造批次)。 """ try: rows = (db.query(ImportLog) .filter(ImportLog.entity_id == entity_id, ImportLog.period == period) .order_by(ImportLog.id.desc()).limit(limit).all()) except Exception: # noqa: BLE001 —— 展示项:取数失败不得影响报表主体 return [] return [{"batch": r.batch, "rows": int(r.success_rows if r.success_rows is not None else (r.total_rows or 0)), "total_rows": int(r.total_rows or 0), "failed_rows": int(r.failed_rows or 0), "import_type": r.import_type, "imported_at": r.created_at.strftime("%Y-%m-%d %H:%M") if r.created_at else None, "filename": r.filename} for r in rows] def reconcile(revenue: Optional[float], variable_cost: Optional[float], fixed_cost: Optional[float], result: Optional[float], tol: float = RECON_TOL_WAN) -> dict: """勾稽自检(纯函数):**收入 − 变动成本 − 固定成本 = 末行**。 不平 ⇒ `balanced=False` + `diff`(报表层必须显式标出,**不许静默不平**); 组件缺数 ⇒ `balanced=False` + `components_present=False` + `missing`(同样不静默)。 """ names = (("revenue", revenue), ("variable_cost", variable_cost), ("fixed_cost", fixed_cost), ("result", result)) missing = [n for n, v in names if v is None] if missing: return {"balanced": False, "diff": None, "lhs": None, "rhs": result, "tolerance": tol, "components_present": False, "missing": missing, "formula": RECON_FORMULA, "note": "组件缺数 ⇒ 无法勾稽(显式标缺,不静默平)"} # 上行已排除 None(missing 为空)⇒ 此处断言收敛类型,供静态检查/防回归 assert revenue is not None and variable_cost is not None \ and fixed_cost is not None and result is not None lhs = round(revenue - variable_cost - fixed_cost, ROUND_DP) diff = round(lhs - result, ROUND_DP) balanced = abs(diff) <= tol return {"balanced": balanced, "diff": diff, "lhs": lhs, "rhs": result, "tolerance": tol, "components_present": True, "missing": [], "formula": RECON_FORMULA, "note": ("收入 − 变动成本 − 固定成本 = 末行 ✓" if balanced else f"⚠️ 勾稽不平:收入−变动−固定={lhs},末行={result},差 {diff}(万元)")} def _calc_chg(cur: Optional[float], prev: Optional[float]) -> Optional[float]: if cur is not None and prev is not None and prev != 0: return round((cur - prev) / prev * 100, 2) return None def build_items(cur: dict, prev: dict, bottom_label: str = BOTTOM_LABEL) -> list: """按管理利润表结构组 5 行(收入/变动成本/边际贡献/固定成本/末行),含环比与占比。 每行附 `terms`(带号科目项)+ `raw_yuan`(元级)⇒ 页面/回执可**逐行对到真实科目**。 口径未注册或无明细行 ⇒ 各行 value 为 None(如实标缺)。 """ cv = cur.get("values") or {} pv = prev.get("values") or {} cre = cur.get("rows") or {} pre = prev.get("rows") or {} def terms_of(key: str, src: dict): row = (cre if src is cur else pre).get(key) return row.get("parts") if row else None def raw(key: str) -> Optional[float]: return (cur.get("raw_yuan") or {}).get(key) def raw_prev(key: str) -> Optional[float]: return (prev.get("raw_yuan") or {}).get(key) def ratio_of(key: str, values: dict) -> Optional[float]: rev = values.get("revenue") v = values.get(key) return round(v / rev * 100, 2) if (v is not None and rev) else None items = [ {"name": "营业收入", "line_key": "revenue", "value": cv.get("revenue"), "prev_value": pv.get("revenue"), "change_rate": _calc_chg(cv.get("revenue"), pv.get("revenue")), "ratio": 100.0 if cv.get("revenue") is not None else None, "raw_yuan": raw("revenue"), "prev_raw_yuan": raw_prev("revenue"), "terms": terms_of("revenue", cur), "prev_terms": terms_of("revenue", prev), "unit": UNIT, "basis": "按 entity 口径的收入科目贷方发生额(e1=5001 / e2=6001)"}, {"name": "减:变动成本", "line_key": "variable_cost", "value": cv.get("variable_cost"), "prev_value": pv.get("variable_cost"), "change_rate": _calc_chg(cv.get("variable_cost"), pv.get("variable_cost")), "ratio": ratio_of("variable_cost", cv), "raw_yuan": raw("variable_cost"), "prev_raw_yuan": raw_prev("variable_cost"), "terms": terms_of("variable_cost", cur), "prev_terms": terms_of("variable_cost", prev), "unit": UNIT, "basis": "ENTITY_CALIBERS[entity]['varcost_terms'](带号科目项)"}, {"name": "= 边际贡献", "line_key": "contribution", "is_subtotal": True, "value": cv.get("contribution"), "prev_value": pv.get("contribution"), "change_rate": _calc_chg(cv.get("contribution"), pv.get("contribution")), "ratio": ratio_of("contribution", cv), "raw_yuan": raw("contribution"), "prev_raw_yuan": raw_prev("contribution"), "derived_from": ["revenue", "variable_cost"], "unit": UNIT, "basis": "= 营业收入 − 变动成本(元级相减后落万元)"}, {"name": "减:固定成本", "line_key": "fixed_cost", "value": cv.get("fixed_cost"), "prev_value": pv.get("fixed_cost"), "change_rate": _calc_chg(cv.get("fixed_cost"), pv.get("fixed_cost")), "ratio": ratio_of("fixed_cost", cv), "raw_yuan": raw("fixed_cost"), "prev_raw_yuan": raw_prev("fixed_cost"), "terms": terms_of("fixed_cost", cur), "prev_terms": terms_of("fixed_cost", prev), "unit": UNIT, "basis": ("ENTITY_CALIBERS[entity] 经 bep_caliber.fixed_cost_spec() 解析:" "fixed_terms=带号科目项(§4-1 博海 6601/6602/6603 仅借方)/ " "fixed_filters=前缀借−贷净额(酣客 5601/5602/5603)")}, {"name": f"= {bottom_label}", "line_key": "operating_profit", "is_total": True, "value": cv.get("result"), "prev_value": pv.get("result"), "change_rate": _calc_chg(cv.get("result"), pv.get("result")), "ratio": ratio_of("result", cv), "raw_yuan": raw("result"), "prev_raw_yuan": raw_prev("result"), "derived_from": ["revenue", "variable_cost", "fixed_cost"], "unit": UNIT, "label": bottom_label, "label_basis": BOTTOM_LABEL_NOTE, "basis": RECON_FORMULA}, ] return items