feat(forecast): расширить горизонт прогноза до 24 мес (ТЗ §12.1, Q1) #1306

Merged
bot-backend merged 1 commit from feat/horizon-24-months into main 2026-06-13 12:38:17 +00:00
7 changed files with 127 additions and 18 deletions

View file

@ -918,8 +918,14 @@ _INLINE_FETCH_POLL_INTERVAL_S = 2
# (SiteFinderReport._SCHEMA_VERSION), отличный от ANALYZE_SCHEMA_VERSION ("analyze-1.0") # (SiteFinderReport._SCHEMA_VERSION), отличный от ANALYZE_SCHEMA_VERSION ("analyze-1.0")
# inline-dict analyze. GET /{cad}/forecast читает именно "1.0". # inline-dict analyze. GET /{cad}/forecast читает именно "1.0".
_FORECAST_SCHEMA_VERSION = "1.0" _FORECAST_SCHEMA_VERSION = "1.0"
# Допустимые горизонты прогноза (мес), #995 — иное → 422. # Допустимые горизонты прогноза (мес), #995 — иное → 422. ТЗ §12.1 называет
_ALLOWED_FORECAST_HORIZONS = frozenset({6, 12, 18}) # 6/12/18/24; 24 добавлен (Q1). Движок уже считает 24 на каждом ране — это первый
# горизонт в _DEFAULT_HORIZONS=(6,12,18,24) всего §9.x-стека (orchestrator/forecast-
# task/demand_supply_forecast/scenarios/special_indices/report_assembler); фазированный
# выход скрытого ПД (_hidden_release_fraction) и взвешивание future_supply по горизонту
# (_horizon_weight) корректно масштабируются/клампятся на 24 без деградации. Сетка
# дискретная (а не ge/le-диапазон): UI даёт фиксированные пресет-кнопки.
_ALLOWED_FORECAST_HORIZONS = frozenset({6, 12, 18, 24})
@router.get("/by-bbox", response_model=ParcelBboxResponse) @router.get("/by-bbox", response_model=ParcelBboxResponse)
@ -1277,7 +1283,7 @@ def analyze_parcel(
] = None, ] = None,
horizon: Annotated[ horizon: Annotated[
int, int,
Query(description="Горизонт прогноза, мес (#995): один из {6, 12, 18}"), Query(description="Горизонт прогноза, мес (#995, ТЗ §12.1): один из {6, 12, 18, 24}"),
] = 12, ] = 12,
body: Annotated[ body: Annotated[
AnalyzeRequest | None, AnalyzeRequest | None,
@ -1310,8 +1316,8 @@ def analyze_parcel(
§22-форсайт (3b-ii, #995): после успешного persist рана best-effort enqueue'им §22-форсайт (3b-ii, #995): после успешного persist рана best-effort enqueue'им
`forecast_site_finder_report.delay(cad_num, horizon, ...)` fire-and-forget, `forecast_site_finder_report.delay(cad_num, horizon, ...)` fire-and-forget,
провал Celery/Redis НЕ валит ответ (он уже успешен). `horizon` {6, 12, 18} провал Celery/Redis НЕ валит ответ (он уже успешен). `horizon` {6, 12, 18, 24}
(иначе 422). Результат добавляется в ответ как `forecast` stub (additive); сам (ТЗ §12.1; иначе 422). Результат добавляется в ответ как `forecast` stub (additive); сам
§22-отчёт клиент забирает через GET /{cad}/forecast (202 pending 200 ready). §22-отчёт клиент забирает через GET /{cad}/forecast (202 pending 200 ready).
""" """
# Валидация горизонта прогноза (#995) — до любой работы с БД: иное → 422. # Валидация горизонта прогноза (#995) — до любой работы с БД: иное → 422.

View file

@ -1,8 +1,8 @@
"""Тесты §22-форсайта в parcels API (3b-ii, #995). """Тесты §22-форсайта в parcels API (3b-ii, #995).
Покрывает: Покрывает:
`horizon` query-param на POST /analyze: default 12; валидные {6,12,18} приняты; `horizon` query-param на POST /analyze: default 12; валидные {6,12,18,24} приняты
невалидные (7, 0) 422 ДО любой работы с БД. (24 ТЗ §12.1, Q1); невалидные (7, 0, 30) 422 ДО любой работы с БД.
Best-effort enqueue после успешного analyze: `forecast_site_finder_report.delay` Best-effort enqueue после успешного analyze: `forecast_site_finder_report.delay`
вызван ОДИН раз с (cad_num, horizon, created_by); ответ несёт forecast.status="pending". вызван ОДИН раз с (cad_num, horizon, created_by); ответ несёт forecast.status="pending".
Enqueue failure: `.delay` бросает analyze всё равно 200, forecast.status="unavailable", Enqueue failure: `.delay` бросает analyze всё равно 200, forecast.status="unavailable",
@ -149,10 +149,15 @@ def test_horizon_default_12_and_enqueued() -> None:
def test_horizon_valid_values_accepted() -> None: def test_horizon_valid_values_accepted() -> None:
"""horizon ∈ {6,12,18} принимается → 200 + enqueue с этим горизонтом.""" """horizon ∈ {6,12,18,24} принимается → 200 + enqueue с этим горизонтом.
24 ТЗ §12.1 (Q1): движок уже считает 24 на каждом ране (первый горизонт в
_DEFAULT_HORIZONS всего §9.x-стека), здесь проверяем что API его ПРИНИМАЕТ как
запрошенный primary и прокидывает в enqueue ровно как остальные.
"""
from app.core.db import get_db from app.core.db import get_db
for h in (6, 12, 18): for h in (6, 12, 18, 24):
db = _make_db_for_analyze() db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db) app.dependency_overrides[get_db] = _override_db(db)
_start_patches() _start_patches()
@ -172,10 +177,14 @@ def test_horizon_valid_values_accepted() -> None:
def test_horizon_invalid_returns_422() -> None: def test_horizon_invalid_returns_422() -> None:
"""horizon вне {6,12,18} (7, 0) → 422 ДО enqueue (delay не вызывается).""" """horizon вне {6,12,18,24} (7, 0, 30) → 422 ДО enqueue (delay не вызывается).
30 (за новым потолком 24) тоже отклоняется сетка дискретная, а не ge/le-диапазон:
guard от случайного расширения до неограниченного горизонта.
"""
from app.core.db import get_db from app.core.db import get_db
for h in (7, 0): for h in (7, 0, 30):
db = _make_db_for_analyze() db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db) app.dependency_overrides[get_db] = _override_db(db)
_start_patches() _start_patches()
@ -351,7 +360,7 @@ _REPORT_FIXTURE = {
} }
], ],
}, },
"meta": {"cad_num": _CAD, "district": "Кировский", "horizons": [6, 12, 18]}, "meta": {"cad_num": _CAD, "district": "Кировский", "horizons": [6, 12, 18, 24]},
} }

