fix(bep): pick target period by period-closed signal, sales threshold as fallback only

Defect: _pick_target_period treated "month sales < 30% of 6-month avg" as
"period not closed", so 2026-08 (actually closed: 3103 carry-forward vouchers
present) was skipped because of a low-sales off-season month, pinning the five
F_BEP* KPIs at 2026-07.

Fix: add _is_period_closed() - primary signal voucher_details
(carry_forward=1 AND subject_code='3103'), secondary signal
ZXT_LANDING_DIR/{YYYY.MM}/check.md containing both "issue PASS" and
"P&L carry-forward PASS". _pick_target_period now scans candidate months desc
and returns the first closed one, with no sales filtering; the old sales
threshold path is kept only as a fallback (warns "sales-threshold fallback").

Scope: run_derive() formulas, collector, kpi_definitions and cron untouched.

Verified: period=2026-08; 167.5 / 60.4 / 39.6 / 118.7 / 85.2;
F_BEP_COVER 2026-08 red pending, 2026-07 pending resolved; idempotent on re-run.
This commit is contained in:
Hermes CI Fix
2026-09-12 20:08:31 +08:00
parent 46fc1a4ecc
commit ce2f6bfe33
+63 -3
View File
@@ -13,7 +13,9 @@
- 折旧: subject_code = '560213'(管理费用-折旧叶子) 借方合计,YTD 月均 → 现金口径剔除项(eq精确匹配,防5602130误含) - 折旧: subject_code = '560213'(管理费用-折旧叶子) 借方合计,YTD 月均 → 现金口径剔除项(eq精确匹配,防5602130误含)
- 渠补率: 厂家政策定死 84.61% (571103渠补支出/5001销售, 1-7月累计凭证验证≈84.0%~84.6%) - 渠补率: 厂家政策定死 84.61% (571103渠补支出/5001销售, 1-7月累计凭证验证≈84.0%~84.6%)
- 边际贡献率 = 1 - 渠补率 = 15.39% - 边际贡献率 = 1 - 渠补率 = 15.39%
- 目标月: --period 显式指定;默认自动选最近完整月(当月跳过;最近月销售额<近6月均30%视为未结账回退) - 目标月: --period 显式指定;默认自动选最近「已结账」完整月(当月跳过)。
结账信号优先:DB 损益结转凭证(carry_forward=1 & subject_code='3103') → check.md 文件;
两者皆无时才退回旧「销售额阈值」兜底(销售低 ≠ 未结账,仅保底用,日志标注依据)
- 单位: voucher 金额为元,聚合后 /10000 → 万元 - 单位: voucher 金额为元,聚合后 /10000 → 万元
- 幂等: 同 kpi_id+period+source_type='derived' 存在则更新,可安全重跑 - 幂等: 同 kpi_id+period+source_type='derived' 存在则更新,可安全重跑
- 只写 source_type='derived' 行;不触碰 auto_collect/manual/excel/ledger 行;不改 collector 逻辑 - 只写 source_type='derived' 行;不触碰 auto_collect/manual/excel/ledger 行;不改 collector 逻辑
@@ -59,6 +61,10 @@ SALES_CODE = "5001" # 主营业务收入
ADMIN_PREFIX = "5602" # 管理费用(含折旧叶子560213) ADMIN_PREFIX = "5602" # 管理费用(含折旧叶子560213)
DEP_SUBJECT = "560213" # 管理费用-折旧(现金口径剔除项,eq 精确匹配防 5602130 误含) DEP_SUBJECT = "560213" # 管理费用-折旧(现金口径剔除项,eq 精确匹配防 5602130 误含)
REBATE_CODE = "571103" # 渠补/捐赠支出(校验用,不直接进公式) REBATE_CODE = "571103" # 渠补/捐赠支出(校验用,不直接进公式)
CARRY_SUBJECT = "3103" # 本年利润(carry_forward=1 且该科目 = 该期损益结转凭证)
# 智享通落盘目录(与 app/api/data.py 的 ZXT_LANDING_DIR 一致)——结账信号次选来源
ZXT_LANDING_DIR = "/root/WB_WS/_data/zxt-hanke"
def _month_range(period: str): def _month_range(period: str):
@@ -101,8 +107,49 @@ def _month_sales(db, entity_id: int, period: str) -> float:
("eq", SALES_CODE)) / 10000.0 ("eq", SALES_CODE)) / 10000.0
def _is_period_closed(db, entity_id: int, period: str) -> tuple:
"""判断该期间是否已结账(损益结转已完成)。返回 (bool, 依据说明)。
主信号(DB): voucher_details 存在 carry_forward=1 且 subject_code='3103'(本年利润) 的行
= 该期损益结转凭证已生成
次信号(文件): ZXT_LANDING_DIR/{YYYY.MM}/check.md 同时含「本期: PASS」与「损益结转: PASS」
均不成立 → (False, "")。任何读取异常均吞掉,不影响主流程。
"""
# 主信号:DB 损益结转凭证
try:
hit = db.query(VoucherDetail.id).filter(
VoucherDetail.entity_id == entity_id,
VoucherDetail.carry_forward == 1,
VoucherDetail.subject_code == CARRY_SUBJECT,
VoucherDetail.period == period,
).first()
if hit:
return True, "DB损益结转"
except Exception as e: # 查询异常不阻断,降级到文件信号
logger.warning("结账信号(DB)查询失败 period=%s: %s", period, e)
# 次信号:智享通落盘 check.md
try:
path = os.path.join(ZXT_LANDING_DIR, 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) -> str: def _pick_target_period(db, entity_id: int, now: date) -> str:
"""自动选择目标月:跳过当月;最近月销售额<近6月完整月均30%则视为未结账回退""" """自动选择目标月:跳过当月。
判定顺序(2026-09-12 修订):
1. 结账信号优先 —— 按 desc 返回第一个 _is_period_closed 为真的月;
结账成立时不再用销售额做任何筛选(销售低 ≠ 未结账,历史缺陷根因)
2. 所有候选月均无结账信号 → 退回旧「销售额 < 近6月均×30%」兜底(日志标注『依据销售额兜底』)
"""
# 收集已有数据月份(5001 贷方) # 收集已有数据月份(5001 贷方)
rows = db.query(VoucherDetail.period).filter( rows = db.query(VoucherDetail.period).filter(
VoucherDetail.entity_id == entity_id, VoucherDetail.entity_id == entity_id,
@@ -121,6 +168,17 @@ def _pick_target_period(db, entity_id: int, now: date) -> str:
if not candidates: if not candidates:
candidates = periods candidates = periods
# 1) 结账信号优先(DB损益结转 → check.md),命中即采用
for p in candidates:
closed, reason = _is_period_closed(db, entity_id, p)
if closed:
logger.info("目标月 %s 判定已结账(依据:%s),采用", p, reason)
return p
# 2) 兜底:无任何结账信号时,退回旧销售额阈值逻辑
logger.warning("候选月 %s 均无结账信号(DB损益结转/check.md),退回销售额阈值兜底",
", ".join(candidates[:6]))
# 参考月均:候选月中最近 6 个不同月的月销售 # 参考月均:候选月中最近 6 个不同月的月销售
ref_months = [] ref_months = []
for p in periods: for p in periods:
@@ -137,8 +195,10 @@ def _pick_target_period(db, entity_id: int, now: date) -> str:
if s <= 0: if s <= 0:
continue # 该月无销售,往前找 continue # 该月无销售,往前找
if ref_avg and s < ref_avg * 0.3: if ref_avg and s < ref_avg * 0.3:
logger.info("目标月 %s 销售 %.1f万 < 参考月均 %.1f万*30%%,视为未结账,回退", p, s, ref_avg) logger.info("目标月 %s 销售 %.1f万 < 参考月均 %.1f万*30%%,依据销售额兜底判定未结账,回退",
p, s, ref_avg)
continue continue
logger.info("目标月 %s 依据销售额兜底采用", p)
return p return p
return candidates[0] return candidates[0]