All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m22s
CI / backend-tests (pull_request) Successful in 6m22s
demand_index used a fixed clamp01(velocity/50); on a live prod refresh all 8 ЕКБ districts sold ≥50/mo so it saturated to 1.0 everywhere — zero discrimination between districts. Redesign to mirror infra_index: normalize each district's unit_velocity against the city reference (MAX district velocity per refresh run), so demand always discriminates and self-calibrates as the market grows (no magic constant to rot). - normalize_demand(velocity, *, city_reference_velocity), pure + graceful (None stays None; reference<=0 -> honest 0.0, no ZeroDivisionError) - refresh_locations now two-pass: collect velocities (one compute_market_metrics per district, no O(n^2)), derive city reference, normalize + upsert; SAVEPOINT-per-row and counters preserved - remove _DEMAND_SATURATION_UPM constant; log city_reference_velocity - tests: rewrite demand normalization + add end-to-end city-relative suite incl. discrimination regression guarding the all-1.0 prod bug Refs #948
517 lines
31 KiB
Python
517 lines
31 KiB
Python
"""Location-сервис — district-level индексы первоклассной «локации» (#948 Part B, §8.2).
|
||
|
||
Промоутит район (district) в queryable сущность `location` (data/sql/146_location.sql),
|
||
несущую 4 нормированных индекса:
|
||
• infra — инфраструктура района
|
||
• competition — конкуренция (доступное/затоваренное конкурирующее предложение)
|
||
• demand — спрос (темп продаж)
|
||
• future_supply — будущее давление предложения
|
||
|
||
ADDITIVE (HARD CONSTRAINT #948B): `district` (string) НЕ рефакторится в FK. Этот
|
||
сервис — НОВЫЙ слой РЯДОМ: ключует `location` по `district_name` (joinable по строке),
|
||
переиспользуя существующие per-district forecast-функции (они #1129-кэшированы, вызов
|
||
per-district дёшев). Существующий код продолжает ходить по district-строкам.
|
||
|
||
ИСТОЧНИКИ ИНДЕКСОВ (reuse, не изобретаем) + НОРМАЛИЗАЦИЯ к [0,1] (None при отсутствии
|
||
данных — НИКОГДА не фабрикуем 0, дух market_metrics.py / future_supply.py):
|
||
|
||
• competition ← market_metrics.compute_market_metrics(db, district=…).overstock_index
|
||
overstock_index = долго-экспонируемый непроданный сток / все доступные ед.
|
||
∈ [0,1] — доля «зависшего» конкурирующего предложения (в продаже > N мес без
|
||
сделки). Это ИСТИННЫЙ сигнал конкуренции = доступное/затоваренное конкурирующее
|
||
предложение: выше = больше конкурирующего стока простаивает = БОЛЬШЕ
|
||
конкурентного давления для нового входящего игрока. УЖЕ нормирован [0,1]
|
||
(доля от доступных) — берём как есть, без пере-нормировки. None когда метрика
|
||
None (нет доступных лотов / пустая выборка).
|
||
|
||
ОРТОГОНАЛЬНОСТЬ к demand: competition мерит ДОСТУПНОЕ/непроданное предложение,
|
||
demand — скорость продаж (unit_velocity). Это разные оси (saturation supply vs
|
||
sales velocity), не монотонно связанные. Раньше тут стоял sell_through_pct
|
||
(доля реализованного стока) — но это absorption/heat-сигнал, монотонно
|
||
растущий с продажами (коррелирован с demand=velocity) И инвертированный
|
||
относительно «свободного конкурирующего стока»: высокий sell-through =
|
||
МЕНЬШЕ доступной конкуренции, а не больше. overstock_index — честная,
|
||
ортогональная спросу замена.
|
||
|
||
• demand ← market_metrics.compute_market_metrics(db, district=…).unit_velocity,
|
||
нормированный CITY-RELATIVE (как infra ниже), а НЕ фиксированной константой.
|
||
unit_velocity = ед., проданных в месяц (за окно) ∈ [0, ∞). Это ТОТ ЖЕ сигнал
|
||
спроса, что использует forecasting/demand_supply_forecast (base_pace). У него
|
||
нет естественного абсолютного потолка → нормируем ОТНОСИТЕЛЬНО самого рынка:
|
||
demand(district) = clamp01(velocity / city_reference_velocity), где
|
||
city_reference_velocity = MAX скорость продаж среди всех районов прогона.
|
||
«Спрос относительно самого горячего района»: busiest = 1.0, остальные
|
||
пропорционально ниже. None когда unit_velocity None (нет выборки).
|
||
|
||
ПОЧЕМУ relative, а НЕ absolute (#948 fix): прежняя реализация делила на жёсткую
|
||
константу _DEMAND_SATURATION_UPM=50 ед./мес. На live-прогоне ВСЕ 8 районов ЕКБ
|
||
продавали ≥50/мес → demand=1.0 У ВСЕХ (плоское насыщение, НОЛЬ дискриминации
|
||
между районами — бесполезно как сравнительный сигнал). Абсолютная константа
|
||
«гниёт»: она была неверна уже сегодня и расходилась бы дальше с ростом рынка.
|
||
Относительная нормировка (как у infra: district POI / city POI) само-
|
||
калибруется — всегда даёт спред для любого невырожденного распределения и
|
||
НИКОГДА не требует ручной перенастройки. Выбран MAX как якорь (простейший
|
||
статистик, гарантирующий дискриминацию; при 8 районах «выброс» = сигнал, а не
|
||
шум — самый горячий район и ЕСТЬ потолок спроса). Вырожденные случаи:
|
||
reference=0 (все честно продали 0) → demand=0.0 у всех (честный ноль, НЕ None);
|
||
все скорости равны → 1.0 у всех («одинаково горячо»).
|
||
|
||
• future_supply ← future_supply.compute_future_supply_pressure(db, district=…).index
|
||
УЖЕ нормирован ∈ [0,1] самим сервисом (насыщение months_of_pressure). Берём
|
||
как есть — НЕ пере-нормируем. None когда index None (нет supply-данных /
|
||
поглощения).
|
||
|
||
• infra ← analytics_queries._district_poi_score(db, district_name) /
|
||
analytics_queries._city_avg_poi_score(db)
|
||
_district_poi_score = средний по ЖК района weighted POI-count (метро/медицина
|
||
тяжелее) в радиусе 1000м; _city_avg_poi_score — то же по всему ЕКБ (готовый
|
||
нормализатор, уже существует). Нормализация: district / city, clamp [0,1] —
|
||
район со средней-по-городу инфраструктурой ≈ 0.5..1.0, лучше города → clamp в
|
||
1.0. Это РЕАЛЬНЫЙ per-district infra-сигнал (НЕ NULL-deferred). None когда в
|
||
районе <3 ЖК с POI (_district_poi_score вернул None) ИЛИ нет city-baseline.
|
||
|
||
DISTRICT SOURCE: 8 официальных АДМИН-районов ЕКБ — через
|
||
district_resolver._admin_names(db) (готовый кэшированный запрос
|
||
`SELECT district_name FROM ekb_districts WHERE geom IS NOT NULL`; 'не определён' там
|
||
нет — и не должно быть). Это канонический district-вокабуляр для наполнения локаций.
|
||
|
||
CENTROID: derivable из ekb_districts_geom (PostGIS-полигоны, миграция 56) через
|
||
ST_Centroid(geom) — наполняется в refresh_locations прямым SQL (район может не иметь
|
||
geom-строки → centroid остаётся NULL, graceful).
|
||
|
||
Conventions: psycopg v3 CAST(:x AS type) — НИКОГДА :x::type; SAVEPOINT per-row в
|
||
refresh-цикле (backend.md); logger не print; graceful — сбой одного района не валит
|
||
остальные. Детерминированно, без LLM.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.schemas.location import LocationList, LocationOut
|
||
from app.services.analytics_queries import _city_avg_poi_score, _district_poi_score
|
||
from app.services.site_finder.district_resolver import _admin_names
|
||
from app.services.site_finder.future_supply import compute_future_supply_pressure
|
||
from app.services.site_finder.market_metrics import compute_market_metrics
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# demand-индекс нормируется CITY-RELATIVE (велосити района / city_reference_velocity),
|
||
# а не фиксированной константой — никакого _DEMAND_SATURATION_UPM (см. #948 fix в
|
||
# docstring выше + normalize_demand ниже). Якорь city_reference выводится в
|
||
# refresh_locations из собранных за прогон скоростей (MAX), потому константы здесь нет.
|
||
|
||
# Окно метрик рынка (мес) — совпадает с дефолтом compute_market_metrics (§9.2).
|
||
_MARKET_WINDOW_MONTHS: int = 6
|
||
|
||
# Горизонт будущего предложения (мес) — дефолт compute_future_supply_pressure (§9.3).
|
||
_FUTURE_SUPPLY_HORIZON_MONTHS: int = 12
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class LocationIndices:
|
||
"""4 district-level индекса (#948 §8.2). Каждый ∈ [0,1] или None (нет данных).
|
||
|
||
None НИКОГДА не подменяется 0 — «нет данных» и «честный ноль» обязаны различаться
|
||
(дух market_metrics.py graceful-on-thin-data).
|
||
|
||
demand_index — CITY-RELATIVE (#948 fix), поэтому считается не локально по одному
|
||
району, а нормировкой против опорной скорости по городу (см. normalize_demand +
|
||
refresh_locations two-pass). compute_location_indices возвращает СЫРУЮ скорость
|
||
района в raw_unit_velocity (входной материал для нормировки) и оставляет
|
||
demand_index=None как плейсхолдер — финальное значение проставляет pass 2 refresh.
|
||
"""
|
||
|
||
infra_index: float | None
|
||
competition_index: float | None
|
||
demand_index: float | None
|
||
future_supply_index: float | None
|
||
# Сырая unit_velocity (ед./мес) района — None при отсутствии выборки. НЕ персистится
|
||
# в колонку; служит входом для city-relative нормировки demand в refresh_locations.
|
||
raw_unit_velocity: float | None = None
|
||
|
||
def as_dict(self) -> dict[str, float | None]:
|
||
return {
|
||
"infra_index": _round_or_none(self.infra_index),
|
||
"competition_index": _round_or_none(self.competition_index),
|
||
"demand_index": _round_or_none(self.demand_index),
|
||
"future_supply_index": _round_or_none(self.future_supply_index),
|
||
}
|
||
|
||
|
||
def _round_or_none(value: float | None, digits: int = 4) -> float | None:
|
||
return round(value, digits) if value is not None else None
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Pure-нормализация — без БД, полностью юнит-тестируемо.
|
||
# Каждая функция graceful: None на входе → None на выходе (не 0).
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _clamp01(value: float) -> float:
|
||
"""Зажать в [0,1]. PURE."""
|
||
return max(0.0, min(1.0, value))
|
||
|
||
|
||
def normalize_competition(overstock_index: float | None) -> float | None:
|
||
"""Индекс конкуренции = доступное/затоваренное конкурирующее предложение → [0,1]. PURE.
|
||
|
||
Источник — market_metrics.overstock_index = долго-экспонируемый непроданный сток /
|
||
все доступные ед. ∈ [0,1]: доля «зависшего» конкурирующего предложения. Выше =
|
||
больше конкурирующего стока простаивает = БОЛЬШЕ конкурентного давления для нового
|
||
входящего игрока. Это ИСТИННЫЙ сигнал конкуренции и он ОРТОГОНАЛЕН demand (скорости
|
||
продаж = unit_velocity), а не коррелирован с ним (как был старый sell_through).
|
||
|
||
Уже нормирован [0,1] самим market_metrics (доля от доступных) — только clamp как
|
||
защита от артефактов. None → None (нет данных / нет доступных лотов, НЕ 0 — иначе
|
||
«нет конкурирующего стока для замера» неотличимо от «честный ноль затоваривания»).
|
||
"""
|
||
if overstock_index is None:
|
||
return None
|
||
return _clamp01(overstock_index)
|
||
|
||
|
||
def normalize_demand(
|
||
unit_velocity: float | None,
|
||
*,
|
||
city_reference_velocity: float | None,
|
||
) -> float | None:
|
||
"""Индекс спроса CITY-RELATIVE: velocity района / city_reference → [0,1]. PURE.
|
||
|
||
Зеркалит дисциплину normalize_infra (district / city-reference, clamp), а НЕ
|
||
фиксированную константу. city_reference_velocity = опорная скорость продаж по городу
|
||
(refresh_locations передаёт MAX среди всех районов прогона). Так demand ВСЕГДА
|
||
дискриминирует районы и само-калибруется — см. #948 fix в module docstring (старый
|
||
/50 давал demand=1.0 у всех 8 районов ЕКБ → ноль дискриминации).
|
||
|
||
Semantics / edge cases (каждый покрыт юнит-тестом):
|
||
• unit_velocity is None → None («нет выборки», НЕ 0 — load-bearing дисциплина
|
||
модуля: «нет данных» ≠ «честный ноль»).
|
||
• city_reference is None → None (нет опоры: все районы без данных).
|
||
• city_reference == 0 (вырождение: все районы честно продали 0) → 0.0, НЕ None
|
||
и без ZeroDivisionError (честный нулевой спрос).
|
||
• unit_velocity == 0 при положительной опоре → 0.0 (честный ноль).
|
||
• все скорости равны / единственный район → 1.0 («одинаково горячо»).
|
||
clamp01 страхует от velocity > reference (плавающая арифметика / редкие артефакты).
|
||
"""
|
||
if unit_velocity is None or city_reference_velocity is None:
|
||
return None
|
||
if city_reference_velocity <= 0:
|
||
# Вырождение: опора 0 ⇒ весь город продал 0/мес. Честный нулевой спрос у всех
|
||
# (НЕ None — выборка была, market_metrics уже отличил «0 продаж» от «нет данных»),
|
||
# без деления на ноль.
|
||
return 0.0
|
||
return _clamp01(unit_velocity / city_reference_velocity)
|
||
|
||
|
||
def normalize_infra(
|
||
district_poi: float | None,
|
||
city_avg_poi: float | None,
|
||
) -> float | None:
|
||
"""Инфра-индекс из POI района относительно POI города → [0,1]. PURE.
|
||
|
||
district_poi / city_avg_poi, clamp [0,1]: район со средней-по-городу
|
||
инфраструктурой ≈ 1.0 (но clamp удержит лучше-города в 1.0; район беднее города
|
||
→ <1.0). None если district_poi None (в районе <3 ЖК с POI) ИЛИ city-baseline
|
||
None/≤0 (нет нормализатора). Никогда не 0-как-заглушка.
|
||
"""
|
||
if district_poi is None or city_avg_poi is None or city_avg_poi <= 0:
|
||
return None
|
||
return _clamp01(district_poi / city_avg_poi)
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# DB-оркестратор индексов — тонкий, graceful. Pure-нормализация выше тестируется без БД.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def compute_location_indices(db: Session, district: str) -> LocationIndices:
|
||
"""Вычислить per-district индексы района (#948 §8.2) — PASS 1 материал для refresh.
|
||
|
||
Переиспользует существующие per-district forecast-функции (#1129-кэшированы) и
|
||
нормализует каждый локально-вычислимый результат к [0,1] (None при отсутствии данных
|
||
— см. модульный docstring про нормализацию каждого индекса). НИКОГДА не фабрикует 0.
|
||
|
||
demand_index НЕ вычисляется здесь: он CITY-RELATIVE (#948 fix) и требует опорной
|
||
скорости по городу, доступной только после сбора всех районов. Возвращаем СЫРУЮ
|
||
скорость района в raw_unit_velocity, а demand_index оставляем None (плейсхолдер);
|
||
финальную city-relative нормировку проставляет refresh_locations (pass 2). Это
|
||
единственный вызов compute_market_metrics на район (без O(n²)).
|
||
|
||
Graceful: если источник бросает — соответствующий индекс = None (logged), остальные
|
||
считаются. Никогда не валит весь расчёт из-за одного сбойного сигнала.
|
||
|
||
Args:
|
||
db: SQLAlchemy sync Session.
|
||
district: имя района (одно из 8 админ-районов ЕКБ).
|
||
|
||
Returns:
|
||
LocationIndices: infra/competition/future_supply нормированы (или None);
|
||
demand_index=None (заполнит pass 2); raw_unit_velocity = сырая скорость района
|
||
(или None) для city-relative нормировки demand.
|
||
"""
|
||
competition_index: float | None = None
|
||
raw_unit_velocity: float | None = None
|
||
future_supply_index: float | None = None
|
||
infra_index: float | None = None
|
||
|
||
# ── competition + raw demand-velocity ← market_metrics (ОДИН вызов, два сигнала) ─
|
||
try:
|
||
metrics = compute_market_metrics(
|
||
db, district=district, window_months=_MARKET_WINDOW_MONTHS
|
||
)
|
||
# competition ← overstock_index (доступное/затоваренное конкурирующее
|
||
# предложение) — ортогонален demand. НЕ sell_through (тот коррелирован с
|
||
# velocity и инвертирован относительно «свободной конкуренции»).
|
||
competition_index = normalize_competition(metrics.overstock_index)
|
||
# demand нормируется city-relative в pass 2 — здесь только собираем сырьё.
|
||
raw_unit_velocity = metrics.unit_velocity
|
||
except Exception:
|
||
logger.exception(
|
||
"location_indices: market_metrics failed (district=%s) → competition/velocity None",
|
||
district,
|
||
)
|
||
|
||
# ── future_supply ← future_supply (уже 0..1) ────────────────────────────────
|
||
try:
|
||
fsp = compute_future_supply_pressure(
|
||
db, district=district, horizon_months=_FUTURE_SUPPLY_HORIZON_MONTHS
|
||
)
|
||
# index уже нормирован [0,1] самим сервисом — берём как есть (None passthrough).
|
||
future_supply_index = fsp.index
|
||
except Exception:
|
||
logger.exception(
|
||
"location_indices: future_supply failed (district=%s) → future_supply None",
|
||
district,
|
||
)
|
||
|
||
# ── infra ← district POI / city POI ─────────────────────────────────────────
|
||
try:
|
||
district_poi = _district_poi_score(db, district_name=district)
|
||
city_poi = _city_avg_poi_score(db)
|
||
infra_index = normalize_infra(district_poi, city_poi)
|
||
except Exception:
|
||
logger.exception(
|
||
"location_indices: infra (POI) failed (district=%s) → infra None", district
|
||
)
|
||
|
||
logger.info(
|
||
"location_indices: district=%s infra=%s competition=%s raw_velocity=%s future_supply=%s",
|
||
district,
|
||
_round_or_none(infra_index),
|
||
_round_or_none(competition_index),
|
||
_round_or_none(raw_unit_velocity),
|
||
_round_or_none(future_supply_index),
|
||
)
|
||
|
||
return LocationIndices(
|
||
infra_index=infra_index,
|
||
competition_index=competition_index,
|
||
demand_index=None, # city-relative, заполнит pass 2 refresh_locations
|
||
future_supply_index=future_supply_index,
|
||
raw_unit_velocity=raw_unit_velocity,
|
||
)
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Refresh — upsert индексов по всем районам (SAVEPOINT per-row, CAST, ON CONFLICT).
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
# centroid derive из ekb_districts_geom: ST_Centroid полигона района. Подзапрос —
|
||
# район может не иметь geom-строки → centroid NULL (graceful). ON CONFLICT по
|
||
# district_name (UNIQUE) обновляет индексы + centroid + indices_computed_at.
|
||
# psycopg v3: ВСЕ binds через CAST(:x AS type) — НИКОГДА :x::type.
|
||
_UPSERT_LOCATION_SQL = text(
|
||
"""
|
||
INSERT INTO location (
|
||
district_name, region, centroid,
|
||
infra_index, competition_index, demand_index, future_supply_index,
|
||
indices_computed_at, updated_at
|
||
) VALUES (
|
||
CAST(:district_name AS text),
|
||
CAST(:region AS text),
|
||
(
|
||
SELECT ST_Centroid(g.geom)
|
||
FROM ekb_districts_geom g
|
||
WHERE g.district_name = CAST(:district_name AS text)
|
||
LIMIT 1
|
||
),
|
||
CAST(:infra_index AS numeric),
|
||
CAST(:competition_index AS numeric),
|
||
CAST(:demand_index AS numeric),
|
||
CAST(:future_supply_index AS numeric),
|
||
now(),
|
||
now()
|
||
)
|
||
ON CONFLICT (district_name) DO UPDATE SET
|
||
region = EXCLUDED.region,
|
||
centroid = EXCLUDED.centroid,
|
||
infra_index = EXCLUDED.infra_index,
|
||
competition_index = EXCLUDED.competition_index,
|
||
demand_index = EXCLUDED.demand_index,
|
||
future_supply_index = EXCLUDED.future_supply_index,
|
||
indices_computed_at = EXCLUDED.indices_computed_at,
|
||
updated_at = now()
|
||
"""
|
||
)
|
||
|
||
|
||
def refresh_locations(db: Session, *, region: str | None = None) -> dict[str, Any]:
|
||
"""Пересчитать + upsert-нуть индексы по всем 8 админ-районам ЕКБ (#948 §8.2).
|
||
|
||
Two-pass (#948 fix — demand стал CITY-RELATIVE):
|
||
• Pass 1: для каждого района считаем locally-вычислимые индексы
|
||
(compute_location_indices) + собираем сырую unit_velocity. ОДИН вызов
|
||
compute_market_metrics на район (без O(n²)).
|
||
• Между проходами: city_reference_velocity = MAX среди собранных НЕ-None
|
||
скоростей (опора для city-relative нормировки demand). None если ни у одного
|
||
района нет выборки → demand=None у всех (graceful).
|
||
• Pass 2: normalize_demand(velocity, city_reference) на район + upsert в `location`.
|
||
|
||
Идемпотентно: ON CONFLICT (district_name) обновляет индексы + centroid +
|
||
indices_computed_at (повторный прогон даёт тот же результат при тех же данных).
|
||
|
||
SAVEPOINT per-row (backend.md): сбойный район откатывается изолированно через
|
||
begin_nested, не роняя всю транзакцию и не сбивая счётчики (#261/pzz_loader bug).
|
||
db.commit() один раз в конце.
|
||
|
||
Args:
|
||
db: SQLAlchemy sync Session.
|
||
region: метка региона в колонку `location.region` (None = ЕКБ/Свердл.).
|
||
|
||
Returns:
|
||
Счётчики: {"districts": N, "upserted": M, "failed": K}.
|
||
"""
|
||
districts = sorted(_admin_names(db))
|
||
if not districts:
|
||
logger.warning(
|
||
"refresh_locations: district source empty (ekb_districts geom?) — nothing to refresh"
|
||
)
|
||
return {"districts": 0, "upserted": 0, "failed": 0}
|
||
|
||
# ── Pass 1: per-district индексы + сырьё для city-relative demand ────────────
|
||
# Сбой compute_location_indices по одному району не валит прогон: индексы=None,
|
||
# район всё равно апсертится в pass 2 (graceful, как было).
|
||
per_district: list[tuple[str, LocationIndices]] = []
|
||
for district in districts:
|
||
try:
|
||
per_district.append((district, compute_location_indices(db, district)))
|
||
except Exception as e:
|
||
logger.warning(
|
||
"refresh_locations: district=%s compute failed: %s — indices None", district, e
|
||
)
|
||
per_district.append((district, LocationIndices(None, None, None, None)))
|
||
|
||
# ── City-reference: MAX скорость среди НЕ-None (опора demand-нормировки). ────
|
||
# «Спрос относительно самого горячего района» — само-калибрующийся якорь, всегда
|
||
# даёт спред (см. #948 fix в module docstring). None ⇒ ни у кого нет данных.
|
||
velocities = [
|
||
idx.raw_unit_velocity for _, idx in per_district if idx.raw_unit_velocity is not None
|
||
]
|
||
city_reference_velocity: float | None = max(velocities) if velocities else None
|
||
logger.info(
|
||
"refresh_locations: city_reference_velocity=%s (from %d non-None of %d districts)",
|
||
_round_or_none(city_reference_velocity),
|
||
len(velocities),
|
||
len(districts),
|
||
)
|
||
|
||
# ── Pass 2: city-relative demand + upsert (SAVEPOINT per-row) ───────────────
|
||
upserted = 0
|
||
failed = 0
|
||
for district, indices in per_district:
|
||
try:
|
||
demand_index = normalize_demand(
|
||
indices.raw_unit_velocity, city_reference_velocity=city_reference_velocity
|
||
)
|
||
with db.begin_nested():
|
||
db.execute(
|
||
_UPSERT_LOCATION_SQL,
|
||
{
|
||
"district_name": district,
|
||
"region": region,
|
||
"infra_index": indices.infra_index,
|
||
"competition_index": indices.competition_index,
|
||
"demand_index": demand_index,
|
||
"future_supply_index": indices.future_supply_index,
|
||
},
|
||
)
|
||
upserted += 1
|
||
except Exception as e:
|
||
failed += 1
|
||
logger.warning("refresh_locations: district=%s upsert failed: %s", district, e)
|
||
|
||
db.commit()
|
||
logger.info(
|
||
"refresh_locations: districts=%d upserted=%d failed=%d",
|
||
len(districts),
|
||
upserted,
|
||
failed,
|
||
)
|
||
return {"districts": len(districts), "upserted": upserted, "failed": failed}
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Read-helpers для GET-API (read-only сущность — без CRUD).
|
||
# centroid не отдаём бинарём: ST_X/ST_Y → lon/lat (NULL если centroid NULL).
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
_SELECT_COLS = """
|
||
id, district_name, region,
|
||
ST_X(centroid) AS lon, ST_Y(centroid) AS lat,
|
||
infra_index, competition_index, demand_index, future_supply_index,
|
||
indices_computed_at, created_at, updated_at
|
||
"""
|
||
|
||
|
||
def _row_to_out(r: Any) -> LocationOut:
|
||
return LocationOut(
|
||
id=int(r["id"]),
|
||
district_name=r["district_name"],
|
||
region=r["region"],
|
||
lon=float(r["lon"]) if r["lon"] is not None else None,
|
||
lat=float(r["lat"]) if r["lat"] is not None else None,
|
||
infra_index=float(r["infra_index"]) if r["infra_index"] is not None else None,
|
||
competition_index=(
|
||
float(r["competition_index"]) if r["competition_index"] is not None else None
|
||
),
|
||
demand_index=float(r["demand_index"]) if r["demand_index"] is not None else None,
|
||
future_supply_index=(
|
||
float(r["future_supply_index"]) if r["future_supply_index"] is not None else None
|
||
),
|
||
indices_computed_at=r["indices_computed_at"],
|
||
created_at=r["created_at"],
|
||
updated_at=r["updated_at"],
|
||
)
|
||
|
||
|
||
def list_locations(db: Any) -> LocationList:
|
||
"""Все локации с индексами (8 районов — пагинация не нужна, отдаём целиком)."""
|
||
rows = (
|
||
db.execute(text(f"SELECT {_SELECT_COLS} FROM location ORDER BY district_name"))
|
||
.mappings()
|
||
.all()
|
||
)
|
||
out = [_row_to_out(r) for r in rows]
|
||
return LocationList(total=len(out), rows=out)
|
||
|
||
|
||
def get_location(db: Any, district_name: str) -> LocationOut | None:
|
||
"""Вернуть одну локацию по district_name. None если нет (роутер → 404)."""
|
||
row = (
|
||
db.execute(
|
||
text(f"SELECT {_SELECT_COLS} FROM location WHERE district_name = :dn"),
|
||
{"dn": district_name},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if row is None:
|
||
return None
|
||
return _row_to_out(row)
|