View file

@ -31,6 +31,7 @@ import pytest
from app.services.forecasting.demand_supply_forecast import ( from app.services.forecasting.demand_supply_forecast import (
_CONFIDENCE_CAP, _CONFIDENCE_CAP,
_CONFIDENCE_RANK,
_DEFICIT_RATIO_SATURATION, _DEFICIT_RATIO_SATURATION,
_HIDDEN_RELEASE_MONTHS, _HIDDEN_RELEASE_MONTHS,
_TOP_COMPETITORS, _TOP_COMPETITORS,
@ -1067,6 +1068,57 @@ class TestHorizonsAndRatePath:
norm_calls = mocks[_NORM].call_args_list norm_calls = mocks[_NORM].call_args_list
assert any(call.kwargs.get("rate_future") == 19.5 for call in norm_calls) assert any(call.kwargs.get("rate_future") == 19.5 for call in norm_calls)
def test_horizon_24_meaningful_not_degenerate(self) -> None:
"""ТЗ §12.1 (Q1): на 24 мес прогноз ОСМЫСЛЕННЫЙ, не пустой/деградировавший.
Не просто «принят» проверяем что КАЖДОЕ числовое поле посчитано (не None),
spрос линейно вырос (24 = 4×6-мес, 2×12-мес), скрытый ПД полностью созрел
(_hidden_release_fraction(24)=1.0 весь hidden в gross-предложении),
deficit_index в валидном [1,+1], confidence cap. С тем же стабом данных
24 НЕ ломается и НЕ обрезается на 18.
"""
with _Patches(
_METRICS=_metrics_stub(unit_velocity=10.0),
_NORM=_norm_stub(coefficient=0.8),
_MACRO_COEF=_macro_coef_stub(coefficient=1.1),
_SUPPLY=_supply_stub(open_units=300, hidden_units=200, future_units_by_horizon=40.0),
):
res = _run(horizons=[24])
f = res[0]
assert f.horizon_months == 24
# Спрос посчитан и ЛИНЕЙНО масштабирован на 24 (а не обрезан/занулён).
assert f.projected_demand_units == pytest.approx(10.0 * 0.8 * 1.1 * 24)
# Скрытый ПД на 24 ПОЛНОСТЬЮ созрел (фаза=1.0): весь hidden=200 в предложении.
assert f.hidden_release_units == pytest.approx(200.0)
# Предложение/баланс/индексы посчитаны (не None) — секция не деградировала.
assert f.projected_supply_units is not None
assert f.balance_units is not None
assert f.deficit_index is not None and -1.0 <= f.deficit_index <= 1.0
assert f.months_of_inventory is not None and f.months_of_inventory >= 0.0
# advisory-движок: confidence не выше cap даже при всех 'high'-компонентах.
assert f.confidence in _ALLOWED_CONFIDENCE
assert _CONFIDENCE_RANK[f.confidence] <= _CONFIDENCE_RANK[_CONFIDENCE_CAP]
def test_horizon_24_differs_from_18(self) -> None:
"""24 — отдельный горизонт, а не «то же что 18»: спрос/MOI растут с горизонтом.
Guard от скрытого clamp'а горизонта на 18 (если бы такой был — поля на 18 и 24
совпали бы). Спрос строго линеен, поэтому demand(24) > demand(18); валовое
предложение на 24 18 (окно шире больше future-юнитов попадает), значит MOI
тоже не вырождается в равенство при ненулевом спросе.
"""
with _Patches(
_METRICS=_metrics_stub(unit_velocity=10.0),
_NORM=_norm_stub(coefficient=0.8),
_MACRO_COEF=_macro_coef_stub(coefficient=1.1),
):
res = _run(horizons=[18, 24])
f18, f24 = res
assert f18.horizon_months == 18 and f24.horizon_months == 24
assert f18.projected_demand_units is not None and f24.projected_demand_units is not None
# Спрос строго растёт с горизонтом (линейность) — 24 ≠ 18.
assert f24.projected_demand_units > f18.projected_demand_units
# ── orchestrator: §9.7 competitors (top-N, graceful) ────────────────────────── # ── orchestrator: §9.7 competitors (top-N, graceful) ──────────────────────────

View file

@ -3,18 +3,18 @@
/** /**
* HorizonSelector segmented control «Горизонт прогноза» (958-B1 / #996). * HorizonSelector segmented control «Горизонт прогноза» (958-B1 / #996).
* *
* Controlled segmented control over the three forecast horizons accepted by * Controlled segmented control over the forecast horizons accepted by
* POST /api/v1/parcels/{cad}/analyze?horizon= (6 / 12 / 18; default 12). * POST /api/v1/parcels/{cad}/analyze?horizon= (6 / 12 / 18 / 24; default 12).
* Drives useParcelAnalyzeQuery on the Site Finder analysis screen. * Drives useParcelAnalyzeQuery on the Site Finder analysis screen.
* *
* A11y: role="radiogroup" wrapper + role="radio"/aria-checked per <button> * A11y: role="radiogroup" wrapper + role="radio"/aria-checked per <button>
* segment (real buttons native keyboard focus; visible focus ring kept). * segment (real buttons native keyboard focus; visible focus ring kept).
*/ */
const HORIZONS = [6, 12, 18] as const; const HORIZONS = [6, 12, 18, 24] as const;
interface Props { interface Props {
/** Currently selected horizon (one of 6 / 12 / 18). */ /** Currently selected horizon (one of 6 / 12 / 18 / 24). */
value: number; value: number;
/** Called with the newly selected horizon. */ /** Called with the newly selected horizon. */
onChange: (horizon: number) => void; onChange: (horizon: number) => void;

View file

@ -0,0 +1,42 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import { HorizonSelector } from "../HorizonSelector";
describe("HorizonSelector", () => {
it("renders one radio per accepted horizon, incl. 24 (ТЗ §12.1)", () => {
render(<HorizonSelector value={12} onChange={() => {}} />);
const radios = screen.getAllByRole("radio");
expect(radios.map((r) => r.textContent)).toEqual([
"6 мес",
"12 мес",
"18 мес",
"24 мес",
]);
});
it("marks the selected horizon via aria-checked", () => {
render(<HorizonSelector value={24} onChange={() => {}} />);
const selected = screen.getByRole("radio", { name: "24 мес" });
expect(selected).toHaveAttribute("aria-checked", "true");
expect(screen.getByRole("radio", { name: "12 мес" })).toHaveAttribute(
"aria-checked",
"false",
);
});
it("calls onChange with 24 when the 24-month segment is clicked", async () => {
const onChange = vi.fn();
render(<HorizonSelector value={12} onChange={onChange} />);
await userEvent.click(screen.getByRole("radio", { name: "24 мес" }));
expect(onChange).toHaveBeenCalledWith(24);
});
it("does not fire onChange while disabled", async () => {
const onChange = vi.fn();
render(<HorizonSelector value={12} onChange={onChange} disabled />);
await userEvent.click(screen.getByRole("radio", { name: "24 мес" }));
expect(onChange).not.toHaveBeenCalled();
});
});

View file

@ -47,7 +47,7 @@ import {
interface Props { interface Props {
cad: string; cad: string;
/** /**
* Horizon chosen in the analysis-screen HorizonSelector (6 / 12 / 18). When * Horizon chosen in the analysis-screen HorizonSelector (6 / 12 / 18 / 24). When
* set it takes priority over the report-derived horizon so the user's choice * set it takes priority over the report-derived horizon so the user's choice
* instantly drives which horizon is treated as the target (client-side), even * instantly drives which horizon is treated as the target (client-side), even
* before the recomputed forecast finishes (see AnalysisPageContent comment). * before the recomputed forecast finishes (see AnalysisPageContent comment).

View file

@ -420,7 +420,7 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
return fixtureAnalyze as ParcelAnalyzeResponse; return fixtureAnalyze as ParcelAnalyzeResponse;
} }
// Backend accepts ?horizon= ∈ {6,12,18} (default 12; 422 otherwise, #995). // Backend accepts ?horizon= ∈ {6,12,18,24} (default 12; 422 otherwise, #995).
const analyzeUrl = `/api/v1/parcels/${encodeURIComponent( const analyzeUrl = `/api/v1/parcels/${encodeURIComponent(
cad, cad,
)}/analyze?horizon=${horizon}`; )}/analyze?horizon=${horizon}`;