fix(dupont): reject out-of-domain window + bidirectional clip + suffix discipline (R1-R4/M2)
ci/woodpecker/push/main Pipeline failed

Per finance ruling 2026-10-05 (project-20261005-dupont-oob-window-fix):

- R1: no intersection with available month domain => explicit error (endpoint 400), no window fields

- R2: with intersection => intersection window, months>=1 (kills inversion and months<=0)

- R3: quarter/half/... suffix kept only when returned window == nominal window (clipped flag)

- R4: symmetric lower-bound clip note; M2: TTM nominal window = [anchor-11, anchor] (12 months)

- M1: docstring correction (lower-bound overflow is clipped, not 400)

No push / no deploy / no restart / no DB write.
This commit is contained in:
wecom-fullstack
2026-10-05 02:02:21 +08:00
parent 3ed9fa0611
commit 4f9bab6bc0
4 changed files with 278 additions and 21 deletions
+4 -4
View File
@@ -23,7 +23,7 @@ from app.utils.deviation_engine import calc_period_deviation, calc_period_diff,
from app.utils.alert_entity_scope import month_period_clause from app.utils.alert_entity_scope import month_period_clause
from app.utils.profitability_params import load_profitability_params from app.utils.profitability_params import load_profitability_params
from app.utils.dupont_basis import ( from app.utils.dupont_basis import (
annualize_factors, available_months, build_window_meta, check_identity, annualize_factors, apply_period_suffix, available_months, build_window_meta, check_identity,
compute_factors, latest_stock_month, load_dupont_params, months_between, prev_month, compute_factors, latest_stock_month, load_dupont_params, months_between, prev_month,
resolve_window, stock_balance, sum_flows, resolve_window, stock_balance, sum_flows,
) )
@@ -2330,9 +2330,9 @@ def get_dupont_analysis(
name_row = db.execute( name_row = db.execute(
text("SELECT name FROM entities WHERE id = :eid"), {"eid": entity_id}).first() text("SELECT name FROM entities WHERE id = :eid"), {"eid": entity_id}).first()
entity_name = (name_row[0] if name_row else None) or ("陕西酣客文化传媒" if slug == "hanke" else "陕西博海网络科技") entity_name = (name_row[0] if name_row else None) or ("陕西酣客文化传媒" if slug == "hanke" else "陕西博海网络科技")
suffix = {"ytd": "(YTD 年初至今)", "ttm": "(TTM 滚动 12 月)", "month": "(单月)", # R3 后缀纪律:仅在「返回窗口 == 名义窗口」(未裁剪)时保留「(季度)/(半年)…」后缀;
"quarter": "(季度)", "half": "(半年)", "year": "(全年)", "custom": "(自定义区间)"} # 发生任何裁剪 ⇒ 去后缀,period = 实际窗口标签(判据由 resolve_window 回传 `clipped`)。
label = win["label"] + suffix.get(win["period_key"], "") label = apply_period_suffix(win["label"], win["period_key"], bool(win.get("clipped")))
cur = _dupont_window_values(db, entity_id, caliber, win["period_start"], win["period_end"]) cur = _dupont_window_values(db, entity_id, caliber, win["period_start"], win["period_end"])
ann = annualize_factors(dict(cur["factors"]), win["months"], annualize) ann = annualize_factors(dict(cur["factors"]), win["months"], annualize)
+63 -15
View File
@@ -31,7 +31,7 @@ from __future__ import annotations
import json import json
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional
from sqlalchemy import text from sqlalchemy import text
@@ -152,6 +152,24 @@ def _fmt_cn(start: str, end: str) -> str:
return f"{start[:4]}年{int(start[5:7])}-{int(end[5:7])}月" return f"{start[:4]}年{int(start[5:7])}-{int(end[5:7])}月"
#: 区间后缀(R3:仅在「返回窗口 == 名义窗口」时保留 —— 统一经 `apply_period_suffix` 施加)
PERIOD_SUFFIX: Dict[str, str] = {
"ytd": "(YTD 年初至今)", "ttm": "(TTM 滚动 12 月)", "month": "(单月)",
"quarter": "(季度)", "half": "(半年)", "year": "(全年)", "custom": "(自定义区间)",
}
def apply_period_suffix(label: str, period_key: Optional[str], clipped: bool = False) -> str:
"""R3 后缀纪律:只在**未发生裁剪**(返回窗口 == 名义窗口)时保留「(季度)/(半年)…」。
发生任何裁剪 ⇒ 去后缀(`label` 已是实际窗口的 `_fmt_cn`)。`period_key` 只表**请求形态**,
**不等于**返回窗口类型(返回窗口类型由 `months` + `period` 标签表达)。
"""
if clipped:
return label
return f"{label}{PERIOD_SUFFIX.get(period_key or '', '')}"
def resolve_window(period: Optional[str], start: Optional[str], end: Optional[str], def resolve_window(period: Optional[str], start: Optional[str], end: Optional[str],
available: List[str], preset: Optional[str] = None, available: List[str], preset: Optional[str] = None,
today: Optional[str] = None, anchor: Optional[str] = None) -> Dict[str, Any]: today: Optional[str] = None, anchor: Optional[str] = None) -> Dict[str, Any]:
@@ -161,21 +179,28 @@ def resolve_window(period: Optional[str], start: Optional[str], end: Optional[st
可用月域 `available`(升序 YYYY-MM 列表)用于:① 缺省锚定;② 区间裁剪。 可用月域 `available`(升序 YYYY-MM 列表)用于:① 缺省锚定;② 区间裁剪。
`anchor` = 默认区间的锚月(端点传入「存量最新月」`latest_stock_month`,缺省取 available 末月)。 `anchor` = 默认区间的锚月(端点传入「存量最新月」`latest_stock_month`,缺省取 available 末月)。
⚠️ 裁剪纪律:区间上界**只裁不扩**——超出可用月域即裁到可用域(并在 `clip_note` 留痕), 裁剪纪律(财务侧 2026-10-05 口径裁定 R1–R4,件
绝不因裁剪而静默改变用户已选下界(下界越界则显式 400,见 `validate`)。 `_reports/project-20261005-dupont-oob-ruling-finance.md`):
· **R1 无交集 ⇒ 显式拒绝**:`e < avail_start` 或 `s > latest` ⇒ 返回 `error`
(调用侧转 HTTP 400),**不返回任何窗口/数值字段**(不得以 null/空串占位);
· **R2 有交集 ⇒ 双向裁剪**:窗口 = 交集 `[max(s, avail_start), min(e, latest)]`(只裁不扩),
`months` = 实际月数 ⇒ 恒 ≥ 1(消灭 `months = 0/负数` 与窗口倒挂);
· **R3 后缀纪律**:是否允许调用侧挂「(季度)/(半年)…」后缀 = `clipped` 字段
(未裁剪才保留;裁剪后 `period` = 实际窗口标签);
· **R4 裁剪留痕**:见 `clip_note`(上界沿用既有文案;下界为对称文案;两端同时裁剪则并存)。
⚠️ 更正(2026-10-05,同裁定 M1):下界越界**不是**无条件 400 —— 「有交集但下界越界」
= 裁到 `avail_start` + 留痕 + 去后缀(R2/R3/R4);**只有「整区间与可用月域无交集」才 400**。
旧 docstring 声称「下界越界则显式 400,见 `validate`」,而本模块从未有 `validate` 函数。
""" """
avail = sorted(available or []) avail = sorted(available or [])
latest = avail[-1] if avail else None latest = avail[-1] if avail else None
avail_start = avail[0] if avail else None
today = today or datetime.now().strftime("%Y-%m") today = today or datetime.now().strftime("%Y-%m")
anchor = min(anchor or latest or today, today) anchor = min(anchor or latest or today, today)
if latest: if latest:
anchor = min(anchor, latest) anchor = min(anchor, latest)
def clip_end(e: str) -> Tuple[str, Optional[str]]:
if latest and e > latest:
return latest, f"上界 {e} 超出可用月域(最新 {latest}),已裁至可用域"
return e, None
p = (period or "").strip().lower() p = (period or "").strip().lower()
if preset: if preset:
p = preset.strip().lower() p = preset.strip().lower()
@@ -188,8 +213,12 @@ def resolve_window(period: Optional[str], start: Optional[str], end: Optional[st
elif p in ("ttm",): elif p in ("ttm",):
if not anchor: if not anchor:
return {"error": "无可用数据月域,无法解析 TTM 区间"} return {"error": "无可用数据月域,无法解析 TTM 区间"}
# M2(同裁定 §4):TTM 名义区间 = [anchor−11, anchor](**12 个月**)。
# 历史实现取 anchor 的「去年同月」(如 anchor=2026-08 ⇒ 2025-08)= 13 个月,与后缀
# 「(TTM 滚动 12 月)」自相矛盾,且 `months` 进年化分母 ⇒ 年化被低估 12/13 ≈ 7.7%。
y, m = int(anchor[:4]), int(anchor[5:7]) y, m = int(anchor[:4]), int(anchor[5:7])
s = f"{y - 1}-{m:02d}" if m != 12 else f"{y}-01" idx = y * 12 + (m - 1) - 11
s = f"{idx // 12}-{idx % 12 + 1:02d}"
e = anchor e = anchor
key = "ttm" key = "ttm"
elif p in ("custom",) or start or end: elif p in ("custom",) or start or end:
@@ -214,13 +243,32 @@ def resolve_window(period: Optional[str], start: Optional[str], end: Optional[st
else: else:
return {"error": f"无法识别的区间参数 period={period!r}(支持 ytd/ttm/YYYY-MM/YYYY-Qn/YYYY-Hn/YYYY/custom)"} return {"error": f"无法识别的区间参数 period={period!r}(支持 ytd/ttm/YYYY-MM/YYYY-Qn/YYYY-Hn/YYYY/custom)"}
e, cn = clip_end(e) # ── R1/R2:与可用月域求交集(无交集 ⇒ 显式拒绝;有交集 ⇒ 双向裁剪) ──────────
note = cn if not avail:
months = months_between(s, e) return {"error": "无可用数据月域,无法计算区间指标"}
latest = avail[-1]
avail_start = avail[0]
if e < avail_start or s > latest:
return {"error": f"请求区间 {s}~{e} 与可用月域 {avail_start}~{latest} 无交集,无法计算区间指标"}
notes: List[str] = []
lo, hi = s, e
if s < avail_start:
lo = avail_start
notes.append(f"下界 {s} 早于可用月域起点({avail_start}),已裁至可用域")
if e > latest:
hi = latest
notes.append(f"上界 {e} 超出可用月域(最新 {latest}),已裁至可用域")
if lo > hi: # 名义区间自身颠倒(如 custom start > end)——不得产出倒挂窗口
return {"error": f"请求区间 {s}~{e} 与可用月域 {avail_start}~{latest} 无交集,无法计算区间指标"}
clipped = (lo, hi) != (s, e)
months = months_between(lo, hi) # R2 ⇒ 恒 ≥ 1
return { return {
"period_key": key, "period_start": s, "period_end": e, "months": months, "period_key": key, "period_start": lo, "period_end": hi, "months": months,
"label": _fmt_cn(s, e), "clip_note": note, "label": _fmt_cn(lo, hi),
"available_start": avail[0] if avail else None, "available_end": latest, "nominal_start": s, "nominal_end": e, "nominal_months": months_between(s, e),
"clipped": clipped,
"clip_note": ";".join(notes) if notes else None,
"available_start": avail_start, "available_end": latest,
} }
@@ -0,0 +1,205 @@
# -*- coding: utf-8 -*-
"""杜邦区间窗口「完全越出可用月域」回归用例(2026-10-05 口径裁定 R1–R4 + M2)。
裁定件(财物侧口径,执行前必读):
/srv/hermes/mailbox/finance/pending-tasks/_reports/project-20261005-dupont-oob-ruling-finance.md
施工单: project-20261005-dupont-oob-window-fix-fullstack.md
缺陷(改前实测,部署树 HEAD 3ed9fa0):
· `?period=2026-Q4` → 200 / period_start=2026-10 > period_end=2026-09 / months=0 /
label「2026年10-9月(季度)」/ 四因子全 None(**窗口倒挂 + 名号不符 + 静默空数据**);
· `?period=2026-H2` → 裁至 2026-07..09(实 3 个月)却仍标「(半年)」。
裁定后口径(本文件逐条断言):
· R1 整区间与可用月域 D 无交集 ⇒ `error`(端点转 400),**不返回任何窗口/数值字段**;
· R2 有交集 ⇒ 窗口 = 交集(双向裁剪),`months` = 实际月数 ≥ 1(不倒挂);
· R3 后缀((季度)/(半年)…)仅在「返回窗口 == 名义窗口」时保留 ⇒ 由 `clipped` 判;
· R4 裁剪留痕:上界沿用既有文案;下界为对称文案;
· M2 TTM 名义区间 = [anchor−11, anchor] = 12 个月。
可用月域取裁定 §2 场景:D = 2026-01 ~ 2026-09(latest=2026-09)。
本文件只测纯函数(`resolve_window` / `apply_period_suffix`),不连库、不写盘、不碰服务。
"""
import pytest
from app.utils import dupont_basis as _du
from app.utils.dupont_basis import months_between, resolve_window
def apply_period_suffix(label, period_key, clipped=False):
"""R3 判据入口(经模块属性取,便于负对照)。
改前件(origin/main 3ed9fa0)**没有** `apply_period_suffix` ⇒ 走显式断言失败
(AssertionError,而不是 import 期 ImportError 导致整个用例文件收集失败),
这样「把实现换回改前版本」时这些断言会以**可读的失败**呈现。
"""
fn = getattr(_du, "apply_period_suffix", None)
assert fn is not None, ("resolve_window/dupont_basis 未落地 R3 后缀纪律(clipped 判据)"
"—— 改前件预期失败")
return fn(label, period_key, clipped)
#: 裁定 §2/§4 场景可用月域(latest = 2026-09,博海账套实测形态)
D_AVAIL = [f"2026-{m:02d}" for m in range(1, 10)]
#: 整区间无交集的统一 400 文案(端点直接把它作为 `detail`,前端零改动可正确呈现)
Q4_DETAIL = "请求区间 2026-10~2026-12 与可用月域 2026-01~2026-09 无交集,无法计算区间指标"
def rwd(period, start=None, end=None, **kw):
kw.setdefault("today", "2026-10")
return resolve_window(period, start, end, D_AVAIL, **kw)
# ── R1:整区间与可用月域无交集 ⇒ 显式拒绝(不返回窗口/数值字段) ──────────────
def test_q4_out_of_domain_rejected_with_exact_detail():
"""`?period=2026-Q4`(A=[2026-10,2026-12] 与 D 无交集)⇒ error,文案逐字符合裁定 §2.1。"""
w = rwd("2026-Q4")
assert w.get("error") == Q4_DETAIL, w
# 不得以 null/空串占位:窗口/月数/标签/裁剪留痕一律**不存在**
for k in ("period_start", "period_end", "months", "months_label", "label",
"period", "clip_note", "nominal_months", "clipped"):
assert k not in w, (k, w)
@pytest.mark.parametrize("period,start,end", [
("2026-Q4", None, None), # 未来侧整季度越界
("2026-10", None, None), # 未来侧单月越界
("2027", None, None), # 未来侧整年越界
("2025-Q1", None, None), # 历史侧整季度(域前)
("2025-Q4", None, None), # 历史侧整季度(域前)
("custom", "2026-10", "2026-12"), # custom 未来侧整段
("custom", "2026-11", "2026-11"), # custom 未来侧单月(改前 months=-1)
("custom", "2025-01", "2025-12"), # custom 历史侧整段
])
def test_all_no_intersection_forms_rejected(period, start, end):
w = rwd(period, start, end)
assert w.get("error"), (period, start, end, w)
assert "无交集" in w["error"]
assert "period_start" not in w and "months" not in w
def test_nominal_inverted_custom_never_returns_inverted_window():
"""名义区间自身颠倒(custom start > end)⇒ 拒绝,绝不产出倒挂窗口。"""
w = rwd("custom", "2026-05", "2026-03")
assert w.get("error"), w
assert "period_start" not in w
# ── R2 + R3 + R4:有交集 ⇒ 双向裁剪 + 月数/标签如实 ──────────────────────────
def test_h2_clips_upper_bound_label_and_months():
"""`?period=2026-H2` ⇒ 窗口 [2026-07,2026-09]、months=3、标签无「(半年)」、上界留痕。"""
w = rwd("2026-H2")
assert "error" not in w
assert w["period_key"] == "half" # 请求形态不变(向后兼容)
assert (w["period_start"], w["period_end"]) == ("2026-07", "2026-09")
assert w["months"] == 3
assert w["label"] == "2026年7-9月"
assert w["nominal_start"] == "2026-07" and w["nominal_end"] == "2026-12"
assert w["nominal_months"] == 6
assert w["clipped"] is True
assert w["clip_note"] == "上界 2026-12 超出可用月域(最新 2026-09),已裁至可用域"
# R3:发生裁剪 ⇒ 去后缀(裁定 §2.2 期望标签「2026年7-9月」,不得带「(半年)」)
assert apply_period_suffix(w["label"], w["period_key"], w["clipped"]) == "2026年7-9月"
assert "半年" not in apply_period_suffix(w["label"], w["period_key"], w["clipped"])
def test_h2_window_equals_q3_window():
"""强判据(裁定 §2.2):同一返回窗口 [2026-07,2026-09] ⇒ H2 与 Q3 窗口逐项相同。"""
h2, q3 = rwd("2026-H2"), rwd("2026-Q3")
assert "error" not in h2 and "error" not in q3
for k in ("period_start", "period_end", "months", "label"):
assert h2[k] == q3[k], (k, h2[k], q3[k])
def test_full_year_clips_lower_upper_and_label():
"""`?period=2026`(全年)⇒ [2026-01,2026-09]、months=9、标签无「(全年)」。"""
w = rwd("2026")
assert (w["period_start"], w["period_end"], w["months"]) == ("2026-01", "2026-09", 9)
assert w["label"] == "2026年1-9月"
assert w["clipped"] is True
assert apply_period_suffix(w["label"], w["period_key"], w["clipped"]) == "2026年1-9月"
def test_custom_lower_bound_clip_note_symmetric():
"""R4:下界越界(域起点晚于名义起点)⇒ 裁到 avail_start + 对称留痕 + 去后缀。"""
w = rwd("custom", "2025-01", "2026-06")
assert (w["period_start"], w["period_end"], w["months"]) == ("2026-01", "2026-06", 6)
assert w["clipped"] is True
assert w["clip_note"] == "下界 2025-01 早于可用月域起点(2026-01),已裁至可用域"
assert apply_period_suffix(w["label"], w["period_key"], w["clipped"]) == "2026年1-6月"
def test_ttm_nominal_12_months_and_lower_clip():
"""M2:TTM 名义 12 个月;域内 ⇒ 下界裁剪 + 去「(TTM 滚动 12 月)」后缀。"""
nom = resolve_window("ttm", None, None, [f"2025-{m:02d}" for m in range(1, 13)]
+ [f"2026-{m:02d}" for m in range(1, 13)], anchor="2026-08")
assert (nom["period_start"], nom["period_end"], nom["nominal_months"]) == (
"2025-09", "2026-08", 12)
w = rwd("ttm", anchor="2026-08")
assert (w["period_start"], w["period_end"], w["months"]) == ("2026-01", "2026-08", 8)
assert w["clipped"] is True
assert w["clip_note"] == "下界 2025-09 早于可用月域起点(2026-01),已裁至可用域"
assert apply_period_suffix(w["label"], w["period_key"], w["clipped"]) == "2026年1-8月"
def test_ytd_unclipped_keeps_suffix():
"""未裁剪(返回窗口 == 名义窗口)⇒ 后缀保留(R3 的另一半)。"""
w = rwd("ytd", anchor="2026-08")
assert w["clipped"] is False
assert w["clip_note"] is None
assert apply_period_suffix(w["label"], w["period_key"], w["clipped"]) == "2026年1-8月(YTD 年初至今)"
# ── R3 后缀纪律(纯函数) ─────────────────────────────────────────────────────
@pytest.mark.parametrize("label,key,unclipped,clipped", [
("2026年1-6月", "half", "2026年1-6月(半年)", "2026年1-6月"),
("2026年1-3月", "quarter", "2026年1-3月(季度)", "2026年1-3月"),
("2026年1-12月", "year", "2026年1-12月(全年)", "2026年1-12月"),
("2026年8月", "month", "2026年8月(单月)", "2026年8月"),
("2026年1-8月", "ytd", "2026年1-8月(YTD 年初至今)", "2026年1-8月"),
("2025年9月-2026年8月", "ttm", "2025年9月-2026年8月(TTM 滚动 12 月)", "2025年9月-2026年8月"),
("2026年6-9月", "custom", "2026年6-9月(自定义区间)", "2026年6-9月"),
])
def test_apply_period_suffix_discipline(label, key, unclipped, clipped):
"""R3:未裁剪 ⇒ 保留后缀;发生任何裁剪 ⇒ 去后缀。"""
assert apply_period_suffix(label, key, False) == unclipped
assert apply_period_suffix(label, key, True) == clipped
# ── 全形态矩阵:任意入参下不得倒挂、months 与标签自洽 ────────────────────────
@pytest.mark.parametrize("period,start,end", [
("2026-Q1", None, None), ("2026-Q3", None, None), ("2026-Q4", None, None),
("2026-H1", None, None), ("2026-H2", None, None),
("2026-08", None, None), ("2026-10", None, None),
("2026", None, None), ("2026-01", None, None), ("2027", None, None),
("2025-Q1", None, None), ("2025-Q4", None, None),
("ytd", None, None), ("ttm", None, None),
("custom", "2026-06", "2026-12"), ("custom", "2026-10", "2026-12"),
("custom", "2026-11", "2026-11"), ("custom", "2025-01", "2026-06"),
])
def test_matrix_no_inversion_and_self_consistency(period, start, end):
w = rwd(period, start, end, anchor="2026-08")
if w.get("error"):
assert "无交集" in w["error"] or "无可用" in w["error"], w
return
assert w["period_start"] <= w["period_end"], w
assert w["months"] == months_between(w["period_start"], w["period_end"]), w
assert w["months"] >= 1, w
assert D_AVAIL[0] <= w["period_start"] <= w["period_end"] <= D_AVAIL[-1], w
assert w["clipped"] is (w["nominal_months"] != w["months"]), w
def test_clip_note_present_iff_clipped():
for period, start, end in [("2026-Q3", None, None), ("2026-H2", None, None),
("2026", None, None), ("2025-Q1", None, None),
("custom", "2025-01", "2026-06")]:
w = rwd(period, start, end)
if w.get("error"):
assert "clip_note" not in w
continue
assert (w["clip_note"] is not None) is w["clipped"], (period, w)
+6 -2
View File
@@ -100,10 +100,14 @@ def test_ytd_ttm_unchanged():
assert (ytd["period_start"], ytd["period_end"]) == ("2026-01", "2026-10") assert (ytd["period_start"], ytd["period_end"]) == ("2026-01", "2026-10")
assert ytd["months"] == 10 assert ytd["months"] == 10
# M2(2026-10-05 裁定 §4):TTM 名义区间 = [anchor−11, anchor] = **12 个月**。
# 历史断言写的是 13 个月(anchor 的「去年同月」),即被裁定为缺陷的口径,见
# test_dupont_oob_window_ruling_20261005.py / 本文件 §裁定回归。
ttm = rw("ttm", anchor="2026-10") ttm = rw("ttm", anchor="2026-10")
assert ttm["period_key"] == "ttm" assert ttm["period_key"] == "ttm"
assert (ttm["period_start"], ttm["period_end"]) == ("2025-10", "2026-10") assert (ttm["period_start"], ttm["period_end"]) == ("2025-11", "2026-10")
assert ttm["months"] == 13 assert ttm["nominal_months"] == 12
assert ttm["months"] == 12
def test_custom_unchanged(): def test_custom_unchanged():