feat(forecasting): §9.x→§22 orchestrator (3a) + supply-side district resolution fix #1056

Merged
bot-backend merged 1 commit from feat/forecast-orchestrator-service into main 2026-06-05 03:21:35 +00:00
4 changed files with 842 additions and 31 deletions
Showing only changes of commit a0e61a38b4 - Show all commits

View file

@ -0,0 +1,340 @@
"""§9.x → §22 оркестратор: посчитать форсайт-слои и собрать `SiteFinderReport`.
Step 3a проводки форсайта (955-A2). §22-сборщик `assemble_report` (#988) ЧИСТЫЙ — он
лишь РАСКЛАДЫВАЕТ уже-посчитанные advisory-входы по восьми секциям, в БД не ходит и
§9.x-сервисы не зовёт. ВЫЗЫВАЮЩИЙ (этот модуль) обязан посчитать §9.x-слои и скормить
их сборщику. Здесь этот вызывающий: ОРКЕСТРАЦИОННЫЙ ШОВ, НЕ новая §9.x-математика.
Что делает `build_site_finder_report`:
1. Дефолт сегмента (audit Q1): если `obj_class` не задан выводим МОДАЛЬНЫЙ (самый
частый) класс конкурентов из `analyze["competitors"]`. Нет конкурентов / нет
классов obj_class=None (ЕКБ-wide all-class).
2. Считает §9.x-слои, КАЖДЫЙ через `_safe_call` (зеркало product_scoring._safe_call):
сбой/тонкие данные одного слоя этот слой None, отчёт всё равно собирается
(graceful: тонкий/сбойный слой ДЕГРАДИРУЕТ отчёт, НЕ роняет его целиком). Сами
§9.x-модули уже graceful внутри это belt-and-suspenders на оркестрационном шве.
3. Раскладывает слои в `assemble_report` (каждый в свой слот, с ПРАВИЛЬНОЙ
ГЕТЕРОГЕННОЙ! сигнатурой: forecasts/scenarios/special_indices берут `horizons=`,
score_card/future_supply берут `horizon_months=`, overlay берёт `target_class=`).
ADVISORY: весь форсайт-стек советующий (cap 'medium', #990) — отчёт лишь СОБИРАЕТ
advisory-выводы, не основание для инвест-решения. `advisory=True` прокидывается всюду.
ГЕТЕРОГЕННЫЕ сигнатуры §9.x (audit поймал ловушки НЕ унифицированы):
compute_market_metrics(db, *, district, premise_kind) §9.2
compute_all_layers(db, *, district, premise_kind) §9.3 (список строк)
compute_future_supply_pressure(db, *, district, horizon_months) §9.3 давление
compute_demand_supply_forecast(db, *, spec, district, cad_num, horizons) §9.8
compute_scenarios(db, *, spec, district, cad_num, horizons) §11
compute_score_card(db, *, spec, district, cad_num, horizon_months) §14.2
compute_special_indices(db, *, spec, district, cad_num, horizons) §25
build_forecast_overlay(db, *, district, cad_num, horizon_months, target_class) §10
МАКРО-СВЕЖЕСТЬ (audit MEDIUM, отложено в 3b): domrf-месячный макро ~123 дня устаревает
(key_rate свежий). У §22-схемы v1.0 (frozen `SiteFinderReport`/`ReportMeta`) НЕТ чистого
слота под caveat без смены контракта (новое поле + bump schema_version) что вне 3a.
Поэтому здесь только ЛОГИРУЕМ `macro_as_of` (последний наблюдаемый месяц макро), чтобы
сигнал не терялся; проводка его в тело отчёта follow-up 3b (вместе с endpoint/Celery).
"""
from __future__ import annotations
import logging
from collections import Counter
from collections.abc import Sequence
from typing import Any
from sqlalchemy.orm import Session
from app.services.forecasting.demand_supply_forecast import compute_demand_supply_forecast
from app.services.forecasting.macro_series import get_monthly_macro
from app.services.forecasting.product_scoring import compute_score_card
from app.services.forecasting.recommendation import build_forecast_overlay
from app.services.forecasting.report import SiteFinderReport
from app.services.forecasting.report_assembler import assemble_report
from app.services.forecasting.sales_series import SegmentSpec
from app.services.forecasting.scenarios import compute_scenarios
from app.services.forecasting.special_indices import compute_special_indices
from app.services.site_finder.future_supply import compute_future_supply_pressure
from app.services.site_finder.market_metrics import compute_market_metrics
from app.services.site_finder.supply_layers import SupplyLayerRow, compute_all_layers
logger = logging.getLogger(__name__)
# Горизонты прогноза по умолчанию (мес) — зеркало §9.x default grid (#952/#984/#986).
_DEFAULT_HORIZONS: tuple[int, ...] = (6, 12, 18, 24)
# Основной горизонт для single-горизонтных сервисов (score_card / overlay / future_supply).
# Берём 12 (типовой средне-срочный продуктовый горизонт) если он в сетке, иначе первый.
_PREFERRED_PRIMARY_HORIZON: int = 12
# Тип помещения — единый дефолт всего §9.x-стека (жилой сегмент ЕКБ).
_PREMISE_KIND: str = "квартира"
# Весь отчёт советующий (зеркало advisory-cap стека) — прокидывается в #990 cap.
_ADVISORY: bool = True
# Маппинг layer-номера → ключ агрегата supply_layers-сводки (#950, §9.3).
_LAYER_UNIT_KEY: dict[int, str] = {1: "open_units", 2: "hidden_units", 3: "future_units"}
def _modal_obj_class(competitors: Any) -> str | None:
"""Вывести МОДАЛЬНЫЙ (самый частый) класс конкурентов как дефолт сегмента. PURE.
audit Q1: user-дефолт сегмента = модальный класс конкуренции вокруг участка. Читаем
`obj_class` (или legacy-ключ `class`) каждого dict'а-конкурента, считаем Counter,
берём самый частый. Ничьи разрешаются детерминированно (по первому встреченному
стабильный порядок Counter.most_common для равных частот).
Graceful: competitors не список / пуст / ни у одного нет класса None
(ЕКБ-wide all-class сегмент честно «класс не уточнён»).
Args:
competitors: `analyze["competitors"]` (loosely-typed: ожидаем список dict'ов).
Returns:
Модальный класс (str) или None.
"""
if not isinstance(competitors, list):
return None
classes: list[str] = []
for comp in competitors:
if not isinstance(comp, dict):
continue
value = comp.get("obj_class")
if not (isinstance(value, str) and value):
value = comp.get("class")
if isinstance(value, str) and value:
classes.append(value)
if not classes:
return None
# most_common(1) — детерминированно: при равенстве частот сохраняется порядок вставки.
return Counter(classes).most_common(1)[0][0]
def _primary_horizon(horizons: Sequence[int]) -> int:
"""Основной горизонт для single-горизонтных сервисов. PURE.
_PREFERRED_PRIMARY_HORIZON (12 мес) если он в сетке, иначе первый горизонт сетки.
Пустая сетка не ожидается (вызывающий даёт дефолт), но на всякий случай 12.
"""
if _PREFERRED_PRIMARY_HORIZON in horizons:
return _PREFERRED_PRIMARY_HORIZON
return horizons[0] if horizons else _PREFERRED_PRIMARY_HORIZON
def _safe_call(label: str, fn: Any) -> Any:
"""Вызвать §9.x-сервис graceful: сбой → None + logger.exception (не crash отчёта).
Зеркало product_scoring._safe_call: любой §9.x-слой может бросить (тонкие данные / нет
геометрии cad_num / сбой SQL) логируем и возвращаем None, чтобы соответствующая
секция отчёта осталась пустой/частичной, а остальной отчёт собрался. Намеренно ловим
широкий Exception (изоляция одного слоя от отчёта) с ОБЯЗАТЕЛЬНЫМ logger.exception
НЕ молчаливое глотание. §9.x уже graceful внутри; это belt-and-suspenders на шве.
Args:
label: имя слоя для лога (диагностика какой §9.x-вызов деградировал).
fn: нулевой-аргумент thunk вокруг §9.x-вызова.
Returns:
Результат `fn()` или None при исключении.
"""
try:
return fn()
except Exception:
logger.exception("orchestrator: §9.x layer %s failed → section degraded", label)
return None
def _summarize_supply_layers(rows: Sequence[SupplyLayerRow] | None) -> dict[str, Any] | None:
"""Свернуть строки §9.3-слоёв (#950) в сводку-dict для слота `supply_layers`. PURE.
`compute_all_layers` отдаёт ПЛОСКИЙ список SupplyLayerRow (L1+L2+L3). Слот
`supply_layers` сборщика (#988 `_build_market_now`/`_domrf_coverage`) ждёт ОДИН dict,
не список поэтому агрегируем: Σ units по слою (open/hidden/future_units) + сырой
список строк (`rows`) для прозрачности. domrf_coverage здесь НЕ выводим (нет дешёвого
продьюсера в сервисах) сборщик возьмёт fallback из `analyze.market_data_coverage_pct`.
Graceful: None/пустой список None (слот останется пустым секция частична).
Args:
rows: вывод `compute_all_layers` (или None при сбое слоя).
Returns:
Сводка-dict (open_units/hidden_units/future_units/n_rows/rows) или None.
"""
if not rows:
return None
units_by_key: dict[str, int] = {"open_units": 0, "hidden_units": 0, "future_units": 0}
row_dicts: list[dict[str, Any]] = []
for row in rows:
row_dicts.append(row.as_dict())
key = _LAYER_UNIT_KEY.get(int(row.layer))
if key is not None and row.units_estimate is not None:
units_by_key[key] += int(row.units_estimate)
return {
"open_units": units_by_key["open_units"],
"hidden_units": units_by_key["hidden_units"],
"future_units": units_by_key["future_units"],
"n_rows": len(row_dicts),
"rows": row_dicts,
}
def _macro_as_of(macro: Any) -> str | None:
"""Последний наблюдаемый месяц макро-ряда (ISO) — для caveat-лога свежести. PURE.
`get_monthly_macro` отдаёт MonthlyMacro по ВОЗРАСТАНИЮ month по непрерывной сетке;
последний элемент самый поздний месяц с (carry-forward) данными. Используем только
для ЛОГА свежести (audit MEDIUM) проводка в тело отчёта отложена в 3b.
Graceful: пустой/None ряд None.
"""
if not macro:
return None
last = macro[-1]
month = getattr(last, "month", None)
isoformat = getattr(month, "isoformat", None)
if callable(isoformat):
return isoformat()
return str(month) if month is not None else None
def build_site_finder_report(
db: Session,
*,
analyze: dict[str, Any],
cad_num: str,
district: str | None,
obj_class: str | None = None,
horizons: Sequence[int] = _DEFAULT_HORIZONS,
) -> SiteFinderReport:
"""Посчитать §9.x-слои и собрать §22 `SiteFinderReport` для участка (ОРКЕСТРАЦИЯ).
ШОВ между §9.x-сервисами (которые ходят в БД) и ЧИСТЫМ §22-сборщиком (#988
`assemble_report`, который только раскладывает уже-посчитанные входы). НЕ новая
§9.x-математика только проводка: считаем слои раскладываем в сборщик.
Поток:
1. Дефолт сегмента: obj_class=None модальный класс конкурентов из `analyze`
(audit Q1). Строим `spec = SegmentSpec(obj_class=, district=)`.
2. Считаем восемь §9.x-слоёв, КАЖДЫЙ через `_safe_call` (graceful: сбой слоя
None его секция деградирует, отчёт собирается). Сигнатуры ГЕТЕРОГЕННЫ
(см. module docstring): forecasts/scenarios/special_indices `horizons=`;
score_card/future_supply `horizon_months=`; overlay `target_class=`.
3. Раскладываем в `assemble_report` (каждый слой в свой слот; supply-строки
сворачиваем в сводку-dict; overlay идёт в `recommendation_overlay=`).
МАКРО-СВЕЖЕСТЬ (audit MEDIUM): логируем `macro_as_of`; проводка в отчёт 3b
(нет чистого слота в схеме v1.0 без смены контракта). См. module docstring.
Args:
db: SQLAlchemy sync Session (read-only по факту §9.x только читают).
analyze: dict вывода analyze_parcel (loosely-typed; читаем defensive `.get()`).
cad_num: кадастровый номер участка (вход §9.7 конкурентов + центроид POI).
district: район участка (None ЕКБ-wide; §9.x резолвят админ-имя микро).
obj_class: целевой класс; None выводим модальный класс конкурентов (Q1).
horizons: горизонты прогноза (мес; по умолчанию _DEFAULT_HORIZONS).
Returns:
SiteFinderReport восемь секций §22, заполнены настолько, насколько хватило
§9.x-слоёв (частичный отчёт валиден); advisory=True; `as_dict()` JSON-safe.
"""
horizon_list = list(horizons)
primary = _primary_horizon(horizon_list)
# ── 1. Дефолт сегмента: модальный класс конкурентов (audit Q1) ──────────────
resolved_class = (
obj_class if obj_class is not None else _modal_obj_class(analyze.get("competitors"))
)
spec = SegmentSpec(obj_class=resolved_class, district=district)
logger.info(
"build_site_finder_report: cad_num=%s district=%s obj_class=%s (given=%s) "
"horizons=%s primary=%d",
cad_num,
district,
resolved_class,
obj_class,
horizon_list,
primary,
)
# ── 2. §9.x-слои, каждый graceful через _safe_call (ГЕТЕРОГЕННЫЕ сигнатуры) ──
market_metrics = _safe_call(
"market_metrics",
lambda: compute_market_metrics(db, district=district, premise_kind=_PREMISE_KIND),
)
supply_rows = _safe_call(
"supply_layers",
lambda: compute_all_layers(db, district=district, premise_kind=_PREMISE_KIND),
)
supply_layers = _summarize_supply_layers(supply_rows)
future_supply = _safe_call(
"future_supply",
lambda: compute_future_supply_pressure(
db, district=district, horizon_months=primary, premise_kind=_PREMISE_KIND
),
)
forecasts = _safe_call(
"demand_supply_forecast",
lambda: compute_demand_supply_forecast(
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
),
)
scenarios = _safe_call(
"scenarios",
lambda: compute_scenarios(
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
),
)
product_scores = _safe_call(
"score_card",
lambda: compute_score_card(
db, spec=spec, district=district, cad_num=cad_num, horizon_months=primary
),
)
special_indices = _safe_call(
"special_indices",
lambda: compute_special_indices(
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
),
)
recommendation_overlay = _safe_call(
"forecast_overlay",
lambda: build_forecast_overlay(
db,
district=district,
cad_num=cad_num,
horizon_months=primary,
target_class=resolved_class,
),
)
# ── Макро-свежесть (audit MEDIUM): только лог; проводка в отчёт — 3b ─────────
macro = _safe_call("monthly_macro", lambda: get_monthly_macro(db))
macro_as_of = _macro_as_of(macro)
if macro_as_of is not None:
logger.info(
"build_site_finder_report: macro_as_of=%s (domrf monthly may be stale; "
"surfacing into report deferred to 3b)",
macro_as_of,
)
# ── 3. Раскладка в ЧИСТЫЙ §22-сборщик (#988) — каждый слой в свой слот ───────
return assemble_report(
analyze,
market_metrics=market_metrics,
supply_layers=supply_layers,
forecasts=forecasts,
future_supply=future_supply,
scenarios=scenarios,
recommendation_overlay=recommendation_overlay,
product_scores=product_scores,
special_indices=special_indices,
segment=spec.as_dict(),
cad_num=cad_num,
district=district,
horizons=horizon_list,
advisory=_ADVISORY,
)

