feat(profitability): real ROI (op profit / invested capital), enable EVA, add RI
ci/woodpecker/push/main Pipeline failed
ci/woodpecker/push/main Pipeline failed
Kaliber: wecom-finance 2026-10-02 ruling (Boss 11:59 CST "li; but (1) add real ROI"). - kpi_definitions seed scripts/sql/profitability_kpi_defs_20261003.sql: F_ROI renamed to F_ROA (same kpi_id=20, all 8 historical values kept), new F_ROI (real IMA caliber = operating profit / invested capital), F_EVA activated with unit and thresholds aligned to 万元, new F_RI (residual income). - parameters are NOT hardcoded in formula text: they live in kpi_definitions.data_source_config.params (tax_rate / wacc / min_return_rate / include_factory_subsidy / include_channel_subsidy / include_sales_discount / annualize / capital_basis); single reader = app/utils/profitability_params.py. - deriver scripts/kpi_profitability_deriver.py: YTD operating profit from voucher_details (carry_forward=0 net), invested capital from subject_balances level-1 rows only, idempotent upsert into kpi_values (source_type=derived, no data_status downgrade, fail-closed when invested capital is 0). - read-only endpoint GET /reports/value-management reads the derived rows only (single source of truth, no recomputation) and exposes the parameter block. - DupontAnalysis.vue: value management section (ROI / ROA / ROE / EVA / RI). - caliber_units.py: RATIO_CALIBER_TABLE key F_ROI renamed to F_ROA, new F_ROI row. - tests: backend/tests/test_profitability_indicators.py (21 cases).
This commit is contained in:
@@ -21,6 +21,7 @@ from app.auth_middleware import require_role, require_auth
|
||||
from app.models import KPIDefinition, KPIValue, BudgetPlan, StrategicMap, KPIAlert, User, Subject, ActionPlan, OperationLog, ReportHistory
|
||||
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.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
|
||||
@@ -2333,6 +2334,88 @@ def get_dupont_analysis(
|
||||
return {"error": "不支持的实体"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 价值管理区 — 真 ROI / ROA / EVA / RI(只读)
|
||||
# 口径: 财务Bot 2026-10-02 裁定(任总 11:59 CST DM「立;但①要补真ROI」)
|
||||
# ============================================================
|
||||
|
||||
@router.get("/value-management")
|
||||
def get_value_management(
|
||||
period: Optional[str] = Query(None, description="期间 YYYY-MM;缺省取该账套最新实测月"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""价值管理区 — 真 ROI / 总资产报酬率(ROA) / EVA / 剩余收益(RI)。
|
||||
|
||||
单一事实源:**只读 `kpi_values` 里 `source_type='derived'` 的派生行**(由
|
||||
`scripts/kpi_profitability_deriver.py` 落库),本端点**不复算**——避免「同一指标两份结果」。
|
||||
参数由 `app/utils/profitability_params.py` 从 `kpi_definitions.data_source_config.params` 读取。
|
||||
口径要点:YTD 累计(非年化);投入资本 = 有息负债(2001+2501) + 所有者权益(3xxx 一级科目);
|
||||
厂补 530103 / 渠补 571103 计入经营(开关可回旧口径)。
|
||||
"""
|
||||
codes = ("F_ROI", "F_ROA", "F_EVA", "F_RI", "F_ROE")
|
||||
defs = {d.kpi_code: d for d in db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.kpi_code.in_(codes),
|
||||
).all()}
|
||||
ids = [d.id for d in defs.values()]
|
||||
|
||||
if period is None and ids:
|
||||
latest = db.query(KPIValue.period).filter(
|
||||
KPIValue.kpi_id.in_(ids),
|
||||
KPIValue.actual_value.isnot(None),
|
||||
month_period_clause(KPIValue.period),
|
||||
).order_by(KPIValue.period.desc()).first()
|
||||
period = latest[0] if latest else None
|
||||
|
||||
rows = []
|
||||
if ids and period:
|
||||
rows = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id.in_(ids),
|
||||
KPIValue.period == period,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
).order_by(KPIValue.id.desc()).all()
|
||||
|
||||
metric = {}
|
||||
for row in rows: # id desc ⇒ 每个 kpi 取最新一条(同指标多来源时显式暴露,不去重掩盖)
|
||||
kpi = next((d for d in defs.values() if d.id == row.kpi_id), None)
|
||||
if kpi is None or kpi.kpi_code in metric:
|
||||
continue
|
||||
metric[kpi.kpi_code] = {
|
||||
"value": row.actual_value,
|
||||
"unit": kpi.unit,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"source_type": row.source_type,
|
||||
"data_status": row.data_status,
|
||||
"source_batch": row.source_batch,
|
||||
"remark": row.remark,
|
||||
"formula_desc": kpi.formula_desc,
|
||||
}
|
||||
|
||||
params = load_profitability_params(db, entity_id)
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"period": period,
|
||||
"roa": metric.get("F_ROA"),
|
||||
"roi": metric.get("F_ROI"),
|
||||
"eva": metric.get("F_EVA"),
|
||||
"ri": metric.get("F_RI"),
|
||||
"roe": metric.get("F_ROE"),
|
||||
"params": {
|
||||
"tax_rate": params["tax_rate"],
|
||||
"wacc": params["wacc"],
|
||||
"min_return_rate": params["min_return_rate"],
|
||||
"include_factory_subsidy": params["include_factory_subsidy"],
|
||||
"include_channel_subsidy": params["include_channel_subsidy"],
|
||||
"include_sales_discount": params["include_sales_discount"],
|
||||
"annualize": params["annualize"],
|
||||
"capital_basis": params["capital_basis"],
|
||||
},
|
||||
"readonly": True,
|
||||
"data_source": "derived",
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 自动报告生成 — ChatBI优化P1
|
||||
# 支持周报/月报/专项报告,定时/事件/手动触发
|
||||
|
||||
@@ -86,10 +86,17 @@ RATIO_CALIBER_TABLE = {
|
||||
"norm_factor": FACTOR_IDENTITY, "verified": True,
|
||||
"evidence": "2026-08 实测 -0.95 已是百分数(小体量亏损月,-0.95% 量级合理)",
|
||||
},
|
||||
"F_ROI": {
|
||||
"F_ROA": {
|
||||
"name": "总资产报酬率(ROA)", "declared_unit": "%", "caliber": "百分比(%)",
|
||||
"norm_factor": FACTOR_IDENTITY, "verified": True,
|
||||
"evidence": "2026-08 实测 -0.57 已是百分数",
|
||||
"evidence": "2026-08 实测 -0.57 已是百分数(kpi_code 2026-10-03 由 F_ROI 正名为 F_ROA,"
|
||||
"kpi_id/历史值不变;真 ROI 另立 F_ROI)",
|
||||
},
|
||||
"F_ROI": {
|
||||
"name": "投入资本回报率(ROI)", "declared_unit": "%", "caliber": "百分比(%)",
|
||||
"norm_factor": FACTOR_IDENTITY, "verified": True,
|
||||
"evidence": "2026-08 派生实测 -6.74(YTD 营业利润÷投入资本,已是百分数;口径见 "
|
||||
"wecom-finance 2026-10-02 裁定)",
|
||||
},
|
||||
"F_DEBT_RATIO": {
|
||||
"name": "资产负债率", "declared_unit": "%", "caliber": "百分比(%)",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""盈利能力三指标(ROI / EVA / RI)参数单一事实源 —— 口径裁定 2026-10-02
|
||||
|
||||
依据(唯一有效出处):财务Bot 2026-10-02 裁定(任总 11:59 CST DM「立;但①要补真ROI」)
|
||||
任务件 `finance-20261002-profitability-indicators-roi-eva-ri-fullstack.md`
|
||||
|
||||
口径(万元):
|
||||
· 真 ROI(IMA/CMA 口径) = 营业利润 ÷ 投入资本
|
||||
投入资本 = 有息负债(2001 短期借款 + 2501 长期借款)+ 所有者权益(3xxx 一级科目净额)
|
||||
· EVA = NOPAT − 投入资本 × WACC ,NOPAT = 经营口径营业利润 × (1 − 所得税率)
|
||||
· RI = 营业利润 − 投入资本 × 最低报酬率
|
||||
· 经营口径营业利润 = (5001 − 5401 − 5403 − 5601 − 5602 − 5603) + 厂补(530103) − 渠补(571103)
|
||||
厂补/渠补**计入经营**(参数开关可回旧口径);571112 商品折扣(销售) 默认**不计入**
|
||||
|
||||
参数化纪律(任务单 §二-1「勿硬编码进公式文本」):
|
||||
所有参数存放于 `kpi_definitions.data_source_config` 的 `params` 子对象,**单一事实源**:
|
||||
· `F_ROI.data_source_config.params` —— 利润口径开关(三条指标共用)
|
||||
· `F_EVA.data_source_config.params` —— tax_rate / wacc
|
||||
· `F_RI.data_source_config.params` —— min_return_rate
|
||||
本模块是**唯一读取入口**(派生器与只读端点共用),读不到即回落 DEFAULTS。
|
||||
改参数 = 改 DB 的 JSON 后重跑派生器(无需改代码),见 `scripts/kpi_profitability_deriver.py`。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
|
||||
from app.models import KPIDefinition
|
||||
|
||||
#: 三条指标的 kpi_code(与种子 SQL `scripts/sql/profitability_kpi_defs_20261003.sql` 一致)
|
||||
ROI_KPI = "F_ROI"
|
||||
EVA_KPI = "F_EVA"
|
||||
RI_KPI = "F_RI"
|
||||
PROFITABILITY_KPIS = (ROI_KPI, EVA_KPI, RI_KPI)
|
||||
|
||||
#: 出厂默认参数(DB 参数缺失/未解析时的兜底;与裁定 §一 一致)
|
||||
DEFAULTS: Dict[str, Any] = {
|
||||
# —— 利润口径开关(三条共用,落 F_ROI.params)——
|
||||
"include_factory_subsidy": True, # 厂补 530103 计入经营
|
||||
"include_channel_subsidy": True, # 渠补 571103 计入经营
|
||||
"include_sales_discount": False, # 571112 商品折扣(销售) 默认不计入(未决开关,任总点头后置 true)
|
||||
# —— 资本成本参数 ——
|
||||
"tax_rate": 0.25, # 所得税率(落 F_EVA.params)
|
||||
"wacc": 0.08, # 加权平均资本成本(落 F_EVA.params)
|
||||
"min_return_rate": 0.08, # RI 最低报酬率(落 F_RI.params,默认同 WACC)
|
||||
# —— 口径开关 ——
|
||||
"annualize": False, # 是否年化(默认 False = YTD 累计口径,公式/备注已声明)
|
||||
"capital_basis": "ending", # 投入资本口径:ending=期末 / average=平均((期初+期末)/2)
|
||||
}
|
||||
|
||||
|
||||
def parse_params(raw: Any) -> Dict[str, Any]:
|
||||
"""把 `data_source_config`(dict / JSON 字符串 / None)解析成 params 子对象。
|
||||
|
||||
兼容形态:`{"params": {...}}`(本单约定) / 裸 `{...}`(历史行无 params 包装) / None。
|
||||
"""
|
||||
if raw is None:
|
||||
return {}
|
||||
if isinstance(raw, (bytes, bytearray)):
|
||||
raw = raw.decode("utf-8", "replace")
|
||||
if isinstance(raw, str):
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
params = raw.get("params", raw)
|
||||
if not isinstance(params, dict):
|
||||
return {}
|
||||
return {k: v for k, v in params.items() if v is not None}
|
||||
|
||||
|
||||
def load_profitability_params(db, entity_id: int) -> Dict[str, Any]:
|
||||
"""读取并合并三条指标行的参数(F_ROI ← F_EVA ← F_RI 逐层覆盖 DEFAULTS)。
|
||||
|
||||
读不到定义/参数 ⇒ 对应层不覆盖(DEFAULTS 兜底)。返回的 dict 恒含 DEFAULTS 的全部键。
|
||||
"""
|
||||
out = dict(DEFAULTS)
|
||||
rows = db.query(KPIDefinition.kpi_code, KPIDefinition.data_source_config).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.kpi_code.in_(list(PROFITABILITY_KPIS)),
|
||||
).all()
|
||||
by_code = {}
|
||||
for code, cfg in rows:
|
||||
by_code[code] = parse_params(cfg)
|
||||
for code in PROFITABILITY_KPIS:
|
||||
for k, v in (by_code.get(code) or {}).items():
|
||||
if k in out:
|
||||
out[k] = v
|
||||
return out
|
||||
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""盈利能力三指标派生器 —— 真 ROI / EVA / RI(cma-profitability 2026-10-03)
|
||||
|
||||
任务:`finance-20261002-profitability-indicators-roi-eva-ri-fullstack.md`(P1,提出者 wecom-finance)
|
||||
放行出处:任总 2026-10-02 11:59 CST DM「立;但①要补真ROI」
|
||||
|
||||
口径(财务裁定,万元):
|
||||
营业利润(旧口径) = 5001 − 5401 − 5403 − (5601 + 5602 + 5603)
|
||||
经营口径营业利润 = 旧口径 + 厂补(530103) − 渠补(571103) [参数开关可回旧口径]
|
||||
(可选 +571112 商品折扣(销售),默认不计入)
|
||||
投入资本 = 有息负债(2001 + 2501) + 所有者权益(3xxx 一级科目净额)
|
||||
真 ROI = 经营口径营业利润 ÷ 投入资本 × 100
|
||||
NOPAT = 经营口径营业利润 × (1 − 所得税率)
|
||||
EVA = NOPAT − 投入资本 × WACC
|
||||
RI = 经营口径营业利润 − 投入资本 × 最低报酬率
|
||||
|
||||
取数:
|
||||
· 损益科目 → `voucher_details`(净额 = 借−贷,`carry_forward=0` 行域,同 kpi_bep_deriver)
|
||||
· 投入资本 → `subject_balances`(期末余额 cb_cr−cb_dr;3xxx 仅取 `level=1` 一级科目,防跨层双计)
|
||||
· 期间口径 = **YTD 累计(年初至目标月)**;是否年化由参数 `annualize` 控制(默认 False,
|
||||
即 ROI/EVA/RI 均为 YTD 非年化口径,已写进 kpi_definitions.formula_desc 与 remark)
|
||||
|
||||
参数:`app/utils/profitability_params.py`(唯一读取入口;落 `kpi_definitions.data_source_config.params`)
|
||||
幂等:同 kpi_id + entity + period + source_type='derived' 存在则原位更新;**不降级 data_status**
|
||||
(ASK-20260924-10 口径:保留既有状态,含人工放行的 verified)
|
||||
边界:只写 `source_type='derived'` 行;不改任何 kpi_definitions(定义由种子 SQL 负责);无 DDL。
|
||||
|
||||
用法:
|
||||
venv/bin/python3 scripts/kpi_profitability_deriver.py --period 2026-08 # 落库
|
||||
venv/bin/python3 scripts/kpi_profitability_deriver.py --period 2026-08 --dry-run # 只算不写
|
||||
venv/bin/python3 scripts/kpi_profitability_deriver.py --entity 1 --period 2026-08
|
||||
"""
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy import func, text
|
||||
|
||||
from app.database import get_session_local
|
||||
from app.models import KPIDefinition, KPIValue, VoucherDetail
|
||||
from app.utils.profitability_params import (
|
||||
EVA_KPI, PROFITABILITY_KPIS, RI_KPI, ROI_KPI, load_profitability_params,
|
||||
)
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("cma.profitability_deriver")
|
||||
|
||||
#: 损益科目口径(与任务单 §一 逐字一致)
|
||||
SALES_CODE = "5001" # 主营业务收入(贷方)
|
||||
COST_CODE = "5401" # 主营业务成本
|
||||
TAX_SURCHARGE_CODE = "5403" # 营业税金及附加
|
||||
EXPENSE_PREFIXES = ("5601", "5602", "5603") # 销售/管理/财务费用
|
||||
FACTORY_SUBSIDY_CODE = "530103" # 厂补(贷方)
|
||||
CHANNEL_SUBSIDY_CODE = "571103" # 渠补(借方)
|
||||
SALES_DISCOUNT_CODE = "571112" # 商品折扣(销售),默认不计入
|
||||
|
||||
#: 有息负债(一级科目)
|
||||
INTEREST_BEARING_CODES = ("2001", "2501")
|
||||
#: 所有者权益前缀(一级科目 3xxx,恰 4 位;用 LIKE 保证 MySQL/SQLite 双引擎可跑)
|
||||
EQUITY_PREFIX = "3"
|
||||
EQUITY_LEVEL1_LIKE = "3___"
|
||||
|
||||
|
||||
# ────────────────────────── 取数 ──────────────────────────
|
||||
|
||||
def ytd_window(period: str) -> tuple:
|
||||
"""目标期间 → YTD 起止(年初 .. 当期)"""
|
||||
year = period[:4]
|
||||
return f"{year}-01", period
|
||||
|
||||
|
||||
def sum_voucher(db, entity_id: int, period_from: str, period_to: str,
|
||||
field: str, subject_filter=None) -> float:
|
||||
"""聚合 voucher_details 金额**元**。field: 'net'(借−贷) / 'debit_amount' / 'credit_amount'。
|
||||
|
||||
净额口径只在 `carry_forward=0` 行域成立(含结转腿时损益类净额恒 0)——见
|
||||
/srv/hermes/wisdom/_decisions/cma-expense-profit-net-caliber-20261002.md。
|
||||
"""
|
||||
expr = (VoucherDetail.debit_amount - VoucherDetail.credit_amount
|
||||
if field == "net" else getattr(VoucherDetail, field))
|
||||
q = db.query(func.sum(expr)).filter(
|
||||
VoucherDetail.entity_id == entity_id,
|
||||
VoucherDetail.carry_forward == 0,
|
||||
VoucherDetail.period >= period_from,
|
||||
VoucherDetail.period <= period_to,
|
||||
)
|
||||
if subject_filter:
|
||||
kind, code = subject_filter
|
||||
if kind == "eq":
|
||||
q = q.filter(VoucherDetail.subject_code == code)
|
||||
elif kind == "like":
|
||||
q = q.filter(VoucherDetail.subject_code.like(code + "%"))
|
||||
return float(q.scalar() or 0.0)
|
||||
|
||||
|
||||
def operating_profit(db, entity_id: int, period: str, params: Dict[str, Any]) -> Dict[str, float]:
|
||||
"""YTD 营业利润(万元):旧口径 / 经营口径 + 各项明细,全部留痕供复核。"""
|
||||
p_from, p_to = ytd_window(period)
|
||||
|
||||
sales = sum_voucher(db, entity_id, p_from, p_to, "credit_amount", ("eq", SALES_CODE))
|
||||
cost = sum_voucher(db, entity_id, p_from, p_to, "net", ("eq", COST_CODE))
|
||||
tax_surcharge = sum_voucher(db, entity_id, p_from, p_to, "net", ("eq", TAX_SURCHARGE_CODE))
|
||||
expenses = sum(sum_voucher(db, entity_id, p_from, p_to, "net", ("like", p))
|
||||
for p in EXPENSE_PREFIXES)
|
||||
|
||||
op_legacy = (sales - cost - tax_surcharge - expenses) / 10000.0
|
||||
|
||||
factory_subsidy = -sum_voucher(db, entity_id, p_from, p_to, "net", ("eq", FACTORY_SUBSIDY_CODE)) / 10000.0
|
||||
channel_subsidy = sum_voucher(db, entity_id, p_from, p_to, "net", ("eq", CHANNEL_SUBSIDY_CODE)) / 10000.0
|
||||
sales_discount = sum_voucher(db, entity_id, p_from, p_to, "net", ("eq", SALES_DISCOUNT_CODE)) / 10000.0
|
||||
|
||||
op = op_legacy
|
||||
if params["include_factory_subsidy"]:
|
||||
op += factory_subsidy
|
||||
if params["include_channel_subsidy"]:
|
||||
op -= channel_subsidy
|
||||
if params["include_sales_discount"]:
|
||||
op -= sales_discount
|
||||
|
||||
return {
|
||||
"sales_w": round(sales / 10000.0, 4),
|
||||
"cost_w": round(cost / 10000.0, 4),
|
||||
"tax_surcharge_w": round(tax_surcharge / 10000.0, 4),
|
||||
"expenses_w": round(expenses / 10000.0, 4),
|
||||
"op_profit_legacy_w": round(op_legacy, 4),
|
||||
"factory_subsidy_w": round(factory_subsidy, 4),
|
||||
"channel_subsidy_w": round(channel_subsidy, 4),
|
||||
"sales_discount_w": round(sales_discount, 4),
|
||||
"op_profit_w": round(op, 4),
|
||||
}
|
||||
|
||||
|
||||
def _equity_sql():
|
||||
return text("""
|
||||
SELECT COALESCE(SUM(cb_cr - cb_dr), 0)
|
||||
FROM subject_balances
|
||||
WHERE entity_id = :eid AND period = :period AND `level` = 1
|
||||
AND subject_code LIKE :eq_like
|
||||
""")
|
||||
|
||||
|
||||
def _debt_sql():
|
||||
return text("""
|
||||
SELECT COALESCE(SUM(cb_cr - cb_dr), 0)
|
||||
FROM subject_balances
|
||||
WHERE entity_id = :eid AND period = :period AND `level` = 1
|
||||
AND subject_code IN ('2001', '2501')
|
||||
""")
|
||||
|
||||
|
||||
def invest_capital(db, entity_id: int, period: str, basis: str = "ending") -> Dict[str, Any]:
|
||||
"""投入资本(万元)= 有息负债 + 所有者权益(`subject_balances` 期末/平均)。
|
||||
|
||||
3xxx **只取 `level=1`(一级科目)**:`row_kind` 有 subtotal_level1/detail 两层,
|
||||
含明细层会把 `3104 利润分配` 与其子科目 `310415` 双计(实测 +598.55 万虚增)。
|
||||
"""
|
||||
def _query(p: str) -> Dict[str, float]:
|
||||
debt = float(db.execute(_debt_sql(), {"eid": entity_id, "period": p}).scalar() or 0.0)
|
||||
equity = float(db.execute(_equity_sql(), {"eid": entity_id, "period": p,
|
||||
"eq_like": EQUITY_LEVEL1_LIKE}).scalar() or 0.0)
|
||||
return {"debt": debt / 10000.0, "equity": equity / 10000.0}
|
||||
|
||||
cur = _query(period)
|
||||
out = {
|
||||
"interest_bearing_debt_w": round(cur["debt"], 4),
|
||||
"equity_w": round(cur["equity"], 4),
|
||||
"basis": basis,
|
||||
"ending_w": round(cur["debt"] + cur["equity"], 4),
|
||||
}
|
||||
total = out["ending_w"]
|
||||
if basis == "average":
|
||||
y, m = int(period[:4]), int(period[5:7])
|
||||
if m == 1:
|
||||
prev_period = f"{y - 1}-12"
|
||||
else:
|
||||
prev_period = f"{y}-{m - 1:02d}"
|
||||
prev = _query(prev_period)
|
||||
prev_ok = (prev["debt"] + prev["equity"]) != 0.0
|
||||
out["prev_period"] = prev_period
|
||||
out["prev_total_w"] = round(prev["debt"] + prev["equity"], 4)
|
||||
if prev_ok:
|
||||
total = (out["ending_w"] + out["prev_total_w"]) / 2.0
|
||||
else:
|
||||
out["average_fallback"] = "上一期 subject_balances 缺数 → 回落期末口径"
|
||||
out["basis"] = "ending(fallback)"
|
||||
out["invest_capital_w"] = round(total, 4)
|
||||
return out
|
||||
|
||||
|
||||
# ────────────────────────── 计算 ──────────────────────────
|
||||
|
||||
def compute_indicators(op_profit_w: float, invest_capital_w: float,
|
||||
months: int, params: Dict[str, Any]) -> Dict[str, Optional[float]]:
|
||||
"""纯计算(无 DB,便于单测):返回三条指标值(None = 不可得,fail-closed)。"""
|
||||
if not invest_capital_w:
|
||||
return {ROI_KPI: None, EVA_KPI: None, RI_KPI: None, "nopat_w": None, "scale": None}
|
||||
scale = (12.0 / months) if (params.get("annualize") and months) else 1.0
|
||||
op = op_profit_w * scale
|
||||
nopat = op * (1.0 - float(params["tax_rate"]))
|
||||
return {
|
||||
ROI_KPI: round(op / invest_capital_w * 100.0, 2),
|
||||
"nopat_w": round(nopat, 4),
|
||||
EVA_KPI: round(nopat - invest_capital_w * float(params["wacc"]), 2),
|
||||
RI_KPI: round(op - invest_capital_w * float(params["min_return_rate"]), 2),
|
||||
"scale": scale,
|
||||
}
|
||||
|
||||
|
||||
# ────────────────────────── 落库 ──────────────────────────
|
||||
|
||||
def run_derive(db, entity_id: int, period: str, write_kpi: bool = True) -> Dict[str, Any]:
|
||||
params = load_profitability_params(db, entity_id)
|
||||
months = int(period[5:7])
|
||||
profit = operating_profit(db, entity_id, period, params)
|
||||
capital = invest_capital(db, entity_id, period, params.get("capital_basis", "ending"))
|
||||
values = compute_indicators(profit["op_profit_w"], capital["invest_capital_w"], months, params)
|
||||
|
||||
defs = {d.kpi_code: d for d in db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.kpi_code.in_(list(PROFITABILITY_KPIS)),
|
||||
).all()}
|
||||
|
||||
switch_txt = (f"厂补{'计入' if params['include_factory_subsidy'] else '不计入'}/"
|
||||
f"渠补{'计入' if params['include_channel_subsidy'] else '不计入'}/"
|
||||
f"商品折扣{'计入' if params['include_sales_discount'] else '不计入'}")
|
||||
basis_txt = ("YTD累计(非年化)" if not params.get("annualize")
|
||||
else f"YTD年化(×12/{months})")
|
||||
common = (f"营业利润({switch_txt}; {basis_txt}) {profit['op_profit_w']:.2f}万 / "
|
||||
f"投入资本({capital['basis']}) {capital['invest_capital_w']:.2f}万")
|
||||
|
||||
remarks = {
|
||||
ROI_KPI: (f"{common} × 100 = {values[ROI_KPI]}%"
|
||||
if values[ROI_KPI] is not None else f"{common}|投入资本为 0,ROI 不可得"),
|
||||
EVA_KPI: (f"NOPAT {values['nopat_w']:.2f}万(税率{params['tax_rate']:.0%}) − "
|
||||
f"投入资本 {capital['invest_capital_w']:.2f}万 × WACC {params['wacc']:.2%} "
|
||||
f"= {values[EVA_KPI]}万" if values[EVA_KPI] is not None else f"{common}|投入资本为 0"),
|
||||
RI_KPI: (f"营业利润 {profit['op_profit_w']:.2f}万 − 投入资本 {capital['invest_capital_w']:.2f}万 × "
|
||||
f"最低报酬率 {params['min_return_rate']:.2%} = {values[RI_KPI]}万"
|
||||
if values[RI_KPI] is not None else f"{common}|投入资本为 0"),
|
||||
}
|
||||
|
||||
batch = f"profit-{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||
written, changed = {}, []
|
||||
for code in PROFITABILITY_KPIS:
|
||||
kpi = defs.get(code)
|
||||
if kpi is None:
|
||||
written[code] = "no-definition"
|
||||
logger.warning("kpi_definitions 缺 %s(entity=%s)——请先应用种子 SQL", code, entity_id)
|
||||
continue
|
||||
if values[code] is None:
|
||||
written[code] = "unavailable(invest_capital=0)"
|
||||
continue
|
||||
if not write_kpi:
|
||||
written[code] = "dry-run"
|
||||
continue
|
||||
existing = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.period == period,
|
||||
KPIValue.source_type == "derived",
|
||||
).first()
|
||||
if existing:
|
||||
old = float(existing.actual_value) if existing.actual_value is not None else None
|
||||
new = float(values[code])
|
||||
existing.actual_value = new
|
||||
existing.source_batch = batch
|
||||
if old is None or abs(old - new) > 1e-9:
|
||||
existing.remark = f"{remarks[code]}|值已更新(batch={batch}, 旧{old} → 新{new}) 待财务复核"
|
||||
changed.append({"code": code, "old": old, "new": new,
|
||||
"data_status": existing.data_status})
|
||||
else:
|
||||
existing.remark = remarks[code]
|
||||
# 不降级 data_status(ASK-20260924-10)
|
||||
written[code] = "upsert"
|
||||
else:
|
||||
db.add(KPIValue(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi.id,
|
||||
period=period,
|
||||
actual_value=values[code],
|
||||
source_type="derived",
|
||||
source_batch=batch,
|
||||
data_status="pending",
|
||||
remark=remarks[code],
|
||||
))
|
||||
written[code] = "insert"
|
||||
db.flush()
|
||||
if write_kpi:
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"period": period,
|
||||
"months": months,
|
||||
"mode": "write" if write_kpi else "dry-run",
|
||||
"batch": batch,
|
||||
"params": params,
|
||||
"profit": profit,
|
||||
"capital": capital,
|
||||
"nopat_w": values.get("nopat_w"),
|
||||
"values": {c: values[c] for c in PROFITABILITY_KPIS},
|
||||
"remarks": remarks,
|
||||
"written": written,
|
||||
"changed": changed,
|
||||
}
|
||||
|
||||
|
||||
def _pick_period(db, entity_id: int) -> Optional[str]:
|
||||
"""默认目标期间 = subject_balances 中该账套最新的期间(投入资本的唯一可取期间)。"""
|
||||
row = db.execute(text(
|
||||
"SELECT MAX(period) FROM subject_balances WHERE entity_id = :eid"
|
||||
), {"eid": entity_id}).scalar()
|
||||
return str(row) if row else None
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="盈利能力三指标派生器(真 ROI / EVA / RI)")
|
||||
ap.add_argument("--period", default=None, help="目标期间 YYYY-MM(默认取 subject_balances 最新期)")
|
||||
ap.add_argument("--entity", type=int, default=1, help="entity_id(默认 1=陕西酣客)")
|
||||
ap.add_argument("--dry-run", action="store_true", help="只计算不落库")
|
||||
args = ap.parse_args()
|
||||
|
||||
db = get_session_local()()
|
||||
try:
|
||||
period = args.period or _pick_period(db, args.entity)
|
||||
if not period:
|
||||
logger.error("entity=%s 无 subject_balances 记录,无可用期间", args.entity)
|
||||
return 2
|
||||
r = run_derive(db, args.entity, period, write_kpi=not args.dry_run)
|
||||
print(f"盈利能力派生完成 entity={r['entity_id']} period={r['period']} "
|
||||
f"(YTD {r['months']}个月) mode={r['mode']} batch={r['batch']}")
|
||||
p, c = r["profit"], r["capital"]
|
||||
print(f" 销售收入 {p['sales_w']:.2f}万 − 成本 {p['cost_w']:.2f}万 − 税金及附加 {p['tax_surcharge_w']:.2f}万 "
|
||||
f"− 三费 {p['expenses_w']:.2f}万 = 旧口径营业利润 {p['op_profit_legacy_w']:.2f}万")
|
||||
print(f" 厂补 {p['factory_subsidy_w']:.2f}万 / 渠补 {p['channel_subsidy_w']:.2f}万 / "
|
||||
f"商品折扣 {p['sales_discount_w']:.2f}万 → 经营口径营业利润 {p['op_profit_w']:.2f}万")
|
||||
print(f" 投入资本({c['basis']}) = 有息负债 {c['interest_bearing_debt_w']:.2f}万 + "
|
||||
f"所有者权益 {c['equity_w']:.2f}万 = {c['invest_capital_w']:.2f}万")
|
||||
print(f" F_ROI = {r['values'][ROI_KPI]} %")
|
||||
print(f" F_EVA = {r['values'][EVA_KPI]} 万元 (NOPAT {r['nopat_w']:.2f}万)")
|
||||
print(f" F_RI = {r['values'][RI_KPI]} 万元")
|
||||
print(f" 落库: {r['written']}")
|
||||
if r["changed"]:
|
||||
print(f" 值变: {r['changed']}")
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main() or 0)
|
||||
@@ -0,0 +1,80 @@
|
||||
-- CMA 盈利能力三指标(真 ROI / EVA / RI)定义收敛 — 2026-10-03
|
||||
-- 任务: finance-20261002-profitability-indicators-roi-eva-ri-fullstack(部署目标 cma-backend · cma-frontend)
|
||||
-- 口径: 财务Bot 2026-10-02 裁定(任总 11:59 CST DM「立;但①要补真ROI」)
|
||||
-- 动作:
|
||||
-- ① F_ROI 正名 → F_ROA(**沿用原 kpi_id=20 与全部 8 期历史值,不删行**;名称「总资产报酬率(ROA)」不变)
|
||||
-- ② 新增 F_ROI(真口径:经营口径营业利润 ÷ 投入资本)
|
||||
-- ③ F_EVA 启用 status='active' + 单位/阈值对齐「万元」(原 unit='元' 而派生值以万元落库 ⇒ 会恒黄/量级失真)
|
||||
-- ④ 新增 F_RI(剩余收益)
|
||||
-- 参数: 全部落 data_source_config.params(可配置、非硬编码进公式文本);
|
||||
-- 读取入口唯一 = app/utils/profitability_params.py
|
||||
-- 幂等: 条件 UPDATE + NOT EXISTS 守卫,可安全重跑
|
||||
-- ⚠️ 顺序不可换:①必须先于②((entity_id,kpi_code) 唯一键 uk_entity_kpi 会撞)
|
||||
|
||||
-- ① F_ROI 正名 → F_ROA(仅当 F_ROA 尚不存在;历史值随 kpi_id 保留)
|
||||
UPDATE kpi_definitions
|
||||
SET kpi_code = 'F_ROA', updated_at = NOW()
|
||||
WHERE entity_id = 1 AND kpi_code = 'F_ROI'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM (SELECT id FROM kpi_definitions
|
||||
WHERE entity_id = 1 AND kpi_code = 'F_ROA') AS t
|
||||
);
|
||||
|
||||
-- ② 新增 F_ROI(真口径)
|
||||
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, epic, created_by, data_source_config)
|
||||
SELECT 1, 'F_ROI', '投入资本回报率(ROI)', 'finance', 'profitability',
|
||||
'经营口径营业利润 / 投入资本 * 100',
|
||||
'真ROI=营业利润÷投入资本; 投入资本=有息负债(2001+2501)+所有者权益(3xxx一级科目); YTD累计(非年化); '
|
||||
'厂补530103/渠补571103计入经营(开关 include_factory_subsidy/include_channel_subsidy); '
|
||||
'571112商品折扣默认不计入(include_sales_discount); 参数见 data_source_config.params',
|
||||
'derived', '盈利能力派生器 kpi_profitability_deriver.py(subject_balances + voucher_details)',
|
||||
'财务部', 'monthly', '%', 0.00,
|
||||
'ratio', NULL, NULL, NULL,
|
||||
'strategic', 'active', 'finance-bot', 'Epic2', 1,
|
||||
'{"params": {"include_factory_subsidy": true, "include_channel_subsidy": true, "include_sales_discount": false, "annualize": false, "capital_basis": "ending"}, "param_note": "利润口径开关,三条指标(F_ROI/F_EVA/F_RI)共用;读取入口 app/utils/profitability_params.py"}'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM kpi_definitions WHERE entity_id = 1 AND kpi_code = 'F_ROI'
|
||||
);
|
||||
|
||||
-- ③ F_EVA 启用 + 万元口径对齐(阈值语义逐字平移:原 绿>=0元 / 黄>=-100000元 / 红<-100000元
|
||||
-- ⇔ 万元 绿>=0 / 黄>=-10 / 红<-10;不改判定语义,只消除 元↔万元 跨量级失真)
|
||||
UPDATE kpi_definitions
|
||||
SET status = 'active',
|
||||
unit = '万元',
|
||||
target_value = 0.00,
|
||||
target_calc_type = 'accumulate',
|
||||
formula = 'NOPAT - 投入资本 * WACC',
|
||||
formula_desc = 'EVA=NOPAT−投入资本×WACC; NOPAT=经营口径营业利润×(1−所得税率); 单位万元; '
|
||||
'YTD累计(非年化); 资本成本参数见 data_source_config.params(tax_rate/wacc)',
|
||||
data_source_type = 'derived',
|
||||
data_source = '盈利能力派生器 kpi_profitability_deriver.py(subject_balances + voucher_details)',
|
||||
data_owner = '财务部',
|
||||
threshold_green = '>=0', threshold_yellow = '>=-10', threshold_red = '<-10',
|
||||
data_source_config = '{"params": {"tax_rate": 0.25, "wacc": 0.08}}',
|
||||
updated_at = NOW()
|
||||
WHERE entity_id = 1 AND kpi_code = 'F_EVA';
|
||||
|
||||
-- ④ 新增 F_RI(剩余收益)
|
||||
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, epic, created_by, data_source_config)
|
||||
SELECT 1, 'F_RI', '剩余收益(RI)', 'finance', 'profitability',
|
||||
'经营口径营业利润 - 投入资本 * 最低报酬率',
|
||||
'RI=营业利润−投入资本×最低报酬率(同WACC族,EVA为先扣税版本); 单位万元; YTD累计(非年化); '
|
||||
'最低报酬率参数见 data_source_config.params.min_return_rate; 部门级 RI 待部门级资产负债后下沉',
|
||||
'derived', '盈利能力派生器 kpi_profitability_deriver.py(subject_balances + voucher_details)',
|
||||
'财务部', 'monthly', '万元', 0.00,
|
||||
'accumulate', NULL, NULL, NULL,
|
||||
'strategic', 'active', 'finance-bot', 'Epic2', 1,
|
||||
'{"params": {"min_return_rate": 0.08}}'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM kpi_definitions WHERE entity_id = 1 AND kpi_code = 'F_RI'
|
||||
);
|
||||
@@ -0,0 +1,372 @@
|
||||
"""盈利能力三指标(真 ROI / EVA / RI)单测 — finance-20261002-profitability-indicators-roi-eva-ri-fullstack
|
||||
|
||||
口径依据(财务Bot 2026-10-02 裁定,任总 11:59 CST DM「立;但①要补真ROI」):
|
||||
营业利润(旧口径) = 5001 − 5401 − 5403 − (5601 + 5602 + 5603)
|
||||
经营口径营业利润 = 旧口径 + 厂补(530103) − 渠补(571103) [开关可回旧口径]
|
||||
(571112 商品折扣默认不计入:include_sales_discount=False)
|
||||
投入资本 = 有息负债(2001 + 2501) + 所有者权益(3xxx **一级科目**净额)
|
||||
ROI = 营业利润 ÷ 投入资本 × 100 ;NOPAT = 营业利润 × (1 − 所得税率)
|
||||
EVA = NOPAT − 投入资本 × WACC ;RI = 营业利润 − 投入资本 × 最低报酬率
|
||||
|
||||
夹具数值逐项复刻财务复算锚(2026-08,万元):
|
||||
旧口径营业利润 −267.61 / 厂补 +871.03 / 渠补 −691.43 ⇒ 经营口径 −88.01
|
||||
投入资本 = 211.50 + 1093.26 = 1304.76
|
||||
⇒ ROI −6.75% | EVA −170.39 万(NOPAT −66.01) | RI −192.39 万
|
||||
⇒ include_sales_discount=true(+商品折扣 33.68 万)⇒ −9.33% / −195.65 / −226.07
|
||||
|
||||
覆盖:
|
||||
· app/utils/profitability_params.py —— 参数单一事实源(合并/兜底/兼容形态)
|
||||
· scripts/kpi_profitability_deriver.py —— 取值 + 计算 + 幂等落库 + fail-closed + 不降级
|
||||
· app/api/reports.py::/value-management —— 只读端点读派生值(单一事实源)
|
||||
"""
|
||||
import datetime
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import pytest # noqa: E402
|
||||
from sqlalchemy import text # noqa: E402
|
||||
|
||||
from app.models import KPIDefinition, KPIValue, VoucherDetail # noqa: E402
|
||||
from app.api.reports import get_value_management # noqa: E402
|
||||
from app.utils import profitability_params as PP # noqa: E402
|
||||
from scripts import kpi_profitability_deriver as D # noqa: E402
|
||||
|
||||
ENTITY = 1
|
||||
PERIOD = "2026-08"
|
||||
D_DATE = datetime.datetime(2026, 8, 31)
|
||||
|
||||
# ── 财务复算锚(元)────────────────────────────────────────────────────────
|
||||
A_SALES_CR = 8_087_955.00 # 5001 贷方累计
|
||||
A_COST_DR = 8_117_644.00 # 5401
|
||||
A_TAX_DR = 404_816.00 # 5403
|
||||
A_5601_DR = 123_144.00
|
||||
A_5602_DR = 2_062_015.00
|
||||
A_5603_DR = 56_427.00
|
||||
A_FACTORY_CR = 8_710_297.00 # 530103 厂补(贷方)
|
||||
A_CHANNEL_DR = 6_914_297.00 # 571103 渠补(借方)
|
||||
A_DISCOUNT_DR = 336_793.00 # 571112 商品折扣(借方)
|
||||
# 2026-08 期末科目余额(元)
|
||||
A_DEBT_2001 = 2_115_000.00
|
||||
A_EQ_3001 = 2_000_000.00
|
||||
A_EQ_3103 = 14_918_101.66
|
||||
A_EQ_3104_NEG = 5_985_512.16 # 利润分配 借方余额(负权益)
|
||||
A_EQ_310415_DETAIL = 5_985_512.16 # 3104 的子科目(level=2,须排除防双计)
|
||||
|
||||
KPI_CODES = (
|
||||
("F_ROI", "投入资本回报率(ROI)", "%"),
|
||||
("F_EVA", "经济增加值(EVA)", "万元"),
|
||||
("F_RI", "剩余收益(RI)", "万元"),
|
||||
)
|
||||
|
||||
|
||||
# ── 夹具 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _create_subject_balances(db):
|
||||
"""subject_balances 不是 ORM 模型(生产为财务裁定导出的快照表)→ 测试期手工建表。
|
||||
|
||||
⚠️ 必须 DROP 后重建:`Base.metadata.drop_all` 不覆盖本表,留表会让行跨用例累积。
|
||||
"""
|
||||
db.execute(text("DROP TABLE IF EXISTS subject_balances"))
|
||||
db.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS subject_balances (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_id INTEGER NOT NULL,
|
||||
period VARCHAR(7) NOT NULL,
|
||||
subject_code VARCHAR(20) NOT NULL,
|
||||
subject_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
level INTEGER NOT NULL,
|
||||
ob_dr NUMERIC NOT NULL DEFAULT 0, ob_cr NUMERIC NOT NULL DEFAULT 0,
|
||||
cur_dr NUMERIC NOT NULL DEFAULT 0, cur_cr NUMERIC NOT NULL DEFAULT 0,
|
||||
ytd_dr NUMERIC NOT NULL DEFAULT 0, ytd_cr NUMERIC NOT NULL DEFAULT 0,
|
||||
cb_dr NUMERIC NOT NULL DEFAULT 0, cb_cr NUMERIC NOT NULL DEFAULT 0,
|
||||
entity_scope VARCHAR(24) NOT NULL DEFAULT '',
|
||||
row_kind VARCHAR(16) NOT NULL DEFAULT '',
|
||||
source_batch VARCHAR(64),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _sb(db, period, code, level, cb_dr=0.0, cb_cr=0.0, row_kind="subtotal_level1"):
|
||||
db.execute(text("""
|
||||
INSERT INTO subject_balances
|
||||
(entity_id, period, subject_code, subject_name, level, cb_dr, cb_cr, row_kind)
|
||||
VALUES (:e, :p, :c, :n, :l, :dr, :cr, :rk)
|
||||
"""), {"e": ENTITY, "p": period, "c": code, "n": code, "l": level,
|
||||
"dr": cb_dr, "cr": cb_cr, "rk": row_kind})
|
||||
|
||||
|
||||
_seq = [900000]
|
||||
|
||||
|
||||
def _voucher(db, subject_code, debit=0.0, credit=0.0, period=PERIOD, cf=0):
|
||||
_seq[0] += 1
|
||||
db.add(VoucherDetail(
|
||||
entity_id=ENTITY, voucher_no=f"P{_seq[0]}",
|
||||
period=period, voucher_date=D_DATE, subject_code=subject_code,
|
||||
subject_name=subject_code, summary="fixture",
|
||||
debit_amount=debit, credit_amount=credit, carry_forward=cf,
|
||||
))
|
||||
|
||||
|
||||
def _seed_kpis(db):
|
||||
for code, name, unit in KPI_CODES:
|
||||
db.add(KPIDefinition(entity_id=ENTITY, kpi_code=code, kpi_name=name,
|
||||
dimension="finance", category="profitability",
|
||||
unit=unit, status="active", target_value=0.0))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _seed_production_shape(db):
|
||||
"""按生产 2026-08 形态铺夹具:损益凭证 + 期末科目余额 + 三条 KPI 定义。"""
|
||||
_create_subject_balances(db)
|
||||
_seed_kpis(db)
|
||||
_voucher(db, "5001", credit=A_SALES_CR)
|
||||
_voucher(db, "5401", debit=A_COST_DR)
|
||||
_voucher(db, "5403", debit=A_TAX_DR)
|
||||
_voucher(db, "5601", debit=A_5601_DR)
|
||||
_voucher(db, "5602", debit=A_5602_DR)
|
||||
_voucher(db, "5603", debit=A_5603_DR)
|
||||
_voucher(db, "530103", credit=A_FACTORY_CR)
|
||||
_voucher(db, "571103", debit=A_CHANNEL_DR)
|
||||
_voucher(db, "571112", debit=A_DISCOUNT_DR)
|
||||
_sb(db, PERIOD, "2001", 1, cb_cr=A_DEBT_2001)
|
||||
_sb(db, PERIOD, "2501", 1)
|
||||
_sb(db, PERIOD, "3001", 1, cb_cr=A_EQ_3001)
|
||||
_sb(db, PERIOD, "3103", 1, cb_cr=A_EQ_3103)
|
||||
_sb(db, PERIOD, "3104", 1, cb_dr=A_EQ_3104_NEG)
|
||||
_sb(db, PERIOD, "310415", 2, cb_dr=A_EQ_310415_DETAIL, row_kind="detail")
|
||||
db.commit()
|
||||
|
||||
|
||||
def _values(db, period=PERIOD):
|
||||
rows = db.query(KPIValue, KPIDefinition).join(
|
||||
KPIDefinition, KPIDefinition.id == KPIValue.kpi_id).filter(
|
||||
KPIValue.period == period).all()
|
||||
return {k.kpi_code: v for v, k in rows}
|
||||
|
||||
|
||||
# ── 参数单一事实源 ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestParams:
|
||||
def test_defaults_when_no_definitions(self, db):
|
||||
p = PP.load_profitability_params(db, ENTITY)
|
||||
assert p["tax_rate"] == 0.25 and p["wacc"] == 0.08
|
||||
assert p["min_return_rate"] == 0.08
|
||||
assert p["include_factory_subsidy"] is True
|
||||
assert p["include_channel_subsidy"] is True
|
||||
assert p["include_sales_discount"] is False
|
||||
assert p["annualize"] is False and p["capital_basis"] == "ending"
|
||||
|
||||
def test_parse_variants(self):
|
||||
assert PP.parse_params(None) == {}
|
||||
assert PP.parse_params("") == {}
|
||||
assert PP.parse_params('{"params": {"wacc": 0.1}}') == {"wacc": 0.1}
|
||||
assert PP.parse_params({"wacc": 0.1}) == {"wacc": 0.1}
|
||||
assert PP.parse_params("not-json") == {}
|
||||
assert PP.parse_params('{"params": null}') == {}
|
||||
assert PP.parse_params({"params": {"x": None}}) == {}
|
||||
|
||||
def test_merge_three_rows_over_defaults(self, db):
|
||||
db.add(KPIDefinition(entity_id=ENTITY, kpi_code="F_ROI", kpi_name="ROI", unit="%",
|
||||
data_source_config={"params": {"include_sales_discount": True}}))
|
||||
db.add(KPIDefinition(entity_id=ENTITY, kpi_code="F_EVA", kpi_name="EVA", unit="万元",
|
||||
data_source_config={"params": {"wacc": 0.12, "tax_rate": 0.15}}))
|
||||
db.add(KPIDefinition(entity_id=ENTITY, kpi_code="F_RI", kpi_name="RI", unit="万元",
|
||||
data_source_config={"params": {"min_return_rate": 0.11}}))
|
||||
db.commit()
|
||||
p = PP.load_profitability_params(db, ENTITY)
|
||||
assert p["wacc"] == 0.12 and p["tax_rate"] == 0.15
|
||||
assert p["min_return_rate"] == 0.11
|
||||
assert p["include_sales_discount"] is True
|
||||
# 未覆盖项仍取默认
|
||||
assert p["include_factory_subsidy"] is True and p["capital_basis"] == "ending"
|
||||
|
||||
|
||||
# ── 纯计算(口径锚) ───────────────────────────────────────────────────────
|
||||
|
||||
class TestCompute:
|
||||
def test_finance_anchor_values(self):
|
||||
v = D.compute_indicators(-88.0091, 1304.75895, 8, dict(PP.DEFAULTS))
|
||||
assert v["F_ROI"] == pytest.approx(-6.74, abs=0.02)
|
||||
assert v["nopat_w"] == pytest.approx(-66.0068, abs=0.01)
|
||||
assert v["F_EVA"] == pytest.approx(-170.4, abs=0.1)
|
||||
assert v["F_RI"] == pytest.approx(-192.4, abs=0.1)
|
||||
|
||||
def test_switch_sales_discount_moves_all_three(self):
|
||||
base = D.compute_indicators(-88.0091, 1304.75895, 8, dict(PP.DEFAULTS))
|
||||
p2 = dict(PP.DEFAULTS, include_sales_discount=True)
|
||||
with_disc = D.compute_indicators(-121.6884, 1304.75895, 8, p2)
|
||||
assert (base["F_ROI"], base["F_EVA"], base["F_RI"]) != \
|
||||
(with_disc["F_ROI"], with_disc["F_EVA"], with_disc["F_RI"])
|
||||
assert with_disc["F_ROI"] == pytest.approx(-9.33, abs=0.02)
|
||||
assert with_disc["F_EVA"] == pytest.approx(-195.6, abs=0.2)
|
||||
assert with_disc["F_RI"] == pytest.approx(-225.8, abs=0.5)
|
||||
|
||||
def test_annualize_changes_roi(self):
|
||||
p = dict(PP.DEFAULTS, annualize=True)
|
||||
v = D.compute_indicators(-88.0091, 1304.75895, 8, p)
|
||||
assert v["scale"] == pytest.approx(1.5)
|
||||
assert v["F_ROI"] == pytest.approx(-10.12, abs=0.05)
|
||||
|
||||
def test_fail_closed_when_capital_zero(self):
|
||||
v = D.compute_indicators(-88.0, 0.0, 8, dict(PP.DEFAULTS))
|
||||
assert v["F_ROI"] is None and v["F_EVA"] is None and v["F_RI"] is None
|
||||
|
||||
|
||||
# ── 派生器取值 / 落库 ──────────────────────────────────────────────────────
|
||||
|
||||
class TestDeriver:
|
||||
def test_production_shape_matches_finance_anchor(self, db):
|
||||
_seed_production_shape(db)
|
||||
r = D.run_derive(db, ENTITY, PERIOD, write_kpi=True)
|
||||
p, c = r["profit"], r["capital"]
|
||||
assert p["sales_w"] == pytest.approx(808.7955, abs=1e-3)
|
||||
assert p["op_profit_legacy_w"] == pytest.approx(-267.61, abs=0.01)
|
||||
assert p["factory_subsidy_w"] == pytest.approx(871.03, abs=0.01)
|
||||
assert p["channel_subsidy_w"] == pytest.approx(691.43, abs=0.01)
|
||||
assert p["op_profit_w"] == pytest.approx(-88.01, abs=0.01)
|
||||
# 一级科目口径:3104 与子科目 310415 不得双计
|
||||
assert c["interest_bearing_debt_w"] == pytest.approx(211.50, abs=0.01)
|
||||
assert c["equity_w"] == pytest.approx(1093.26, abs=0.01)
|
||||
assert c["invest_capital_w"] == pytest.approx(1304.76, abs=0.01)
|
||||
assert r["values"]["F_ROI"] == pytest.approx(-6.74, abs=0.02)
|
||||
assert r["values"]["F_EVA"] == pytest.approx(-170.4, abs=0.1)
|
||||
assert r["values"]["F_RI"] == pytest.approx(-192.4, abs=0.1)
|
||||
assert r["written"] == {"F_ROI": "insert", "F_EVA": "insert", "F_RI": "insert"}
|
||||
|
||||
def test_rows_written_with_derived_source(self, db):
|
||||
_seed_production_shape(db)
|
||||
D.run_derive(db, ENTITY, PERIOD, write_kpi=True)
|
||||
vals = _values(db)
|
||||
assert set(vals) == {"F_ROI", "F_EVA", "F_RI"}
|
||||
for code, row in vals.items():
|
||||
assert row.source_type == "derived"
|
||||
assert row.entity_id == ENTITY and row.period == PERIOD
|
||||
assert row.actual_value is not None and row.source_batch.startswith("profit-")
|
||||
assert "投入资本" in (row.remark or "")
|
||||
|
||||
def test_idempotent_rerun_no_duplicate(self, db):
|
||||
_seed_production_shape(db)
|
||||
D.run_derive(db, ENTITY, PERIOD, write_kpi=True)
|
||||
r2 = D.run_derive(db, ENTITY, PERIOD, write_kpi=True)
|
||||
assert r2["written"] == {"F_ROI": "upsert", "F_EVA": "upsert", "F_RI": "upsert"}
|
||||
assert r2["changed"] == [] # 值未变 ⇒ 无值变留痕
|
||||
n = db.query(KPIValue).filter(KPIValue.source_type == "derived").count()
|
||||
assert n == 3
|
||||
|
||||
def test_does_not_downgrade_verified_status(self, db):
|
||||
_seed_production_shape(db)
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_ROI").first()
|
||||
db.add(KPIValue(entity_id=ENTITY, kpi_id=kpi.id, period=PERIOD, actual_value=-99.0,
|
||||
source_type="derived", source_batch="old", data_status="verified"))
|
||||
db.commit()
|
||||
r = D.run_derive(db, ENTITY, PERIOD, write_kpi=True)
|
||||
row = _values(db)["F_ROI"]
|
||||
assert row.data_status == "verified" # 不降级(ASK-20260924-10)
|
||||
assert row.actual_value == pytest.approx(-6.74, abs=0.02)
|
||||
assert r["changed"] and r["changed"][0]["code"] == "F_ROI"
|
||||
|
||||
def test_switch_true_changes_values(self, db):
|
||||
_seed_production_shape(db)
|
||||
D.run_derive(db, ENTITY, PERIOD, write_kpi=True)
|
||||
base = {c: v.actual_value for c, v in _values(db).items()}
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_ROI").first()
|
||||
kpi.data_source_config = {"params": {"include_sales_discount": True}}
|
||||
db.commit()
|
||||
D.run_derive(db, ENTITY, PERIOD, write_kpi=True)
|
||||
now = {c: v.actual_value for c, v in _values(db).items()}
|
||||
assert now["F_ROI"] == pytest.approx(-9.33, abs=0.02)
|
||||
assert now["F_EVA"] == pytest.approx(-195.6, abs=0.2)
|
||||
assert now["F_RI"] == pytest.approx(-225.8, abs=0.5)
|
||||
assert now != base
|
||||
|
||||
def test_wacc_change_moves_eva_only(self, db):
|
||||
_seed_production_shape(db)
|
||||
D.run_derive(db, ENTITY, PERIOD, write_kpi=True)
|
||||
base = {c: v.actual_value for c, v in _values(db).items()}
|
||||
eva = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_EVA").first()
|
||||
eva.data_source_config = {"params": {"tax_rate": 0.25, "wacc": 0.10}}
|
||||
db.commit()
|
||||
D.run_derive(db, ENTITY, PERIOD, write_kpi=True)
|
||||
now = {c: v.actual_value for c, v in _values(db).items()}
|
||||
assert now["F_ROI"] == base["F_ROI"] and now["F_RI"] == base["F_RI"]
|
||||
# WACC 8%→10% ⇒ EVA 少 1304.75895 × 2% = 26.10 万
|
||||
assert now["F_EVA"] == pytest.approx(base["F_EVA"] - 26.10, abs=0.02)
|
||||
|
||||
def test_fail_closed_without_capital_no_write(self, db):
|
||||
_seed_production_shape(db)
|
||||
db.execute(text("DELETE FROM subject_balances"))
|
||||
db.commit()
|
||||
r = D.run_derive(db, ENTITY, PERIOD, write_kpi=True)
|
||||
assert r["capital"]["invest_capital_w"] == 0.0
|
||||
assert r["values"] == {"F_ROI": None, "F_EVA": None, "F_RI": None}
|
||||
assert db.query(KPIValue).filter(KPIValue.source_type == "derived").count() == 0
|
||||
|
||||
def test_missing_definition_reported_not_crash(self, db):
|
||||
_create_subject_balances(db)
|
||||
_seed_kpis(db)
|
||||
_voucher(db, "5001", credit=A_SALES_CR)
|
||||
_sb(db, PERIOD, "3001", 1, cb_cr=A_EQ_3001)
|
||||
ri = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_RI").first()
|
||||
db.delete(ri)
|
||||
db.commit()
|
||||
r = D.run_derive(db, ENTITY, PERIOD, write_kpi=True)
|
||||
assert r["written"]["F_RI"] == "no-definition"
|
||||
assert r["written"]["F_ROI"] == "insert"
|
||||
|
||||
def test_pick_period_uses_latest_subject_balances(self, db):
|
||||
_create_subject_balances(db)
|
||||
_sb(db, "2026-07", "3001", 1, cb_cr=A_EQ_3001)
|
||||
_sb(db, "2026-08", "3001", 1, cb_cr=A_EQ_3001)
|
||||
db.commit()
|
||||
assert D._pick_period(db, ENTITY) == "2026-08"
|
||||
|
||||
def test_average_basis_falls_back_when_prev_missing(self, db):
|
||||
_seed_production_shape(db)
|
||||
c = D.invest_capital(db, ENTITY, PERIOD, "average")
|
||||
assert c["basis"] == "ending(fallback)"
|
||||
assert c["invest_capital_w"] == pytest.approx(1304.76, abs=0.01)
|
||||
|
||||
def test_average_basis_uses_prev_period(self, db):
|
||||
_seed_production_shape(db)
|
||||
_sb(db, "2026-07", "2001", 1, cb_cr=A_DEBT_2001)
|
||||
_sb(db, "2026-07", "3001", 1, cb_cr=A_EQ_3001)
|
||||
db.commit()
|
||||
c = D.invest_capital(db, ENTITY, PERIOD, "average")
|
||||
assert c["basis"] == "average"
|
||||
# 上一期 (2026-07) = 有息负债 211.50万 + 权益 200.00万 = 411.50万
|
||||
assert c["prev_total_w"] == pytest.approx(411.50, abs=0.01)
|
||||
# (411.50 + 1304.75895) / 2
|
||||
assert c["invest_capital_w"] == pytest.approx((411.5 + 1304.75895) / 2, abs=0.01)
|
||||
|
||||
|
||||
# ── 只读端点(读派生值单一事实源) ─────────────────────────────────────────
|
||||
|
||||
class TestValueManagementEndpoint:
|
||||
def test_reads_derived_values(self, db):
|
||||
_seed_production_shape(db)
|
||||
D.run_derive(db, ENTITY, PERIOD, write_kpi=True)
|
||||
r = get_value_management(period=PERIOD, db=db, entity_id=ENTITY)
|
||||
assert r["readonly"] is True and r["data_source"] == "derived"
|
||||
assert r["period"] == PERIOD
|
||||
assert r["roi"]["value"] == pytest.approx(-6.74, abs=0.02)
|
||||
assert r["eva"]["value"] == pytest.approx(-170.4, abs=0.1)
|
||||
assert r["ri"]["value"] == pytest.approx(-192.4, abs=0.1)
|
||||
assert r["roa"] is None # 未 seed F_ROA
|
||||
assert r["params"]["wacc"] == 0.08
|
||||
assert r["params"]["include_sales_discount"] is False
|
||||
|
||||
def test_default_period_is_latest(self, db):
|
||||
_seed_production_shape(db)
|
||||
D.run_derive(db, ENTITY, PERIOD, write_kpi=True)
|
||||
r = get_value_management(period=None, db=db, entity_id=ENTITY)
|
||||
assert r["period"] == PERIOD
|
||||
|
||||
def test_empty_when_no_rows(self, db):
|
||||
_seed_kpis(db)
|
||||
r = get_value_management(period=None, db=db, entity_id=ENTITY)
|
||||
assert r["period"] is None and r["roi"] is None and r["params"]["tax_rate"] == 0.25
|
||||
@@ -10,6 +10,41 @@
|
||||
<el-button type="primary" size="small" @click="fetchDupont" :loading="loading">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 价值管理区:真 ROI / ROA / EVA / RI(口径:财务裁定 2026-10-02,任总 11:59「立;但①要补真ROI」) -->
|
||||
<div class="value-mgmt" v-if="vm && vm.period">
|
||||
<div class="vm-title">
|
||||
<svg class="vm-icon" viewBox="0 0 24 24" width="16" height="16" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 17l6-6 4 4 8-8"/><path d="M21 7v10H11"/>
|
||||
</svg>
|
||||
<span>价值管理({{ vm.period }})</span>
|
||||
<span class="vm-sub">YTD 累计口径 · 派生值单源(source_type=derived)</span>
|
||||
</div>
|
||||
<div class="vm-cards">
|
||||
<div class="vm-card" v-for="m in vmCards" :key="m.key">
|
||||
<div class="vm-value" :class="{ neg: m.raw !== null && m.raw !== undefined && m.raw < 0 }">
|
||||
{{ m.text }}<span class="vm-unit">{{ m.unit }}</span>
|
||||
</div>
|
||||
<div class="vm-label">{{ m.label }}</div>
|
||||
<div class="vm-desc">{{ m.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vm-params" v-if="vm.params">
|
||||
<span class="vm-params-title">参数(kpi_definitions.data_source_config)</span>
|
||||
所得税率 {{ pct(vm.params.tax_rate) }} · WACC {{ pct(vm.params.wacc) }} ·
|
||||
最低报酬率 {{ pct(vm.params.min_return_rate) }} ·
|
||||
厂补{{ vm.params.include_factory_subsidy ? '计入' : '不计入' }} ·
|
||||
渠补{{ vm.params.include_channel_subsidy ? '计入' : '不计入' }} ·
|
||||
商品折扣{{ vm.params.include_sales_discount ? '计入' : '不计入' }} ·
|
||||
{{ vm.params.annualize ? '年化(×12/月数)' : 'YTD 非年化' }} ·
|
||||
投入资本{{ vm.params.capital_basis === 'average' ? '平均' : '期末' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="value-mgmt empty" v-else-if="vmLoaded && !loading">
|
||||
<div class="vm-title"><span>价值管理</span></div>
|
||||
<div class="vm-empty">ROI / ROA / EVA / RI 尚无派生值(请先运行 kpi_profitability_deriver.py)</div>
|
||||
</div>
|
||||
|
||||
<template v-if="data.roe !== null && data.roe !== undefined">
|
||||
<!-- 1. ROE大数字 -->
|
||||
<div class="roe-hero">
|
||||
@@ -111,7 +146,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { sessionEntityName } from '../utils/entity'
|
||||
// 统一走 api/index.ts 的 axios 实例(拦截器自动带 Bearer / 401 跳登录;语义同为「已剥层」)
|
||||
import api from '../api/index'
|
||||
@@ -119,6 +154,36 @@ import api from '../api/index'
|
||||
const loading = ref(false)
|
||||
const entityLabel = ref(sessionEntityName())
|
||||
const data = ref<any>({})
|
||||
// 价值管理区(真 ROI / ROA / EVA / RI)——只读 /reports/value-management(派生值单源)
|
||||
const vm = ref<any>({})
|
||||
const vmLoaded = ref(false)
|
||||
|
||||
function fmtVal(v: any): string {
|
||||
if (v === null || v === undefined) return '-'
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n.toFixed(2) : '-'
|
||||
}
|
||||
|
||||
function pct(v: any): string {
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? (n * 100).toFixed(2).replace(/\.00$/, '') + '%' : '-'
|
||||
}
|
||||
|
||||
const vmCards = computed(() => {
|
||||
const d = vm.value || {}
|
||||
return [
|
||||
{ key: 'roi', label: d.roi?.kpi_name || '投入资本回报率(ROI)', raw: d.roi?.value,
|
||||
unit: d.roi?.unit || '%', text: fmtVal(d.roi?.value), desc: '营业利润 ÷ 投入资本(真 ROI)' },
|
||||
{ key: 'roa', label: d.roa?.kpi_name || '总资产报酬率(ROA)', raw: d.roa?.value,
|
||||
unit: d.roa?.unit || '%', text: fmtVal(d.roa?.value), desc: '净利润 ÷ 平均总资产(原 F_ROI 正名)' },
|
||||
{ key: 'roe', label: d.roe?.kpi_name || '净资产收益率(ROE)', raw: d.roe?.value,
|
||||
unit: d.roe?.unit || '%', text: fmtVal(d.roe?.value), desc: '净利润 ÷ 平均净资产' },
|
||||
{ key: 'eva', label: d.eva?.kpi_name || '经济增加值(EVA)', raw: d.eva?.value,
|
||||
unit: d.eva?.unit || '万元', text: fmtVal(d.eva?.value), desc: 'NOPAT − 投入资本 × WACC' },
|
||||
{ key: 'ri', label: d.ri?.kpi_name || '剩余收益(RI)', raw: d.ri?.value,
|
||||
unit: d.ri?.unit || '万元', text: fmtVal(d.ri?.value), desc: '营业利润 − 投入资本 × 最低报酬率' },
|
||||
].filter(m => m.raw !== null && m.raw !== undefined)
|
||||
})
|
||||
|
||||
function formatPct(val: number): string {
|
||||
if (val === null || val === undefined) return '-'
|
||||
@@ -153,7 +218,22 @@ async function fetchDupont() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchDupont)
|
||||
async function fetchValueMgmt() {
|
||||
try {
|
||||
const r: any = await api.get('/reports/value-management')
|
||||
vm.value = r.data || r
|
||||
} catch (e) {
|
||||
console.error('价值管理加载失败:', e)
|
||||
vm.value = {}
|
||||
} finally {
|
||||
vmLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDupont()
|
||||
fetchValueMgmt()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -305,4 +385,41 @@ onMounted(fetchDupont)
|
||||
.insight-label { font-size: 14px; font-weight: 600; color: #d48806; margin-bottom: 8px; }
|
||||
.insight-text { font-size: 14px; color: #5a4e2b; line-height: 1.7; }
|
||||
.insight-improvement { font-size: 14px; color: #2c3e50; font-weight: 600; margin-top: 8px; padding-top: 8px; border-top: 1px solid #ffe58f; }
|
||||
|
||||
/* 价值管理区(真 ROI / ROA / EVA / RI) */
|
||||
.value-mgmt {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.value-mgmt.empty { padding: 16px 20px; }
|
||||
.vm-title { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 600; color: #2c3e50; }
|
||||
.vm-icon { color: #667eea; }
|
||||
.vm-sub { font-size: 12px; font-weight: 400; color: #95a5a6; margin-left: 4px; }
|
||||
.vm-empty { font-size: 13px; color: #95a5a6; margin-top: 8px; }
|
||||
.vm-cards { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
|
||||
.vm-card {
|
||||
flex: 1 1 150px;
|
||||
min-width: 150px;
|
||||
background: #f8f9ff;
|
||||
border: 1px solid #e4e8f7;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.vm-value { font-size: 24px; font-weight: 700; color: #2c3e50; }
|
||||
.vm-value.neg { color: #e74c3c; }
|
||||
.vm-unit { font-size: 12px; font-weight: 400; color: #95a5a6; margin-left: 4px; }
|
||||
.vm-label { font-size: 13px; color: #5a6b7f; margin-top: 4px; }
|
||||
.vm-desc { font-size: 11px; color: #b0b0b0; margin-top: 6px; }
|
||||
.vm-params {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #e8e8e8;
|
||||
font-size: 12px;
|
||||
color: #7f8c8d;
|
||||
line-height: 1.9;
|
||||
}
|
||||
.vm-params-title { font-weight: 600; color: #5a6b7f; margin-right: 6px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user