589 lines
34 KiB
Python
589 lines
34 KiB
Python
"""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.
|
||
Тот же exclusion — И при flat_count IS NULL (#2445-A3: заполняется независимо от
|
||
free_flats, м.124) — иначе GREATEST(0, COALESCE(flat_count,0) − free_flats) молча
|
||
давал 0 «скрытых» для объекта с генуинно неизвестным flat_count, раздувая coverage.
|
||
• 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 — намеренно.
|
||
#
|
||
# #2445-A3 (audit): flat_count ТОЖЕ nullable и заполняется НЕЗАВИСИМО от free_flats
|
||
# (м.124 — "NULL = не заполнено"). Раньше фильтр гейтил ТОЛЬКО free_flats IS NOT NULL:
|
||
# при flat_count IS NULL (частично забэкфилленный снапшот) GREATEST(0, COALESCE(flat_count,
|
||
# 0) - free_flats) = GREATEST(0, -free_flats) = 0 — объект молча давал 0 hidden units,
|
||
# ХОТЯ его истинный hidden НЕИЗВЕСТЕН (не 0), И при этом всё равно попадал в
|
||
# n_with_free_flats → раздувал coverage-деноминатор (denominator inflation). Зеркалит баг,
|
||
# которого уже избегает pure-хелпер _hidden_units() (возвращает None при flat_count IS
|
||
# NULL) — но тот хелпер не вызывается из этого SQL-пути (только из тестов), поэтому баг
|
||
# жил в проде. Fix: гейтим ОБА (COUNT-деноминатор и SUM) на
|
||
# flat_count IS NOT NULL AND free_flats IS NOT NULL — объект с любым из двух NULL
|
||
# полностью исключается (как из числителя coverage, так и из hidden_units), а не
|
||
# засчитывается как "0 скрытых". Сиблинг-конвенция: 60_v_zk_rosreestr_velocity.sql /
|
||
# 64_v_zk_rosreestr_velocity.sql уже гейтят `o.flat_count IS NOT NULL AND o.flat_count > 0`
|
||
# на этой же таблице.
|
||
#
|
||
# Граница 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 AND flat_count IS NOT NULL
|
||
) AS n_with_free_flats,
|
||
COALESCE(SUM(
|
||
GREATEST(0, flat_count - free_flats)
|
||
) FILTER (
|
||
WHERE free_flats IS NOT NULL AND flat_count 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 ИЛИ flat_count IS NULL полностью исключаются
|
||
(#2445-A3: обе колонки nullable и заполняются независимо — объект с известным
|
||
free_flats, но flat_count IS NULL, даёт ГЕНУИННО неизвестный hidden, а не 0; такой
|
||
объект НЕ считается «покрытым» и не входит в coverage-числитель) — снижают 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_free_flats (SQL) = объекты, где ОБЕ free_flats и flat_count известны
|
||
# (#2445-A3) — это единственные объекты, для которых hidden вообще вычислим.
|
||
# Имя колонки оставлено как есть (ripple в SQL/тестах/logger); семантика теперь
|
||
# "покрыто и вычислимо", не только "free_flats заполнен".
|
||
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:
|
||
# Нет ни одного объекта с ВЫЧИСЛИМЫМ hidden (оба flat_count и 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 flat_count & free_flats known"
|
||
),
|
||
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'а целиком.
|
||
|
||
SAVEPOINT (``db.begin_nested()``): ``compute_all_layers`` вызывает эту функцию 3×
|
||
(L1/L2/L3) на ОДНОЙ Session — без savepoint сбой L1 отравил бы транзакцию и L2/L3
|
||
молча получили бы [] тоже (плюс любой db.execute ПОСЛЕ compute_all_layers на этом же
|
||
db, включая caller'а — worker owns SessionLocal(), но при вызове из shared
|
||
request-scoped db, напр. forecasting/orchestrator.build_site_finder_report,
|
||
отравился бы весь оставшийся запрос). Голый db.rollback() здесь НЕЛЬЗЯ — см.
|
||
velocity.py:170.
|
||
"""
|
||
try:
|
||
with db.begin_nested():
|
||
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 []
|