feat(dupont): period-basis design (cumulative flow x average stock, optional annualize, identity guard)
ci/woodpecker/push/main Pipeline failed
ci/woodpecker/push/main Pipeline failed
Task: finance-20261003-dupont-period-basis-design-fullstack (P1; proposer wecom-finance) Basis ruling: finance 2026-10-03 (Boss 09:49 CST DM approval of the design task) - /reports/dupont accepts period/start/end/annualize (default YTD), returns period_start/period_end/basis/annualized metadata; identity guard |ROE - npm x turnover x multiplier| <= 0.05 pp, else identity_broken (no values). - new app/utils/dupont_basis.py = single source of truth (window resolve, flow cumulative via voucher_details, stock average via subject_balances level=1, fail-closed basis_fallback, pure calc + identity check). - scripts/kpi_dupont_deriver.py (idempotent derived writer, dry-run/check/write), scripts/apply_dupont_kpi_defs.py + sql/dupont_kpi_defs_20261003.sql (seed: F_ASSET_TOTAL/F_EQUITY_TOTAL/F_EQUITY_MULTIPLIER as derived; F_ASSET_TURNOVER manual -> derived). Seed already applied under approval 296cc1fd. - scripts/verify_dupont_period_basis.py: read-only acceptance battery A1-A9 (independent SQL recompute). BEFORE (base tree) 1/10 PASS -> AFTER 10/13 PASS; remaining 3 need the deriver value write (approval-gated). - frontend DupontAnalysis.vue: period selector (YTD default) + annualize switch + caliber metadata bar + identity_broken alert. No DDL. No ERP direct connection (old register_dupont_kpis.py caliber dropped).
This commit is contained in:
+279
-161
@@ -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 直连近似口径"),
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user