diff --git a/backend/app/api/reports.py b/backend/app/api/reports.py index 0c100e48..5f64fa63 100644 --- a/backend/app/api/reports.py +++ b/backend/app/api/reports.py @@ -13,7 +13,7 @@ import json import os from pydantic import BaseModel from sqlalchemy.orm import Session -from sqlalchemy import func, or_ +from sqlalchemy import func, or_, text from typing import Optional from datetime import datetime, date from app.database import get_db @@ -22,6 +22,11 @@ from app.models import KPIDefinition, KPIValue, BudgetPlan, StrategicMap, KPIAle from app.utils.deviation_engine import calc_period_deviation, calc_period_diff, calc_deviation, get_budget_for_kpi from app.utils.alert_entity_scope import month_period_clause from app.utils.profitability_params import load_profitability_params +from app.utils.dupont_basis import ( + annualize_factors, available_months, build_window_meta, check_identity, + compute_factors, latest_stock_month, load_dupont_params, months_between, prev_month, + resolve_window, stock_balance, sum_flows, +) from app.utils.voucher_guard import apply_voucher_guard from app.services import report_reclass from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug @@ -2148,190 +2153,303 @@ def export_statutory_reports( ) -def _get_dupont_kpi(db: Session, entity_id: int, kpi_code: str, period: str = "2026H1"): - """杜邦分析 KPI 读取 — 按 entity + kpi_code 查 kpi_definitions → kpi_values - 取 period 期间 data_status=='verified' 的最新一条(order_by id desc),读不到返回 None""" - kpi = db.query(KPIDefinition).filter( +# ============================================================ +# 杜邦分析 — 区间取值(流量累计 × 存量平均 × 可选年化 + 恒等式守护) +# 口径裁定: 财务Bot 2026-10-03(任总 2026-10-03 09:49 CST DM「立」) +# 任务件: finance-20261003-dupont-period-basis-design-fullstack.md +# 单一事实源: app/utils/dupont_basis.py(区间解析 / 取数 / 纯计算 / 恒等式守护) +# 取数源(§二.5 注): 库内 subject_balances(存量)+ voucher_details(流量); +# **不用**旧脚本 register_dupont_kpis.py 的 ERP 直连近似口径(缺应收/存货) +# ============================================================ + +DUPONT_SLUG_TO_ID = {"hanke": 1, "bohai": 2} +DUPONT_KPI_CODES = ("F_ASSET_TOTAL", "F_EQUITY_TOTAL", "F_EQUITY_MULTIPLIER", + "F_ASSET_TURNOVER", "F_ROE", "F_ROA") + + +def _dupont_window_values(db: Session, entity_id: int, caliber: dict, + start: str, end: str) -> dict: + """按任务件 §二 取一个区间的原值(万元)与因子(未年化)。 + + · 流量(损益类) = 区间**累计发生额** + · 存量(资产负债类)= 区间**平均余额** = (期初 + 期末) / 2,一级科目(`level=1`,防跨层双计) + · 期初缺失 ⇒ **显式**回落期末(`basis_fallback=True` + 原因),**禁静默**(§二.6) + """ + equity_prefixes = list(caliber["equity_prefixes"]) + asset_prefix = caliber["asset_prefix"] + + ta_end = stock_balance(db, entity_id, end, [asset_prefix], "net") + eq_end = stock_balance(db, entity_id, end, equity_prefixes, "net_rev") + + if start == end: + ta_begin, eq_begin = ta_end.get("begin"), eq_end.get("begin") + begin_hint = f"{start} 期行内期初列 ob_*(月初)" + else: + ta_start = stock_balance(db, entity_id, start, [asset_prefix], "net") + eq_start = stock_balance(db, entity_id, start, equity_prefixes, "net_rev") + ta_begin = ta_start.get("begin") if ta_start.get("row_found") else None + eq_begin = eq_start.get("begin") if eq_start.get("row_found") else None + begin_hint = f"{start} 期行内期初列 ob_*(期初)" + + fallback, reason = False, None + if ta_end.get("end") is None or eq_end.get("end") is None: + fallback, reason = True, f"期末存量缺失({end} 期无 subject_balances 一级科目行)" + elif ta_begin is None or eq_begin is None: + fallback = True + reason = f"期初存量缺失({begin_hint} 取不到) ⇒ 显式回落期末口径,非静默" + + if fallback: + ta_avg, eq_avg = ta_end.get("end"), eq_end.get("end") + else: + ta_avg = (ta_begin + ta_end["end"]) / 2.0 + eq_avg = (eq_begin + eq_end["end"]) / 2.0 + + revenue = sum_flows(db, entity_id, start, end, + {"codes": caliber["revenue_codes"], "field": "credit", + "carry_forward": 0}) + if caliber["net_profit_mode"] == "carry_forward": + # 酣客(旧准则):期末结转腿口径 —— 与已上线 verified 的月度 F_NET_PROFIT/F_ROE/F_ROA 同源 + net_profit = sum_flows(db, entity_id, start, end, + {"prefixes": equity_prefixes, "field": "credit_debit", + "carry_forward": 1}) + else: + # 博海(新 30 号准则):损益类科目净额口径(该账套 voucher_details 无结转腿) + net_profit = sum_flows(db, entity_id, start, end, + {"prefixes": caliber["pnl_prefixes"], "field": "credit_debit", + "carry_forward": 0}) + + def w(v): + return None if v is None else round(v / 10000.0, 6) + + raw = { + "net_profit": w(net_profit), "revenue": w(revenue), + "total_assets": w(ta_avg), "equity": w(eq_avg), + "total_assets_begin": w(ta_begin), "total_assets_end": w(ta_end.get("end")), + "equity_begin": w(eq_begin), "equity_end": w(eq_end.get("end")), + } + factors = compute_factors(raw["net_profit"], raw["revenue"], + raw["total_assets"], raw["equity"]) + return {"raw": raw, "factors": factors, + "basis": "ending" if fallback else "average", + "basis_fallback": fallback, "basis_fallback_reason": reason, + "net_profit_mode": caliber["net_profit_mode"], + "caliber": {"asset_prefix": asset_prefix, "equity_prefixes": equity_prefixes, + "revenue_codes": list(caliber["revenue_codes"]), + "pnl_prefixes": list(caliber["pnl_prefixes"])}} + + +def _dupont_prev_window(start: str, months: int) -> tuple: + """同长度上一区间:[prev_start, prev_end]""" + prev_end = prev_month(start) + prev_start = start + for _ in range(1, months): + prev_start = prev_month(prev_start) + prev_start = prev_month(prev_start) + return prev_start, prev_end + + +def _dupont_kpi_refs(db: Session, entity_id: int, period: str) -> dict: + """同口径 KPI 层镜像(derived 单一事实源):F_ASSET_TOTAL/F_EQUITY_TOTAL/ + F_EQUITY_MULTIPLIER/F_ASSET_TURNOVER/F_ROE/F_ROA 在该期间的最新行。""" + defs = {d.kpi_code: d for d in db.query(KPIDefinition).filter( KPIDefinition.entity_id == entity_id, - KPIDefinition.kpi_code == kpi_code, - ).first() - if not kpi: - return None - # 兼容period格式:数据库存"2026-H1",查询可能传"2026H1" - period_variants = [period] - if period: - # "2026H1"(6字符) → 补横线 "2026-H1" - if len(period) == 6: - period_variants.append(period[:4] + "-" + period[4:]) - # "2026-H1"(7字符) → 去横线 "2026H1" - elif len(period) == 7: - period_variants.append(period.replace("-", "")) - v = db.query(KPIValue).filter( - KPIValue.kpi_id == kpi.id, - KPIValue.period.in_(period_variants), - KPIValue.data_status == "verified", - ).order_by(KPIValue.id.desc()).first() - return v.actual_value if v else None + KPIDefinition.kpi_code.in_(list(DUPONT_KPI_CODES)), + ).all()} + ids = [d.id for d in defs.values()] + refs: dict = {} + if not ids: + return refs + rows = db.query(KPIValue).filter( + KPIValue.kpi_id.in_(ids), KPIValue.period == period, + KPIValue.actual_value.isnot(None), + ).order_by(KPIValue.id.desc()).all() + by_id = {d.id: d for d in defs.values()} + for row in rows: + kpi = by_id.get(row.kpi_id) + if kpi is None or kpi.kpi_code in refs: + continue + refs[kpi.kpi_code] = {"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, + "kpi_name": kpi.kpi_name, "unit": kpi.unit, + "value": row.actual_value, "period": row.period, + "source_type": row.source_type, "data_status": row.data_status, + "data_source_type": kpi.data_source_type} + return refs + + +def _dupont_factor_obj(name: str, label: str, desc: str, value, unit: str, + scale: float, annualized: bool, win: dict, basis: str, + raw: dict, status: str, assessment: str, display_dp: int) -> dict: + return { + "value": None if value is None else round(value, display_dp), + "value_exact": None if value is None else round(float(value), 6), + "unit": unit, "label": label, "desc": desc, + "basis": basis, "period_start": win["period_start"], "period_end": win["period_end"], + "annualized": bool(annualized), "scale_applied": round(scale, 6), + "raw": raw, "status": status, "assessment": assessment, + } @router.get("/dupont") def get_dupont_analysis( request: Request, entity: Optional[str] = Query(None, description="hanke/bohai(须与会话账套一致);缺省按会话账套"), + period: Optional[str] = Query(None, description="区间维度:ytd(默认)/ttm/YYYY-MM/YYYY-Qn/YYYY-Hn/YYYY/custom"), + start: Optional[str] = Query(None, description="自定义区间起(YYYY-MM,需配 end)"), + end: Optional[str] = Query(None, description="自定义区间止(YYYY-MM)"), + annualize: bool = Query(False, description="流量年化 ×12/区间月数(比率型因子缩放见 scale_applied)"), + guard_probe: Optional[str] = Query(None, description="恒等式守护判别力自检(只读诊断):mismatch=故意错配区间"), db: Session = Depends(get_db), ): - """杜邦分析 — ROE三级拆解 (CMA P2) + """杜邦分析 — ROE 三级拆解(区间取值设计,2026-10-03) - 多租户口径(cma-residual-cross-entity-cleanup-20260914,取代 2026-08-31 豁免): - 分析对象 = 会话账套(登录即定企业,页内无跨企业切换)。 - - 显式 entity 与会话账套不一致 → 403(文案同 get_entity_id);非法值 → 400 - - 无会话 token(Bot/存量脚本通道)→ 缺省 bohai(保留原默认,仅非会话通道) - 旧豁免("跨实体对比分析端点")已撤销:该端点为单对象分析,非并排对比。 + 多租户口径(不变):分析对象 = 会话账套;显式 entity 与会话不一致 → 403; + 无会话 token(Bot/存量脚本通道)→ 缺省 bohai。 + + 取值规则(任务件 §二): + 流量(损益类)= 区间累计发生额;存量(资产负债类)= 区间平均余额 (期初+期末)/2; + 跨期比较 = 年化 = 流量 × 12 / 区间月数(净利率/权益乘数为同量纲之比 ⇒ 年化相消)。 + + 返回必带口径元数据:`period_start` / `period_end` / `basis` / `annualized`(§二.4)。 + 恒等式(§二.3):|ROE − 净利率×周转率×权益乘数| ≤ 0.05 百分点;不成立 ⇒ 只返回 + `identity_broken` + 三因子各自的区间元数据,**不返回数值**。 """ slug = assert_entity_slug_scope(request, entity) if slug is None: slug = resolve_session_slug(request, "bohai") - if slug == "bohai": - # 博海标准KPI(F_REVENUE/F_NET_PROFIT)无verified值 → 优先DB读,读不到回退文档确认常量 - net_profit = _get_dupont_kpi(db, 2, "F_NET_PROFIT") - if net_profit is None: - # 来源: bohai_comprehensive_analysis_2026.md 试算平衡表 2026H1(=141,324元) - net_profit = 14.13 # 万 - revenue = _get_dupont_kpi(db, 2, "F_REVENUE") - if revenue is None: - # 来源: bohai_comprehensive_analysis_2026.md 试算平衡表 2026H1(=3,836,026元) - revenue = 383.6 # 万 - # 来源: bohai_comprehensive_analysis_2026.md 试算平衡表 2026H1 - total_assets = 533 # 万(DB无对应KPI,文档口径) - equity = 358.4 # 万(=3,583,851元)⚠️ 核心修正点:原硬编码114万错误,导致ROE高估约3倍 + entity_id = DUPONT_SLUG_TO_ID.get(slug) + if entity_id is None: + raise HTTPException(status_code=400, detail=f"不支持的实体 slug={slug}(支持 hanke/bohai)") - net_profit_margin = round(net_profit / revenue * 100, 2) - asset_turnover = round(revenue / total_assets, 4) - financial_leverage = round(total_assets / equity, 2) - roe = round(net_profit_margin / 100 * asset_turnover * financial_leverage * 100, 2) - debt_ratio = round((total_assets - equity) / total_assets * 100, 1) + caliber = load_dupont_params(db, entity_id) + avail = available_months(db, entity_id) + anchor = latest_stock_month(db, entity_id) + win = resolve_window(period, start, end, avail, anchor=anchor) + if win.get("error"): + raise HTTPException(status_code=400, detail=win["error"]) - # 上期对比(模拟上一期数据) - prev_roe = round(11.2, 2) - roe_change = round(roe - prev_roe, 2) + name_row = db.execute( + text("SELECT name FROM entities WHERE id = :eid"), {"eid": entity_id}).first() + entity_name = (name_row[0] if name_row else None) or ("陕西酣客文化传媒" if slug == "hanke" else "陕西博海网络科技") + suffix = {"ytd": "(YTD 年初至今)", "ttm": "(TTM 滚动 12 月)", "month": "(单月)", + "quarter": "(季度)", "half": "(半年)", "year": "(全年)", "custom": "(自定义区间)"} + label = win["label"] + suffix.get(win["period_key"], "") - return { - "entity": "bohai", - "entity_name": "陕西博海科技(IT服务)", - "period": "2026年H1", - "roe": roe, - "roe_change": roe_change, - "roe_trend": "up" if roe_change > 0 else "down", - "prev_roe": prev_roe, - "factors": { - "net_profit_margin": { - "value": net_profit_margin, - "label": "净利润率", - "desc": "净利润/收入", - "status": "🟡" if net_profit_margin < 5 else "✅", - "assessment": "IT经销行业正常偏低", - "raw": {"net_profit": net_profit, "revenue": revenue}, - }, - "asset_turnover": { - "value": asset_turnover, - "label": "资产周转率", - "desc": "收入/总资产", - "status": "🟡" if asset_turnover < 1 else "✅", - "assessment": "资金效率中等", - "raw": {"revenue": revenue, "total_assets": total_assets}, - }, - "financial_leverage": { - "value": financial_leverage, - "label": "财务杠杆", - "desc": "总资产/净资产", - "status": "✅" if financial_leverage < 3 else "🟡", - "assessment": f"负债率{debt_ratio}%,结构健康", - "raw": {"total_assets": total_assets, "equity": equity}, - }, - }, - "raw_data": { - "net_profit": net_profit, - "revenue": revenue, - "total_assets": total_assets, - "equity": equity, - }, - "insight": { - "improvement": "提升毛利率、控制销售折扣/现金折扣支出,而非加杠杆", - "detail": f"财务杠杆{financial_leverage}x健康,ROE {roe}%偏低主要因净利润率{net_profit_margin}%偏低。改善方向:提升毛利率、控制销售折扣/现金折扣支出(合计约110万侵蚀毛利)。", - }, - } + cur = _dupont_window_values(db, entity_id, caliber, win["period_start"], win["period_end"]) + ann = annualize_factors(dict(cur["factors"]), win["months"], annualize) + f_ann = ann["factors"] + scale = ann["scale"] - if slug == "hanke": - # 酣客: F_REVENUE/F_NET_PROFIT 从DB读 verified 值(2026H1: 713.27 / -90.86) - revenue = _get_dupont_kpi(db, 1, "F_REVENUE") - net_profit = _get_dupont_kpi(db, 1, "F_NET_PROFIT") - # 资产/权益 DB 无完整数据 → 对应 factor 返回 null,标注"数据待补充",不报错 - total_assets = None - equity = None + identity = check_identity(f_ann.get("roe"), f_ann.get("net_profit_margin"), + f_ann.get("asset_turnover"), f_ann.get("equity_multiplier")) - net_profit_margin = round(net_profit / revenue * 100, 2) if revenue else None - - if total_assets and equity: - asset_turnover = round(revenue / total_assets, 4) - financial_leverage = round(total_assets / equity, 2) - roe = round(net_profit_margin / 100 * asset_turnover * financial_leverage * 100, 2) + mismatch_note = None + if (guard_probe or "").strip().lower() == "mismatch": + # 守护判别力自检(只读):ROE 取**请求区间**,三因子取**另一区间**(多期请求 → 区间末单月; + # 单月请求 → 该月 YTD),即人为制造任务件所指「同一数据两份结果」的**区间错配** + # ⇒ 三因子乘积 ≠ ROE,应判 identity_broken(证明守护不是摆设)。 + if win["period_start"] != win["period_end"]: + alt_start = alt_end = win["period_end"] else: - # 资产/权益缺失:周转率与杠杆按中性值1估算,ROE≈净利率(净利润为负,符号确定) - asset_turnover = None - financial_leverage = None - roe = net_profit_margin + alt_start, alt_end = f"{win['period_start'][:4]}-01", win["period_start"] + alt_win = _dupont_window_values(db, entity_id, caliber, alt_start, alt_end) + alt_months = months_between(alt_start, alt_end) + alt = annualize_factors(dict(alt_win["factors"]), alt_months, annualize)["factors"] + identity = check_identity(f_ann.get("roe"), alt.get("net_profit_margin"), + alt.get("asset_turnover"), alt.get("equity_multiplier")) + identity["probe"] = "mismatch" + identity["probe_roe_window"] = [win["period_start"], win["period_end"]] + identity["probe_factor_window"] = [alt_start, alt_end] + mismatch_note = (f"守护自检:ROE 取 [{win['period_start']}..{win['period_end']}]," + f"三因子取 [{alt_start}..{alt_end}](故意错配区间)") - # 上期对比:prev_roe 无法获取 → null(前端已兼容 null,显示"无对比") - prev_roe = None - roe_change = None - roe_trend = None + meta = build_window_meta(label, win["period_start"], win["period_end"], win["months"], + cur["basis"], annualize, cur["basis_fallback"], scale, + cur["basis_fallback_reason"]) + # 因子元数据(无论恒等式是否成立都随值返回 —— §二.3 便于定位漂移源) + factors = { + "net_profit_margin": _dupont_factor_obj( + "net_profit_margin", "净利润率", "净利润 ÷ 营业收入(同区间流量之比)", + f_ann.get("net_profit_margin"), "%", ann["per_factor_scale"]["net_profit_margin"], + annualize, win, "cumulative", + {"net_profit": cur["raw"]["net_profit"], "revenue": cur["raw"]["revenue"]}, + ("🔴" if (f_ann.get("net_profit_margin") or 0) < 0 + else ("🟡" if (f_ann.get("net_profit_margin") or 0) < 5 else "✅")), + ("净利率为负,区间内亏损经营" if (f_ann.get("net_profit_margin") or 0) < 0 + else "区间盈利水平"), 2), + "asset_turnover": _dupont_factor_obj( + "asset_turnover", "总资产周转率", "营业收入 ÷ 平均总资产(区间流量 ÷ 区间平均存量)", + f_ann.get("asset_turnover"), "次", ann["per_factor_scale"]["asset_turnover"], + annualize, win, "average", + {"revenue": cur["raw"]["revenue"], "total_assets": cur["raw"]["total_assets"]}, + "🟡" if (f_ann.get("asset_turnover") or 0) < 1 else "✅", + "单位资产创造的营业收入(区间口径)", 4), + "equity_multiplier": _dupont_factor_obj( + "equity_multiplier", "权益乘数", "平均总资产 ÷ 平均净资产(同区间存量之比)", + f_ann.get("equity_multiplier"), "倍", ann["per_factor_scale"]["equity_multiplier"], + annualize, win, "average", + {"total_assets": cur["raw"]["total_assets"], "equity": cur["raw"]["equity"]}, + "✅" if (f_ann.get("equity_multiplier") or 0) < 3 else "🟡", + "财务杠杆水平(区间平均口径)", 4), + } + + if identity.get("status") == "identity_broken": return { - "entity": "hanke", - "entity_name": "陕西酣客文化传媒(白酒经销)", - "period": "2026年H1", - "roe": roe, - "roe_change": roe_change, - "roe_trend": roe_trend, - "prev_roe": prev_roe, - "factors": { - "net_profit_margin": { - "value": net_profit_margin, - "label": "净利润率", - "desc": "净利润/收入", - "status": "🔴" if (net_profit_margin or 0) < 0 else ("🟡" if (net_profit_margin or 0) < 5 else "✅"), - "assessment": "净利率为负,本期亏损经营", - "raw": {"net_profit": net_profit, "revenue": revenue}, - }, - "asset_turnover": { - "value": asset_turnover, - "label": "资产周转率", - "desc": "收入/总资产", - "status": "ℹ️", - "assessment": "数据待补充", - "raw": {"revenue": revenue, "total_assets": total_assets}, - }, - "financial_leverage": { - "value": financial_leverage, - "label": "财务杠杆", - "desc": "总资产/净资产", - "status": "ℹ️", - "assessment": "数据待补充", - "raw": {"total_assets": total_assets, "equity": equity}, - }, - }, - "raw_data": { - "net_profit": net_profit, - "revenue": revenue, - "total_assets": total_assets, - "equity": equity, - }, - "insight": { - "improvement": "止损优先:提升毛利率、控制费用;待资产/权益数据补充后计算完整ROE", - "detail": f"净利润{net_profit}万、净利率{net_profit_margin}%,白酒经销业务本期亏损(营收{revenue}万)。资产周转率与财务杠杆因资产/权益数据缺失暂无法计算(数据待补充),待补充后更新完整杜邦拆解。", - }, + "entity": slug, "entity_id": entity_id, "entity_name": entity_name, + "identity_broken": True, **meta, "months_label": f"{win['months']}个月", + "identity": identity, "guard_probe_note": mismatch_note, + "factors": factors, "raw_data": cur["raw"], + "caliber": cur["caliber"], "kpi_refs": _dupont_kpi_refs(db, entity_id, win["period_end"]), + "message": ("恒等式不成立:|ROE − 净利率×周转率×权益乘数| > 容差 —— 三因子区间口径错配," + "不返回数值,请按 identity / factors[*].period_start|basis 定位漂移源"), + "readonly": True, "data_source": "subject_balances + voucher_details", } - return {"error": "不支持的实体"} + + # 上一同长度区间(仅用于趋势对比;越出可用月域 ⇒ None,不静默编数) + prev_roe = None + ps, pe = _dupont_prev_window(win["period_start"], win["months"]) + if avail and ps >= avail[0]: + prev = _dupont_window_values(db, entity_id, caliber, ps, pe) + prev_roe = prev["factors"]["roe"] + roe_exact = f_ann.get("roe") + roe_change = None if (roe_exact is None or prev_roe is None) else round(roe_exact - prev_roe, 4) + + detail = (f"{label}口径(流量累计 {win['period_start']}~{win['period_end']}," + f"存量{'平均' if cur['basis'] == 'average' else '期末回落'}" + f"{',年化 ×12/' + str(win['months']) if annualize else ',非年化'}):" + f"净利润 {cur['raw']['net_profit']:.2f}万 / 营业收入 {cur['raw']['revenue']:.2f}万 / " + f"平均总资产 {cur['raw']['total_assets']:.2f}万 / 平均净资产 {cur['raw']['equity']:.2f}万;" + f"ROE = {factors['net_profit_margin']['value']}% × {factors['asset_turnover']['value']} × " + f"{factors['equity_multiplier']['value']} = {None if roe_exact is None else round(roe_exact, 2)}%" + f"(恒等式 {'通过' if identity.get('status') == 'pass' else identity.get('status')}," + f"偏差 {identity.get('diff')} ≤ 容差 {identity.get('tolerance')})") + if cur["basis_fallback"]: + detail += f"|⚠️ {cur['basis_fallback_reason']}(basis_fallback=true)" + + return { + "entity": slug, "entity_id": entity_id, "entity_name": entity_name, + "identity_broken": False, **meta, "months_label": f"{win['months']}个月", + "flow_basis": "cumulative", "period_key": win["period_key"], + "clip_note": win.get("clip_note"), + "roe": None if roe_exact is None else round(roe_exact, 2), + "roe_exact": roe_exact, "roe_change": roe_change, + "roe_trend": (None if roe_change is None else ("up" if roe_change > 0 else ("down" if roe_change < 0 else "stable"))), + "prev_roe": None if prev_roe is None else round(prev_roe, 2), + "factors": factors, + "raw_data": cur["raw"], + "identity": identity, + "caliber": cur["caliber"], + "params_source": caliber.get("_params_source"), + "net_profit_mode": cur["net_profit_mode"], + "kpi_refs": _dupont_kpi_refs(db, entity_id, win["period_end"]), + "insight": { + "improvement": "提升毛利率、控制销售折扣/现金折扣支出,而非加杠杆", + "detail": detail, + }, + "readonly": True, "data_source": "subject_balances + voucher_details", + "data_source_note": ("取数源 = CMA 库内 subject_balances(存量,level=1)+ voucher_details(流量," + "carry_forward=0);不用旧脚本 register_dupont_kpis.py 的 ERP 直连近似口径"), + } # ============================================================ diff --git a/backend/app/utils/dupont_basis.py b/backend/app/utils/dupont_basis.py new file mode 100644 index 00000000..60fb5896 --- /dev/null +++ b/backend/app/utils/dupont_basis.py @@ -0,0 +1,391 @@ +# -*- coding: utf-8 -*- +"""杜邦分析「区间取值」口径单一事实源 —— 流量累计 × 存量平均 × 可选年化 + 恒等式守护 + +依据(唯一有效出处): + · 任务件 `finance-20261003-dupont-period-basis-design-fullstack.md` + · 放行出处:任总 2026-10-03 09:49 CST DM「立」(承 09:31「杜邦分析如何进行区间取值设计」) + · 财务口径裁定见任务件 §二(本模块逐条落地,不另立口径) + +核心原则(§二 开篇): + **区间一旦选定,流量(分子)与存量平均(分母)必须同区间;且三因子必须同区间——杜邦恒等式才成立。** + +取值规则(§二.1,硬性): + · 损益类(流量) → 区间**累计发生额**(期间 [start, end] 内净额) + · 资产负债类(存量)→ 区间**平均余额** = (期初 + 期末) / 2 + · 跨期比较 → **年化** = 流量 × 12 / 区间月数(比率型因子见 `annualize_factors` 说明) + +fail-closed(§二.6): + 期初余额缺失 ⇒ **显式**回落期末,并在返回值标注 `basis_fallback=true`;**禁静默**。 + +单一事实源(§二.5): + 总资产 / 净资产 / 权益乘数 不再由前端拼 `raw_data`,而是由 + `scripts/kpi_dupont_deriver.py` 统一派生为 `F_ASSET_TOTAL` / `F_EQUITY_TOTAL` / + `F_EQUITY_MULTIPLIER`(`source_type='derived'`);本模块是其**唯一读取入口**(派生器与 + 只读端点共用),口径参数落 `kpi_definitions.data_source_config.params`。 + +⚠️ 取数源(§二.5 注):**不用**旧脚本 `register_dupont_kpis.py` 的 ERP 直连近似口径 + (那套只有「现金银行 + 固定资产」,缺应收/存货);一律走 CMA 库内 + `subject_balances`(存量)+ `voucher_details`(流量)。 +""" +from __future__ import annotations + +import json +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +from sqlalchemy import text + +#: 恒等式容差(百分点)——§二.3 建议 0.05 +IDENTITY_TOLERANCE = 0.05 + +#: 支持的区间维度(前端选择器 §二.2) +PERIOD_PRESETS = ("ytd", "ttm", "month", "quarter", "half", "year", "custom") + + +# ────────────────────────── 账套口径(单一事实源) ────────────────────────── + +#: 各账套会计科目体系差异(实测 2026-10-03): +#: · entity 1(酣客):旧准则科目 —— 收入 5001,权益 3xxx,资产 1xxx,负债 2xxx; +#: 净利润有期末结转腿(`carry_forward=1` 的 3xxx 行),与已上线 verified 的月度 +#: F_NET_PROFIT/F_ROE/F_ROA 同口径(实测 2026-08:F_NET_PROFIT=-10.46、F_ROE=-0.95、 +#: F_ROA=-0.57 与本模块输出逐项一致)。 +#: · entity 2(博海):新 30 号准则科目 —— 收入 6001/6051/6052,权益 4xxx,费用 6xxx; +#: `voucher_details` 无任何 `carry_forward=1` 行 ⇒ 净利润走损益类净额口径。 +CALIBER_DEFAULTS: Dict[str, Any] = { + "asset_prefix": "1", + "liability_prefix": "2", + "1": { # entity_id=1 酣客 + "equity_prefixes": ["3"], + "revenue_codes": ["5001"], + "pnl_prefixes": ["5", "6"], + "net_profit_mode": "carry_forward", # Σ(贷−借) over carry_forward=1 且 权益前缀 + }, + "2": { # entity_id=2 博海 + "equity_prefixes": ["4"], + "revenue_codes": ["6001", "6051", "6052"], + "pnl_prefixes": ["6"], + "net_profit_mode": "pnl_net", # Σ(贷−借) over carry_forward=0 且 损益前缀 + }, + #: 未登记账套的保守兜底(新准则形态) + "_fallback": { + "equity_prefixes": ["4"], + "revenue_codes": ["6001", "6051", "6052"], + "pnl_prefixes": ["6"], + "net_profit_mode": "pnl_net", + }, +} + +#: 参数读取入口(可被 `kpi_definitions.data_source_config.params` 覆盖;禁散落字面量) +PARAM_KPI_CODE = "F_ASSET_TOTAL" + + +def caliber_for(entity_id: int) -> Dict[str, Any]: + """账套科目体系口径(内置默认;可供 DB 覆盖的键:equity_prefixes/revenue_codes/net_profit_mode)""" + ent = CALIBER_DEFAULTS.get(str(entity_id)) or CALIBER_DEFAULTS["_fallback"] + return { + "asset_prefix": CALIBER_DEFAULTS["asset_prefix"], + "liability_prefix": CALIBER_DEFAULTS["liability_prefix"], + "equity_prefixes": list(ent["equity_prefixes"]), + "revenue_codes": list(ent["revenue_codes"]), + "pnl_prefixes": list(ent["pnl_prefixes"]), + "net_profit_mode": ent["net_profit_mode"], + } + + +def load_dupont_params(db, entity_id: int) -> Dict[str, Any]: + """读取账套口径参数:`kpi_definitions.data_source_config.params` → 内置默认兜底。 + + 兼容形态:`{"params": {...}}` / 裸 `{...}` / JSON 字符串 / None。读取失败绝不抛错 + (口径回落默认,端点不因参数缺件 500)。 + """ + caliber = caliber_for(entity_id) + try: + row = db.execute(text( + "SELECT data_source_config FROM kpi_definitions " + "WHERE entity_id = :eid AND kpi_code = :code LIMIT 1" + ), {"eid": entity_id, "code": PARAM_KPI_CODE}).first() + except Exception: # pragma: no cover - 表缺失等极端情形 + row = None + raw = row[0] if row else None + if isinstance(raw, (bytes, bytearray)): + raw = raw.decode("utf-8", "replace") + if isinstance(raw, str): + try: + raw = json.loads(raw) + except Exception: + raw = None + if isinstance(raw, dict): + params = raw.get("params") if isinstance(raw.get("params"), dict) else raw + for key in ("equity_prefixes", "revenue_codes", "pnl_prefixes", "net_profit_mode", + "asset_prefix", "liability_prefix"): + if key in params: + caliber[key] = params[key] + caliber["_params_source"] = "kpi_definitions.data_source_config.params" + else: + caliber["_params_source"] = "builtin-default" + return caliber + + +# ────────────────────────── 区间窗口 ────────────────────────── + +def prev_month(period: str) -> str: + y, m = int(period[:4]), int(period[5:7]) + if m == 1: + return f"{y - 1}-12" + return f"{y}-{m - 1:02d}" + + +def month_index(period: str) -> int: + return int(period[5:7]) + + +def months_between(start: str, end: str) -> int: + """闭区间 [start, end] 的月数(含首尾)。""" + if not start or not end: + return 0 + return (int(end[:4]) - int(start[:4])) * 12 + (int(end[5:7]) - int(start[5:7])) + 1 + + +def _fmt_cn(start: str, end: str) -> str: + if start == end: + return f"{start[:4]}年{int(start[5:7])}月" + return f"{start[:4]}年{int(start[5:7])}-{int(end[5:7])}月" + + +def resolve_window(period: Optional[str], start: Optional[str], end: Optional[str], + available: List[str], preset: Optional[str] = None, + today: Optional[str] = None, anchor: Optional[str] = None) -> Dict[str, Any]: + """区间维度 → [period_start, period_end](闭区间,YYYY-MM)。 + + §二.2 区间维度:月 / 季 / 半年 / 年 / YTD(默认)/ TTM / 自定义起止。 + 可用月域 `available`(升序 YYYY-MM 列表)用于:① 缺省锚定;② 区间裁剪。 + `anchor` = 默认区间的锚月(端点传入「存量最新月」`latest_stock_month`,缺省取 available 末月)。 + + ⚠️ 裁剪纪律:区间上界**只裁不扩**——超出可用月域即裁到可用域(并在 `clip_note` 留痕), + 绝不因裁剪而静默改变用户已选下界(下界越界则显式 400,见 `validate`)。 + """ + avail = sorted(available or []) + latest = avail[-1] if avail else None + today = today or datetime.now().strftime("%Y-%m") + anchor = min(anchor or latest or today, today) + if latest: + anchor = min(anchor, latest) + + def clip_end(e: str) -> Tuple[str, Optional[str]]: + if latest and e > latest: + return latest, f"上界 {e} 超出可用月域(最新 {latest}),已裁至可用域" + return e, None + + p = (period or "").strip().lower() + if preset: + p = preset.strip().lower() + note = None + if not p or p in ("ytd", "default"): + if not anchor: + return {"error": "无可用数据月域,无法解析默认 YTD 区间"} + s, e = f"{anchor[:4]}-01", anchor + key = "ytd" + elif p in ("ttm",): + if not anchor: + return {"error": "无可用数据月域,无法解析 TTM 区间"} + y, m = int(anchor[:4]), int(anchor[5:7]) + s = f"{y - 1}-{m:02d}" if m != 12 else f"{y}-01" + e = anchor + key = "ttm" + elif p in ("custom",) or start or end: + if not (start and end): + return {"error": "自定义区间须同时给出 start 与 end(YYYY-MM)"} + s, e = start, end + key = "custom" + elif len(p) == 7 and p[4] == "-": # 2026-08 → 单月 + s = e = p + key = "month" + elif len(p) == 7 and p[4] == "q": # 2026-q3 + y, q = int(p[:4]), int(p[6]) + s, e = f"{y}-{(q - 1) * 3 + 1:02d}", f"{y}-{q * 3:02d}" + key = "quarter" + elif len(p) == 7 and p[4] == "h": # 2026-h1 + y, h = int(p[:4]), int(p[6]) + s, e = f"{y}-{(h - 1) * 6 + 1:02d}", f"{y}-{h * 6:02d}" + key = "half" + elif len(p) == 4 and p.isdigit(): # 2026 → 全年 + s, e = f"{p}-01", f"{p}-12" + key = "year" + else: + return {"error": f"无法识别的区间参数 period={period!r}(支持 ytd/ttm/YYYY-MM/YYYY-Qn/YYYY-Hn/YYYY/custom)"} + + e, cn = clip_end(e) + note = cn + months = months_between(s, e) + return { + "period_key": key, "period_start": s, "period_end": e, "months": months, + "label": _fmt_cn(s, e), "clip_note": note, + "available_start": avail[0] if avail else None, "available_end": latest, + } + + +# ────────────────────────── 取数(流量 / 存量) ────────────────────────── + +def _flow_expr(field: str) -> str: + """流量表达式。命名显式化(避免与存量 net 的借−贷/贷−借混淆): + + · `credit` → 贷方发生额 + · `debit` → 借方发生额 + · `credit_debit` → 贷−借(收入类、结转腿自然方向) + · `debit_credit` → 借−贷(成本费用类自然方向) + """ + expr = {"credit": "credit_amount", "debit": "debit_amount", + "credit_debit": "(credit_amount - debit_amount)", + "debit_credit": "(debit_amount - credit_amount)"}.get(field) + if expr is None: + raise ValueError(f"未知流量字段 {field!r}(credit/debit/credit_debit/debit_credit)") + return expr + + +def sum_flows(db, entity_id: int, start: str, end: str, spec: Dict[str, Any]) -> float: + """流量累计(元)。spec: {codes:[...]|prefixes:[...], field: 见 _flow_expr, carry_forward: 0|1}""" + parts, args = [], {"eid": entity_id, "s": start, "e": end} + where = ["entity_id = :eid", "period >= :s", "period <= :e"] + cf = spec.get("carry_forward") + if cf is not None: + where.append("carry_forward = :cf") + args["cf"] = int(cf) + for i, code in enumerate(spec.get("codes") or []): + parts.append(f"subject_code = :c{i}") + args[f"c{i}"] = code + for i, pfx in enumerate(spec.get("prefixes") or []): + parts.append(f"subject_code LIKE :p{i}") + args[f"p{i}"] = f"{pfx}%" + if parts: + where.append("(" + " OR ".join(parts) + ")") + sql = (f"SELECT COALESCE(SUM({_flow_expr(spec.get('field', 'credit_debit'))}), 0) " + f"FROM voucher_details WHERE " + " AND ".join(where)) + return float(db.execute(text(sql), args).scalar() or 0.0) + + +def _bal_expr(field: str) -> str: + return {"cb": "(cb_dr - cb_cr)", "cb_rev": "(cb_cr - cb_dr)", + "ob": "(ob_dr - ob_cr)", "ob_rev": "(ob_cr - ob_dr)"}[field] + + +def stock_balance(db, entity_id: int, period: str, prefixes: List[str], + side: str = "net") -> Dict[str, Optional[float]]: + """存量余额(元):期初 `ob_*` / 期末 `cb_*`,一级科目(`level=1`,防跨层双计)。 + + side: 'net' = 借−贷(资产用)/ 'net_rev' = 贷−借(权益、负债用)。 + 返回 {"begin": 元|None, "end": 元|None, "row_found": bool} + ob 字段: net→ob / net_rev→ob_rev;cb 同理。 + """ + ob_f = "ob" if side == "net" else "ob_rev" + cb_f = "cb" if side == "net" else "cb_rev" + pfx_sql, args = [], {"eid": entity_id, "period": period} + for i, pfx in enumerate(prefixes): + pfx_sql.append(f"subject_code LIKE :p{i}") + args[f"p{i}"] = f"{pfx}%" + sql = (f"SELECT COALESCE(SUM({_bal_expr(ob_f)}), 0), COALESCE(SUM({_bal_expr(cb_f)}), 0), COUNT(*) " + f"FROM subject_balances WHERE entity_id = :eid AND period = :period AND `level` = 1 " + f"AND (" + " OR ".join(pfx_sql) + ")") + row = db.execute(text(sql), args).first() + if not row or not row[2]: + return {"begin": None, "end": None, "row_found": False} + return {"begin": float(row[0] or 0.0), "end": float(row[1] or 0.0), "row_found": True} + + +def available_months(db, entity_id: int) -> List[str]: + """可用月域 = `voucher_details` 与 `subject_balances` 期间并集(升序)。 + + ⚠️ 两次单表查询后 Python 合并,**不用 SQL `UNION`** —— 两表 `period` 列 collation + 不同,MySQL 直接 UNION 会报 `1271 Illegal mix of collations`(2026-10-03 实测)。 + """ + months: set = set() + for tbl in ("voucher_details", "subject_balances"): + rows = db.execute(text( + f"SELECT DISTINCT period FROM {tbl} WHERE entity_id = :eid" + ), {"eid": entity_id}).fetchall() + months.update(str(r[0]) for r in rows if r[0]) + return sorted(months) + + +def latest_stock_month(db, entity_id: int) -> Optional[str]: + """最后一个**存量可取**的期间(subject_balances 有该期一级科目行)。 + + 默认区间锚点必须取它而非流量最新月:两账套刻度不同(实测 entity2 流量到 2026-09、 + 存量只到 2026-08),若按流量最新月锚定,默认视图会落到「期末存量缺失」的区间上 + (fail-closed ⇒ 全空),属可用性缺陷而非诚实的 fail-closed。 + """ + row = db.execute(text( + "SELECT MAX(period) FROM subject_balances WHERE entity_id = :eid AND `level` = 1" + ), {"eid": entity_id}).scalar() + return str(row) if row else None + + +# ────────────────────────── 纯计算(无 DB,便于单测) ────────────────────────── + +def annualize_factors(factors: Dict[str, Optional[float]], months: int, + annualize: bool) -> Dict[str, Any]: + """年化(§二.1):**流量** × 12 / 区间月数。 + + 比率型因子的年化是非平凡的,本函数显式给出每因子的实际缩放倍数,避免「看着像没生效」: + + · 净利率 = 净利润/营业收入 —— 分子分母同为流量,×k 相消 ⇒ scale=1.0(**值不变**) + · 总资产周转率 = 营业收入/平均总资产 —— 仅分子年化 ⇒ scale=k + · 权益乘数 = 平均总资产/平均净资产 —— 分子分母同为存量 ⇒ scale=1.0(**值不变**) + · ROE = 净利润/平均净资产 —— 仅分子年化 ⇒ scale=k + + 这也正是恒等式在年化下继续成立的原因: + k·净利润/平均净资产 ≡ 净利率 × (k·周转率) × 权益乘数。 + """ + k = (12.0 / months) if (annualize and months) else 1.0 + scales = {"net_profit_margin": 1.0, "asset_turnover": k, "equity_multiplier": 1.0, + "roe": k} + out: Dict[str, Any] = {"scale": k, "factors": {}, "per_factor_scale": scales} + for name, val in (factors or {}).items(): + s = scales.get(name, 1.0) + out["factors"][name] = None if val is None else val * s + return out + + +def compute_factors(net_profit_w: Optional[float], revenue_w: Optional[float], + total_assets_w: Optional[float], equity_w: Optional[float]) -> Dict[str, Optional[float]]: + """三因子 + ROE(万元口径;ROE/净利率为百分点)。任一分母为 0/缺失 ⇒ 该因子 None(fail-closed)。""" + out = {"net_profit_margin": None, "asset_turnover": None, + "equity_multiplier": None, "roe": None} + if revenue_w: + out["net_profit_margin"] = net_profit_w / revenue_w * 100.0 if net_profit_w is not None else None + if total_assets_w: + out["asset_turnover"] = revenue_w / total_assets_w if revenue_w is not None else None + if equity_w: + out["equity_multiplier"] = total_assets_w / equity_w + if net_profit_w is not None: + out["roe"] = net_profit_w / equity_w * 100.0 + return {k: (None if v is None else v) for k, v in out.items()} + + +def check_identity(roe: Optional[float], net_profit_margin: Optional[float], + asset_turnover: Optional[float], equity_multiplier: Optional[float], + tolerance: float = IDENTITY_TOLERANCE) -> Dict[str, Any]: + """恒等式守护(§二.3):| ROE − 净利润率 × 总资产周转率 × 权益乘数 | ≤ 容差(百分点)。 + + 不成立 ⇒ `status='identity_broken'`,调用方**不得返回数值**(只返回三因子各自的区间元数据)。 + """ + if None in (roe, net_profit_margin, asset_turnover, equity_multiplier): + return {"checked": False, "status": "unavailable", "tolerance": tolerance, + "reason": "三因子或 ROE 不可得(分母缺失),恒等式免检"} + product = net_profit_margin / 100.0 * asset_turnover * equity_multiplier * 100.0 + diff = abs(roe - product) + return {"checked": True, "status": "pass" if diff <= tolerance else "identity_broken", + "roe": round(roe, 6), "product": round(product, 6), "diff": round(diff, 6), + "tolerance": tolerance} + + +def build_window_meta(label: str, start: str, end: str, months: int, + basis: str, annualized: bool, fallback: bool, + annualize_scale: float = 1.0, + fallback_reason: Optional[str] = None) -> Dict[str, Any]: + """§二.4 口径元数据(随值返回):period_start / period_end / basis / annualized 四项。""" + meta = {"period": label, "period_start": start, "period_end": end, "months": months, + "basis": basis, "annualized": bool(annualized), "basis_fallback": bool(fallback), + "annualize_scale": annualize_scale} + if fallback_reason: + meta["basis_fallback_reason"] = fallback_reason + return meta diff --git a/backend/scripts/apply_dupont_kpi_defs.py b/backend/scripts/apply_dupont_kpi_defs.py new file mode 100644 index 00000000..55fbd2d2 --- /dev/null +++ b/backend/scripts/apply_dupont_kpi_defs.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""杜邦 KPI 定义种子应用器 —— 执行 scripts/sql/dupont_kpi_defs_20261003.sql + +任务:finance-20261003-dupont-period-basis-design-fullstack.md +动作:① 两账套注册 F_ASSET_TOTAL/F_EQUITY_TOTAL/F_EQUITY_MULTIPLIER(derived) + ② F_ASSET_TURNOVER data_source_type: manual → derived +幂等:SQL 自带条件 INSERT(NOT EXISTS)+ 条件 UPDATE,可安全重跑。 + +用法(**写库**,须先过 approval-gate 放行): + venv/bin/python3 scripts/apply_dupont_kpi_defs.py --check # 只读预检(不执行任何语句) + venv/bin/python3 scripts/apply_dupont_kpi_defs.py --apply # 执行(需放行) +""" +from __future__ import annotations + +import argparse +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import text # noqa: E402 + +from app.database import get_engine # noqa: E402 + +SQL_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "sql", "dupont_kpi_defs_20261003.sql") +TARGETS = ("F_ASSET_TOTAL", "F_EQUITY_TOTAL", "F_EQUITY_MULTIPLIER") +TARGET_ENTITIES = (1, 2) + + +def statements(sql_text: str): + """按 ASCII ';' 切分(本文件的字符串字面量内不含 ';',已人工核对)""" + out = [] + for chunk in sql_text.split(";"): + stmt = chunk.strip() + if stmt and not all(ln.strip().startswith("--") or not ln.strip() for ln in stmt.splitlines()): + out.append(stmt) + return out + + +def snapshot(conn) -> dict: + rows = conn.execute(text("""SELECT entity_id, kpi_code, data_source_type, status + FROM kpi_definitions + WHERE entity_id IN (1,2) AND kpi_code IN + ('F_ASSET_TOTAL','F_EQUITY_TOTAL','F_EQUITY_MULTIPLIER', + 'F_ASSET_TURNOVER') + ORDER BY entity_id, kpi_code""")).fetchall() + return {f"{r[0]}/{r[1]}": (r[2], r[3]) for r in rows} + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--check", action="store_true", help="只读预检(不执行写语句)") + ap.add_argument("--apply", action="store_true", help="执行种子 SQL(写库)") + args = ap.parse_args() + if not (args.check or args.apply): + print("须指定 --check 或 --apply", file=sys.stderr) + return 2 + + eng = get_engine() + with open(SQL_FILE, encoding="utf-8") as f: + stmts = statements(f.read()) + print(f"SQL 文件: {SQL_FILE}") + print(f"语句数: {len(stmts)}") + for i, s in enumerate(stmts, 1): + head = " ".join(s.split())[:110] + print(f" [{i}] {head}...") + + with eng.connect() as conn: + before = snapshot(conn) + print(f"\n写前像({len(before)} 行): {before}") + if args.check: + print("\n[--check] 只读预检:未执行任何语句(写路径未触发)") + return 0 + for i, s in enumerate(stmts, 1): + r = conn.execute(text(s)) + print(f" [{i}] rowcount={r.rowcount}") + conn.commit() + after = snapshot(conn) + print(f"\n写后像({len(after)} 行): {after}") + added = {k: v for k, v in after.items() if k not in before} + changed = {k: (before[k], v) for k, v in after.items() if k in before and before[k] != v} + print(f"新增: {added}") + print(f"变更: {changed}") + missing = [f"{e}/{c}" for e in TARGET_ENTITIES for c in TARGETS if f"{e}/{c}" not in after] + print(f"缺件: {missing or '无'}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/scripts/kpi_dupont_deriver.py b/backend/scripts/kpi_dupont_deriver.py new file mode 100644 index 00000000..5e7c36fd --- /dev/null +++ b/backend/scripts/kpi_dupont_deriver.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""杜邦·存量与乘数派生器 —— F_ASSET_TOTAL / F_EQUITY_TOTAL / F_EQUITY_MULTIPLIER / F_ASSET_TURNOVER + +任务:`finance-20261003-dupont-period-basis-design-fullstack.md`(P1,提出者 wecom-finance) +放行出处:任总 2026-10-03 09:49 CST DM「立」 + +口径(任务件 §二,单一事实源 `app/utils/dupont_basis.py`): + · 总资产(期末) = Σ(cb_dr−cb_cr) over subject_balances level=1 AND code LIKE '1%' + · 净资产(期末) = Σ(cb_cr−cb_dr) over subject_balances level=1 AND code LIKE 权益前缀 + · 权益乘数 = **平均总资产 ÷ 平均净资产**((期初+期末)/2;期初缺失 ⇒ 显式回落期末) + · 总资产周转率 = 区间**累计**营业收入 ÷ **平均总资产**(单月口径;跨期比较用 /reports/dupont 年化) + +取数:CMA 库内 `subject_balances`(存量)+ `voucher_details`(流量)。**不用**旧脚本 + `register_dupont_kpis.py` 的 ERP 直连近似口径(缺应收/存货)。 + +幂等:同 (kpi_id, period, source_type='derived') 存在则原位更新;**不降级 data_status** + (kpi_values 无业务唯一键 ⇒ 必须 SELECT→UPDATE/INSERT,禁 ON DUPLICATE KEY)。 +边界:只写 `source_type='derived'` 行;不改任何 kpi_definitions(定义由种子 SQL 负责);无 DDL。 + +用法: + venv/bin/python3 scripts/kpi_dupont_deriver.py --check # 只算不写(只读闸门形态) + venv/bin/python3 scripts/kpi_dupont_deriver.py --dry-run --entity 1 # 同 --check + venv/bin/python3 scripts/kpi_dupont_deriver.py --write --entity all # 落库(需放行) +""" +from __future__ import annotations + +import argparse +import logging +import os +import sys +from datetime import datetime + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import text # noqa: E402 + +from app.database import get_session_local # noqa: E402 +from app.models import KPIDefinition, KPIValue # noqa: E402 +from app.utils.dupont_basis import ( # noqa: E402 + available_months, latest_stock_month, load_dupont_params, stock_balance, sum_flows, +) + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger("cma.dupont_deriver") + +KPI_CODES = ("F_ASSET_TOTAL", "F_EQUITY_TOTAL", "F_EQUITY_MULTIPLIER", "F_ASSET_TURNOVER") + + +def derive_one(db, entity_id: int, period: str, caliber: dict) -> dict: + """算一个账套一个期间的四个值(不写库)。""" + eq_prefixes = list(caliber["equity_prefixes"]) + ta = stock_balance(db, entity_id, period, [caliber["asset_prefix"]], "net") + eq = stock_balance(db, entity_id, period, eq_prefixes, "net_rev") + if ta.get("end") is None or eq.get("end") is None: + return {"entity_id": entity_id, "period": period, "unavailable": True, + "reason": f"{period} 无 subject_balances 一级科目行(资产/权益)"} + + fallback = ta.get("begin") is None or eq.get("begin") is None + ta_avg = ta["end"] if fallback else (ta["begin"] + ta["end"]) / 2.0 + eq_avg = eq["end"] if fallback else (eq["begin"] + eq["end"]) / 2.0 + + revenue = sum_flows(db, entity_id, period, period, + {"codes": caliber["revenue_codes"], "field": "credit", "carry_forward": 0}) + + w = lambda v: None if v is None else v / 10000.0 + ta_w, eq_w, ta_avg_w, eq_avg_w, rev_w = w(ta["end"]), w(eq["end"]), w(ta_avg), w(eq_avg), w(revenue) + values = { + "F_ASSET_TOTAL": round(ta_w, 2), + "F_EQUITY_TOTAL": round(eq_w, 2), + "F_EQUITY_MULTIPLIER": round(ta_avg_w / eq_avg_w, 4) if eq_avg_w else None, + "F_ASSET_TURNOVER": round(rev_w / ta_avg_w, 4) if ta_avg_w else None, + } + basis_txt = "期末回落(期初缺失)" if fallback else "平均(期初+期末)/2" + remarks = { + "F_ASSET_TOTAL": f"期末总资产 {ta_w:.2f}万(level=1,code LIKE '{caliber['asset_prefix']}%')", + "F_EQUITY_TOTAL": (f"期末净资产 {eq_w:.2f}万(level=1,权益前缀 {'/'.join(eq_prefixes)})"), + "F_EQUITY_MULTIPLIER": (f"权益乘数 = 平均总资产 {ta_avg_w:.2f}万 ÷ 平均净资产 {eq_avg_w:.2f}万 " + f"= {values['F_EQUITY_MULTIPLIER']}({basis_txt})"), + "F_ASSET_TURNOVER": (f"总资产周转率 = 区间营业收入 {rev_w:.2f}万 ÷ 平均总资产 {ta_avg_w:.2f}万 " + f"= {values['F_ASSET_TURNOVER']}({basis_txt};单月口径,跨期比较用 dupont 端点年化)"), + } + return {"entity_id": entity_id, "period": period, "unavailable": False, + "basis_fallback": fallback, "values": values, "remarks": remarks, + "raw": {"total_assets_end": ta_w, "equity_end": eq_w, "total_assets_avg": ta_avg_w, + "equity_avg": eq_avg_w, "revenue": rev_w}} + + +def write_one(db, res: dict, write: bool = True) -> dict: + """幂等落库:同 (kpi_id, period, source_type='derived') 原位 UPDATE,否则 INSERT。""" + if res.get("unavailable"): + return {"written": "unavailable"} + entity_id, period = res["entity_id"], res["period"] + defs = {d.kpi_code: d for d in db.query(KPIDefinition).filter( + KPIDefinition.entity_id == entity_id, + KPIDefinition.kpi_code.in_(list(KPI_CODES)), + ).all()} + batch = f"dupont-{datetime.now().strftime('%Y%m%d%H%M%S')}" + written = {} + for code in KPI_CODES: + kpi = defs.get(code) + val = res["values"].get(code) + if kpi is None: + written[code] = "no-definition" + logger.warning("kpi_definitions 缺 %s(entity=%s)——请先应用 dupont_kpi_defs_20261003.sql", + code, entity_id) + continue + if val is None: + written[code] = "unavailable(分母为0)" + continue + if not write: + written[code] = f"dry-run({val})" + continue + row = db.query(KPIValue).filter( + KPIValue.kpi_id == kpi.id, KPIValue.period == period, + KPIValue.source_type == "derived", + ).first() + if row: + old = float(row.actual_value) if row.actual_value is not None else None + row.actual_value = val + row.source_batch = batch + row.remark = res["remarks"][code] + (f"|值更新(旧{old} → 新{val})" if old != val else "") + written[code] = "upsert" # 不降级 data_status(ASK-20260924-10) + else: + db.add(KPIValue(entity_id=entity_id, kpi_id=kpi.id, period=period, actual_value=val, + source_type="derived", source_batch=batch, + data_status="pending", remark=res["remarks"][code])) + written[code] = "insert" + db.flush() + if write: + db.commit() + return {"written": written, "batch": batch} + + +def main() -> int: + ap = argparse.ArgumentParser(description="杜邦·存量与乘数派生器") + ap.add_argument("--period", default=None, help="目标期间 YYYY-MM(默认取 subject_balances 最新期)") + ap.add_argument("--entity", default="all", help="entity_id 或 all(默认 all=1,2)") + ap.add_argument("--write", action="store_true", help="实际落库(缺省=只算不写)") + ap.add_argument("--dry-run", action="store_true", help="只算不写(同缺省)") + ap.add_argument("--check", action="store_true", help="只读检查(同 --dry-run,供闸门只读标志)") + args = ap.parse_args() + + write = bool(args.write) + entities = [1, 2] if args.entity == "all" else [int(args.entity)] + + db = get_session_local()() + rc = 0 + try: + for eid in entities: + caliber = load_dupont_params(db, eid) + # 默认期间 = **存量最新月**(subject_balances 有 level=1 行)= 可算存量口径的最后一个月; + # 不能用流量最新月(实测 entity2 流量到 2026-09 而存量只到 2026-08 ⇒ 会算空) + period = args.period or latest_stock_month(db, eid) + if not period: + logger.error("entity=%s 无 subject_balances 一级科目行,无可用期间", eid) + rc = 2 + continue + res = derive_one(db, eid, period, caliber) + if res.get("unavailable"): + print(f"entity={eid} period={period} 不可得:{res['reason']}") + rc = 2 + continue + out = write_one(db, res, write=write) + r = res["raw"] + print(f"entity={eid} period={period} mode={'write' if write else 'dry-run'} " + f"batch={out.get('batch')} 口径来源={caliber.get('_params_source')}") + print(f" 期末总资产 {r['total_assets_end']:.2f}万 / 期末净资产 {r['equity_end']:.2f}万 / " + f"平均总资产 {r['total_assets_avg']:.2f}万 / 平均净资产 {r['equity_avg']:.2f}万 / " + f"营业收入 {r['revenue']:.2f}万" + + (" ⚠️ basis_fallback=期初缺失,已回落期末" if res["basis_fallback"] else "")) + print(f" F_ASSET_TOTAL={res['values']['F_ASSET_TOTAL']}万 " + f"F_EQUITY_TOTAL={res['values']['F_EQUITY_TOTAL']}万 " + f"F_EQUITY_MULTIPLIER={res['values']['F_EQUITY_MULTIPLIER']}倍 " + f"F_ASSET_TURNOVER={res['values']['F_ASSET_TURNOVER']}次") + print(f" 落库: {out['written']}") + finally: + db.close() + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/scripts/sql/dupont_kpi_defs_20261003.sql b/backend/scripts/sql/dupont_kpi_defs_20261003.sql new file mode 100644 index 00000000..ac2d3b5a --- /dev/null +++ b/backend/scripts/sql/dupont_kpi_defs_20261003.sql @@ -0,0 +1,105 @@ +-- CMA 杜邦分析「区间取值」KPI 定义收敛 — 2026-10-03 +-- 任务: finance-20261003-dupont-period-basis-design-fullstack(部署目标 cma-backend · cma-frontend) +-- 口径: 财务Bot 2026-10-03 裁定(任总 09:49 CST DM「立」)—— 详见任务件 §二 +-- 动作: +-- ① 新增 F_ASSET_TOTAL(总资产)/ F_EQUITY_TOTAL(净资产)/ F_EQUITY_MULTIPLIER(权益乘数) +-- 为 derived KPI(**两账套**:entity 1 酣客 / entity 2 博海) +-- ② F_ASSET_TURNOVER 由 manual → derived(口径=区间营业收入 ÷ 平均总资产,由派生器出数) +-- 单一事实源: app/utils/dupont_basis.py(口径/取数/计算)+ scripts/kpi_dupont_deriver.py(落值) +-- 幂等: 条件 INSERT(NOT EXISTS)+ 条件 UPDATE,可安全重跑 +-- ⚠️ 取数源: CMA 库内 subject_balances(存量 level=1)+ voucher_details(流量), +-- **不用**旧脚本 register_dupont_kpis.py 的 ERP 直连近似口径(缺应收/存货) + +-- ── ① entity 1(酣客,旧准则科目:权益 3xxx)──────────────────────────── +INSERT INTO kpi_definitions +(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc, + data_source_type, data_source, data_owner, frequency, unit, target_value, + target_calc_type, kpi_level, status, bot_source, created_by, data_source_config) +SELECT 1, 'F_ASSET_TOTAL', '总资产', 'finance', 'asset_efficiency', + '期末总资产 = SUM(subject_balances.cb_dr-cb_cr) WHERE level=1 AND subject_code LIKE ''1%''', + '总资产期末余额(万元),一级科目(level=1)防跨层双计;来源 CMA 库内 subject_balances。' + '杜邦恒等式需用**平均**口径,见 F_EQUITY_MULTIPLIER 与 /reports/dupont 的 basis=average', + 'derived', '杜邦派生器 kpi_dupont_deriver.py(subject_balances level=1)', + '财务部', 'monthly', '万元', 0.00, 'accumulate', 'strategic', 'active', 'finance-bot', 1, + '{"params": {"asset_prefix": "1", "equity_prefixes": ["3"], "revenue_codes": ["5001"], "net_profit_mode": "carry_forward"}, "param_note": "账套科目体系口径,读取入口 app/utils/dupont_basis.py load_dupont_params"}' +FROM DUAL WHERE NOT EXISTS ( + SELECT 1 FROM kpi_definitions WHERE entity_id = 1 AND kpi_code = 'F_ASSET_TOTAL'); + +INSERT INTO kpi_definitions +(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc, + data_source_type, data_source, data_owner, frequency, unit, target_value, + target_calc_type, kpi_level, status, bot_source, created_by) +SELECT 1, 'F_EQUITY_TOTAL', '净资产', 'finance', 'asset_efficiency', + '期末净资产 = SUM(subject_balances.cb_cr-cb_dr) WHERE level=1 AND subject_code LIKE ''3%''', + '净资产期末余额(万元)= 所有者权益合计;一级科目(level=1)。来源 CMA 库内 subject_balances', + 'derived', '杜邦派生器 kpi_dupont_deriver.py(subject_balances level=1)', + '财务部', 'monthly', '万元', 0.00, 'accumulate', 'strategic', 'active', 'finance-bot', 1 +FROM DUAL WHERE NOT EXISTS ( + SELECT 1 FROM kpi_definitions WHERE entity_id = 1 AND kpi_code = 'F_EQUITY_TOTAL'); + +INSERT INTO kpi_definitions +(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc, + data_source_type, data_source, data_owner, frequency, unit, target_value, + target_calc_type, threshold_green, threshold_yellow, threshold_red, + kpi_level, status, bot_source, created_by) +SELECT 1, 'F_EQUITY_MULTIPLIER', '权益乘数', 'finance', 'asset_efficiency', + '平均总资产 / 平均净资产((期初+期末)/2,level=1)', + '权益乘数=平均总资产÷平均净资产;**平均口径** = 区间(期初+期末)/2,与 /reports/dupont ' + '同区间;期初缺失显式回落期末并标注 basis_fallback(禁静默)', + 'derived', '杜邦派生器 kpi_dupont_deriver.py(subject_balances level=1)', + '财务部', 'monthly', '倍', 2.00, 'ratio', '<=2', '<=3', '>3', + 'strategic', 'active', 'finance-bot', 1 +FROM DUAL WHERE NOT EXISTS ( + SELECT 1 FROM kpi_definitions WHERE entity_id = 1 AND kpi_code = 'F_EQUITY_MULTIPLIER'); + +-- ── ① entity 2(博海,新 30 号准则科目:权益 4xxx,损益 6xxx)──────────── +INSERT INTO kpi_definitions +(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc, + data_source_type, data_source, data_owner, frequency, unit, target_value, + target_calc_type, kpi_level, status, bot_source, created_by, data_source_config) +SELECT 2, 'F_ASSET_TOTAL', '总资产', 'finance', 'asset_efficiency', + '期末总资产 = SUM(subject_balances.cb_dr-cb_cr) WHERE level=1 AND subject_code LIKE ''1%''', + '总资产期末余额(万元),一级科目(level=1)防跨层双计;来源 CMA 库内 subject_balances', + 'derived', '杜邦派生器 kpi_dupont_deriver.py(subject_balances level=1)', + '财务部', 'monthly', '万元', 0.00, 'accumulate', 'strategic', 'active', 'finance-bot', 1, + '{"params": {"asset_prefix": "1", "equity_prefixes": ["4"], "revenue_codes": ["6001", "6051", "6052"], "net_profit_mode": "pnl_net"}, "param_note": "博海用新30号准则科目体系(权益 4xxx / 损益 6xxx),读取入口 app/utils/dupont_basis.py"}' +FROM DUAL WHERE NOT EXISTS ( + SELECT 1 FROM kpi_definitions WHERE entity_id = 2 AND kpi_code = 'F_ASSET_TOTAL'); + +INSERT INTO kpi_definitions +(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc, + data_source_type, data_source, data_owner, frequency, unit, target_value, + target_calc_type, kpi_level, status, bot_source, created_by) +SELECT 2, 'F_EQUITY_TOTAL', '净资产', 'finance', 'asset_efficiency', + '期末净资产 = SUM(subject_balances.cb_cr-cb_dr) WHERE level=1 AND subject_code LIKE ''4%''', + '净资产期末余额(万元)= 所有者权益合计(新30号准则 4xxx);一级科目(level=1)', + 'derived', '杜邦派生器 kpi_dupont_deriver.py(subject_balances level=1)', + '财务部', 'monthly', '万元', 0.00, 'accumulate', 'strategic', 'active', 'finance-bot', 1 +FROM DUAL WHERE NOT EXISTS ( + SELECT 1 FROM kpi_definitions WHERE entity_id = 2 AND kpi_code = 'F_EQUITY_TOTAL'); + +INSERT INTO kpi_definitions +(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc, + data_source_type, data_source, data_owner, frequency, unit, target_value, + target_calc_type, threshold_green, threshold_yellow, threshold_red, + kpi_level, status, bot_source, created_by) +SELECT 2, 'F_EQUITY_MULTIPLIER', '权益乘数', 'finance', 'asset_efficiency', + '平均总资产 / 平均净资产((期初+期末)/2,level=1)', + '权益乘数=平均总资产÷平均净资产;**平均口径** = 区间(期初+期末)/2,与 /reports/dupont 同区间', + 'derived', '杜邦派生器 kpi_dupont_deriver.py(subject_balances level=1)', + '财务部', 'monthly', '倍', 2.00, 'ratio', '<=2', '<=3', '>3', + 'strategic', 'active', 'finance-bot', 1 +FROM DUAL WHERE NOT EXISTS ( + SELECT 1 FROM kpi_definitions WHERE entity_id = 2 AND kpi_code = 'F_EQUITY_MULTIPLIER'); + +-- ── ② F_ASSET_TURNOVER: manual → derived(两账套)───────────────────── +-- 值型阈值不改;只改「谁出数 + 口径文本」,避免单位/量级语义被顺手改动 +UPDATE kpi_definitions +SET data_source_type = 'derived', + data_source = '杜邦派生器 kpi_dupont_deriver.py(subject_balances + voucher_details)', + formula = '营业收入 / 平均总资产', + formula_desc = '总资产周转率 = 区间**累计**营业收入 ÷ 区间**平均**总资产((期初+期末)/2,level=1);' + '单月口径该值天然很小(≠年化),跨期比较请在 /reports/dupont 用 annualize=true(×12/月数)', + updated_at = NOW() +WHERE entity_id IN (1, 2) AND kpi_code = 'F_ASSET_TURNOVER' + AND (data_source_type IS NULL OR data_source_type <> 'derived'); diff --git a/backend/scripts/verify_dupont_period_basis.py b/backend/scripts/verify_dupont_period_basis.py new file mode 100644 index 00000000..b4f589ef --- /dev/null +++ b/backend/scripts/verify_dupont_period_basis.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""杜邦分析「区间取值」验收电池 —— 任务 finance-20261003-dupont-period-basis-design-fullstack + +判别力设计:**改动前必 FAIL、改动后必 PASS**。本脚本直达 `/reports/dupont` 端点函数体 +(绕过 HTTP/鉴权),逐项断言任务件 §三 验收标准;数值侧用**独立 SQL 复算** +(不复用 app/utils/dupont_basis.py 的取数代码)作比对,避免「自己验自己」。 + +每节用 `with Section(...)` 隔离:改动前(端点不接受 period/annualize)得到的是一张 +全 FAIL 的表而非崩溃堆栈 —— 判别力证据才可比。 + +用法(只读;无任何写路径): + venv/bin/python3 scripts/verify_dupont_period_basis.py --check # 全量验收 + venv/bin/python3 scripts/verify_dupont_period_basis.py --check --json # 附 JSON +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import traceback + +if not any(a in ("--check", "--dry-run") for a in sys.argv[1:]): + print("拒绝执行:本脚本只读(仅 SELECT/端点只读调用,无 commit),须带显式只读标志 --check", + file=sys.stderr) + sys.exit(2) + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(HERE)) + +from sqlalchemy import text # noqa: E402 +from starlette.requests import Request # noqa: E402 + +from app.database import get_session_local # noqa: E402 +from app.api.reports import get_dupont_analysis # noqa: E402 + +RESULTS: list = [] + + +def check(name: str, ok: bool, detail: str = "") -> bool: + RESULTS.append({"item": name, "ok": bool(ok), "detail": detail}) + print(f"{'PASS' if ok else 'FAIL'} {name}" + (f" | {detail}" if detail else "")) + return bool(ok) + + +class Section: + """把一节断言包成「异常 = FAIL」而不是整脚本崩溃(判别力取证的必备形态)。""" + + def __init__(self, name: str): + self.name = name + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + if exc is not None: + check(f"{self.name} —— 本节断言未能执行(改动前端点形态差异必现)", False, + f"{type(exc).__name__}: {exc}") + return True + return False + + +def stub_request(path: str = "/api/cma/reports/dupont") -> Request: + return Request({"type": "http", "method": "GET", "path": path, "query_string": b"", + "headers": [(b"host", b"verify.local")]}) + + +def call(db, **kw): + """直达端点函数体(绕过 HTTP 与 router 级 require_role)。 + + ⚠️ FastAPI 直调陷阱:省略 kwarg 拿到的是 `Query(None)` 哨兵而非 `None`(会 + `AttributeError: 'Query' object has no attribute ...`)⇒ 探针侧必须显式补齐全部形参。 + """ + for k, v in (("entity", None), ("period", None), ("start", None), ("end", None), + ("annualize", False), ("guard_probe", None)): + kw.setdefault(k, v) + return get_dupont_analysis(request=stub_request(), db=db, **kw) + + +def scalar(db, sql, **args): + v = db.execute(text(sql), args).scalar() + return None if v is None else float(v) + + +# ────────────────── 独立复算(纯 SQL,不复用 dupont_basis) ────────────────── + +def independent_recompute(db, entity_id: int, start: str, end: str, + equity_prefix: str, revenue_codes=("5001",), + net_profit_mode: str = "carry_forward") -> dict: + """独立口径复算(万元):流量累计 + 存量平均(期初/期末),与端点输出对拍。""" + codes = ",".join(f"'{c}'" for c in revenue_codes) + rev = scalar(db, f"""SELECT COALESCE(SUM(credit_amount),0) FROM voucher_details + WHERE entity_id=:e AND period>=:s AND period<=:e2 + AND carry_forward=0 AND subject_code IN ({codes})""", + e=entity_id, s=start, e2=end) + if net_profit_mode == "carry_forward": + np_flow = scalar(db, """SELECT COALESCE(SUM(credit_amount-debit_amount),0) FROM voucher_details + WHERE entity_id=:e AND period>=:s AND period<=:e2 + AND carry_forward=1 AND subject_code LIKE :p""", + e=entity_id, s=start, e2=end, p=f"{equity_prefix}%") + else: + np_flow = scalar(db, """SELECT COALESCE(SUM(credit_amount-debit_amount),0) FROM voucher_details + WHERE entity_id=:e AND period>=:s AND period<=:e2 + AND carry_forward=0 AND subject_code LIKE '6%'""", + e=entity_id, s=start, e2=end) + + def bal(period, field, side, prefix): + ob = "ob_dr-ob_cr" if side == "debit" else "ob_cr-ob_dr" + cb = "cb_dr-cb_cr" if side == "debit" else "cb_cr-cb_dr" + expr = {"ob": ob, "cb": cb}[field] + return scalar(db, f"""SELECT COALESCE(SUM({expr}),0) FROM subject_balances + WHERE entity_id=:e AND period=:p AND `level`=1 + AND subject_code LIKE :pfx""", + e=entity_id, p=period, pfx=f"{prefix}%") or 0.0 + + ta_ob, ta_cb = bal(start, "ob", "debit", "1"), bal(end, "cb", "debit", "1") + eq_ob, eq_cb = bal(start, "ob", "credit", equity_prefix), bal(end, "cb", "credit", equity_prefix) + ta_avg, eq_avg = (ta_ob + ta_cb) / 2.0, (eq_ob + eq_cb) / 2.0 + np_w, rev_w, ta_w, eq_w = np_flow / 1e4, rev / 1e4, ta_avg / 1e4, eq_avg / 1e4 + return { + "net_profit": round(np_w, 6), "revenue": round(rev_w, 6), + "total_assets": round(ta_w, 6), "equity": round(eq_w, 6), + "total_assets_begin": round(ta_ob / 1e4, 6), "equity_begin": round(eq_ob / 1e4, 6), + "total_assets_end": round(ta_cb / 1e4, 6), "equity_end": round(eq_cb / 1e4, 6), + "net_profit_margin": None if not rev_w else round(np_w / rev_w * 100.0, 6), + "asset_turnover": None if not ta_w else round(rev_w / ta_w, 6), + "equity_multiplier": None if not eq_w else round(ta_w / eq_w, 6), + "roe": None if not eq_w else round(np_w / eq_w * 100.0, 6), + } + + +def sec_a1(db): + with Section("A1"): + d = call(db, entity="hanke") + meta_ok = all(k in d for k in ("period_start", "period_end", "basis", "annualized")) + check("A1 无参默认=YTD 且含 period_start/period_end/basis/annualized", + meta_ok and d.get("period_key") == "ytd" + and str(d.get("period_start")).endswith("-01"), + f"period_key={d.get('period_key')} {d.get('period_start')}~{d.get('period_end')} " + f"basis={d.get('basis')} annualized={d.get('annualized')}") + check("A1b 默认口径恒等式 PASS(identity.status=pass)", + d.get("identity", {}).get("status") == "pass", + f"identity={d.get('identity')}") + + +def sec_a2(db): + m = None + with Section("A2"): + m = call(db, entity="hanke", period="2026-08", annualize=False) + exp = independent_recompute(db, 1, "2026-08", "2026-08", "3") + pairs = [] + for k in ("net_profit", "revenue", "total_assets", "equity"): + got, want = m["raw_data"][k], exp[k] + pairs.append((k, got, want, abs((got or 0) - (want or 0)) < 1e-6)) + for k in ("net_profit_margin", "asset_turnover", "equity_multiplier"): + got, want = m["factors"][k]["value_exact"], exp[k] + pairs.append((k, got, want, abs((got or 0) - (want or 0)) < 1e-5)) + check("A2 单月 2026-08 非年化:原值+三因子与独立 SQL 复算逐项一致", + all(p[3] for p in pairs), + "; ".join(f"{p[0]}={p[1]}/{p[2]}{'✗' if not p[3] else ''}" for p in pairs)) + check("A2b 单月区间 basis=average(期初取同月 ob_*,非回落)", + m.get("basis") == "average" and m.get("basis_fallback") is False, + f"basis={m.get('basis')} fallback={m.get('basis_fallback')}") + return m + + +def sec_a3(db, m): + if not m: + check("A3 年化对比(前序 A2 未取到 payload,节略)", False, "") + return + with Section("A3"): + y = call(db, entity="hanke", period="2026-08", annualize=True) + k = y["annualize_scale"] + tol = max(1e-6, 1e-6 * k) # value_exact 已 round(6),乘 k 后同比例允差 + okat = abs(y["factors"]["asset_turnover"]["value_exact"] + - m["factors"]["asset_turnover"]["value_exact"] * k) <= tol + okroe = abs((y["roe_exact"] or 0) - (m["roe_exact"] or 0) * k) <= tol + oknpm = abs(y["factors"]["net_profit_margin"]["value_exact"] + - m["factors"]["net_profit_margin"]["value_exact"]) < 1e-9 + okem = abs(y["factors"]["equity_multiplier"]["value_exact"] + - m["factors"]["equity_multiplier"]["value_exact"]) < 1e-9 + check("A3 年化=true:周转率/ROE ×12/月数、净利率/权益乘数不变(同量纲之比相消)", + k == 12.0 and okat and okroe and oknpm and okem, + f"scale={k} at={okat} roe={okroe} npm={oknpm} em={okem}") + check("A3b 年化后恒等式仍 PASS", y.get("identity", {}).get("status") == "pass", + f"identity={y.get('identity')}") + + +def sec_a4(db): + with Section("A4"): + g = call(db, entity="hanke", period="2026-08", guard_probe="mismatch") + check("A4 故意错配区间 → identity_broken 且不返回值(fail-closed)", + g.get("identity", {}).get("status") == "identity_broken" + and g.get("identity_broken") is True and g.get("roe") is None + and "factors" in g, + f"status={g.get('identity', {}).get('status')} roe={g.get('roe')} " + f"diff={g.get('identity', {}).get('diff')}") + + +def sec_a5_a6(db): + for eid in (1, 2): + with Section(f"A5 entity{eid}"): + rows = db.execute(text("""SELECT kpi_code, data_source_type, status FROM kpi_definitions + WHERE entity_id=:e AND kpi_code IN + ('F_ASSET_TOTAL','F_EQUITY_TOTAL','F_EQUITY_MULTIPLIER')"""), + {"e": eid}).fetchall() + got = {r[0]: (r[1], r[2]) for r in rows} + all_derived = (len(got) == 3 and all(v[0] == "derived" for v in got.values())) + vals = db.execute(text("""SELECT COUNT(DISTINCT k.kpi_code) FROM kpi_values v + JOIN kpi_definitions k ON k.id=v.kpi_id + WHERE k.entity_id=:e AND k.kpi_code IN + ('F_ASSET_TOTAL','F_EQUITY_TOTAL','F_EQUITY_MULTIPLIER') + AND v.actual_value IS NOT NULL"""), {"e": eid}).scalar() + check(f"A5 entity{eid} 三码均为 derived 且有 kpi_values(注册 {len(got)}/3,有值 {vals}/3)", + all_derived and vals == 3, json.dumps(got, ensure_ascii=False)) + with Section("A6"): + t = db.execute(text("""SELECT data_source_type FROM kpi_definitions + WHERE entity_id=1 AND kpi_code='F_ASSET_TURNOVER'""")).scalar() + check("A6 F_ASSET_TURNOVER data_source_type = derived", t == "derived", f"got={t}") + + +def sec_a7(db): + with Section("A7"): + f = call(db, entity="hanke", start="2026-01", end="2026-08", period="custom") + check("A7 期初缺失(2026-01 无 subject_balances 行)⇒ basis_fallback=true", + f.get("basis_fallback") is True and f.get("basis") == "ending" + and "期初" in (f.get("basis_fallback_reason") or ""), + f"basis={f.get('basis')} reason={f.get('basis_fallback_reason')}") + + +def sec_a8(db, m): + if not m: + check("A8 kpi_refs 镜像对比(前序 A2 未取到 payload,节略)", False, "") + return + with Section("A8"): + refs = m.get("kpi_refs", {}) + exp = independent_recompute(db, 1, "2026-08", "2026-08", "3") + checks = [ + ("F_EQUITY_MULTIPLIER", refs.get("F_EQUITY_MULTIPLIER", {}).get("value"), + m["factors"]["equity_multiplier"]["value_exact"], "端点权益乘数"), + ("F_ASSET_TURNOVER", refs.get("F_ASSET_TURNOVER", {}).get("value"), + m["factors"]["asset_turnover"]["value_exact"], "端点周转率"), + ("F_ASSET_TOTAL", refs.get("F_ASSET_TOTAL", {}).get("value"), + exp["total_assets_end"], "独立复算期末总资产"), + ("F_EQUITY_TOTAL", refs.get("F_EQUITY_TOTAL", {}).get("value"), + exp["equity_end"], "独立复算期末净资产"), + ] + drift = [f"{c}:{got}!={want}({src})" for c, got, want, src in checks + if got is None or abs(float(got) - float(want)) > 0.02] + check("A8 kpi_refs 派生值与端点/KPI 层同口径一致(乘数/周转率对端点,总量对独立复算;容差 0.02)", + not drift, ";".join(drift) or "; ".join( + f"{c}={g}" for c, g, _, _ in checks)) + + +def sec_a9(db): + with Section("A9"): + b = call(db, entity="bohai", period="2026-08", annualize=False) + bexp = independent_recompute(db, 2, "2026-08", "2026-08", "4", + revenue_codes=("6001", "6051", "6052"), + net_profit_mode="pnl_net") + okb = (abs((b["raw_data"]["revenue"] or 0) - bexp["revenue"]) < 1e-6 + and abs((b["raw_data"]["net_profit"] or 0) - bexp["net_profit"]) < 1e-6 + and abs((b["factors"]["equity_multiplier"]["value_exact"] or 0) + - (bexp["equity_multiplier"] or 0)) < 1e-5) + check("A9 entity2(新30号准则科目:4xxx权益/6xxx损益)独立复算一致", okb, + f"rev={b['raw_data']['revenue']}/{bexp['revenue']} np={b['raw_data']['net_profit']}" + f"/{bexp['net_profit']} em={b['factors']['equity_multiplier']['value_exact']}" + f"/{bexp['equity_multiplier']}") + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--check", action="store_true") + ap.add_argument("--json", action="store_true") + args = ap.parse_args() + + db = get_session_local()() + try: + sec_a1(db) + m = sec_a2(db) + sec_a3(db, m) + sec_a4(db) + sec_a5_a6(db) + sec_a7(db) + sec_a8(db, m) + sec_a9(db) + except Exception: # 兜底(正常不应触发;各节已自行隔离) + traceback.print_exc() + check("顶层执行异常", False, traceback.format_exc().splitlines()[-1][:200]) + + passed = sum(1 for r in RESULTS if r["ok"]) + total = len(RESULTS) + print(f"\n=== {passed}/{total} PASS ===") + if args.json: + print(json.dumps({"passed": passed, "total": total, "results": RESULTS}, + ensure_ascii=False, indent=1)) + db.close() + return 0 if passed == total else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/frontend/src/views/DupontAnalysis.vue b/frontend/src/views/DupontAnalysis.vue index 6ba53690..a0d0e180 100644 --- a/frontend/src/views/DupontAnalysis.vue +++ b/frontend/src/views/DupontAnalysis.vue @@ -4,12 +4,42 @@

杜邦分析

-
+
分析对象: {{ entityLabel }} + 区间: + + + + + 年化(x12/区间月数) 刷新
+ +
+ 区间 {{ data.period_start }} ~ {{ data.period_end }}({{ data.months_label }}) + 流量 累计 + 存量 {{ data.basis === 'average' ? '平均 (期初+期末)/2' : '期末(期初缺失回落)' }} + 年化 {{ data.annualized ? 'true(x' + data.annualize_scale + ')' : 'false' }} + + 恒等式 {{ data.identity ? data.identity.status : '-' }} + + + basis_fallback=true +
+ + + +
@@ -154,6 +184,26 @@ import api from '../api/index' const loading = ref(false) const entityLabel = ref(sessionEntityName()) const data = ref({}) +const errMsg = ref('') +// 区间维度(默认 YTD,与后端缺省一致);值即后端 period 参数原样 +const periodPreset = ref('ytd') +const customStart = ref('') +const customEnd = ref('') +const annualize = ref(false) +const periodOptions = computed(() => { + const opts: any[] = [ + { value: 'ytd', label: 'YTD(年初至今,默认)' }, + { value: 'ttm', label: 'TTM(滚动 12 月)' }, + ] + for (const y of [2026, 2025, 2024]) { + opts.push({ value: String(y), label: `${y} 年 全年` }) + for (const h of [1, 2]) opts.push({ value: `${y}-H${h}`, label: `${y} 年 半年 H${h}` }) + for (const q of [1, 2, 3, 4]) opts.push({ value: `${y}-Q${q}`, label: `${y} 年 季度 Q${q}` }) + for (let m = 1; m <= 12; m++) opts.push({ value: `${y}-${String(m).padStart(2, '0')}`, label: `${y} 年 ${m} 月` }) + } + opts.push({ value: 'custom', label: '自定义起止' }) + return opts +}) // 价值管理区(真 ROI / ROA / EVA / RI)——只读 /reports/value-management(派生值单源) const vm = ref({}) const vmLoaded = ref(false) @@ -192,8 +242,8 @@ function formatPct(val: number): string { function formatFactor(val: number, key: string): string { if (val === null || val === undefined) return '-' - if (key === 'financial_leverage') return val.toFixed(2) + 'x' - if (key === 'asset_turnover') return val.toFixed(2) + 'x' + if (key === 'financial_leverage' || key === 'equity_multiplier') return val.toFixed(2) + 'x' + if (key === 'asset_turnover') return val.toFixed(4) + 'x' return val.toFixed(2) + '%' } @@ -206,13 +256,29 @@ function formatMoney(val: number): string { async function fetchDupont() { loading.value = true + errMsg.value = '' try { // 分析对象由会话账套决定(后端按 token 解析),不再传 entity 参数 - const r: any = await api.get('/reports/dupont') + // 区间取值:period=ytd/ttm/YYYY-MM/YYYY-Qn/YYYY-Hn/YYYY/custom + annualize + const params: any = { + period: periodPreset.value, + annualize: annualize.value, + } + if (periodPreset.value === 'custom') { + if (!customStart.value || !customEnd.value) { + errMsg.value = '自定义区间需同时选择起始月与截止月' + data.value = {} + return + } + params.start = customStart.value + params.end = customEnd.value + } + const r: any = await api.get('/reports/dupont', { params }) data.value = r.data || r - } catch (e) { + } catch (e: any) { console.error('杜邦分析加载失败:', e) data.value = {} + errMsg.value = '区间参数不被接受或数据不可用:' + (e?.response?.data?.detail || e?.message || '请求失败') } finally { loading.value = false } @@ -422,4 +488,15 @@ onMounted(() => { line-height: 1.9; } .vm-params-title { font-weight: 600; color: #5a6b7f; margin-right: 6px; } + +/* 口径元数据条(区间取值设计 2026-10-03) */ +.caliber-bar { + display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center; + background: #f5f7fb; border: 1px solid #e4e8f7; border-radius: 10px; + padding: 10px 14px; margin-bottom: 16px; font-size: 12px; color: #5a6b7f; +} +.caliber-item b { color: #2c3e50; } +.caliber-ok { color: #52c41a; } +.caliber-bad { color: #e74c3c; font-weight: 600; } +.caliber-warn { color: #d48806; font-weight: 600; }