From ce2f6bfe33d239b5a92085efc49e341d56236a0a Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Sat, 12 Sep 2026 20:08:31 +0800 Subject: [PATCH] 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. --- backend/scripts/kpi_bep_deriver.py | 66 ++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/backend/scripts/kpi_bep_deriver.py b/backend/scripts/kpi_bep_deriver.py index 57a73218..19b76d2f 100644 --- a/backend/scripts/kpi_bep_deriver.py +++ b/backend/scripts/kpi_bep_deriver.py @@ -13,7 +13,9 @@ - 折旧: subject_code = '560213'(管理费用-折旧叶子) 借方合计,YTD 月均 → 现金口径剔除项(eq精确匹配,防5602130误含) - 渠补率: 厂家政策定死 84.61% (571103渠补支出/5001销售, 1-7月累计凭证验证≈84.0%~84.6%) - 边际贡献率 = 1 - 渠补率 = 15.39% -- 目标月: --period 显式指定;默认自动选最近完整月(当月跳过;最近月销售额<近6月均30%视为未结账回退) +- 目标月: --period 显式指定;默认自动选最近「已结账」完整月(当月跳过)。 + 结账信号优先:DB 损益结转凭证(carry_forward=1 & subject_code='3103') → check.md 文件; + 两者皆无时才退回旧「销售额阈值」兜底(销售低 ≠ 未结账,仅保底用,日志标注依据) - 单位: voucher 金额为元,聚合后 /10000 → 万元 - 幂等: 同 kpi_id+period+source_type='derived' 存在则更新,可安全重跑 - 只写 source_type='derived' 行;不触碰 auto_collect/manual/excel/ledger 行;不改 collector 逻辑 @@ -59,6 +61,10 @@ SALES_CODE = "5001" # 主营业务收入 ADMIN_PREFIX = "5602" # 管理费用(含折旧叶子560213) DEP_SUBJECT = "560213" # 管理费用-折旧(现金口径剔除项,eq 精确匹配防 5602130 误含) 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): @@ -101,8 +107,49 @@ def _month_sales(db, entity_id: int, period: str) -> float: ("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: - """自动选择目标月:跳过当月;最近月销售额<近6月完整月均30%则视为未结账回退""" + """自动选择目标月:跳过当月。 + + 判定顺序(2026-09-12 修订): + 1. 结账信号优先 —— 按 desc 返回第一个 _is_period_closed 为真的月; + 结账成立时不再用销售额做任何筛选(销售低 ≠ 未结账,历史缺陷根因) + 2. 所有候选月均无结账信号 → 退回旧「销售额 < 近6月均×30%」兜底(日志标注『依据销售额兜底』) + """ # 收集已有数据月份(5001 贷方) rows = db.query(VoucherDetail.period).filter( VoucherDetail.entity_id == entity_id, @@ -121,6 +168,17 @@ def _pick_target_period(db, entity_id: int, now: date) -> str: if not candidates: 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 个不同月的月销售 ref_months = [] for p in periods: @@ -137,8 +195,10 @@ def _pick_target_period(db, entity_id: int, now: date) -> str: if s <= 0: continue # 该月无销售,往前找 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 + logger.info("目标月 %s 依据销售额兜底采用", p) return p return candidates[0]