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
+63 -15
View File
@@ -31,7 +31,7 @@ from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional
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])}月"
#: 区间后缀(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],
available: List[str], preset: Optional[str] = None,
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 列表)用于:① 缺省锚定;② 区间裁剪。
`anchor` = 默认区间的锚月(端点传入「存量最新月」`latest_stock_month`,缺省取 available 末月)。
⚠️ 裁剪纪律:区间上界**只裁不扩**——超出可用月域即裁到可用域(并在 `clip_note` 留痕),
绝不因裁剪而静默改变用户已选下界(下界越界则显式 400,见 `validate`)。
裁剪纪律(财务侧 2026-10-05 口径裁定 R1–R4,件
`_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 [])
latest = avail[-1] if avail else None
avail_start = avail[0] if avail else None
today = today or datetime.now().strftime("%Y-%m")
anchor = min(anchor or latest or today, today)
if 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()
if preset:
p = preset.strip().lower()
@@ -188,8 +213,12 @@ def resolve_window(period: Optional[str], start: Optional[str], end: Optional[st
elif p in ("ttm",):
if not anchor:
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])
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
key = "ttm"
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:
return {"error": f"无法识别的区间参数 period={period!r}(支持 ytd/ttm/YYYY-MM/YYYY-Qn/YYYY-Hn/YYYY/custom)"}
e, cn = clip_end(e)
note = cn
months = months_between(s, e)
# ── R1/R2:与可用月域求交集(无交集 ⇒ 显式拒绝;有交集 ⇒ 双向裁剪) ──────────
if not avail:
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 {
"period_key": key, "period_start": s, "period_end": e, "months": months,
"label": _fmt_cn(s, e), "clip_note": note,
"available_start": avail[0] if avail else None, "available_end": latest,
"period_key": key, "period_start": lo, "period_end": hi, "months": months,
"label": _fmt_cn(lo, hi),
"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,
}