Files
cma-management/backend/scripts/kpi_bep_deriver.py
T
Hermes CI Fix 289cc8f2cc
ci/woodpecker/push/main Pipeline was successful
fix(profit-caliber): 博海口径 §4-1/§4-2/§4-5 落地 —— 固定成本仅借方 + 6403 进变动 + 财务收益单列 + 批次备注
ASK-20261014-04(任总 2026-10-14 11:20「放行4」)批后口径:
- §4-1 博海固定成本弃净额:6601/6602/6603 仅借方(133.98 万,2026-01..09);2026-03 单月由 -9.02 万转正;
  6603 贷方(现金折扣收益 64.40 万)改 finance_income_terms 单列备查,不进固定成本/末行/勾稽。
- §4-2 博海变动成本追加 6403 营业税金及附加(530.17 万)。
- §4-3/§4-4 维持现行(酣客收入/边际贡献基数不动)。
- §4-5 管理利润表页加「数据批次/导入时刻」纯展示备注(import_logs)。
- 口径唯一源仍为 bep_caliber.ENTITY_CALIBERS;报表/卡面/派生器一律经 fixed_cost_spec() 解析(读口写口一致)。
2026-10-16 12:32:52 +08:00

979 lines
54 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""保本点(盈亏平衡)派生KPI执行器 — 管理会计OS (cma-bep-kpi 2026-09-08)
按财务Bot权威口径(陕西酣客_保本点分析_2026年1-7月.md)自动计算并落库六个派生KPI:
F_BEP_REVENUE 保本销售额(万元) = 月均固定成本 ÷ 边际贡献率 (账面口径,含折旧)
F_BEP_COVER 保本覆盖率(%) = 月均销售 ÷ 保本销售额 × 100 (正向: >=100绿 >=85黄 <85红)
F_BEP_GAP 保本缺口(%) = (保本销售额-月均销售) ÷ 保本销售额 × 100 (反向: <=0绿 <=15黄 >15红)
F_BEP_REVENUE_CASH 现金保本销售额(万元) = (月均固定成本-月均折旧) ÷ 边际贡献率 (现金口径,中性无阈值)
F_BEP_COVER_CASH 现金保本覆盖率(%) = 月均销售 ÷ 现金保本销售额 × 100 (正向: >=100绿 >=85黄 <85红)
F_BEP_VC_RATE 变动成本率(%) = 见下方「实体口径注册表」 (YTD累计; 保本公式分母,无阈值)
F_BEP_REBATE 实测渠补率(%) = e1 专用展示 chip(Σ571103÷Σ5001,YTD; e2 无渠补科目 ⇒ NULL)
实体口径注册表(2026-10-03 新增,任务 finance-20261003-cvp-fix-and-bohai-coverage-fullstack):
· entity 1 陕西酣客:变动成本率 = Σ(5401销货成本 + 5403税金及附加 + 571103渠补 − 530103厂补) ÷ Σ5001贷方
〔⑤口径统一 2026-10-03,任总 18:00「统一」;勾稽:808.80 − 672.65 − 224.16 = −88.01 ≈ −88.00 营业利润〕;
固定成本 = 5601% + 5602% + 5603% 净额;折旧剔除 = 560213(管理费用-折旧叶子)。
实测渠补率 F_BEP_REBATE 保留(仍是渠补单一事实源),但**不再充当变动成本率**。
· entity 2 博海:**无渠补科目(571103)**,变动成本 = Σ6401借(主营业务成本),
变动成本率 = Σ6401借 ÷ Σ6001贷;固定成本 = (6601 销售费用 + 6602 管理费用 + 6603 财务费用) 净额;
折旧剔除 = 1602 累计折旧「当期贷方发生额」(博海科目表只到 4 位一级,无费用类折旧叶子;
2026-08 起博海 1602 无计提行,剔除额为 0 —— 见回报件)。
博海明细账的期间损益结转腿(summary='结转期间损溢',账套内 **未** 标 carry_forward=1)
必须显式剔除,否则净额恒 0 会把已结账期间清零(口径见
/srv/hermes/wisdom/_decisions/cma-expense-profit-net-caliber-20261002.md)。
口径(与财务Bot报告一致):
- 销售收入: entity1 = subject_code='5001'(主营业务收入) 贷方合计;entity2 = '6001' 贷方合计。当年1月至目标月 YTD 月均
- 固定成本: entity1 = subject_code LIKE '5601%'/'5602%'/'5603%'(销售/管理/财务费用,含560213折旧) 借−贷净额合计(carry_forward=0),YTD 月均〔⑤扩容〕
entity2 = 6601/6602/6603 借−贷净额合计(同上,但另需剔除结转腿)
- 折旧: entity1 = '560213' 借−贷净额;entity2 = '1602' 贷方发生额(现金口径剔除项)
- 变动成本率: **完整实测口径**(⑤口径统一 2026-10-03,任总 18:00「统一」;勾稽件 _reports/陕西酣客_边际贡献式损益表_20261003.md)
酣客 = Σ(5401销货成本 + 5403税金及附加 + 571103渠补 − 530103厂补) ÷ Σ(5001 贷方) × 100,YTD,保留 2 位小数。
`POLICY_REBATE_RATE_PCT = 84.61` 政策常量**仅作对照展示**(写进返回值 rebate_policy_pct / remark),
**不参与任何计算**;`--rebate-rate` 显式传值时视为人工诊断覆盖(正常调用不再使用)。
- 边际贡献率 = 100 − 变动成本率
- fail-closed 边界(2026-10-02 裁定):某期 571103 或 5001 缺数/为 0 ⇒ **该期不出保本值**
(六条派生行写 actual_value=NULL + data_status='pending' + remark 注明「变动成本率不可得」),
**不得静默回落 84.61 常量**;累计口径天然平滑单月波动(8 月单月 571103/5001=134% 不影响累计值)。
- fail-closed 边界 ②(2026-10-03 裁定,本单):**变动成本率 ≥ 100% ⇔ 边际贡献率 ≤ 0 ⇒ 保本点不适用**。
该期 五条保本派生值(REVENUE/COVER/GAP/REVENUE_CASH/COVER_CASH)写 actual_value=NULL +
data_status='pending' + remark 含「边际贡献非正,保本点不适用」,**禁止回落 0.0**
(0.0 会被前端显示成「月保本 0 万、覆盖率 0%」,语义完全错误);变动成本率本身照常落库。
与既有 fail-closed(缺数/为 0)**同一处理路径风格**,仅文案与触发条件不同。
- 目标月: --period 显式指定;默认自动选最近「已结账」完整月(当月跳过)。
结账信号优先:DB 损益结转凭证(carry_forward=1 & 本年利润科目 3103(酣客)/4103(博海)) → check.md 文件;
两者皆无时才退回旧「销售额阈值」兜底(销售低 ≠ 未结账,仅保底用,日志标注依据)
- 单位: voucher 金额为元,聚合后 /10000 → 万元
- 幂等: 同 kpi_id+period+source_type='derived' 存在则更新,可安全重跑;
**不降级**(ASK-20260924-10 / 2026-09-24):值未变或值变都不回写 data_status,
保留既有状态(含人工/任总放行的 verified),避免「人工转正 → 次日派生静默打回 pending」。
值变时 remark 留痕并落复核提示件(见 _reports/fullstack-<YYYYMMDD>-bep-value-changed-review.md)。
**例外**:fail-closed 两条路径(不可得 / 边际贡献非正)按裁定显式写 data_status='pending'。
- 只写 source_type='derived' 行;不触碰 auto_collect/manual/excel/ledger 行;不改 collector 逻辑
用法:
venv/bin/python3 scripts/kpi_bep_deriver.py # 自动选目标月
venv/bin/python3 scripts/kpi_bep_deriver.py --period 2026-07 # 指定期间(实跑验证用)
venv/bin/python3 scripts/kpi_bep_deriver.py --entity 2 --period 2026-08 # 博海(新口径)
venv/bin/python3 scripts/kpi_bep_deriver.py --entity 1 --rebate-rate 84.61
venv/bin/python3 scripts/kpi_bep_deriver.py --dry-run # 只计算不落库
"""
import argparse
import logging
import os
import sys
from datetime import datetime, date
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import re
from sqlalchemy import func, or_
from app.database import get_session_local
from app.models import KPIValue, KPIDefinition, VoucherDetail, KPIAlert
# 裁定 R1(2026-09-19 财务口径):demo 账套不产生业务预警(派生值仍落库,仅跳过预警同步)
from app.utils.alert_entity_scope import is_excluded_entity
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("cma.bep_deriver")
# 厂家政策渠补率(定死不可谈判,财务Bot报告权威值 2026-09-08)
# ⚠️ 2026-10-02 起**仅作对照展示**(返回值 rebate_policy_pct / remark),不参与任何计算。
# 参与计算的渠补率 = 逐期实测累计(见 compute_rebate_rate / 文件头「渠补率」条)。
POLICY_REBATE_RATE_PCT = 84.61
# 派生KPI 编码 → (kpi_code, 名称)
TARGET_KPIS = [
("F_BEP_REVENUE", "保本销售额"),
("F_BEP_COVER", "保本覆盖率"),
("F_BEP_GAP", "保本缺口"),
("F_BEP_REVENUE_CASH", "现金口径保本销售额"),
("F_BEP_COVER_CASH", "现金口径保本覆盖率"),
("F_BEP_VC_RATE", "变动成本率"),
("F_BEP_REBATE", "实测渠补率"),
]
# 变动成本率 KPI(⑤ 口径统一 2026-10-03:保本公式分子口径 = 销货成本+税金及附加+渠补−厂补)
VC_RATE_KPI = "F_BEP_VC_RATE"
# 实测渠补率 KPI 编码(卡面单一事实源;无阈值,不参与预警级别判定;⑤ 后**不再充当变动成本率**)
REBATE_KPI = "F_BEP_REBATE"
# 保本口径 KPI(缺 F_BEP_REBATE 定义时不阻断派生主流程,见 run_derive 告警分支)
CORE_KPI_CODES = [c for c, _ in TARGET_KPIS if c != REBATE_KPI]
# 现金口径 KPI 编码(计算路径独立于账面,remark 文案用现金口径)
CASH_CODES = {"F_BEP_REVENUE_CASH", "F_BEP_COVER_CASH"}
SALES_CODE = "5001" # 主营业务收入(酣客)
ADMIN_PREFIX = "5602" # 管理费用(含折旧叶子560213)
DEP_SUBJECT = "560213" # 管理费用-折旧(现金口径剔除项,eq 精确匹配防 5602130 误含)
REBATE_CODE = "571103" # 渠补/捐赠支出(校验用,不直接进公式)
CARRY_SUBJECT = "3103" # 本年利润(carry_forward=1 且该科目 = 该期损益结转凭证)
# ── 实体口径注册表(2026-10-03)——单一事实源见 app/utils/bep_caliber.py ─────────
# 变动成本率(rate)= numerator ÷ denominator(YTD 累计):
# · 酣客:Σ571103借方(渠补) ÷ Σ5001贷方(收入) —— 2026-10-02 财务裁定;结转账腿已由 carry_forward=1 标识
# · 博海:Σ6401借方(主营业务成本) ÷ Σ6001贷方(收入) —— 2026-10-03 财务裁定「新口径,勿套酣客渠补公式」
# exclude_closing:该账套的「期间损益结转腿」是否需要用 summary 显式剔除
# (博海账套的结转行 carry_forward 仍是 0,不剔除则已结账期间净额恒 0 ⇒ 指标被清零)
# 同表被 backend/app/api/dashboard.py::/bep-card 共用(口径不漂移)。
from app.utils.bep_caliber import ( # noqa: E402 (紧随 sys.path 之后导入)
CLOSING_SUMMARY_LIKE, ENTITY_CALIBERS, caliber_for, fixed_cost_spec,
)
# 智享通落盘目录(与 app/api/data.py 的 ZXT_LANDING_DIR 一致)——结账信号次选来源
ZXT_LANDING_DIR = "/root/WB_WS/_data/zxt-hanke"
# ── ASK-20260924-10(2026-09-24 任总放行):派生器不得静默降级已转正行 ──────────
# 缺陷(原实现):upsert 分支无条件 `existing.data_status = "pending"`(与值是否变化无关)
# ⇒ 人工/任总放行的值转正为 verified 后,次日 06:35 派生器一跑就被打回 pending,
# 看板 / 预警 / Bot 全链路再次读不到,交付在 24 小时内静默消失。
# 修正口径(本单指定,属待财务复核的裁定点):
# · 值未变 → 不写 data_status(保留既有,含 verified);source_batch/remark/actual_value 照旧更新
# · 值变 → 同样不写 data_status(不降级,避免看板数字忽现忽隐)
# + remark 追加「值已更新(batch=…, 旧X → 新Y) 待财务复核」+ 落复核提示件
# · 新期间(原本无行)→ 维持 pending(9-14 复核流入口不变)
# 提示件为**报告类**,落 `_reports/` 而非对方队列根(避免误唤醒其他 Bot);
# 落点可用环境变量 BEP_REVIEW_NOTE_DIR 覆盖(沙箱/测试用)。
DEFAULT_REVIEW_NOTE_DIR = "/root/.hermes/profiles/wecom-finance/pending-tasks/_reports"
def _review_note_dir() -> str:
return os.environ.get("BEP_REVIEW_NOTE_DIR") or DEFAULT_REVIEW_NOTE_DIR
def _write_value_changed_note(entity_id: int, period: str, batch: str, changed: list):
"""值变时落「待财务复核」提示件。写失败只告警,绝不阻断派生主流程。"""
if not changed:
return None
try:
d = _review_note_dir()
os.makedirs(d, exist_ok=True)
path = os.path.join(d, "fullstack-%s-bep-value-changed-review.md" % datetime.now().strftime("%Y%m%d"))
lines = [
"# 保本派生值变动 · 待财务复核(派生器自动生成;报告类产物,非队列任务)",
"",
"- 生成器: `backend/scripts/kpi_bep_deriver.py`(值变路径,ASK-20260924-10 口径)",
"- entity_id: %s | period: %s | batch: %s" % (entity_id, period, batch),
"- 生成时刻: %s" % datetime.now().strftime("%F %T"),
"- 口径: 值变**不降级** data_status(保留既有,含 verified),仅 remark 留痕 + 请财务复核",
"",
"| kpi_code | 旧值 | 新值 | data_status(写后) |",
"|---|---|---|---|",
]
for c in changed:
lines.append("| %s | %s | %s | %s |" % (c["code"], c["old"], c["new"], c["status"]))
lines += ["", "> 本件落在 `_reports/`(报告类),不进队列根,避免误唤醒其他 Bot。", ""]
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
logger.warning("值变留痕:复核提示件已落 %s(%d 个 KPI)", path, len(changed))
return path
except Exception as e: # 提示件失败不影响派生落库
logger.error("值变复核提示件写入失败(不阻断派生): %s", e)
return None
def _month_range(period: str):
"""返回该期间当年1月1日到月末的日期边界(用于 voucher_date 过滤兜底)"""
y = int(period[:4])
m = int(period[5:7])
start = date(y, 1, 1)
# 月末
if m == 12:
end = date(y, 12, 31)
else:
end = date(y, m + 1, 1).replace(day=1) # 次月1日
from datetime import timedelta
end = end - timedelta(days=1)
return start, end
def _sum_field(db, entity_id: int, period_from: str, period_to: str,
field: str, subject_filter=None, *, exclude_closing: bool = False,
subject_filters=None) -> float:
"""聚合 voucher_details 金额(元)。subject_filter: ('eq', code) / ('like', prefix)
field="net" = 借−贷 净额(口径4:费用/损益一律净额)。
⚠️ 净额只在 carry_forward==0 行域成立(含结转腿时损益类净额恒 0)
—— 见 /srv/hermes/wisdom/_decisions/cma-expense-profit-net-caliber-20261002.md。
exclude_closing=True 时再显式剔除「期间损益结转腿」(summary 含「结转」)——
用于结转行未被标 carry_forward=1 的账套(博海),否则净额恒 0 会把已结账期间清零。
subject_filters: [('like','6601'), ('like','6602'), ...] 多科目 OR(与 subject_filter 二选一)
"""
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 explicit := subject_filters:
q = q.filter(or_(*[_subject_clause(k, c) for k, c in explicit]))
if subject_filter:
kind, code = subject_filter
q = q.filter(_subject_clause(kind, code))
if exclude_closing:
q = q.filter(or_(VoucherDetail.summary.is_(None),
~VoucherDetail.summary.like(CLOSING_SUMMARY_LIKE)))
val = q.scalar()
return float(val or 0.0)
def _subject_clause(kind: str, code: str):
"""科目过滤谓词:('eq', code) 精确 / ('like', prefix) 前缀"""
if kind == "eq":
return VoucherDetail.subject_code == code
if kind == "like":
return VoucherDetail.subject_code.like(code + "%")
raise ValueError(f"未知科目过滤类型: {kind}")
# 带号科目项的字段映射(terms 记法见 bep_caliber.ENTITY_CALIBERS)
_TERM_FIELD = {
"debit": lambda: VoucherDetail.debit_amount,
"credit": lambda: VoucherDetail.credit_amount,
"net": lambda: VoucherDetail.debit_amount - VoucherDetail.credit_amount,
}
def _sum_terms(db, entity_id: int, period_from: str, period_to: str,
terms, *, exclude_closing: bool = False) -> float:
"""按「带号科目项」聚合金额(元)。terms = [(sign, code, field), ...]
· sign='+' ⇒ 加该科目 field 发生额;sign='-' ⇒ 减之
· field ∈ {debit, credit, net}
· code 以 '%' 结尾时按前缀(like)匹配,否则精确匹配
⑤ 口径统一(2026-10-03):酣客变动成本 = (Σ5401借 + Σ5403借 + Σ571103借 − Σ530103贷)。
"""
total = 0.0
for sign, code, field in terms:
expr = _TERM_FIELD[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,
)
q = (q.filter(VoucherDetail.subject_code.like(code)) if code.endswith("%")
else q.filter(VoucherDetail.subject_code == code))
if exclude_closing:
q = q.filter(or_(VoucherDetail.summary.is_(None),
~VoucherDetail.summary.like(CLOSING_SUMMARY_LIKE)))
v = float(q.scalar() or 0.0)
total += v if sign == "+" else -v
return total
def compute_margin_rate(db, entity_id: int, period_from: str, period_to: str,
caliber: dict = None) -> dict:
"""变动成本率(YTD 累计,2 位小数)—— 按实体口径现算。
· 酣客(entity1):Σ571103借方 ÷ Σ5001贷方(实测渠补率)
· 博海(entity2):Σ6401借方 ÷ Σ6001贷方(主营业务成本率)
返回兼容既有键名:{'available', 'rebate_pct', 'rebate_sum_w', 'sales_sum_w', 'reason'}
(rebate_sum_w = 分子/变动成本,sales_sum_w = 分母/收入基数;供卡面/报告沿用)。
**fail-closed**:分子或分母缺数/为 0 ⇒ available=False, rebate_pct=None
(调用方不得回落政策常量,须按「不可得」处理)。
"""
cal = caliber or ENTITY_CALIBERS[entity_id]
num_raw = _sum_terms(db, entity_id, period_from, period_to,
cal["varcost_terms"], exclude_closing=cal["exclude_closing"])
den_raw = _sum_field(db, entity_id, period_from, period_to,
cal["revenue_field"], cal["revenue_filter"],
exclude_closing=cal["exclude_closing"])
num_w, den_w = num_raw / 10000.0, den_raw / 10000.0
info = {
"rebate_sum_w": round(num_w, 2),
"sales_sum_w": round(den_w, 2),
"rebate_pct": None,
"available": False,
"reason": "",
"label": cal["margin_label"],
"desc": cal["margin_desc"],
}
if den_w <= 0:
info["reason"] = f"{cal['margin_label']}不可得:{cal['revenue_filter'][1]} 累计缺数或为 0"
return info
if num_w <= 0:
info["reason"] = f"{cal['margin_label']}不可得:变动成本累计缺数或为 0"
return info
info["available"] = True
info["rebate_pct"] = round(num_w / den_w * 100.0, 2)
return info
def compute_rebate_rate(db, entity_id: int, period_from: str, period_to: str) -> dict:
"""实测累计**渠补率**(F_BEP_REBATE,2026-10-02 口径)—— 酣客(entity1) 专用。
渠补率(实测) = Σ(571103 借方) ÷ Σ(5001 贷方) × 100,期间 = 年初至当期(YTD),保留 2 位小数。
· 571103 = 渠补/捐赠支出(损益类费用科目)⇒ 本期发生额落**借方**;
· 5001 = 主营业务收入 ⇒ 本期发生额落**贷方**;
· 行域 = carry_forward=0(排除期间损益结转腿)。
⚠️ ⑤ 口径统一(2026-10-03)后:本函数只算**渠补口径**,是 F_BEP_REBATE 的单一事实源;
保本公式所用的**变动成本率**(销货成本+税金及附加+渠补−厂补)见 compute_margin_rate。
博海(rebate_terms=None)无渠补科目 ⇒ 返回 available=False(F_BEP_REBATE=NULL)。
"""
cal = ENTITY_CALIBERS.get(entity_id)
if not cal or not cal.get("rebate_terms"):
return {"available": False, "rebate_pct": None, "rebate_sum_w": 0.0,
"sales_sum_w": 0.0, "label": "实测渠补率",
"reason": f"{cal['name'] if cal else entity_id} 无渠补科目"}
num_raw = _sum_terms(db, entity_id, period_from, period_to,
cal["rebate_terms"], exclude_closing=cal["exclude_closing"])
den_raw = _sum_field(db, entity_id, period_from, period_to,
cal["revenue_field"], cal["revenue_filter"],
exclude_closing=cal["exclude_closing"])
num_w, den_w = num_raw / 10000.0, den_raw / 10000.0
info = {"rebate_sum_w": round(num_w, 2), "sales_sum_w": round(den_w, 2),
"rebate_pct": None, "available": False, "reason": "", "label": "实测渠补率"}
if den_w <= 0:
info["reason"] = "渠补率不可得:Σ5001贷累计缺数或为 0"
return info
if num_w <= 0:
info["reason"] = "渠补率不可得:Σ571103借累计缺数或为 0"
return info
info["available"] = True
info["rebate_pct"] = round(num_w / den_w * 100.0, 2)
return info
def _month_sales(db, entity_id: int, period: str, caliber: dict = None) -> float:
"""单月销售收入(万元)——用于目标月完整度判断(按实体口径的收入科目)"""
cal = caliber or ENTITY_CALIBERS.get(entity_id) or ENTITY_CALIBERS[1]
return _sum_field(db, entity_id, period, period, cal["revenue_field"],
cal["revenue_filter"], exclude_closing=cal["exclude_closing"]) / 10000.0
def _is_period_closed(db, entity_id: int, period: str, caliber: dict = None) -> tuple:
"""判断该期间是否已结账(损益结转已完成)。返回 (bool, 依据说明)。
主信号(DB): voucher_details 存在 carry_forward=1 且 subject_code=该账套本年利润科目
(酣客 3103 / 博海 4103)= 该期损益结转凭证已生成
次信号(文件): {landing_dir}/{YYYY.MM}/check.md 同时含「本期: PASS」与「损益结转: PASS」
(仅酣客有智享通落盘目录;博海 landing_dir=None ⇒ 跳过该信号)
均不成立 → (False, "")。任何读取异常均吞掉,不影响主流程。
"""
cal = caliber or ENTITY_CALIBERS.get(entity_id) or ENTITY_CALIBERS[1]
# 主信号:DB 损益结转凭证
try:
hit = db.query(VoucherDetail.id).filter(
VoucherDetail.entity_id == entity_id,
VoucherDetail.carry_forward == 1,
VoucherDetail.subject_code == cal["closing_subject"],
VoucherDetail.period == period,
).first()
if hit:
return True, "DB损益结转"
except Exception as e: # 查询异常不阻断,降级到文件信号
logger.warning("结账信号(DB)查询失败 period=%s: %s", period, e)
# 次信号:智享通落盘 check.md(仅酣客)
landing = cal.get("landing_dir")
try:
if landing:
path = os.path.join(landing, period.replace("-", "."), "check.md")
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
txt = f.read()
if "本期: PASS" in txt and "损益结转: PASS" in txt:
return True, "check.md"
except Exception:
pass # 文件缺失/读取失败一律忽略
return False, ""
def _pick_target_period(db, entity_id: int, now: date, caliber: dict = None) -> str:
"""自动选择目标月:跳过当月。
判定顺序(2026-09-12 修订):
1. 结账信号优先 —— 按 desc 返回第一个 _is_period_closed 为真的月;
结账成立时不再用销售额做任何筛选(销售低 ≠ 未结账,历史缺陷根因)
2. 所有候选月均无结账信号 → 退回旧「销售额 < 近6月均×30%」兜底(日志标注『依据销售额兜底』)
"""
cal = caliber or ENTITY_CALIBERS.get(entity_id) or ENTITY_CALIBERS[1]
# 收集已有数据月份(5001 贷方)
rows = db.query(VoucherDetail.period).filter(
VoucherDetail.entity_id == entity_id,
VoucherDetail.period.like("%-__"),
).distinct().order_by(VoucherDetail.period.desc()).all()
periods = [r[0] for r in rows if r[0] and len(r[0]) == 7]
if not periods:
logger.warning("voucher_details 无 period 数据,无法自动选月")
return now.strftime("%Y-%m")
# 过滤掉未来/当月(当月未结束数据不全)
cur = now.strftime("%Y-%m")
candidates = [p for p in periods if p < cur]
if not candidates:
candidates = [p for p in periods if p <= cur]
if not candidates:
candidates = periods
# 1) 结账信号优先(DB损益结转 → check.md),命中即采用
for p in candidates:
closed, reason = _is_period_closed(db, entity_id, p, cal)
if closed:
logger.info("目标月 %s 判定已结账(依据:%s),采用", p, reason)
return p
# 2) 兜底:无任何结账信号时,退回旧销售额阈值逻辑
logger.warning("候选月 %s 均无结账信号(DB损益结转/check.md),退回销售额阈值兜底",
", ".join(candidates[:6]))
# 参考月均:候选月中最近 6 个不同月的月销售
ref_months = []
for p in periods:
if p >= cur:
continue
s = _month_sales(db, entity_id, p, cal)
ref_months.append((p, s))
if len(ref_months) >= 6:
break
ref_avg = (sum(s for _, s in ref_months) / len(ref_months)) if ref_months else None
for p in candidates:
s = _month_sales(db, entity_id, p, cal)
if s <= 0:
continue # 该月无销售,往前找
if ref_avg and s < ref_avg * 0.3:
logger.info("目标月 %s 销售 %.1f万 < 参考月均 %.1f万*30%%,依据销售额兜底判定未结账,回退",
p, s, ref_avg)
continue
logger.info("目标月 %s 依据销售额兜底采用", p)
return p
return candidates[0]
def _eval_static_threshold(value: float, kpi) -> tuple:
"""按 KPI 定义 threshold_green/yellow/red 判定级别(绿→黄→红,命中即止)。
与 alert_generator._check_threshold 语义一致。green=正常不落预警。"""
for level, field in (("green", "threshold_green"), ("yellow", "threshold_yellow"),
("red", "threshold_red")):
expr = getattr(kpi, field, None)
if not expr:
continue
m = re.match(r"(>=|<=|>|<|=|!=)\s*([\d.]+)", str(expr).strip())
if not m:
continue
op, val = m.group(1), float(m.group(2))
hit = {
">=": value >= val, "<=": value <= val,
">": value > val, "<": value < val,
"=": value == val, "!=": value != val,
}[op]
if hit:
level_names = {"green": "正常", "yellow": "预警", "red": "紧急"}
msg = (f"KPI当前值 {value:.2f},触发{level_names[level]}阈值 ({expr})")
return level, msg
return None, None
def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float = None,
write_kpi: bool = True) -> dict:
"""计算并落库六个派生KPI(账面3条+现金2条+变动成本率1条)。返回结果摘要。
实体口径见 ENTITY_CALIBERS(entity1 酣客=渠补率;entity2 博海=主营业务成本率)。
变动成本率(2026-10-02 裁定):默认按 YTD 从 voucher_details **现算**,
政策常量 84.61% 仅作对照返回(rebate_policy_pct,仅 entity1 有对照物)。
显式传 rebate_rate_pct(`--rebate-rate`)时视为人工诊断覆盖(仅 entity1 语境)。
fail-closed ①:变动成本率不可得且无人工覆盖 ⇒ 该期不出保本值(六行 pending/NULL)。
fail-closed ②(2026-10-03):变动成本率 ≥100%(边际贡献率 ≤0)⇒ 五条保本值 NULL +
remark「边际贡献非正,保本点不适用」,**禁写 0.0**。
"""
cal = ENTITY_CALIBERS.get(entity_id)
if cal is None:
raise RuntimeError(f"entity={entity_id} 未注册 BEP 口径(见 ENTITY_CALIBERS)")
kpis = {k.kpi_code: k for k in db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code.in_([c for c, _ in TARGET_KPIS]),
KPIDefinition.status == "active",
).all()}
missing = [c for c in CORE_KPI_CODES if c not in kpis]
if missing:
raise RuntimeError(f"entity={entity_id} 缺少派生KPI定义: {missing}")
if REBATE_KPI not in kpis:
# 变动成本率定义缺失不阻断保本主流程(部署顺序容错),但必须显式告警:
# 卡面此时取不到 rebate_pct(单一事实源缺失),需尽快补 bep_kpi_defs*/rebate 种子 SQL。
logger.error("entity=%s 缺少 %s 定义(见 scripts/sql/bep_kpi_defs*.sql);"
"本期跳过变动成本率落库,卡面将判「不可得」", entity_id, REBATE_KPI)
year = period[:4]
period_from = f"{year}-01"
period_to = period
months = int(period[5:7]) # YTD 月数(1月=1 ... 7月=7)
# 1. 取数(元 → 万元)——按实体口径
# 固定成本经 bep_caliber.fixed_cost_spec() 唯一解析口:e1 前缀净额 / e2 带号科目项仅借方(§4-1)
fix_mode, fix_spec = fixed_cost_spec(cal)
sales_sum_w = _sum_field(db, entity_id, period_from, period_to,
cal["revenue_field"], cal["revenue_filter"],
exclude_closing=cal["exclude_closing"]) / 10000.0
if fix_mode == "terms":
admin_sum_w = _sum_terms(db, entity_id, period_from, period_to, fix_spec,
exclude_closing=cal["exclude_closing"]) / 10000.0
else:
admin_sum_w = _sum_field(db, entity_id, period_from, period_to, "net",
subject_filters=fix_spec,
exclude_closing=cal["exclude_closing"]) / 10000.0
dep_sum_w = _sum_field(db, entity_id, period_from, period_to,
cal["dep_field"], cal["dep_filter"],
exclude_closing=cal["exclude_closing"]) / 10000.0
sales_avg = sales_sum_w / months
admin_avg = admin_sum_w / months
dep_avg = dep_sum_w / months
cash_admin_avg = admin_avg - dep_avg # 现金口径固定成本(剔除折旧纸面费用)
# 2. 变动成本率(⑤ 后=销货成本+税金及附加+渠补−厂补 完整口径):默认逐期实测累计
rebate_info = compute_margin_rate(db, entity_id, period_from, period_to, cal)
calc_pct = rebate_info["rebate_pct"]
# ⑤:渠补率(e1 专用;e2 无 ⇒ available=False)。F_BEP_REBATE 专用,独立于上面变动成本率。
rebate_kpi_info = compute_rebate_rate(db, entity_id, period_from, period_to)
if rebate_rate_pct is not None:
rebate_info = dict(rebate_info, available=True, rebate_pct=round(float(rebate_rate_pct), 2),
reason="人工覆盖(--rebate-rate)")
logger.warning("变动成本率使用人工覆盖值 %.2f%%(YTD 实算值 %s)", rebate_rate_pct,
("%.2f%%" % calc_pct) if calc_pct is not None else "不可得")
elif not rebate_info["available"]:
# fail-closed:不出保本值,绝不静默回落政策常量 84.61
logger.error("period=%s %s ⇒ 本期不出保本值(fail-closed,不回落政策常量)",
period, rebate_info["reason"])
return _fail_closed_rebate_unavailable(db, entity_id, period, months, rebate_info, cal,
kpis, write_kpi)
rebate_pct = rebate_info["rebate_pct"]
cm_pct = 100.0 - rebate_pct # 边际贡献率(%) = 100 − 变动成本率
# 2b. fail-closed ②(2026-10-03):边际贡献率 ≤ 0 ⇒ 保本点不适用(禁写 0.0)
if cm_pct <= 0:
logger.error("period=%s %s=%.2f%% ≥100%% ⇒ 边际贡献非正,保本点不适用(fail-closed,禁写 0.0)",
period, cal["margin_label"], rebate_pct)
return _fail_closed_cm_non_positive(db, entity_id, period, months, rebate_info, cal,
kpis, write_kpi, sales_avg=sales_avg,
admin_avg=admin_avg, dep_avg=dep_avg,
rebate_kpi_info=rebate_kpi_info)
# 2c. fail-closed ③(2026-10-03,同族补充):固定成本退化 ⇒ 保本点不适用
# 边际贡献率 > 0 但分子(固定成本)退化时,公式给出 保本额≈0 / 覆盖率趋于无穷,
# 与 0.0 兜底同属「语义崩溃」类(实测:博海 2026-01 账面净固定成本月均 0.01万≈0、
# 现金固定成本月均 −0.14万 ⇒ 公式值 保本 0.1万 / 覆盖率 175981% / 现金保本 −2.3万)。
# 判据(同族、无新增经验常量):月均账面固定成本 ≤ 0 **或** 月均现金固定成本 ≤ 0。
if admin_avg <= 0 or cash_admin_avg <= 0:
logger.error("period=%s %s月均=%.2f万、现金固定成本月均=%.2f万(含折旧月均%.2f万)"
"⇒ 固定成本退化,保本点不适用(fail-closed)",
period, cal["fixed_label"], admin_avg, cash_admin_avg, dep_avg)
return _fail_closed_cm_non_positive(db, entity_id, period, months, rebate_info, cal,
kpis, write_kpi, sales_avg=sales_avg,
admin_avg=admin_avg, dep_avg=dep_avg, guard="fixed",
cash_admin_avg=cash_admin_avg,
rebate_kpi_info=rebate_kpi_info)
# 3. 派生计算(万元 / %)——账面3条路径不变;现金口径在账面基础上剔除折旧
bep = admin_avg / (cm_pct / 100.0)
cover = (sales_avg / bep * 100.0) if bep > 0 else 0.0
gap = ((bep - sales_avg) / bep * 100.0) if bep > 0 else 0.0
cash_bep = cash_admin_avg / (cm_pct / 100.0)
cash_cover = (sales_avg / cash_bep * 100.0) if cash_bep > 0 else 0.0
values = {
"F_BEP_REVENUE": round(bep, 1),
"F_BEP_COVER": round(cover, 1),
"F_BEP_GAP": round(gap, 1),
"F_BEP_REVENUE_CASH": round(cash_bep, 1),
"F_BEP_COVER_CASH": round(cash_cover, 1),
"F_BEP_VC_RATE": round(rebate_pct, 2), # 变动成本率(⑤ 保本分子口径)
"F_BEP_REBATE": (round(rebate_kpi_info["rebate_pct"], 2)
if rebate_kpi_info["available"] else None), # 实测渠补率(e1;e2=NULL)
}
# 4. upsert kpi_values (source_type='derived',幂等)
batch = f"bep-{datetime.now().strftime('%Y%m%d%H%M%S')}"
written = {}
value_rows = {}
changed = []
for code, _ in TARGET_KPIS:
kpi = kpis.get(code)
if kpi is None: # 定义缺失(仅可能为 F_BEP_REBATE,见上方告警)
written[code] = "no-definition"
continue
if code == VC_RATE_KPI:
remark = (f"变动成本率(YTD累计): {cal['margin_desc']} = "
f"{rebate_info['rebate_sum_w']:.2f}万 ÷ {rebate_info['sales_sum_w']:.2f}万 "
f"× 100 = {rebate_pct:.2f}%(⑤口径统一:销货成本+税金及附加+渠补−厂补;"
f"保本额 = 固定成本月均 ÷ (1−变动成本率))")
elif code == REBATE_KPI:
if rebate_kpi_info["available"]:
rr = rebate_kpi_info["rebate_pct"]
remark = (f"实测渠补率(YTD累计): Σ571103借方 {rebate_kpi_info['rebate_sum_w']:.2f}万 ÷ "
f"Σ5001贷方 {rebate_kpi_info['sales_sum_w']:.2f}万 × 100 = {rr:.2f}%"
f"(政策常量 {POLICY_REBATE_RATE_PCT:.2f}% 仅供对照;⑤ 后**不再充当变动成本率**,"
f"变动成本率见 F_BEP_VC_RATE)")
else:
remark = f"{cal['name']}无渠补科目(实测渠补率不适用);⑤ 后变动成本率见 F_BEP_VC_RATE"
elif code in CASH_CODES:
if entity_id == 1:
remark = (f"现金口径: 管理费月均{admin_avg:.2f}万-折旧月均{dep_avg:.2f}万"
f"=固定现金{cash_admin_avg:.2f}万 ÷ 边际贡献率{cm_pct:.2f}% → 保本{cash_bep:.2f}万"
f"(实测渠补率{rebate_pct:.2f}%)")
else:
remark = (f"现金口径: {cal['fixed_label']}月均{admin_avg:.2f}万-折旧月均{dep_avg:.2f}万"
f"=固定现金{cash_admin_avg:.2f}万 ÷ 边际贡献率{cm_pct:.2f}% → 保本{cash_bep:.2f}万"
f"({cal['margin_label']}{rebate_pct:.2f}%)")
else:
if entity_id == 1:
remark = (f"保本派生器: 销售月均{sales_avg:.1f}万/管理费月均{admin_avg:.1f}万/"
f"实测渠补率{rebate_pct:.2f}%(政策{POLICY_REBATE_RATE_PCT:.2f}%仅对照)")
else:
remark = (f"保本派生器: 销售月均{sales_avg:.1f}万/{cal['fixed_label']}月均{admin_avg:.1f}万/"
f"{cal['margin_label']}{rebate_pct:.2f}%")
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
KPIValue.source_type == "derived",
).first()
if write_kpi:
is_new = existing is None
if existing:
old_value = float(existing.actual_value) if existing.actual_value is not None else None
nv = values[code]
new_value = float(nv) if nv is not None else None
value_changed = ((old_value is None) != (new_value is None)
or (old_value is not None and abs(old_value - new_value) > 1e-9))
existing.actual_value = nv
existing.source_batch = batch
if value_changed:
existing.remark = (f"{remark}|值已更新(batch={batch}, "
f"旧{old_value} → 新{new_value}) 待财务复核")
changed.append({"code": code, "old": old_value, "new": new_value,
"status": existing.data_status})
else:
existing.remark = remark
# 2026-09-24 ASK-20260924-10:**不再**无条件 `existing.data_status = "pending"`。
# 值未变 / 值变 一律不写 data_status ⇒ 保留既有状态(含人工放行的 verified)。
# 原写法会让「人工转正」在次日 06:35 静默被打回 pending(看板数字忽现忽隐)。
# 新期间(下方 else 分支)仍写 pending,复核流入口不变。
else:
existing = 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=remark,
)
db.add(existing)
db.flush()
value_rows[kpi.id] = existing
written[code] = "insert" if is_new else "upsert"
else:
written[code] = "dry-run"
if write_kpi:
# 5. 同步预警: 仅对本派生KPI评估 threshold(green=正常不落 pending,yellow/red 落 pending)。
# 幂等规则:同一 KPIValue 已有同级别 pending → 保持不动(值未变不重建);
# 其它/旧级别 pending → resolve;最新派生行永远只留一条当前级别。
# R1(裁定 2026-09-19):demo 账套不产生业务预警 → 整段预警同步跳过(派生值照落库)。
target_sync = [] if is_excluded_entity(db, entity_id) else TARGET_KPIS
if not target_sync:
logger.info("demo 账套(entity=%s)跳过预警同步(R1,裁定 2026-09-19)", entity_id)
for code, _ in target_sync:
kpi = kpis[code]
kpi_value = value_rows.get(kpi.id)
if not kpi_value:
continue
level, msg = _eval_static_threshold(kpi_value.actual_value, kpi)
all_pending = db.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi.id,
KPIAlert.status == "pending",
).all()
same_value = [a for a in all_pending if a.kpi_value_id == kpi_value.id]
if level in ("yellow", "red") and any(a.alert_level == level for a in same_value):
# 当前值级别未变 → 只清其它旧值预警,不重建
for a in all_pending:
if a.kpi_value_id != kpi_value.id:
a.status = "resolved"
a.resolution = "保本派生器: 派生值更新,旧预警自动解除"
a.resolved_at = datetime.now()
continue
for a in all_pending:
a.status = "resolved"
a.resolution = "保本派生器: 派生值更新/级别变更,旧预警自动解除"
a.resolved_at = datetime.now()
if level in ("yellow", "red"):
db.add(KPIAlert(
entity_id=entity_id,
kpi_id=kpi.id,
kpi_value_id=kpi_value.id,
alert_level=level,
alert_message=f"{kpi.kpi_name}[{period}] {msg}",
status="pending",
))
db.commit()
# 值变留痕(ASK-20260924-10):落复核提示件(报告类 → _reports/,不进队列根)
_write_value_changed_note(entity_id, period, batch, changed)
return {
"entity_id": entity_id,
"period": period,
"months": months,
"data_status": "ok",
"sales_sum_w": round(sales_sum_w, 2),
"sales_avg": round(sales_avg, 2),
"admin_sum_w": round(admin_sum_w, 2),
"admin_avg": round(admin_avg, 2),
"dep_sum_w": round(dep_sum_w, 2),
"dep_avg": round(dep_avg, 2),
"cash_admin_avg": round(cash_admin_avg, 2),
"rebate_sum_w": rebate_info["rebate_sum_w"],
"rebate_sales_base_w": rebate_info["sales_sum_w"],
"rebate_actual_pct": rebate_pct,
"vc_rate_pct": rebate_pct,
"rebate_kpi_pct": (rebate_kpi_info["rebate_pct"] if rebate_kpi_info["available"] else None),
"rebate_policy_pct": POLICY_REBATE_RATE_PCT,
"rebate_source": rebate_info.get("reason") or "YTD实测累计(⑤完整口径)",
"rebate_calc_pct": round(calc_pct, 2) if calc_pct is not None else None,
"cm_pct": round(cm_pct, 2),
"values": values,
"written": written,
"value_changed": changed,
}
def _fail_closed_rebate_unavailable(db, entity_id: int, period: str, months: int,
rebate_info: dict, cal: dict, kpis: dict,
write_kpi: bool) -> dict:
"""fail-closed 落库:变动成本率不可得 ⇒ 该期不出保本值。
六条派生行写 actual_value=NULL + data_status='pending' + remark 注明「不可得」,
**不回落**政策常量 84.61(2026-10-02 财务裁定 / 任总 11:43 CST 放行)。
不评估阈值、不产生/解除预警(无值 ⇒ 无级别可评)。
"""
reason = rebate_info["reason"] or f"{cal['margin_label']}不可得"
if entity_id == 1:
detail = (f"{reason};Σ571103借方 {rebate_info['rebate_sum_w']:.2f}万 / "
f"Σ5001贷方 {rebate_info['sales_sum_w']:.2f}万 ⇒ 本期不出保本值(fail-closed,"
f"不回落政策常量 {POLICY_REBATE_RATE_PCT:.2f}%)")
else:
detail = (f"{reason};{cal['margin_desc']} = {rebate_info['rebate_sum_w']:.2f}万 / "
f"{rebate_info['sales_sum_w']:.2f}万 ⇒ 本期不出保本值(fail-closed,禁回落、禁跨期)")
batch = f"bep-{datetime.now().strftime('%Y%m%d%H%M%S')}"
written = {}
if write_kpi:
for code, _ in TARGET_KPIS:
kpi = kpis.get(code)
if kpi is None:
written[code] = "no-definition"
continue
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
KPIValue.source_type == "derived",
).first()
if existing is None:
db.add(KPIValue(
entity_id=entity_id,
kpi_id=kpi.id,
period=period,
actual_value=None,
source_type="derived",
source_batch=batch,
data_status="pending",
remark=detail,
))
written[code] = "insert"
else:
existing.actual_value = None
existing.source_batch = batch
existing.data_status = "pending"
existing.remark = detail
written[code] = "upsert"
db.commit()
else:
written = {c: "dry-run" for c, _ in TARGET_KPIS}
return {
"entity_id": entity_id,
"period": period,
"months": months,
"data_status": "pending",
"rebate_unavailable": True,
"reason": detail,
"sales_sum_w": rebate_info["sales_sum_w"],
"rebate_sum_w": rebate_info["rebate_sum_w"],
"rebate_actual_pct": None,
"rebate_policy_pct": POLICY_REBATE_RATE_PCT,
"rebate_source": reason,
"values": {},
"written": written,
"value_changed": [],
}
def _fail_closed_cm_non_positive(db, entity_id: int, period: str, months: int,
rebate_info: dict, cal: dict, kpis: dict, write_kpi: bool,
sales_avg: float = None, admin_avg: float = None,
dep_avg: float = None, guard: str = "cm",
cash_admin_avg: float = None,
rebate_kpi_info: dict = None) -> dict:
"""fail-closed ② 落库:保本点**不适用**(2026-10-03 财务裁定)。
五条保本派生值(REVENUE/COVER/GAP/REVENUE_CASH/COVER_CASH)写
actual_value=NULL + data_status='pending' + remark 含「保本点不适用」,
**禁止回落 0.0**(0.0 会被前端显示成「月保本 0 万、覆盖率 0%」,语义完全错误)。
变动成本率本身是可测量的 ⇒ 该 KPI 照常落库(值 + YTD 累计 remark)。
不评估阈值、不产生/解除预警(保本值无值 ⇒ 无级别可评)。
guard 触发条件(同族,均属「公式退化」):
· 'cm' 边际贡献率 ≤ 0(变动成本率 ≥ 100%)——本单裁定 ①
· 'fixed' 月均账面固定成本 ≤ 0 ——同族补充③(分子非正 ⇒ 保本额≈0、覆盖率趋于无穷)
"""
rebate_pct = rebate_info["rebate_pct"]
cm_pct = round(100.0 - float(rebate_pct), 2)
if guard == "fixed":
detail = (f"保本点不适用(固定成本退化:{cal['fixed_label']}月均 {admin_avg:.2f}万 ≤ 0 或 "
f"现金固定成本月均 {cash_admin_avg:.2f}万 ≤ 0 ⇒ 分子退化,保本额≈0、覆盖率无意义;"
f"{cal['margin_label']} {rebate_pct:.2f}%、边际贡献率 {cm_pct:.2f}%;"
f"fail-closed,禁写 0.0)")
else:
detail = (f"边际贡献非正,保本点不适用({cal['margin_label']} {rebate_pct:.2f}% ≥ 100%,"
f"边际贡献率 {cm_pct:.2f}% ⇒ 每增一元销售不产生正贡献,保本点无经济意义;"
f"fail-closed,禁写 0.0)")
vc_rate_remark = (f"变动成本率(YTD累计): {cal['margin_desc']} = "
f"{rebate_info['rebate_sum_w']:.2f}万 ÷ {rebate_info['sales_sum_w']:.2f}万 "
f"× 100 = {rebate_pct:.2f}%(⑤口径统一;保本点因边际贡献非正/固定成本退化不适用)")
if rebate_kpi_info and rebate_kpi_info.get("available"):
rebate_remark = (f"实测渠补率(YTD累计): Σ571103借方 {rebate_kpi_info['rebate_sum_w']:.2f}万 ÷ "
f"Σ5001贷方 {rebate_kpi_info['sales_sum_w']:.2f}万 × 100 = "
f"{rebate_kpi_info['rebate_pct']:.2f}%(政策常量 {POLICY_REBATE_RATE_PCT:.2f}% 仅供对照;"
f"⑤ 后不再充当变动成本率)")
else:
rebate_remark = f"{cal['name']}无渠补科目(实测渠补率不适用);变动成本率见 F_BEP_VC_RATE"
batch = f"bep-{datetime.now().strftime('%Y%m%d%H%M%S')}"
written = {}
changed = []
if write_kpi:
for code, _ in TARGET_KPIS:
kpi = kpis.get(code)
if kpi is None:
written[code] = "no-definition"
continue
if code == VC_RATE_KPI:
new_val = round(float(rebate_pct), 2) # 变动成本率可测 ⇒ 照常落库
rm = vc_rate_remark
elif code == REBATE_KPI:
new_val = (round(float(rebate_kpi_info["rebate_pct"]), 2)
if rebate_kpi_info and rebate_kpi_info.get("available") else None)
rm = rebate_remark
else:
new_val = None
rm = detail
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
KPIValue.source_type == "derived",
).first()
if existing is None:
db.add(KPIValue(
entity_id=entity_id,
kpi_id=kpi.id,
period=period,
actual_value=new_val,
source_type="derived",
source_batch=batch,
data_status="pending",
remark=rm,
))
written[code] = "insert"
else:
old_value = float(existing.actual_value) if existing.actual_value is not None else None
value_changed = ((old_value is None) != (new_val is None)
or (old_value is not None and abs(old_value - float(new_val)) > 1e-9))
existing.actual_value = new_val
existing.source_batch = batch
existing.data_status = "pending" # 裁定:fail-closed 路径显式 pending
existing.remark = rm
if value_changed:
changed.append({"code": code, "old": old_value, "new": new_val,
"status": "pending"})
written[code] = "upsert"
db.commit()
_write_value_changed_note(entity_id, period, batch, changed)
else:
written = {c: "dry-run" for c, _ in TARGET_KPIS}
return {
"entity_id": entity_id,
"period": period,
"months": months,
"data_status": "pending",
"cm_non_positive": True,
"reason": detail,
"rebate_sum_w": rebate_info["rebate_sum_w"],
"sales_sum_w": rebate_info["sales_sum_w"],
"rebate_actual_pct": rebate_pct,
"rebate_policy_pct": POLICY_REBATE_RATE_PCT,
"rebate_source": rebate_info.get("reason") or cal["margin_desc"],
"cm_pct": cm_pct,
"sales_avg": round(sales_avg, 2) if sales_avg is not None else None,
"admin_avg": round(admin_avg, 2) if admin_avg is not None else None,
"dep_avg": round(dep_avg, 2) if dep_avg is not None else None,
"values": {},
"written": written,
"value_changed": changed,
}
def main():
ap = argparse.ArgumentParser(description="保本点派生KPI执行器")
ap.add_argument("--period", default=None, help="目标期间 YYYY-MM(默认自动选最近完整月)")
ap.add_argument("--entity", type=int, default=1, help="entity_id(1=陕西酣客 2=博海)")
ap.add_argument("--rebate-rate", type=float, default=None,
help="变动成本率%%人工覆盖(诊断用;**默认按 YTD 从 voucher_details 实测现算**,不再取政策常量)")
ap.add_argument("--dry-run", action="store_true", help="只计算不落库")
args = ap.parse_args()
db = get_session_local()()
try:
cal = ENTITY_CALIBERS.get(args.entity)
if cal is None:
print(f"❌ entity={args.entity} 未注册 BEP 口径(见 ENTITY_CALIBERS)")
return 2
period = args.period or _pick_target_period(db, args.entity, date.today(), cal)
r = run_derive(db, args.entity, period, args.rebate_rate,
write_kpi=not args.dry_run)
print(f"保本点派生计算完成 entity={r['entity_id']}({cal['name']}) period={r['period']} "
f"(YTD {r['months']}个月) mode={'dry-run' if args.dry_run else 'write'} "
f"data_status={r['data_status']}")
if r["data_status"] == "pending":
kind = "边际贡献非正 ⇒ 保本点不适用" if r.get("cm_non_positive") else "不可得"
print(f" ⚠ fail-closed({kind}):{r['reason']}")
if r.get("rebate_actual_pct") is not None:
print(f" {cal['margin_label']}(YTD) = {r['rebate_actual_pct']:.2f}% "
f"| 边际贡献率 {r.get('cm_pct')}% ⇒ 仅落变动成本率,五条保本值 NULL")
print(f" 落库: {r['written']}")
return
print(f" 销售收入累计 {r['sales_sum_w']:.2f}万 月均 {r['sales_avg']:.2f}万")
print(f" 固定成本累计 {r['admin_sum_w']:.2f}万 月均 {r['admin_avg']:.2f}万 ({cal['fixed_label']},账面口径)")
print(f" 折旧累计 {r['dep_sum_w']:.2f}万 月均 {r['dep_avg']:.2f}万 ({cal['dep_filter'][1]}) | "
f"现金固定成本月均 {r['cash_admin_avg']:.2f}万 (固定成本-折旧)")
print(f" 变动成本累计 {r['rebate_sum_w']:.2f}万 | {cal['margin_label']}(YTD) {r['rebate_actual_pct']:.2f}% "
f"({cal['margin_desc']}) | 边际贡献率 {r['cm_pct']:.2f}%")
print(f" F_BEP_REVENUE 保本销售额 = {r['values']['F_BEP_REVENUE']} 万元")
print(f" F_BEP_COVER 保本覆盖率 = {r['values']['F_BEP_COVER']} %")
print(f" F_BEP_GAP 保本缺口 = {r['values']['F_BEP_GAP']} %")
print(f" F_BEP_REVENUE_CASH 现金保本销售额 = {r['values']['F_BEP_REVENUE_CASH']} 万元")
print(f" F_BEP_COVER_CASH 现金保本覆盖率 = {r['values']['F_BEP_COVER_CASH']} %")
print(f" F_BEP_REBATE {cal['margin_label']} = {r['values']['F_BEP_REBATE']} %")
print(f" 落库: {r['written']}")
finally:
db.close()
if __name__ == "__main__":
main()