View file

@ -52,6 +52,7 @@ from typing import Any, Literal
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.site_finder.district_resolver import resolve_objective_districts
from app.services.site_finder.market_metrics import compute_market_metrics
logger = logging.getLogger(__name__)
@ -266,6 +267,15 @@ def _min_confidence(values: Sequence[Confidence | None]) -> Confidence:
# / insider_manual живут только в таблице и невыводимы пересчётом. Берём построчно
# (layer / units / expected_online_date / confidence) — взвешивание L3 по горизонту
# делаем в Python (per-row дата vs today+horizon), агрегацию L1/L2 — тоже в Python.
#
# District-фильтр — по НАБОРУ имён (:names), не по скаляру: view МИКС-вокабулярный.
# Layer1 (open) строки ключуются неформальным МИКРО-районом (objective_lots.district,
# напр. «Втузгородок»/«ЖБИ»), а Layer2/3 (hidden/future) — АДМИН-районом (domrf
# district_name, напр. «Кировский»). Скалярный фильтр по админ-имени промахивался бы
# мимо ВСЕХ L1 микро-строк → open=0 → ложный дефицит. Резолвер разворачивает админ →
# микро, а сам админ добавляем в набор для L2/3 (см. compute_future_supply_pressure).
# Bug: парцель 66:41:0702048:27 (Кировский) давал projected_supply=0 → ложный BUY.
# psycopg v3: ANY(CAST(:names AS text[])) — массивный bind, НИКОГДА :x::type.
_SUPPLY_LAYERS_SQL = text(
"""
SELECT
@ -275,8 +285,8 @@ _SUPPLY_LAYERS_SQL = text(
confidence
FROM v_supply_layers_latest
WHERE (
CAST(:district AS text) IS NULL
OR district_name = CAST(:district AS text)
CAST(:has_district AS boolean) IS FALSE
OR district_name = ANY(CAST(:names AS text[]))
)
"""
)
@ -292,6 +302,9 @@ def compute_future_supply_pressure(
"""Вычислить индекс будущего давления предложения ТЗ §9.3 для района (на лету).
Шаги:
0. Резолвить district (админ-имя ЕКБ) набор имён для МИКС-вокабулярного view:
resolve_objective_districts даёт МИКРО-районы (ключ L1-строк), к ним добавляем
сам админ (ключ L2/3-строк). None EKB-wide (без фильтра).
1. Прочитать ВСЕ свежие supply-строки района из VIEW `v_supply_layers_latest`
(включает невыводимые L3 graddoc_stub/insider_manual).
2. Агрегировать: open=Σ L1, hidden=Σ L2, future=Σ L3·горизонт-вес (NULL-дата
@ -304,8 +317,27 @@ def compute_future_supply_pressure(
Возвращает FutureSupplyPressure ВСЕГДА (даже на пустых данных): тогда index=None,
months_of_pressure=None, confidence='low'. Никогда не бросает, никогда деления на 0.
"""
# ── 0. District → набор имён для МИКС-вокабулярного view ───────────────────
# view ключует L1-строки МИКРО-районом (objective_lots.district), а L2/3 —
# АДМИН-районом (domrf district_name). resolve_objective_districts разворачивает
# админ-имя в чистые микро (для L1); сам админ добавляем в набор (для L2/3). Для
# raw-микро входа резолвер вернёт [micro] → names=[micro] (legacy сохранён). None
# (вне 8 полигонов / 'не определён') → has_district=False → EKB-wide без фильтра.
# micros is None ⇔ EKB-wide (вход None/'не определён' ИЛИ админ без чистых алиасов);
# зеркало market_metrics has_district. Иначе micros — непустой набор микро-районов.
micros = resolve_objective_districts(db, district)
names: list[str] = []
if micros is not None and district is not None:
# dedup сохраняя порядок: микро (L1) + сам админ-район (L2/3). Инлайн-проверка
# district is not None (а не через has_district-флаг) сужает тип для mypy —
# resolver вернул бы None на None-входе, так что эта ветка ⇔ has_district.
names = list(dict.fromkeys([*micros, district]))
has_district = bool(names)
# ── 1. Supply-строки района из VIEW (graceful: сбой/пусто → []) ────────────
rows = _query_supply_rows(db, {"district": district})
rows = _query_supply_rows(
db, {"has_district": has_district, "names": names, "district": district}
)
# ── 2. Агрегация по слоям + сбор компонентных confidence ──────────────────
today = date.today()

View file

@ -0,0 +1,332 @@
"""Unit-тесты §9.x → §22 оркестратора (`build_site_finder_report`, step 3a, 955-A2).
Оркестратор ШОВ: считает §9.x-слои и раскладывает их в ЧИСТЫЙ §22-сборщик
(`assemble_report`). Тесты пинят ТРИ контракта шва, БЕЗ живой БД (db = sentinel,
§9.x-вызовы запатчены):
МОДАЛЬНЫЙ дефолт класса (audit Q1): competitors [Комфорт, Комфорт, Бизнес]
obj_class="Комфорт"; пустые competitors None (ЕКБ-wide). Явный obj_class имеет
приоритет над выводом.
ПРОВОДКА/СИГНАТУРЫ (ловушки audit): патчим §9.x на sentinel'ы, ассертим что
`assemble_report` получил КАЖДЫЙ вход в правильном слоте, а §9.x вызваны с
ПРАВИЛЬНОЙ ГЕТЕРОГЕННОЙ сигнатурой (forecasts/scenarios/special_indices `horizons=`;
score_card/future_supply `horizon_months=`; overlay `target_class=`). Регрессия
(не тот kwarg) тест падает.
GRACEFUL: один §9.x-слой бросает отчёт всё равно собирается (его слот None),
исключение НЕ всплывает.
Детерминированно, без сети, без LLM. DATABASE_URL до импорта app-модулей (зеркало
соседних тестов на случай side-effect'ов импорта пакета forecasting).
"""
from __future__ import annotations
import contextlib
import os
from collections.abc import Iterator, Mapping
from typing import Any
from unittest.mock import MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.forecasting.orchestrator import (
_modal_obj_class,
_primary_horizon,
_summarize_supply_layers,
build_site_finder_report,
)
from app.services.forecasting.report import SiteFinderReport
# Полностью-квалифицированный путь к каждому §9.x-символу как он ИМПОРТИРОВАН в
# orchestrator (патчим там, где используется, не там, где определён).
_MOD = "app.services.forecasting.orchestrator"
def _analyze(competitors: list[dict[str, Any]] | None = None) -> dict[str, Any]:
"""Минимальный analyze-dict (только то, что читает оркестратор/сборщик)."""
return {
"cad_num": "66:41:0702048:27",
"district": {"district_name": "Ленинский"},
"competitors": competitors if competitors is not None else [],
}
# ──────────────────────────────────────────────────────────────────────────────
# Модальный дефолт класса (audit Q1).
# ──────────────────────────────────────────────────────────────────────────────
class TestModalObjClass:
def test_modal_picks_most_common(self) -> None:
comps = [{"obj_class": "Комфорт"}, {"obj_class": "Комфорт"}, {"obj_class": "Бизнес"}]
assert _modal_obj_class(comps) == "Комфорт"
def test_empty_competitors_none(self) -> None:
assert _modal_obj_class([]) is None
def test_competitors_without_class_none(self) -> None:
# есть конкуренты, но ни у одного нет класса → None (честно «не уточнён»).
assert _modal_obj_class([{"obj_id": 1}, {"comm_name": "ЖК Альфа"}]) is None
def test_legacy_class_key_supported(self) -> None:
# legacy-ключ `class` (а не `obj_class`) тоже читается.
assert _modal_obj_class([{"class": "Стандарт"}, {"class": "Стандарт"}]) == "Стандарт"
def test_obj_class_preferred_over_legacy(self) -> None:
comps = [{"obj_class": "Бизнес", "class": "Комфорт"}]
assert _modal_obj_class(comps) == "Бизнес"
def test_non_list_none(self) -> None:
assert _modal_obj_class(None) is None
assert _modal_obj_class("Комфорт") is None # type: ignore[arg-type]
class TestPrimaryHorizon:
def test_prefers_12_when_present(self) -> None:
assert _primary_horizon([6, 12, 18, 24]) == 12
def test_falls_back_to_first_when_no_12(self) -> None:
assert _primary_horizon([6, 18, 24]) == 6
def test_empty_defaults_to_12(self) -> None:
assert _primary_horizon([]) == 12
# ──────────────────────────────────────────────────────────────────────────────
# Хелпер сводки supply-слоёв (#950 → слот supply_layers сборщика).
# ──────────────────────────────────────────────────────────────────────────────
class TestSummarizeSupplyLayers:
def test_none_or_empty_none(self) -> None:
assert _summarize_supply_layers(None) is None
assert _summarize_supply_layers([]) is None
def test_aggregates_units_by_layer(self) -> None:
rows = [
MagicMock(layer=1, units_estimate=1200, as_dict=lambda: {"layer": 1}),
MagicMock(layer=2, units_estimate=800, as_dict=lambda: {"layer": 2}),
MagicMock(layer=3, units_estimate=500, as_dict=lambda: {"layer": 3}),
MagicMock(layer=1, units_estimate=None, as_dict=lambda: {"layer": 1}), # None skip
]
summary = _summarize_supply_layers(rows)
assert summary is not None
assert summary["open_units"] == 1200
assert summary["hidden_units"] == 800
assert summary["future_units"] == 500
assert summary["n_rows"] == 4
# ──────────────────────────────────────────────────────────────────────────────
# ПРОВОДКА + СИГНАТУРНЫЕ ловушки (audit): §9.x → sentinel, ассерт слот+kwarg.
# ──────────────────────────────────────────────────────────────────────────────
def _patched_layers() -> dict[str, Any]:
"""Контекст-менеджеры-патчи всех §9.x на распознаваемые sentinel-возвраты."""
# supply_layers возвращает СПИСОК строк (оркестратор свернёт в сводку-dict).
supply_row = MagicMock(layer=1, units_estimate=10, as_dict=lambda: {"layer": 1})
return {
"compute_market_metrics": MagicMock(return_value="MM"),
"compute_all_layers": MagicMock(return_value=[supply_row]),
"compute_future_supply_pressure": MagicMock(return_value="FSP"),
"compute_demand_supply_forecast": MagicMock(return_value=["F6", "F12"]),
"compute_scenarios": MagicMock(return_value=["SC"]),
"compute_score_card": MagicMock(return_value="PSC"),
"compute_special_indices": MagicMock(return_value="SI"),
"build_forecast_overlay": MagicMock(return_value="OVL"),
"get_monthly_macro": MagicMock(return_value=[]),
}
@contextlib.contextmanager
def _patch_layers(mocks: Mapping[str, Any], assemble: Any) -> Iterator[None]:
"""Запатчить ВСЕ §9.x-символы + assemble_report (по `mocks`-словарю). Компактно.
Патчим там, где символы ИМПОРТИРОВАНЫ в orchestrator (`_MOD`), не там, где определены.
"""
with contextlib.ExitStack() as stack:
for name, mock in mocks.items():
stack.enter_context(patch(f"{_MOD}.{name}", mock))
stack.enter_context(patch(f"{_MOD}.assemble_report", side_effect=assemble))
yield
class TestWiringAndSignatures:
"""Патчим §9.x на sentinel'ы + перехватываем assemble_report → ассертим слоты+kwargs."""
def _run(
self, *, obj_class: str | None = None, competitors: list[dict[str, Any]] | None = None
) -> tuple[SiteFinderReport, dict[str, Any], dict[str, Any]]:
mocks = _patched_layers()
captured: dict[str, Any] = {}
def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport:
captured["analyze"] = analyze
captured.update(kwargs)
return SiteFinderReport()
with _patch_layers(mocks, _fake_assemble):
report = build_site_finder_report(
MagicMock(name="db"),
analyze=_analyze(competitors),
cad_num="66:41:0702048:27",
district="Ленинский",
obj_class=obj_class,
horizons=[6, 12, 18],
)
return report, captured, mocks
def test_each_layer_lands_in_correct_slot(self) -> None:
_report, captured, _mocks = self._run(obj_class="Комфорт")
# assemble_report вызван (captured заполнен) — каждый sentinel в СВОЁМ слоте.
assert captured["market_metrics"] == "MM"
assert captured["future_supply"] == "FSP"
assert captured["forecasts"] == ["F6", "F12"]
assert captured["scenarios"] == ["SC"]
assert captured["product_scores"] == "PSC"
assert captured["special_indices"] == "SI"
# overlay идёт именно в recommendation_overlay (НЕ product_tz/product_scores).
assert captured["recommendation_overlay"] == "OVL"
# supply_layers свёрнут в сводку-dict (не сырой список строк).
assert isinstance(captured["supply_layers"], dict)
assert captured["supply_layers"]["open_units"] == 10
# meta-проводка.
assert captured["cad_num"] == "66:41:0702048:27"
assert captured["district"] == "Ленинский"
assert captured["horizons"] == [6, 12, 18]
assert captured["advisory"] is True
assert captured["segment"] == {
"obj_class": "Комфорт",
"room_bucket": None,
"district": "Ленинский",
"price_bucket": None,
}
def test_horizons_services_get_horizons_kwarg(self) -> None:
"""forecasts/scenarios/special_indices ДОЛЖНЫ получить `horizons=` (Sequence)."""
_report, _captured, mocks = self._run(obj_class="Комфорт")
horizons_services = (
"compute_demand_supply_forecast",
"compute_scenarios",
"compute_special_indices",
)
for name in horizons_services:
_args, kwargs = mocks[name].call_args
assert kwargs.get("horizons") == [6, 12, 18], f"{name} must take horizons="
assert "horizon_months" not in kwargs, f"{name} must NOT take horizon_months"
assert kwargs.get("cad_num") == "66:41:0702048:27"
assert kwargs.get("district") == "Ленинский"
# spec прокинут как SegmentSpec.
assert kwargs["spec"].obj_class == "Комфорт"
def test_horizon_months_services_get_int_kwarg(self) -> None:
"""score_card/future_supply ДОЛЖНЫ получить `horizon_months=` (int=primary=12)."""
_report, _captured, mocks = self._run(obj_class="Комфорт")
_args, sc_kwargs = mocks["compute_score_card"].call_args
assert sc_kwargs.get("horizon_months") == 12 # 12 в сетке [6,12,18] → primary
assert "horizons" not in sc_kwargs
assert sc_kwargs["spec"].obj_class == "Комфорт"
_args, fsp_kwargs = mocks["compute_future_supply_pressure"].call_args
assert fsp_kwargs.get("horizon_months") == 12
assert "horizons" not in fsp_kwargs
def test_overlay_gets_target_class_not_spec(self) -> None:
"""overlay ДОЛЖЕН получить `target_class=` (str) + `horizon_months=`, НЕ spec."""
_report, _captured, mocks = self._run(obj_class="Бизнес")
_args, kwargs = mocks["build_forecast_overlay"].call_args
assert kwargs.get("target_class") == "Бизнес"
assert kwargs.get("horizon_months") == 12
assert "spec" not in kwargs
assert "horizons" not in kwargs
def test_market_metrics_and_supply_use_premise_kind(self) -> None:
"""§9.2/§9.3 ходят по premise_kind, без spec/horizons."""
_report, _captured, mocks = self._run(obj_class="Комфорт")
_args, mm_kwargs = mocks["compute_market_metrics"].call_args
assert mm_kwargs.get("premise_kind") == "квартира"
assert mm_kwargs.get("district") == "Ленинский"
_args, al_kwargs = mocks["compute_all_layers"].call_args
assert al_kwargs.get("premise_kind") == "квартира"
def test_modal_class_default_flows_into_spec_and_overlay(self) -> None:
"""obj_class=None + конкуренты [Комфорт×2, Бизнес] → модальный 'Комфорт' всюду."""
comps = [{"obj_class": "Комфорт"}, {"obj_class": "Комфорт"}, {"obj_class": "Бизнес"}]
_report, captured, mocks = self._run(obj_class=None, competitors=comps)
assert captured["segment"]["obj_class"] == "Комфорт"
_args, ovl_kwargs = mocks["build_forecast_overlay"].call_args
assert ovl_kwargs.get("target_class") == "Комфорт"
_args, f_kwargs = mocks["compute_demand_supply_forecast"].call_args
assert f_kwargs["spec"].obj_class == "Комфорт"
def test_empty_competitors_default_class_none(self) -> None:
"""obj_class=None + пустые конкуренты → None (ЕКБ-wide all-class)."""
_report, captured, mocks = self._run(obj_class=None, competitors=[])
assert captured["segment"]["obj_class"] is None
_args, ovl_kwargs = mocks["build_forecast_overlay"].call_args
assert ovl_kwargs.get("target_class") is None
# ──────────────────────────────────────────────────────────────────────────────
# GRACEFUL: один §9.x-слой бросает → отчёт всё равно собирается, слот None.
# ──────────────────────────────────────────────────────────────────────────────
class TestGracefulDegradation:
def test_one_layer_raises_report_still_assembles(self) -> None:
mocks = _patched_layers()
# forecasts-слой бросает — отчёт ДОЛЖЕН собраться с forecasts=None.
mocks["compute_demand_supply_forecast"] = MagicMock(side_effect=RuntimeError("boom"))
captured: dict[str, Any] = {}
def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport:
captured.update(kwargs)
return SiteFinderReport()
with _patch_layers(mocks, _fake_assemble):
# НЕ должно бросить, несмотря на падение слоя.
report = build_site_finder_report(
MagicMock(name="db"),
analyze=_analyze([{"obj_class": "Комфорт"}]),
cad_num="66:41:0702048:27",
district="Ленинский",
horizons=[6, 12, 18],
)
assert isinstance(report, SiteFinderReport)
# упавший слой → None в слоте; остальные сохранены.
assert captured["forecasts"] is None
assert captured["market_metrics"] == "MM"
assert captured["scenarios"] == ["SC"]
def test_all_layers_raise_report_still_assembles(self) -> None:
"""Даже если ВСЕ §9.x-слои бросают — отчёт собирается (все слоты None)."""
boom = MagicMock(side_effect=RuntimeError("boom"))
# Каждый §9.x-символ → бросающий mock (имена зеркалят _patched_layers).
mocks = {name: boom for name in _patched_layers()}
captured: dict[str, Any] = {}
def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport:
captured.update(kwargs)
return SiteFinderReport()
with _patch_layers(mocks, _fake_assemble):
report = build_site_finder_report(
MagicMock(name="db"),
analyze=_analyze([{"obj_class": "Комфорт"}]),
cad_num="66:41:0702048:27",
district="Ленинский",
)
assert isinstance(report, SiteFinderReport)
for slot in (
"market_metrics",
"supply_layers",
"future_supply",
"forecasts",
"scenarios",
"product_scores",
"special_indices",
"recommendation_overlay",
):
assert captured[slot] is None, f"{slot} should degrade to None"
# сегмент всё равно проброшен (он PURE, не от §9.x).
assert captured["segment"]["obj_class"] == "Комфорт"

View file

@ -5,17 +5,22 @@
_months_between, _months_of_pressure (guard div-by-zero None), _saturating_index
(монотонность + clamp [0,1] + named-saturation), _monthly_absorption_units (оба
пути из market_metrics), _min_confidence (худшая тянет вниз, whitelisted).
compute_future_supply_pressure через MagicMock-сессию + mock compute_market_metrics:
форма SQL (читает VIEW v_supply_layers_latest, НЕ базовую таблицу / recompute;
CAST(:x AS type) не :x::type), агрегация по слоям, горизонт-взвешивание L3,
compute_future_supply_pressure через MagicMock-сессию + mock compute_market_metrics
+ mock resolve_objective_districts: форма SQL (читает VIEW v_supply_layers_latest,
НЕ базовую таблицу / recompute; district-фильтр через ANY(CAST(:names AS text[])),
НЕ скаляр :district::type), resolver-wiring (админ микро + сам админ, дедуп;
None EKB-wide без фильтра), агрегация по слоям, горизонт-взвешивание L3,
graceful empty/thin index None + confidence 'low', confidence=MIN.
Детерминированно, без LLM. Мокаем compute_market_metrics + db (нет живой БД).
Детерминированно, без LLM. Мокаем compute_market_metrics + resolve_objective_districts
+ db (нет живой БД).
"""
from __future__ import annotations
import os
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import date
from unittest.mock import MagicMock, patch
@ -40,6 +45,21 @@ from app.services.site_finder.future_supply import (
# Путь патча reused-сервиса (импортирован в модуль future_supply).
_MARKET = "app.services.site_finder.future_supply.compute_market_metrics"
# Путь патча district-резолвера (импортирован в модуль future_supply). Резолвер бьёт
# живую БД (ekb_districts / ekb_district_alias) — в unit-тестах всегда мокаем.
_RESOLVE = "app.services.site_finder.future_supply.resolve_objective_districts"
def _resolve_passthrough(_db: object, district: str | None) -> list[str] | None:
"""Дефолтная заглушка резолвера: raw-микро pass-through (зеркало legacy-ветки).
district raw-микро («X»/«Автовокзал» в большинстве тестов) `[district]` (фильтр
по самому имени, как было до фикса); None None (EKB-wide). Тесты, проверяющие
adminmicro разворот, передают свой side_effect через `_patched(resolve=)`.
"""
return [district] if district is not None else None
# Разрешённый vocab confidence (зеркало CHECK м.125 / market_metrics).
_ALLOWED_CONFIDENCE = {"high", "medium", "low"}
@ -313,6 +333,25 @@ def _metrics_stub(
return m
@contextmanager
def _patched(
*, metrics: MagicMock | None = None, resolve: object = _resolve_passthrough
) -> Iterator[tuple[MagicMock, MagicMock]]:
"""Патчит compute_market_metrics + resolve_objective_districts разом.
`resolve`: либо callable (side_effect резолвит вход в набор микро), либо значение
(return_value). По умолчанию pass-through `[district]` legacy фильтр-по-микро, под
который написано большинство compute-тестов (district там raw-микро «X»/«Автовокзал»).
"""
m = metrics if metrics is not None else _metrics_stub()
with patch(_MARKET, return_value=m) as mm, patch(_RESOLVE) as rs:
if callable(resolve):
rs.side_effect = resolve
else:
rs.return_value = resolve
yield mm, rs
# ── compute_future_supply_pressure: SQL форма (читает VIEW) ────────────────────
@ -322,7 +361,7 @@ class TestComputeReadsView:
# включает невыводимые L3 graddoc_stub(#956)/insider_manual. НЕ базовую
# таблицу supply_layers напрямую и НЕ пересчёт compute_all_layers.
db = _mock_db([])
with patch(_MARKET, return_value=_metrics_stub()):
with _patched():
compute_future_supply_pressure(db, district="Автовокзал")
sql = _executed_sql(db).lower()
assert "v_supply_layers_latest" in sql
@ -331,25 +370,93 @@ class TestComputeReadsView:
assert "objective_lots" not in sql
assert "domrf_kn_objects" not in sql
def test_sql_uses_cast_not_double_colon(self) -> None:
def test_sql_uses_array_bind_not_double_colon(self) -> None:
# psycopg v3: district-фильтр через ANY(CAST(:names AS text[])) — массивный bind,
# НЕ :x::type (SQLAlchemy его молча роняет → syntax error).
db = _mock_db([])
with patch(_MARKET, return_value=_metrics_stub()):
with _patched():
compute_future_supply_pressure(db, district="Автовокзал")
sql = _executed_sql(db)
assert "CAST(:district AS text)" in sql
assert "ANY(CAST(:names AS text[]))" in sql
assert "CAST(:has_district AS boolean)" in sql
_assert_no_double_colon_cast(sql)
def test_params_pass_district(self) -> None:
def test_district_not_a_bare_scalar_filter(self) -> None:
# Regression guard: МИКС-вокабулярный view нельзя фильтровать скаляром по одному
# имени (промахивается мимо L1 микро ИЛИ L2/3 админ строк → ложный дефицит, баг
# парцеля 66:41:0702048:27 / Кировский). Фильтр обязан идти по НАБОРУ имён.
db = _mock_db([])
with patch(_MARKET, return_value=_metrics_stub()) as mm:
with _patched():
compute_future_supply_pressure(db, district="Автовокзал")
sql = _executed_sql(db)
# ни один из старых скалярных вариантов фильтра по district не должен вернуться.
assert "district_name = CAST(:district AS text)" not in sql
assert "district_name = :district" not in sql
def test_params_pass_district_to_market_metrics(self) -> None:
# district (админ-имя) пробрасывается как есть в reused market_metrics — там свой
# резолвер развернёт его в микро (единый район для absorption).
db = _mock_db([])
with _patched() as (mm, _rs):
compute_future_supply_pressure(
db, district="Академический", horizon_months=24, premise_kind="квартира"
)
assert _executed_params(db)["district"] == "Академический"
# district пробрасывается и в reused market_metrics (единый район).
assert mm.call_args.kwargs["district"] == "Академический"
# ── compute_future_supply_pressure: resolver-wiring (admin→micro+admin) ────────
class TestComputeResolverWiring:
def test_admin_resolves_to_micros_plus_admin(self) -> None:
# Баг-репро: «Кировский» (АДМИН) → резолвер даёт микро [Втузгородок, ЖБИ]. Фильтр
# :names обязан быть микро + сам админ (для L2/3 admin-named строк) и дедупнут.
db = _mock_db([])
with _patched(resolve=lambda _db, _d: ["Втузгородок", "ЖБИ"]) as (_mm, rs):
compute_future_supply_pressure(db, district="Кировский")
params = _executed_params(db)
assert params["names"] == ["Втузгородок", "ЖБИ", "Кировский"]
assert params["has_district"] is True
# резолвер вызван именно с входным district.
assert rs.call_args.args[1] == "Кировский"
def test_admin_micros_already_containing_admin_deduped(self) -> None:
# Если резолвер вернул набор, уже содержащий сам админ, — не дублируем имя.
db = _mock_db([])
with _patched(resolve=lambda _db, _d: ["Кировский", "Втузгородок"]):
compute_future_supply_pressure(db, district="Кировский")
assert _executed_params(db)["names"] == ["Кировский", "Втузгородок"]
def test_raw_micro_input_filters_by_itself(self) -> None:
# raw-микро вход (legacy внутренний вызов): резолвер вернул [micro] → names=[micro]
# (сам админ == вход → дедуп оставляет одно имя). Legacy фильтр-по-микро сохранён.
db = _mock_db([])
with _patched(resolve=lambda _db, _d: ["Втузгородок"]):
compute_future_supply_pressure(db, district="Втузгородок")
assert _executed_params(db)["names"] == ["Втузгородок"]
assert _executed_params(db)["has_district"] is True
def test_none_district_no_filter(self) -> None:
# district None → резолвер None → has_district False, names=[] (EKB-wide, без
# district-фильтра). Зеркало market_metrics EKB-wide ветки.
db = _mock_db([])
with _patched(resolve=lambda _db, _d: None):
compute_future_supply_pressure(db, district=None)
params = _executed_params(db)
assert params["has_district"] is False
assert params["names"] == []
def test_admin_without_aliases_ekb_wide(self) -> None:
# Админ-имя, но резолвер вернул None (дыра в словаре) → EKB-wide fallback (НЕ
# пустой фильтр-«0 строк гарантированно»). has_district False.
db = _mock_db([])
with _patched(resolve=lambda _db, _d: None):
compute_future_supply_pressure(db, district="Кировский")
params = _executed_params(db)
assert params["has_district"] is False
assert params["names"] == []
# ── compute_future_supply_pressure: агрегация + композит ───────────────────────
@ -381,7 +488,7 @@ class TestComputeAggregation:
]
db = _mock_db(rows)
# absorption: available 120, mos 6 → 20 ед/мес.
with patch(_MARKET, return_value=_metrics_stub(months_of_supply=6.0, n_available=120)):
with _patched(metrics=_metrics_stub(months_of_supply=6.0, n_available=120)):
res = compute_future_supply_pressure(db, district="X", horizon_months=12)
assert res.open_units == 300
assert res.hidden_units == 200
@ -408,7 +515,7 @@ class TestComputeAggregation:
{"layer": 2, "units_estimate": 100, "expected_online_date": None, "confidence": "high"},
]
db = _mock_db(rows)
with patch(_MARKET, return_value=_metrics_stub(months_of_supply=5.0, n_available=100)):
with _patched(metrics=_metrics_stub(months_of_supply=5.0, n_available=100)):
res = compute_future_supply_pressure(db, district="X")
# absorption 100/5=20; давление только от hidden 100 → 100/20=5 мес.
assert res.monthly_absorption_units == pytest.approx(20.0)
@ -421,7 +528,7 @@ class TestComputeAggregation:
{"layer": 2, "units_estimate": 50, "expected_online_date": None, "confidence": "high"},
]
db = _mock_db(rows)
with patch(_MARKET, return_value=_metrics_stub(confidence="low")):
with _patched(metrics=_metrics_stub(confidence="low")):
res = compute_future_supply_pressure(db, district="X")
assert res.confidence == "low"
@ -430,7 +537,7 @@ class TestComputeAggregation:
{"layer": 2, "units_estimate": 50, "expected_online_date": None, "confidence": "high"},
]
db = _mock_db(rows)
with patch(_MARKET, return_value=_metrics_stub(confidence="high")):
with _patched(metrics=_metrics_stub(confidence="high")):
res = compute_future_supply_pressure(db, district="X")
assert res.confidence == "high"
assert res.confidence in _ALLOWED_CONFIDENCE
@ -444,11 +551,10 @@ class TestComputeGraceful:
# Пустой склад (worker ещё не наполнил м.125 на prod) + нет поглощения →
# index None, months_of_pressure None, confidence low. НЕ crash.
db = _mock_db([])
with patch(
_MARKET,
return_value=_metrics_stub(
with _patched(
metrics=_metrics_stub(
absorption_rate=None, months_of_supply=None, n_available=0, confidence="low"
),
)
):
res = compute_future_supply_pressure(db, district="Пусто")
assert res.index is None
@ -463,7 +569,7 @@ class TestComputeGraceful:
# Склад пуст, НО рынок измерим → очереди нет → давление 0.0, index 0.0
# (валидное «нет будущего давления», НЕ None).
db = _mock_db([])
with patch(_MARKET, return_value=_metrics_stub(months_of_supply=6.0, n_available=120)):
with _patched(metrics=_metrics_stub(months_of_supply=6.0, n_available=120)):
res = compute_future_supply_pressure(db, district="X")
assert res.months_of_pressure == 0.0
assert res.index == 0.0
@ -480,11 +586,10 @@ class TestComputeGraceful:
},
]
db = _mock_db(rows)
with patch(
_MARKET,
return_value=_metrics_stub(
with _patched(
metrics=_metrics_stub(
absorption_rate=None, months_of_supply=None, n_available=0, confidence="medium"
),
)
):
res = compute_future_supply_pressure(db, district="X")
assert res.hidden_units == 500
@ -496,9 +601,11 @@ class TestComputeGraceful:
def test_view_query_exception_graceful(self) -> None:
# Сбой чтения view → [] (graceful), index по absorption (нет очереди → 0.0).
# Резолвер мокаем отдельно (иначе RuntimeError-side_effect db.execute пробил бы
# и резолвер) — изолируем именно сбой view-запроса.
db = MagicMock()
db.execute.side_effect = RuntimeError("view gone")
with patch(_MARKET, return_value=_metrics_stub(months_of_supply=6.0, n_available=120)):
with _patched(metrics=_metrics_stub(months_of_supply=6.0, n_available=120)):
res = compute_future_supply_pressure(db, district="Сбой")
assert res.hidden_units == 0
assert res.future_units_by_horizon == 0.0
@ -517,14 +624,14 @@ class TestComputeGraceful:
},
]
db = _mock_db(rows)
with patch(_MARKET, return_value=_metrics_stub(months_of_supply=6.0, n_available=120)):
with _patched(metrics=_metrics_stub(months_of_supply=6.0, n_available=120)):
res = compute_future_supply_pressure(db, district="X")
assert res.future_units_by_horizon == 0.0
assert res.months_of_pressure == 0.0
def test_returns_dataclass_always(self) -> None:
db = _mock_db([])
with patch(_MARKET, return_value=_metrics_stub()):
with _patched(resolve=lambda _db, _d: None):
res = compute_future_supply_pressure(db, district=None)
assert isinstance(res, FutureSupplyPressure)
assert res.district is None