gendesign/backend/app/schemas/location.py
Light1YT 8da1c00138
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy / changes (push) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m42s
Deploy / deploy (push) Successful in 1m11s
CI / backend-tests (push) Successful in 6m27s
CI / backend-tests (pull_request) Successful in 6m22s
feat(location): district-level Location entity + indices (#948 part B)
Promote district to a first-class `location` entity (ТЗ §8.2), ADDITIVE — no
district->FK refactor. New `location` table keyed by district_name (joinable by
string), carrying 4 normalized [0,1] indices (NULL when no data, never 0):
- infra: _district_poi_score / _city_avg_poi_score (per-district POI aggregate)
- competition: market_metrics.overstock_index (available/stuck competing supply
  — orthogonal to demand; NOT sell-through, which is market-heat correlated w/ demand)
- demand: market_metrics.unit_velocity (saturating /50)
- future_supply: future_supply_pressure.index (passthrough, already 0..1)

- data/sql/146_location.sql: idempotent table + UNIQUE(district_name) + range
  CHECK + centroid GIST
- services/site_finder/locations.py: compute_location_indices (reuses forecast
  per-district fns) + refresh_locations (SAVEPOINT per-row, CAST, ON CONFLICT)
- workers/tasks/location_refresh.py + beat (Mon 07:00 MSK, after supply-layers)
- api/v1/locations.py: read-only GET list + GET by name (analyst+admin via rbac,
  frontend pilot-gated)
- tests: 34 (normalization 0..1/null, competition⊥demand orthogonality, idempotent
  upsert, read API list/by-name/404)

Part of #948 (Part A insight shipped #1164).
2026-06-08 13:28:19 +05:00

68 lines
2.9 KiB
Python
Raw Permalink 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.

"""Pydantic schemas для location (#948 Part B, ТЗ §8.2).
LocationOut — ответ API: район + 4 district-level индекса (0..1 или None).
LocationList — ответ list-эндпоинта (total + rows).
Read-only: индексы ВЫЧИСЛЯЮТСЯ воркером (tasks/location_refresh.py), не вводятся
пользователем → Create/Update-схем нет (CRUD отсутствует, только GET-API).
Все индексы Optional[float] ∈ [0,1]: None = нет данных (graceful-on-thin-data —
никогда не фабрикуем 0). centroid не отдаём бинарём — lon/lat достаточно для карты
(NULL если centroid не вычислен).
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class LocationOut(BaseModel):
"""Локация (район) с district-level индексами (#948 Part B, §8.2)."""
id: int
district_name: str = Field(..., description="Логический ключ района (8 админ-районов ЕКБ)")
region: str | None = Field(None, description="Регион (NULL = ЕКБ/Свердл.)")
lon: float | None = Field(None, description="Долгота центроида района (WGS-84), None если нет")
lat: float | None = Field(None, description="Широта центроида района (WGS-84), None если нет")
infra_index: float | None = Field(
None,
ge=0.0,
le=1.0,
description="Инфра-индекс 0..1 (POI района / POI города); None=нет данных",
)
competition_index: float | None = Field(
None,
ge=0.0,
le=1.0,
description=(
"Индекс конкуренции 0..1 (доступное/затоваренное конкурирующее предложение "
"= overstock; выше = больше конкур. давления; ортогонален demand); "
"None=нет данных"
),
)
demand_index: float | None = Field(
None,
ge=0.0,
le=1.0,
description="Индекс спроса 0..1 (saturated velocity); None=нет данных",
)
future_supply_index: float | None = Field(
None,
ge=0.0,
le=1.0,
description="Индекс будущего предложения 0..1; None=нет данных",
)
indices_computed_at: datetime | None = Field(
None, description="Когда индексы последний раз пересчитаны (None до первого refresh)"
)
created_at: datetime
updated_at: datetime
class LocationList(BaseModel):
"""Ответ list-эндпоинта: total + страница строк (зеркало InsightList)."""
total: int
rows: list[LocationOut]