feat(kpi): collect cashflow-direct caliber for F_OP_CFLOW/F_FCF
- kpi_value_collector: new calc=cashflow_direct branch (line-level counterpart classification: cash vouchers -> operating/financing/investing buckets), no hardcoded voucher numbers; optional minus_capex for FCF - verify_cashflow_direct_caliber.py: 8-period reconcile + identity + negative control (voucher-level classification would overstate 2026-06 by 18,700) task: yanxue-budget-cashflow-kpi-activate-20260914
This commit is contained in:
@@ -27,7 +27,8 @@ if __name__ == "__main__":
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import and_, func, not_, or_
|
||||
from sqlalchemy.orm import aliased
|
||||
from app.database import get_session_local
|
||||
from app.models import (
|
||||
KPIValueSource, KPIValueCollectLog, KPIValue, KPIDefinition,
|
||||
@@ -71,6 +72,13 @@ def collect_for_mapping(db, mapping: KPIValueSource, period: str, write_kpi: boo
|
||||
period_field = mapping.period_field or "period"
|
||||
unit = mapping.unit_conversion or 1
|
||||
|
||||
# 现金流两族KPI 激活(2026-09-14): 现金流量表直接法·对手方分类口径
|
||||
# filter_rule 形如 {"calc":"cashflow_direct","bucket":"operating","cash_subjects":["1001%","1002%","1012%"],
|
||||
# "financing_counterpart":[...],"investing_counterpart":[...],"round":4,
|
||||
# "minus_capex":{"counterpart":[...],"side":"debit"}}
|
||||
if isinstance(filter_rule, dict) and filter_rule.get("calc") == "cashflow_direct":
|
||||
return collect_cashflow_direct(db, mapping, period, write_kpi=write_kpi)
|
||||
|
||||
# P1③④ 激活(2026-09-14): 科目组合映射 —— 由 kpi_subject_map 推导的多科目加权归集
|
||||
# filter_rule 形如 {"subject_codes":["5001%"],"measure":"credit","weights":{"5001%":1.0},
|
||||
# "calc":"sum|ratio","denominator":"5001%","scale":100}
|
||||
@@ -258,6 +266,118 @@ def collect_subject_group(db, mapping, period: str, write_kpi: bool = True):
|
||||
return value, message
|
||||
|
||||
|
||||
# ── 现金流量表直接法·对手方分类归集(2026-09-14 激活)──────────────────
|
||||
# 任务: yanxue-budget-cashflow-kpi-activate-20260914(财务Bot 口径说明 §二/§三/§五)
|
||||
# 口径: 以**凭证为单元**取含现金类科目(1001%/1002%/1012%)的凭证 → 对手方**行级**分类
|
||||
# (筹资类/投资类/其余=经营);经营净额 = Σ(经营类对手方行净额 × -1)
|
||||
# 恒等式: 经营净额 + 筹资净额 + 投资净额 = 现金总净额
|
||||
# 关键: 必须行级拆分,不能按凭证级分类 —— 反例 2026-06 凭证 31226 = 还借款 2,550,000
|
||||
# + 手续费用 18,700 同单,整单归筹资会把 18,700 手续费误算作经营(高估 18,700)。
|
||||
# 纪律: 不硬编码凭证号/常量;对手方集合、bucket、舍位全部来自 filter_rule。
|
||||
_DEFAULT_CASH_SUBJECTS = ("1001%", "1002%", "1012%")
|
||||
_CASHFLOW_BUCKETS = ("operating", "financing", "investing", "net_cash")
|
||||
|
||||
|
||||
def _cashflow_voucher_subquery(db, entity_id: int, period: Optional[str], cash_subjects):
|
||||
"""含现金类科目的凭证键集合(entity_id + voucher_date + voucher_no + period)。"""
|
||||
q = db.query(
|
||||
VoucherDetail.entity_id.label("entity_id"),
|
||||
VoucherDetail.voucher_date.label("voucher_date"),
|
||||
VoucherDetail.voucher_no.label("voucher_no"),
|
||||
VoucherDetail.period.label("period"),
|
||||
).filter(VoucherDetail.entity_id == entity_id)
|
||||
if period:
|
||||
q = q.filter(VoucherDetail.period == period)
|
||||
q = q.filter(or_(*[VoucherDetail.subject_code.like(p) for p in cash_subjects]))
|
||||
voucher_cnt = q.distinct().count()
|
||||
return q.distinct().subquery(), voucher_cnt
|
||||
|
||||
|
||||
def _cashflow_side_amount(db, entity_id: int, period: Optional[str], cash_subjects,
|
||||
include_prefixes=None, exclude_prefixes=None, side: str = "cashflow"):
|
||||
"""现金凭证内「对手方行级」金额 → (金额元, 行数, 凭证数)。
|
||||
|
||||
side="cashflow" : Σ(贷方 − 借方),即现金流口径(流入为正)
|
||||
side="debit_net" : Σ(借方 − 贷方),即资本支出/借方净额口径(付现为正)
|
||||
include_prefixes / exclude_prefixes: 4 位科目前缀,命中即纳入 / 排除(均为 LIKE 前缀,非硬编码)。
|
||||
"""
|
||||
sub, voucher_cnt = _cashflow_voucher_subquery(db, entity_id, period, cash_subjects)
|
||||
c = aliased(VoucherDetail)
|
||||
if side == "debit_net":
|
||||
expr = func.coalesce(func.sum(c.debit_amount - c.credit_amount), 0)
|
||||
else:
|
||||
expr = func.coalesce(func.sum(c.credit_amount - c.debit_amount), 0)
|
||||
q = db.query(expr, func.count(c.id)).select_from(c).join(
|
||||
sub,
|
||||
and_(sub.c.entity_id == c.entity_id, sub.c.voucher_date == c.voucher_date,
|
||||
sub.c.voucher_no == c.voucher_no, sub.c.period == c.period),
|
||||
).filter(c.entity_id == entity_id)
|
||||
if period:
|
||||
q = q.filter(c.period == period)
|
||||
# 只保留对手方行(剔除现金类科目自身)
|
||||
q = q.filter(not_(or_(*[c.subject_code.like(p) for p in cash_subjects])))
|
||||
if include_prefixes:
|
||||
q = q.filter(or_(*[c.subject_code.like(str(p) + "%") for p in include_prefixes]))
|
||||
if exclude_prefixes:
|
||||
q = q.filter(not_(or_(*[c.subject_code.like(str(p) + "%") for p in exclude_prefixes])))
|
||||
row = q.first()
|
||||
amount = float(row[0] or 0) if row else 0.0
|
||||
rows = int(row[1] or 0) if row else 0
|
||||
return amount, rows, voucher_cnt
|
||||
|
||||
|
||||
def collect_cashflow_direct(db, mapping, period: str, write_kpi: bool = True):
|
||||
"""现金流量表直接法归集(filter_rule.calc = cashflow_direct),返回 (采集值, 说明)。
|
||||
|
||||
bucket: operating(经营/默认) | financing(筹资) | investing(投资) | net_cash(现金总净额)
|
||||
minus_capex: 付现资本支出扣减项 {"counterpart":[4位前缀...],"side":"debit"}
|
||||
"""
|
||||
rule = mapping.filter_rule or {}
|
||||
cash_subjects = [str(p) for p in (rule.get("cash_subjects") or _DEFAULT_CASH_SUBJECTS)]
|
||||
financing = [str(p) for p in (rule.get("financing_counterpart") or [])]
|
||||
investing = [str(p) for p in (rule.get("investing_counterpart") or [])]
|
||||
bucket = str(rule.get("bucket") or "operating")
|
||||
dp = int(rule.get("round", 4) or 4)
|
||||
unit = mapping.unit_conversion or 1
|
||||
|
||||
if bucket not in _CASHFLOW_BUCKETS:
|
||||
return None, f"未知 bucket={bucket}(可选 {'/'.join(_CASHFLOW_BUCKETS)})"
|
||||
|
||||
if bucket == "net_cash":
|
||||
amount, rows, vouchers = _cashflow_side_amount(db, mapping.entity_id, period, cash_subjects)
|
||||
elif bucket == "financing":
|
||||
amount, rows, vouchers = _cashflow_side_amount(db, mapping.entity_id, period, cash_subjects,
|
||||
include_prefixes=financing)
|
||||
elif bucket == "investing":
|
||||
amount, rows, vouchers = _cashflow_side_amount(db, mapping.entity_id, period, cash_subjects,
|
||||
include_prefixes=investing)
|
||||
else:
|
||||
amount, rows, vouchers = _cashflow_side_amount(db, mapping.entity_id, period, cash_subjects,
|
||||
exclude_prefixes=financing + investing)
|
||||
|
||||
if vouchers == 0 or rows == 0:
|
||||
return None, f"源表无记录(现金流量直接法 period={period}, bucket={bucket})"
|
||||
|
||||
value_yuan = amount
|
||||
parts = [f"{bucket} {amount:.2f}元"]
|
||||
capex_rule = rule.get("minus_capex") or {}
|
||||
if capex_rule:
|
||||
capex_prefixes = [str(p) for p in (capex_rule.get("counterpart") or [])]
|
||||
capex_side = "debit_net" if str(capex_rule.get("side") or "debit").startswith("debit") else "cashflow"
|
||||
capex, capex_rows, _ = _cashflow_side_amount(db, mapping.entity_id, period, cash_subjects,
|
||||
include_prefixes=capex_prefixes, side=capex_side)
|
||||
value_yuan = amount - capex
|
||||
parts.append(f"资本支出({capex_side}) {capex:.2f}元/{capex_rows}行")
|
||||
|
||||
value = round(float(value_yuan) * unit, dp)
|
||||
message = (f"现金流量直接法(period={period}, bucket={bucket}, 现金凭证{vouchers}张/对手方{rows}行): "
|
||||
+ " − ".join(parts) + f" × {unit}")
|
||||
if write_kpi:
|
||||
_upsert_auto_value(db, mapping, period, value,
|
||||
f"自动归集: 现金流量直接法({bucket})")
|
||||
return value, message
|
||||
|
||||
|
||||
def _batch_no() -> str:
|
||||
return f"auto-{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user