ci/woodpecker/push/main Pipeline failed
- kpi_bep_deriver.py: 实体口径注册表(app/utils/bep_caliber.py 单一事实源);博海(6001/6401/6601-03/1602); fail-closed② 边际贡献非正/固定成本退化 ⇒ 五条保本值 NULL+pending+remark'不适用'(禁回落0.0) - dashboard.py /bep-card: 多实体口径 + not_applicable 态返回 label/reason - predict.py /cvp-detailed: 显式 source=scenario_simulation + is_mock=true(影子CVP标注) - 前端 BEPBreakEvenCard.vue: 全量数值判空 + '不适用'态;PredictDashboard.vue: 情景模拟·非实测标签 验收 accept-bep-cvp-20261003.py: BEFORE 2/11 → AFTER 11/11 PASS 任务 finance-20261003-cvp-fix-and-bohai-coverage-fullstack
884 lines
48 KiB
Python
884 lines
48 KiB
Python
"""保本点(盈亏平衡)派生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_REBATE 变动/渠补率(%) = 见下方「实体口径注册表」 (YTD累计; 卡面单一事实源,无阈值)
|
||
|
||
实体口径注册表(2026-10-03 新增,任务 finance-20261003-cvp-fix-and-bohai-coverage-fullstack):
|
||
· entity 1 陕西酣客:变动成本率 = Σ571103借方 ÷ Σ5001贷方(实测渠补率);固定成本 = 5602% 净额;
|
||
折旧剔除 = 560213(管理费用-折旧叶子)。
|
||
· 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 '5602%'(管理费用,含560213折旧) 借−贷净额合计(carry_forward=0),YTD 月均
|
||
entity2 = 6601/6602/6603 借−贷净额合计(同上,但另需剔除结转腿)
|
||
- 折旧: entity1 = '560213' 借−贷净额;entity2 = '1602' 贷方发生额(现金口径剔除项)
|
||
- 变动成本率: **实测累计口径**(2026-10-02 财务裁定,任总 11:43 CST 放行「改,这样数据更加真实」)
|
||
酣客 渠补率(实测) = Σ(571103 借方) ÷ Σ(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_REBATE", "实测渠补率"),
|
||
]
|
||
|
||
# 实测渠补率 KPI 编码(卡面单一事实源;无阈值,不参与预警级别判定)
|
||
REBATE_KPI = "F_BEP_REBATE"
|
||
|
||
# 保本口径 KPI(缺 F_BEP_REBATE 定义时不阻断派生主流程,仅跳过渠补率落库并告警)
|
||
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,
|
||
)
|
||
|
||
# 智享通落盘目录(与 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}")
|
||
|
||
|
||
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_field(db, entity_id, period_from, period_to,
|
||
cal["varcost_field"], cal["varcost_filter"],
|
||
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']}不可得:{cal['varcost_filter'][1]} 累计缺数或为 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:
|
||
"""实测累计渠补率(2026-10-02 财务裁定口径)—— 酣客(entity1) 专用薄封装。
|
||
|
||
渠补率(实测) = Σ(571103 借方) ÷ Σ(5001 贷方) × 100,期间 = 年初至当期(YTD),保留 2 位小数。
|
||
· 571103 = 渠补/捐赠支出(损益类费用科目)⇒ 本期发生额落**借方**;
|
||
· 5001 = 主营业务收入 ⇒ 本期发生额落**贷方**;
|
||
· 行域 = carry_forward=0(与 _sum_field 一致,排除期间损益结转腿)。
|
||
|
||
保留本函数名/键名以兼容既有调用方与单测;通用实现见 compute_margin_rate。
|
||
"""
|
||
return compute_margin_rate(db, entity_id, period_from, period_to,
|
||
ENTITY_CALIBERS.get(entity_id))
|
||
|
||
|
||
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. 取数(元 → 万元)——按实体口径
|
||
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
|
||
admin_sum_w = _sum_field(db, entity_id, period_from, period_to, "net",
|
||
subject_filters=cal["fixed_filters"],
|
||
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. 变动成本率:默认逐期实测累计(不再取政策常量;常量仅对照,仅 entity1 有)
|
||
rebate_info = compute_margin_rate(db, entity_id, period_from, period_to, cal)
|
||
calc_pct = rebate_info["rebate_pct"]
|
||
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)
|
||
|
||
# 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)
|
||
|
||
# 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_REBATE": round(rebate_pct, 2),
|
||
}
|
||
|
||
# 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 == REBATE_KPI:
|
||
if entity_id == 1:
|
||
remark = (f"实测渠补率(YTD累计): Σ571103借方 {rebate_info['rebate_sum_w']:.2f}万 ÷ "
|
||
f"Σ5001贷方 {rebate_info['sales_sum_w']:.2f}万 × 100 = {rebate_pct:.2f}%"
|
||
f"(政策常量 {POLICY_REBATE_RATE_PCT:.2f}% 仅供对照,不参与计算)")
|
||
else:
|
||
remark = (f"{cal['margin_label']}(YTD累计): {cal['margin_desc']} = "
|
||
f"{rebate_info['rebate_sum_w']:.2f}万 ÷ {rebate_info['sales_sum_w']:.2f}万 "
|
||
f"× 100 = {rebate_pct:.2f}%({cal['name']}口径,无渠补科目)")
|
||
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
|
||
new_value = float(values[code])
|
||
value_changed = old_value is None or abs(old_value - new_value) > 1e-9
|
||
existing.actual_value = values[code]
|
||
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,
|
||
"rebate_policy_pct": POLICY_REBATE_RATE_PCT,
|
||
"rebate_source": rebate_info.get("reason") or "YTD实测累计(Σ571103借方/Σ5001贷方)",
|
||
"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) -> 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)")
|
||
if entity_id == 1:
|
||
rebate_remark = (f"实测渠补率(YTD累计): Σ571103借方 {rebate_info['rebate_sum_w']:.2f}万 ÷ "
|
||
f"Σ5001贷方 {rebate_info['sales_sum_w']:.2f}万 × 100 = {rebate_pct:.2f}%"
|
||
f"(政策常量 {POLICY_REBATE_RATE_PCT:.2f}% 仅供对照,不参与计算)")
|
||
else:
|
||
rebate_remark = (f"{cal['margin_label']}(YTD累计): {cal['margin_desc']} = "
|
||
f"{rebate_info['rebate_sum_w']:.2f}万 ÷ {rebate_info['sales_sum_w']:.2f}万 "
|
||
f"× 100 = {rebate_pct:.2f}%({cal['name']}口径,无渠补科目)")
|
||
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
|
||
is_rebate = (code == REBATE_KPI)
|
||
new_val = round(float(rebate_pct), 2) if is_rebate else None
|
||
rm = rebate_remark if is_rebate else 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()
|