gendesign/backend/app/services/site_finder/supply_layers.py
Light1YT e33273591b
Some checks failed
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m10s
CI / backend-tests (pull_request) Failing after 14m34s
perf(objective_lots): inline DISTINCT ON for 3 view-fullscan consumers + cache landing stats (#1953)
#1964 repointed landing/supply_layers/special_indices to v_objective_lots_latest,
whose DISTINCT ON is an optimization barrier — outer WHERE can't push below it, so
the planner full Parallel-Seq-Scans 1.76M rows + external merge sorts (~6-8s) per call.

- landing._query_stats: add 1h in-process TTL cache (homepage /landing/stats was
  uncached; KPI3 full-view-aggregate measured 6.7s/load). Threadpool-aware lock,
  DB query outside lock, only successful result cached. Mirrors weather_cache.
- supply_layers._L1_OPEN_SQL: view->inline DISTINCT ON, stable filters
  (premise_kind/district) pushed below dedup -> Bitmap on district_class_idx.
  district-scoped 5.9s->0.45s (~13x).
- special_indices._ARTIFICIAL_DEMAND_SQL: view->inline 2-layer CTE; stable filters
  before dedup, changing filters (is_sold/contract_date/class) AFTER dedup.
  class has 248 transient physflats -> before-dedup drifted +193 on standard.
  district-scoped 6.7s->0.57s (~12x), EKB-wide 8.1s->5.5s.

Result bit-identical to view, verified on prod across all classes/districts.
Uses existing covering / district_class indexes (no migration).
2026-06-28 21:41:35 +05:00

550 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Supply-layers service — детерминированный расчёт 3-слойного склада предложения.
#950 (Site Finder v2 / GG-форсайт, EPIC 6 «3-layer склад предложения», ТЗ §9.3).
Это **измерительный слой**: считает агрегаты текущего/скрытого/будущего предложения
по districts и ВОЗВРАЩАЕТ их dataclass'ами. В таблицу `supply_layers` (м.125) их
UPSERT-ит worker #950 Step 5 — ЭТОТ сервис НИЧЕГО не пишет (read-only compute).
Принцип: **детерминированно, без LLM** — чистый set-based SQL + арифметика
(зеркало `market_metrics.py`). NO LLM-импортов нигде.
Три слоя (ТЗ §9.3 / м.125):
• layer1 (open) — открытое предложение в текущей продаже. Источник: `objective_lots`
(выставленные лоты). source='objective', confidence='high'.
• layer2 (hidden) — скрытый запас застройщика, ЗАЯВЛЕННЫЙ в ПД но НЕ выставленный
сейчас: per dom.рф-объект hidden = max(0, flat_count free_flats). Обе колонки на
ОДНОЙ строке `domrf_kn_objects` (free_flats — м.124, #950 PR A) → **mapping-free**:
domrf↔objective маппинг покрывает ~2.5% и здесь НЕ нужен (это и есть весь смысл
разблокировки free_flats). source='domrf_declared'.
• layer3 (future) — будущее предложение, ещё НЕ на рынке: multiphase-проекты dom.рф
(ready_dt далеко в будущем + почти нет выставленных квартир). source='domrf_multiphase',
expected_online_date=ready_dt, confidence='low'.
Whitelisted значения (CHECK в м.125 — иначе worker'ский INSERT падает ГРОМКО):
source ∈ {objective, domrf_declared, domrf_multiphase, graddoc_stub, insider_manual};
confidence ∈ {high, medium, low}. Этот сервис эмитит только objective / domrf_declared /
domrf_multiphase. Строки graddoc_stub / insider_manual сюда НЕ приходят — они
живут в таблице независимо (поэтому м.125 — TABLE, не MV).
TODO(#956): источник 'graddoc_stub' (layer3) — граддок-NLP, LLM-фаза, отложена.
insider_manual — ручной ввод. Оба вставляются ВНЕ этого сервиса (см. м.125 docstring).
Graceful-on-thin-data (КРИТИЧНО, зеркало market_metrics): пустые/тонкие данные →
пустой список строк (НЕ crash). Когда строка есть, но сигнал слабый → confidence='low'.
──────────────────────────────────────────────────────────────────────────────
Колонки, проверенные по реальной схеме (НЕ предполагались):
domrf_kn_objects (43_anton_import + м.57 + м.113 + м.124):
obj_id, snapshot_date, region_cd (66=ЕКБ), district_name (м.57, PostGIS spatial-join,
NULL вне 8 районов ЕКБ), flat_count, free_flats (м.124, NULLABLE — старые снапшоты),
obj_class, square_living, ready_dt, dev_group_name (м.113), site_status.
objective_lots (м.68): objective_lot_id, project_name, district, premise_kind, class
(obj_class, lowercase), is_sold, status, contract_date, area_pd, snapshot_date.
Известные ограничения (degraded-gracefully):
• L2 fallback: у `domrf_kn_objects` НЕТ колонки `sold` (проверено: 43_anton_import,
м.108-audit). Поэтому при free_flats IS NULL hidden НЕ вычислим (flat_count в одиночку
бессмыслен). Такие объекты ИСКЛЮЧАЮТСЯ из L2 (preferred path из ТЗ — чище, чем
выдуманный sold-proxy) → coverage снижается → confidence падает с medium до low.
• L2 district-resolution: используем domrf_kn_objects.district_name НАПРЯМУЮ (м.57
spatial-join по lat/long в полигоны ekb_districts_geom). Это самый прямой путь —
БЕЗ complexes/complex_sources join (которые сами строятся из этой же колонки, м.73).
Объекты с district_name IS NULL (вне 8 районов ЕКБ / без координат) в district-grain
L2 НЕ попадают (documented gap).
• Per-ЖК L2 (complex_id grain) НЕ эмитим: это потребовало бы domrf↔objective маппинг
(2.5% покрытие) — district-агрегат и есть deliverable (ТЗ §9.3).
"""
from __future__ import annotations
import logging
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import date
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
logger = logging.getLogger(__name__)
Confidence = Literal["high", "medium", "low"]
Source = Literal["objective", "domrf_declared", "domrf_multiphase"]
# Регион данных (ЕКБ / Свердловская обл.). district_name на domrf_kn_objects заполнен
# spatial-join'ом только для region_cd=66 (м.57) — фильтруем по нему явно.
_EKB_REGION_CODE: int = 66
# L2: доля объектов района с заполненным free_flats, выше которой доверяем 'medium'.
# Ниже — слишком много исключённых (NULL free_flats) объектов → 'low' (см. limitations).
_L2_MEDIUM_MIN_COVERAGE: float = 0.6
# L2/L3 граница (включительно в L2, исключительно в L3) — половинно-открытые интервалы:
# L2 (hidden) : ready_dt IS NULL OR ready_dt <= now + L2_NEAR_HORIZON [now, now+12mo]
# L3 (future) : ready_dt IS NOT NULL AND ready_dt > now + L3_FUTURE_HORIZON (now+12mo, ∞)
# Оба используют одно и то же значение 12 мес., но по разные стороны границы — нет overlap.
_L2_NEAR_HORIZON_MONTHS: int = 12 # L2 берёт объекты с ready_dt ≤ сейчас+12мес (или NULL)
# L3: «будущее» = ready_dt дальше этого горизонта от сегодня. Раньше — это уже
# почти-на-рынке предложение (ловится L1/L2), не «future».
_L3_FUTURE_HORIZON_MONTHS: int = 12 # L3 берёт объекты с ready_dt > сейчас+12мес
# L3: «ещё не продаётся» = выставлено не больше этого числа квартир. >0 порог (не строго
# 0) терпит единичные тестовые/служебные листинги в проекте, который по сути не открыт.
_L3_MAX_FREE_FLATS_NOT_SELLING: int = 5
@dataclass(frozen=True)
class SupplyLayerRow:
"""Одна строка склада предложения — зеркало колонок таблицы `supply_layers` (м.125).
Grain: district_name (primary) + optional obj_class. complex_id здесь всегда None
(per-ЖК grain — это L3-stubs/reviewed-L2, вне этого сервиса). expected_online_date
заполнен только для layer3 (когда предложение выйдет на рынок); для L1/L2 — None.
source/confidence — ТОЛЬКО whitelisted значения (CHECK м.125). units_estimate /
area_estimate — NULLABLE (None при отсутствии дешёвого сигнала, НЕ 0-как-заглушка).
"""
layer: Literal[1, 2, 3]
district_name: str | None
complex_id: int | None
obj_class: str | None
# L3-grain (#970, м.128): бакет future-supply по группе застройщика. ЧАСТЬ логического
# ключа uq_supply_layers_logical → разные dev_group'ы района дают разные ключи и больше
# не схлопываются (~95.6% потерь L3 до фикса). NULL для L1/L2 (у них dev_group'а нет).
dev_group_name: str | None
units_estimate: int | None
area_estimate: float | None
expected_online_date: date | None
source: Source
confidence: Confidence
method: str
snapshot_date: date | None # None → worker подставит DEFAULT CURRENT_DATE на write
def as_dict(self) -> dict[str, Any]:
return {
"layer": self.layer,
"district_name": self.district_name,
"complex_id": self.complex_id,
"obj_class": self.obj_class,
"dev_group_name": self.dev_group_name,
"units_estimate": self.units_estimate,
"area_estimate": _round_or_none(self.area_estimate, 1),
"expected_online_date": self.expected_online_date,
"source": self.source,
"confidence": self.confidence,
"method": self.method,
"snapshot_date": self.snapshot_date,
}
def _round_or_none(value: float | None, digits: int) -> float | None:
return round(value, digits) if value is not None else None
# ──────────────────────────────────────────────────────────────────────────────
# Pure-арифметика — без БД, полностью юнит-тестируемо. Каждая функция graceful.
# ──────────────────────────────────────────────────────────────────────────────
def _hidden_units(flat_count: int | None, free_flats: int | None) -> int | None:
"""L2 per-объект hidden = max(0, flat_count free_flats). Clamp ≥ 0 ВСЕГДА.
None при отсутствии любого из слагаемых — такой объект нельзя посчитать и он
ИСКЛЮЧАЕТСЯ из L2-агрегата (free_flats IS NULL → нет сигнала, sold-колонки нет).
Никогда не эмитим отрицательное hidden (free_flats > flat_count из-за рассинхрона
снапшотов/перепланировки — клампим в 0, не в негатив).
"""
if flat_count is None or free_flats is None:
return None
return max(0, int(flat_count) - int(free_flats))
def _l2_confidence(n_objects_total: int, n_objects_with_free_flats: int) -> Confidence:
"""L2 confidence по покрытию free_flats (ТЗ §9.3 + м.124 fallback-note).
'medium' когда free_flats заполнен у БОЛЬШИНСТВА объектов района (надёжный hidden);
'low' когда заполнен у меньшинства (много исключённых NULL-объектов → недооценка
скрытого запаса) или объектов нет вовсе. НИКОГДА 'high' — L2 это оценка по ПД, не
факт продаж (high зарезервирован за L1/наблюдаемым).
"""
if n_objects_total <= 0 or n_objects_with_free_flats <= 0:
return "low"
coverage = n_objects_with_free_flats / n_objects_total
return "medium" if coverage >= _L2_MEDIUM_MIN_COVERAGE else "low"
# ──────────────────────────────────────────────────────────────────────────────
# SQL — все bind-параметры через CAST(:x AS type) (psycopg v3; НИКОГДА :x::type).
# ──────────────────────────────────────────────────────────────────────────────
# ── Layer1: открытое предложение (objective_lots), per район × класс ───────────
# Доступно = выставлено и НЕ продано. is_sold распознаём И флагом, И статусом 'продан'
# / наличием contract_date (Объектив заполняет неконсистентно — зеркало _STOCK_SQL).
# Группируем по (district, class). class — lowercase в lots (м.68 коммент).
#
# #1964: дедуп до ФИЗИЧЕСКОГО лота. Сырая objective_lots раздута ~2.91× (мульти
# objective_lot_id на один физлот через пере-листинги) → units_available по району
# был завышен ~3-4.6× (прод: Юго-Западный комфорт 58 606 → 12 620) → дефицит-форсайт
# переоценивал открытое предложение L1.
#
# Перф-батч #2 (#1953-followup): дедупим INLINE через DISTINCT ON (physflat-ключ,
# последний снапшот), НЕ через v_objective_lots_latest. Причина: view селектит ol.* и
# его DISTINCT ON — оптимизационный барьер: WHERE снаружи (premise_kind/district) НЕ
# проталкивается ниже DISTINCT ON → планировщик материализует ВСЮ таблицу (1.76M,
# Parallel Seq Scan width≈891 + external merge sort 76-101 МБ/воркер) ПЕРЕД фильтром
# (~6.0 s, прод-EXPLAIN). Inline протолкивает СТАБИЛЬНЫЕ фильтры (premise_kind, district
# — НЕ меняются между снапшотами физлота: премайз-кайнд 0 mixed-групп на проде, район ~4
# transient на 605k) В WHERE ДО DISTINCT ON → bitmap по objective_lots_district_class_idx
# + узкий sort (~5 МБ/воркер) ~0.47 s district-scoped (≈13× быстрее; EKB-wide остаётся
# seq-scan, но width≈99 вместо 891 → ~4.6 s). Эквивалентность view-vs-inline доказана
# бит-в-бит на проде (все district×class пары, 0 расхождений) — зеркало parcels.py.
# sold_now (is_sold/contract_date/status='продан') — МЕНЯЮЩИЕСЯ атрибуты → считаем
# ПОСЛЕ дедупа (на последнем снапшоте), как делал view.
_L1_OPEN_SQL = text(
"""
WITH lots AS (
SELECT DISTINCT ON (
project_name, corpus_name, section, floor, lot_number
)
district,
class AS obj_class,
area_pd,
(
is_sold IS TRUE
OR contract_date IS NOT NULL
OR LOWER(COALESCE(status, '')) = 'продан'
) AS sold_now
FROM objective_lots
WHERE premise_kind = CAST(:premise_kind AS text)
AND (
CAST(:has_district AS boolean) IS FALSE
OR district = ANY(CAST(:districts AS text[]))
)
ORDER BY project_name, corpus_name, section, floor,
lot_number, snapshot_date DESC, id DESC
)
SELECT
district,
obj_class,
COUNT(*) FILTER (WHERE NOT sold_now) AS units_available,
COALESCE(SUM(area_pd) FILTER (WHERE NOT sold_now), 0) AS area_available
FROM lots
WHERE district IS NOT NULL
GROUP BY district, obj_class
HAVING COUNT(*) FILTER (WHERE NOT sold_now) > 0
"""
)
# ── Layer2: скрытый запас (domrf_kn_objects ТОЛЬКО — mapping-free), per район ───
# Берём свежий снапшот на obj_id (DISTINCT ON), затем агрегируем Σ max(0, flat_count
# free_flats) к району. ИСКЛЮЧАЕМ free_flats IS NULL (нет sold-колонки → не вычислим;
# documented). clamp ≥ 0 через GREATEST. Считаем coverage (всего объектов района vs с
# free_flats) для confidence. NO join на objective_lots / complexes — намеренно.
#
# Граница L2/L3 (#1355): L2 захватывает ТОЛЬКО объекты с ready_dt ≤ горизонту (или NULL —
# объект уже должен был выйти). ready_dt > горизонту → это L3 (future), НЕ L2.
# Полу-открытые интервалы (half-open): L2 = [now, now+12mo], L3 = (now+12mo, ∞).
_L2_HIDDEN_SQL = text(
"""
WITH latest AS (
SELECT DISTINCT ON (o.obj_id)
o.obj_id,
o.district_name,
o.flat_count,
o.free_flats,
o.ready_dt
FROM domrf_kn_objects o
WHERE o.region_cd = :region_cd
AND o.district_name IS NOT NULL
AND (
CAST(:district AS text) IS NULL
OR o.district_name = CAST(:district AS text)
)
ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
)
SELECT
district_name,
COUNT(*) AS n_objects_total,
COUNT(*) FILTER (WHERE free_flats IS NOT NULL) AS n_with_free_flats,
COALESCE(SUM(
GREATEST(0, COALESCE(flat_count, 0) - free_flats)
) FILTER (WHERE free_flats IS NOT NULL), 0) AS hidden_units
FROM latest
WHERE ready_dt IS NULL
OR ready_dt <= CURRENT_DATE + CAST(:near_horizon AS interval)
GROUP BY district_name
"""
)
# ── Layer3: будущее предложение (domrf_kn_objects multiphase) ───────────────────
# Свежий снапшот на obj_id; «future» = ready_dt дальше горизонта И выставлено ≤ порога
# квартир (free_flats мал/0 → проект объявлен, но ещё не открыт). Группируем по
# (район, dev_group_name) — поздние очереди застройщика. expected_online_date = MIN
# ready_dt группы (ближайший ввод). flat_count Σ как оценка будущего объёма.
_L3_FUTURE_SQL = text(
"""
WITH latest AS (
-- #1212: сначала ВСЕГДА берём последний снапшот на obj_id
-- (DISTINCT ON + ORDER BY snapshot_date DESC). Внутри CTE — только
-- стабильные фильтры (region_cd / админ-район). Volatile-фильтры
-- (ready_dt, free_flats) применяются ПОСЛЕ DISTINCT ON во внешнем
-- WHERE. Иначе WHERE применяется ДО DISTINCT ON и берётся «последний
-- снапшот, прошедший фильтр»: проект, когда-то бывший «объявлен,
-- не продаётся», остаётся в L3 и после открытия продаж (свежий
-- снапшот с большим free_flats отфильтрован, взят старый) →
-- двойной счёт с L1/L2, stale flat_count/ready_dt.
SELECT DISTINCT ON (o.obj_id)
o.obj_id,
o.district_name,
o.dev_group_name,
o.obj_class,
o.flat_count,
o.square_living,
o.free_flats,
o.ready_dt
FROM domrf_kn_objects o
WHERE o.region_cd = :region_cd
AND o.district_name IS NOT NULL
AND (
CAST(:district AS text) IS NULL
OR o.district_name = CAST(:district AS text)
)
ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
)
SELECT
district_name,
dev_group_name,
COUNT(*) AS n_objects,
COALESCE(SUM(flat_count), 0) AS units_future,
COALESCE(SUM(square_living), 0) AS area_future,
MIN(ready_dt) AS expected_online_date
FROM latest
WHERE ready_dt IS NOT NULL
AND ready_dt > CURRENT_DATE + CAST(:horizon AS interval)
AND COALESCE(free_flats, 0) <= :max_free_flats
GROUP BY district_name, dev_group_name
"""
)
def compute_layer1_open(
db: Session,
*,
district: str | None = None,
premise_kind: str = "квартира",
snapshot_date: date | None = None,
) -> list[SupplyLayerRow]:
"""Layer1 (open): открытое предложение в продаже, per район × класс.
Источник — `objective_lots` (выставленные лоты), переиспользует district-stock
паттерн market_metrics. source='objective', confidence='high'. Возвращает по строке
на (district, obj_class) с доступными лотами. Пустые данные → []. Не бросает.
district резолвится через district_resolver: админ-имя ЕКБ → набор informal микро
(objective_lots хранит микро, не админ — иначе админ-имя дало бы 0 строк). None →
EKB-wide (без district-фильтра). L2/L3 ниже НЕ резолвятся: они бьют по
domrf_kn_objects.district_name, а это УЖЕ админ-вокабуляр (м.57 spatial-join).
"""
micros = resolve_objective_districts(db, district)
params: dict[str, Any] = {
"premise_kind": premise_kind,
"has_district": micros is not None,
"districts": micros if micros is not None else [],
}
rows = _safe_rows(db, _L1_OPEN_SQL, params, layer=1)
out: list[SupplyLayerRow] = []
for r in rows:
units = int(r["units_available"] or 0)
if units <= 0:
continue
area = float(r["area_available"] or 0.0)
out.append(
SupplyLayerRow(
layer=1,
district_name=r["district"],
complex_id=None,
obj_class=r["obj_class"],
dev_group_name=None, # L1 (open) — у района-агрегата dev_group'а нет
units_estimate=units,
area_estimate=area if area > 0 else None,
expected_online_date=None,
source="objective",
confidence="high",
method="objective_lots: available (not sold) lots per district×class",
snapshot_date=snapshot_date,
)
)
logger.info(
"supply_layers L1(open): district=%s premise=%s rows=%d",
district,
premise_kind,
len(out),
)
return out
def compute_layer2_hidden(
db: Session,
*,
district: str | None = None,
snapshot_date: date | None = None,
) -> list[SupplyLayerRow]:
"""Layer2 (hidden): скрытый запас застройщика, per район (mapping-free).
Per dom.рф-объект hidden = max(0, flat_count free_flats); Σ к району. ОБЕ колонки
на одной строке domrf_kn_objects → НЕ нужен domrf↔objective маппинг (см. docstring
модуля). Объекты с free_flats IS NULL исключаются (нет sold-колонки → не вычислить;
documented limitation) — снижают coverage → confidence medium→low.
source='domrf_declared'. obj_class=None (district-агрегат класс-agnostic).
units_estimate всегда ≥ 0 (clamp). Пустые данные → []. Не бросает.
"""
params: dict[str, Any] = {
"region_cd": _EKB_REGION_CODE,
"district": district,
"near_horizon": f"{_L2_NEAR_HORIZON_MONTHS} months",
}
rows = _safe_rows(db, _L2_HIDDEN_SQL, params, layer=2)
out: list[SupplyLayerRow] = []
for r in rows:
n_total = int(r["n_objects_total"] or 0)
n_with = int(r["n_with_free_flats"] or 0)
# hidden_units приходит из SQL уже клампленным (GREATEST(0, ...)); страхуемся.
hidden = max(0, int(r["hidden_units"] or 0))
if n_with <= 0:
# Нет ни одного объекта с free_flats → нечего объявлять скрытым (а не «0»).
continue
confidence = _l2_confidence(n_total, n_with)
out.append(
SupplyLayerRow(
layer=2,
district_name=r["district_name"],
complex_id=None,
obj_class=None,
dev_group_name=None, # L2 (hidden) — district-агрегат, dev_group'а нет
units_estimate=hidden,
area_estimate=None, # площадь скрытого недёшево/ненадёжно вывести — None
expected_online_date=None,
source="domrf_declared",
confidence=confidence,
method=(
f"domrf_kn_objects: Σ max(0, flat_count free_flats); "
f"coverage {n_with}/{n_total} objects with free_flats"
),
snapshot_date=snapshot_date,
)
)
logger.info(
"supply_layers L2(hidden): district=%s rows=%d (mapping-free, domrf_declared)",
district,
len(out),
)
return out
def compute_layer3_future(
db: Session,
*,
district: str | None = None,
snapshot_date: date | None = None,
) -> list[SupplyLayerRow]:
"""Layer3 (future): будущее предложение ещё НЕ на рынке (domrf multiphase).
Детерминированный скелет: dom.рф-объекты с ready_dt дальше горизонта И почти без
выставленных квартир (объявлены, но не открыты), сгруппированные по (район,
dev_group_name) как поздние очереди застройщика. source='domrf_multiphase',
expected_online_date=MIN(ready_dt), confidence='low'.
Граддок-stubs (#956, LLM) и insider_manual сюда НЕ приходят — живут в таблице
независимо (см. docstring модуля). Пустые данные → []. Не бросает.
"""
params: dict[str, Any] = {
"region_cd": _EKB_REGION_CODE,
"district": district,
"horizon": f"{_L3_FUTURE_HORIZON_MONTHS} months",
"max_free_flats": _L3_MAX_FREE_FLATS_NOT_SELLING,
}
rows = _safe_rows(db, _L3_FUTURE_SQL, params, layer=3)
out: list[SupplyLayerRow] = []
for r in rows:
units = int(r["units_future"] or 0)
area = float(r["area_future"] or 0.0)
dev_group = r["dev_group_name"]
out.append(
SupplyLayerRow(
layer=3,
district_name=r["district_name"],
complex_id=None,
obj_class=None,
# #970: dev_group_name теперь КЛЮЧ-колонка (uq_supply_layers_logical),
# не только текст в method → dev_group'ы района не схлопываются на UPSERT.
dev_group_name=dev_group,
units_estimate=units if units > 0 else None,
area_estimate=area if area > 0 else None,
expected_online_date=r["expected_online_date"],
source="domrf_multiphase",
confidence="low",
method=(
"domrf_kn_objects: ready_dt beyond horizon & ~no listed flats; "
f"grouped by dev_group_name={dev_group!r}"
),
snapshot_date=snapshot_date,
)
)
logger.info(
"supply_layers L3(future): district=%s rows=%d (domrf_multiphase)",
district,
len(out),
)
return out
def compute_all_layers(
db: Session,
*,
district: str | None = None,
premise_kind: str = "квартира",
snapshot_date: date | None = None,
) -> list[SupplyLayerRow]:
"""Удобный объединённый расчёт L1+L2+L3 для района (для worker'а #950 Step 5).
Каждый слой graceful-независим: сбой/пустота одного не валит остальные (компонуем
из самих compute_*, которые уже не бросают). Возвращает плоский список строк всех
трёх слоёв (worker UPSERT-ит их по uq_supply_layers_logical).
"""
rows: list[SupplyLayerRow] = []
rows.extend(
compute_layer1_open(
db, district=district, premise_kind=premise_kind, snapshot_date=snapshot_date
)
)
rows.extend(compute_layer2_hidden(db, district=district, snapshot_date=snapshot_date))
rows.extend(compute_layer3_future(db, district=district, snapshot_date=snapshot_date))
return rows
def _safe_rows(
db: Session,
sql: Any,
params: Mapping[str, Any],
*,
layer: int,
) -> Sequence[Mapping[str, Any]]:
"""Выполнить read-query, graceful: на ошибке логируем и возвращаем [] (не бросаем).
Зеркало market_metrics._query_* — тонкие данные/сбой БД не должны валить расчёт
остальных слоёв или worker'а целиком.
"""
try:
return db.execute(sql, dict(params)).mappings().all()
except Exception:
# L1 фильтрует по resolved-микро (`districts`); L2/L3 — по админ `district`.
district_dbg = params.get("district", params.get("districts"))
logger.exception("supply_layers: L%d query failed (district=%s)", layer, district_dbg)
return []