diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index cedaef5f..80d82b6d 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -918,8 +918,14 @@ _INLINE_FETCH_POLL_INTERVAL_S = 2 # (SiteFinderReport._SCHEMA_VERSION), отличный от ANALYZE_SCHEMA_VERSION ("analyze-1.0") # inline-dict analyze. GET /{cad}/forecast читает именно "1.0". _FORECAST_SCHEMA_VERSION = "1.0" -# Допустимые горизонты прогноза (мес), #995 — иное → 422. -_ALLOWED_FORECAST_HORIZONS = frozenset({6, 12, 18}) +# Допустимые горизонты прогноза (мес), #995 — иное → 422. ТЗ §12.1 называет +# 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) @@ -1277,7 +1283,7 @@ def analyze_parcel( ] = None, horizon: Annotated[ int, - Query(description="Горизонт прогноза, мес (#995): один из {6, 12, 18}"), + Query(description="Горизонт прогноза, мес (#995, ТЗ §12.1): один из {6, 12, 18, 24}"), ] = 12, body: Annotated[ AnalyzeRequest | None, @@ -1310,8 +1316,8 @@ def analyze_parcel( §22-форсайт (3b-ii, #995): после успешного persist рана best-effort enqueue'им `forecast_site_finder_report.delay(cad_num, horizon, ...)` — fire-and-forget, - провал Celery/Redis НЕ валит ответ (он уже успешен). `horizon` ∈ {6, 12, 18} - (иначе 422). Результат добавляется в ответ как `forecast` stub (additive); сам + провал Celery/Redis НЕ валит ответ (он уже успешен). `horizon` ∈ {6, 12, 18, 24} + (ТЗ §12.1; иначе 422). Результат добавляется в ответ как `forecast` stub (additive); сам §22-отчёт клиент забирает через GET /{cad}/forecast (202 pending → 200 ready). """ # Валидация горизонта прогноза (#995) — до любой работы с БД: иное → 422. diff --git a/backend/tests/api/v1/test_parcels_forecast.py b/backend/tests/api/v1/test_parcels_forecast.py index ec07b74f..8b645155 100644 --- a/backend/tests/api/v1/test_parcels_forecast.py +++ b/backend/tests/api/v1/test_parcels_forecast.py @@ -1,8 +1,8 @@ """Тесты §22-форсайта в parcels API (3b-ii, #995). Покрывает: - • `horizon` query-param на POST /analyze: default 12; валидные {6,12,18} приняты; - невалидные (7, 0) → 422 ДО любой работы с БД. + • `horizon` query-param на POST /analyze: default 12; валидные {6,12,18,24} приняты + (24 — ТЗ §12.1, Q1); невалидные (7, 0, 30) → 422 ДО любой работы с БД. • Best-effort enqueue после успешного analyze: `forecast_site_finder_report.delay` вызван ОДИН раз с (cad_num, horizon, created_by); ответ несёт forecast.status="pending". • 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: - """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 - for h in (6, 12, 18): + for h in (6, 12, 18, 24): db = _make_db_for_analyze() app.dependency_overrides[get_db] = _override_db(db) _start_patches() @@ -172,10 +177,14 @@ def test_horizon_valid_values_accepted() -> 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 - for h in (7, 0): + for h in (7, 0, 30): db = _make_db_for_analyze() app.dependency_overrides[get_db] = _override_db(db) _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]}, } diff --git a/backend/tests/services/forecasting/test_demand_supply_forecast.py b/backend/tests/services/forecasting/test_demand_supply_forecast.py index 78e3bfcc..abf56350 100644 --- a/backend/tests/services/forecasting/test_demand_supply_forecast.py +++ b/backend/tests/services/forecasting/test_demand_supply_forecast.py @@ -31,6 +31,7 @@ import pytest from app.services.forecasting.demand_supply_forecast import ( _CONFIDENCE_CAP, + _CONFIDENCE_RANK, _DEFICIT_RATIO_SATURATION, _HIDDEN_RELEASE_MONTHS, _TOP_COMPETITORS, @@ -1067,6 +1068,57 @@ class TestHorizonsAndRatePath: norm_calls = mocks[_NORM].call_args_list 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) ────────────────────────── diff --git a/frontend/src/components/site-finder/HorizonSelector.tsx b/frontend/src/components/site-finder/HorizonSelector.tsx index d0c64aa6..40608ba7 100644 --- a/frontend/src/components/site-finder/HorizonSelector.tsx +++ b/frontend/src/components/site-finder/HorizonSelector.tsx @@ -3,18 +3,18 @@ /** * HorizonSelector — segmented control «Горизонт прогноза» (958-B1 / #996). * - * Controlled segmented control over the three forecast horizons accepted by - * POST /api/v1/parcels/{cad}/analyze?horizon= (6 / 12 / 18; default 12). + * Controlled segmented control over the forecast horizons accepted by + * POST /api/v1/parcels/{cad}/analyze?horizon= (6 / 12 / 18 / 24; default 12). * Drives useParcelAnalyzeQuery on the Site Finder analysis screen. * * A11y: role="radiogroup" wrapper + role="radio"/aria-checked per