"""§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.forecast_request_cache import forecast_cache 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` для участка (ОРКЕСТРАЦИЯ). PERF (#1129): активируем per-отчёт кэш (`forecast_cache`) на ВСЮ сборку. §9.x-слои массово пере-вызывают одни и те же горизонт/сегмент-инвариантные БД-загрузки с совпадающими аргументами (профиль: get_competitors ×69, market_metrics ×124, get_monthly_macro ×290 на один отчёт) — кэш считает каждую уникальную загрузку ОДИН раз и переиспользует тот же (frozen, read-only) результат. Вывод ПОБАЙТОВО идентичен несжатому пути (детерминизм §16/§26 сохранён); ускорение — чисто за счёт устранения дублирующих roundtrip'ов, БЕЗ конкурентности. Кэш живёт ровно одну сборку (на выходе из `with` отбрасывается — никакой кросс-отчётной утечки). """ with forecast_cache(): return _build_site_finder_report_impl( db, analyze=analyze, cad_num=cad_num, district=district, obj_class=obj_class, horizons=horizons, ) def _build_site_finder_report_impl( db: Session, *, analyze: dict[str, Any], cad_num: str, district: str | None, obj_class: str | None = None, horizons: Sequence[int] = _DEFAULT_HORIZONS, ) -> SiteFinderReport: """Реальная сборка §22-отчёта (тело #1056). Вызывается ВНУТРИ `forecast_cache()`. ШОВ между §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, )