diff --git a/backend/app/api/reports.py b/backend/app/api/reports.py index 5fedd5a0..d736f80c 100644 --- a/backend/app/api/reports.py +++ b/backend/app/api/reports.py @@ -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.profitability_params import load_profitability_params 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, resolve_window, stock_balance, sum_flows, ) @@ -2330,9 +2330,9 @@ def get_dupont_analysis( name_row = db.execute( 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 "陕西博海网络科技") - suffix = {"ytd": "(YTD 年初至今)", "ttm": "(TTM 滚动 12 月)", "month": "(单月)", - "quarter": "(季度)", "half": "(半年)", "year": "(全年)", "custom": "(自定义区间)"} - label = win["label"] + suffix.get(win["period_key"], "") + # R3 后缀纪律:仅在「返回窗口 == 名义窗口」(未裁剪)时保留「(季度)/(半年)…」后缀; + # 发生任何裁剪 ⇒ 去后缀,period = 实际窗口标签(判据由 resolve_window 回传 `clipped`)。 + 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"]) ann = annualize_factors(dict(cur["factors"]), win["months"], annualize) diff --git a/backend/app/utils/dupont_basis.py b/backend/app/utils/dupont_basis.py index c448a557..9fccce3e 100644 --- a/backend/app/utils/dupont_basis.py +++ b/backend/app/utils/dupont_basis.py @@ -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, } diff --git a/backend/tests/test_dupont_oob_window_ruling_20261005.py b/backend/tests/test_dupont_oob_window_ruling_20261005.py new file mode 100644 index 00000000..4e0d49c3 --- /dev/null +++ b/backend/tests/test_dupont_oob_window_ruling_20261005.py @@ -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) diff --git a/backend/tests/test_dupont_period_options.py b/backend/tests/test_dupont_period_options.py index 4669a569..ddeef6c9 100644 --- a/backend/tests/test_dupont_period_options.py +++ b/backend/tests/test_dupont_period_options.py @@ -100,10 +100,14 @@ def test_ytd_ttm_unchanged(): assert (ytd["period_start"], ytd["period_end"]) == ("2026-01", "2026-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") assert ttm["period_key"] == "ttm" - assert (ttm["period_start"], ttm["period_end"]) == ("2025-10", "2026-10") - assert ttm["months"] == 13 + assert (ttm["period_start"], ttm["period_end"]) == ("2025-11", "2026-10") + assert ttm["nominal_months"] == 12 + assert ttm["months"] == 12 def test_custom_unchanged():