Merge pull request 'fix(week-review): аудит код-ревью — 181 фиксов (label week ревью 1)' (#1543) from fix/week-review-audit into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 7s
Deploy / changes (push) Successful in 6s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Failing after 36s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
Deploy / build-backend (push) Successful in 1m55s
Deploy / build-worker (push) Successful in 2m59s
Deploy / build-frontend (push) Successful in 3m10s
Deploy / deploy (push) Successful in 1m21s

Reviewed-on: #1543
This commit is contained in:
lekss361 2026-06-16 05:46:11 +00:00
commit 8d1a13ee5e
118 changed files with 2438 additions and 757 deletions

View file

@ -11,6 +11,7 @@ Returns the Celery task id; poll /api/v1/admin/scrape/runs/{run_id} to track DB-
from __future__ import annotations from __future__ import annotations
import logging
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Annotated, Any from typing import Annotated, Any
@ -22,6 +23,8 @@ from sqlalchemy.orm import Session
from app.core.config import settings from app.core.config import settings
from app.core.db import get_db from app.core.db import get_db
logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -69,7 +72,7 @@ def trigger_kn_sweep(
"task_id": result.id, "task_id": result.id,
"region_code": payload.region_code, "region_code": payload.region_code,
"developers": payload.developers, "developers": payload.developers,
"queued_at": result.date_done.isoformat() if result.date_done else None, "queued_at": "now",
"force": payload.force, "force": payload.force,
"lock_was_released": lock_released, "lock_was_released": lock_released,
} }
@ -175,9 +178,16 @@ def queue_status(
return out return out
def _safe(fn): def _safe(fn):
# Деградация (return None при недоступном broker) намеренна для UI-poll
# эндпоинта, но логируем чтобы устойчиво сломанный broker был виден в логах.
try: try:
return fn() return fn()
except Exception: except Exception:
logger.warning(
"queue_status: celery inspect call %s failed",
getattr(fn, "__name__", fn),
exc_info=True,
)
return None return None
deadline = time.monotonic() + 0.8 deadline = time.monotonic() + 0.8
@ -203,6 +213,9 @@ def queue_status(
with conn.channel() as channel: with conn.channel() as channel:
queue_depth = channel.client.llen("celery") queue_depth = channel.client.llen("celery")
except Exception: except Exception:
# Намеренная деградация для UI-poll; логируем чтобы недоступный broker
# не был невидим в логах (см. .claude/rules/backend.md).
logger.warning("queue_status: broker queue_depth probe failed", exc_info=True)
queue_depth = None queue_depth = None
return { return {
@ -769,6 +782,7 @@ class BulkGeoEnqueueRequest(BaseModel):
"all_in_region — UNION всех cad из rosreestr_deals + cad_buildings + complexes" "all_in_region — UNION всех cad из rosreestr_deals + cad_buildings + complexes"
), ),
) )
rate_ms: int = Field(default=600, ge=100, le=10000)
# Маппинг thematic_id → таблица проверки существования + колонка + job_kind-метка # Маппинг thematic_id → таблица проверки существования + колонка + job_kind-метка
@ -929,6 +943,7 @@ def bulk_enqueue_geo(
"source": payload.source, "source": payload.source,
}, },
cad_nums_with_thematic=cad_with_thematic, cad_nums_with_thematic=cad_with_thematic,
rate_ms=payload.rate_ms,
triggered_by="bulk_admin", triggered_by="bulk_admin",
) )
process_nspd_geo_job.apply_async(args=[job_id], queue=geo_queue) process_nspd_geo_job.apply_async(args=[job_id], queue=geo_queue)

View file

@ -1,6 +1,7 @@
import logging import logging
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.concurrency import run_in_threadpool
from app.schemas.concept import ConceptInput, ConceptOutput from app.schemas.concept import ConceptInput, ConceptOutput
from app.services.generative import geometry from app.services.generative import geometry
@ -23,7 +24,9 @@ async def create_concept(payload: ConceptInput) -> ConceptOutput:
422 rather than empty variants that is a bad request, not a valid empty result. 422 rather than empty variants that is a bad request, not a valid empty result.
""" """
try: try:
variants = geometry.generate(payload) # geometry.generate — синхронный CPU-bound (Shapely/STRtree), мостим через
# run_in_threadpool, чтобы НЕ блокировать event loop (тот же приём, что и в chat.py).
variants = await run_in_threadpool(geometry.generate, payload)
except ParcelGeometryError as exc: except ParcelGeometryError as exc:
logger.warning("concept generation rejected parcel: %s", exc) logger.warning("concept generation rejected parcel: %s", exc)
raise HTTPException(status_code=422, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc

View file

@ -1457,7 +1457,8 @@ def analyze_parcel(
) AS dist_to_center ) AS dist_to_center
FROM ekb_districts d FROM ekb_districts d
LEFT JOIN mv_quarter_price_per_m2 mq LEFT JOIN mv_quarter_price_per_m2 mq
ON mq.quarter_cad_number = REGEXP_REPLACE(:cad_num, ':[^:]+$', '') ON mq.quarter_cad_number =
REGEXP_REPLACE(:cad_num, '^([^:]+:[^:]+:[^:]+).*$', '\\1')
WHERE ST_DWithin( WHERE ST_DWithin(
d.geom::geography, d.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
@ -1841,9 +1842,10 @@ def analyze_parcel(
# 9c) Hydrology — водоёмы и реки в радиусе 2 км из osm_noise_sources_ekb # 9c) Hydrology — водоёмы и реки в радиусе 2 км из osm_noise_sources_ekb
hydrology: dict[str, Any] | None = None hydrology: dict[str, Any] | None = None
try: try:
hydro_rows = ( with db.begin_nested():
db.execute( hydro_rows = (
text(""" db.execute(
text("""
SELECT source_type, road_class, name, SELECT source_type, road_class, name,
ST_Distance( ST_Distance(
n.geom::geography, n.geom::geography,
@ -1859,11 +1861,11 @@ def analyze_parcel(
ORDER BY distance_m ASC ORDER BY distance_m ASC
LIMIT 10 LIMIT 10
"""), """),
{"wkt": geom_wkt}, {"wkt": geom_wkt},
)
.mappings()
.all()
) )
.mappings()
.all()
)
hydrology = { hydrology = {
"nearest": [ "nearest": [
{ {
@ -1890,9 +1892,10 @@ def analyze_parcel(
# 9d) Utilities — power lines + pipelines из OSM (магистральные сети) # 9d) Utilities — power lines + pipelines из OSM (магистральные сети)
utilities: dict[str, Any] | None = None utilities: dict[str, Any] | None = None
try: try:
util_rows = ( with db.begin_nested():
db.execute( util_rows = (
text(""" db.execute(
text("""
SELECT road_class, name, SELECT road_class, name,
ST_Distance( ST_Distance(
n.geom::geography, n.geom::geography,
@ -1908,11 +1911,11 @@ def analyze_parcel(
ORDER BY distance_m ASC ORDER BY distance_m ASC
LIMIT 10 LIMIT 10
"""), """),
{"wkt": geom_wkt}, {"wkt": geom_wkt},
)
.mappings()
.all()
) )
.mappings()
.all()
)
# Группировка по типу для compactness. util_rows отсортированы по # Группировка по типу для compactness. util_rows отсортированы по
# distance_m ASC → первый встреченный road_class = ближайший. # distance_m ASC → первый встреченный road_class = ближайший.
by_subtype: dict[str, dict[str, Any]] = {} by_subtype: dict[str, dict[str, Any]] = {}
@ -1974,9 +1977,10 @@ def analyze_parcel(
# 9f) Parcel meta — ВРИ и кадастровые метаданные из cad_parcels (#29 G2) # 9f) Parcel meta — ВРИ и кадастровые метаданные из cad_parcels (#29 G2)
parcel_meta: ParcelMeta | None = None parcel_meta: ParcelMeta | None = None
try: try:
pm_row = ( with db.begin_nested():
db.execute( pm_row = (
text(""" db.execute(
text("""
SELECT permitted_use_established_by_document AS permitted_use, SELECT permitted_use_established_by_document AS permitted_use,
land_record_category_type AS land_category, land_record_category_type AS land_category,
land_record_subtype AS land_subtype, land_record_subtype AS land_subtype,
@ -1985,11 +1989,11 @@ def analyze_parcel(
WHERE cad_num = CAST(:c AS text) WHERE cad_num = CAST(:c AS text)
LIMIT 1 LIMIT 1
"""), """),
{"c": cad_num}, {"c": cad_num},
)
.mappings()
.first()
) )
.mappings()
.first()
)
if pm_row: if pm_row:
parcel_meta = ParcelMeta( parcel_meta = ParcelMeta(
permitted_use=pm_row["permitted_use"], permitted_use=pm_row["permitted_use"],
@ -2003,9 +2007,10 @@ def analyze_parcel(
# B5-1) EGRN block — расширенные данные из cad_parcels (SF-B5) # B5-1) EGRN block — расширенные данные из cad_parcels (SF-B5)
egrn_block: dict[str, Any] = {} egrn_block: dict[str, Any] = {}
try: try:
egrn_row = ( with db.begin_nested():
db.execute( egrn_row = (
text(""" db.execute(
text("""
SELECT cost_value AS cadastral_value_rub, SELECT cost_value AS cadastral_value_rub,
cost_index AS cost_index_per_m2, cost_index AS cost_index_per_m2,
land_record_category_type AS land_category, land_record_category_type AS land_category,
@ -2021,11 +2026,11 @@ def analyze_parcel(
WHERE cad_num = CAST(:c AS text) WHERE cad_num = CAST(:c AS text)
LIMIT 1 LIMIT 1
"""), """),
{"c": cad_num}, {"c": cad_num},
)
.mappings()
.first()
) )
.mappings()
.first()
)
if egrn_row: if egrn_row:
_cad_val = ( _cad_val = (
float(egrn_row["cadastral_value_rub"]) float(egrn_row["cadastral_value_rub"])
@ -2070,19 +2075,20 @@ def analyze_parcel(
"zouit_count": 0, "zouit_count": 0,
} }
try: try:
zouit_rows = ( with db.begin_nested():
db.execute( zouit_rows = (
text(""" db.execute(
text("""
SELECT type_zone, name_by_doc SELECT type_zone, name_by_doc
FROM cad_zouit FROM cad_zouit
WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326)) WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326))
ORDER BY id ORDER BY id
"""), """),
{"wkt": geom_wkt}, {"wkt": geom_wkt},
)
.mappings()
.all()
) )
.mappings()
.all()
)
if zouit_rows: if zouit_rows:
_zouit_types = list({r["type_zone"] for r in zouit_rows if r["type_zone"]}) _zouit_types = list({r["type_zone"] for r in zouit_rows if r["type_zone"]})
encumbrance_block = { encumbrance_block = {
@ -2096,9 +2102,10 @@ def analyze_parcel(
# B5-3) Red lines block — пересечение с cad_red_lines (SF-B5) # B5-3) Red lines block — пересечение с cad_red_lines (SF-B5)
red_lines_block: dict[str, Any] = {"intersects": False, "count": 0} red_lines_block: dict[str, Any] = {"intersects": False, "count": 0}
try: try:
rl_row = ( with db.begin_nested():
db.execute( rl_row = (
text(""" db.execute(
text("""
SELECT COUNT(*) AS cnt SELECT COUNT(*) AS cnt
FROM cad_red_lines FROM cad_red_lines
WHERE ST_Intersects( WHERE ST_Intersects(
@ -2106,11 +2113,11 @@ def analyze_parcel(
ST_GeomFromText(:wkt, 4326) ST_GeomFromText(:wkt, 4326)
) )
"""), """),
{"wkt": geom_wkt}, {"wkt": geom_wkt},
)
.mappings()
.first()
) )
.mappings()
.first()
)
if rl_row: if rl_row:
_rl_cnt = int(rl_row["cnt"]) _rl_cnt = int(rl_row["cnt"])
red_lines_block = { red_lines_block = {
@ -2132,9 +2139,10 @@ def analyze_parcel(
} }
if district_row and district_row["district_name"]: if district_row and district_row["district_name"]:
try: try:
dp_row = ( with db.begin_nested():
db.execute( dp_row = (
text(""" db.execute(
text("""
SELECT SELECT
MIN(price_per_m2_rub) AS price_min, MIN(price_per_m2_rub) AS price_min,
MAX(price_per_m2_rub) AS price_max, MAX(price_per_m2_rub) AS price_max,
@ -2147,11 +2155,11 @@ def analyze_parcel(
AND price_per_m2_rub IS NOT NULL AND price_per_m2_rub IS NOT NULL
AND price_per_m2_rub BETWEEN 30000 AND 600000 AND price_per_m2_rub BETWEEN 30000 AND 600000
"""), """),
{"dn": district_row["district_name"]}, {"dn": district_row["district_name"]},
)
.mappings()
.first()
) )
.mappings()
.first()
)
if dp_row and dp_row["sample_size"] and int(dp_row["sample_size"]) > 0: if dp_row and dp_row["sample_size"] and int(dp_row["sample_size"]) > 0:
district_price_block = { district_price_block = {
"district_price_per_m2_min": ( "district_price_per_m2_min": (
@ -2175,9 +2183,10 @@ def analyze_parcel(
"geology_risk_label": None, "geology_risk_label": None,
} }
try: try:
flood_row = ( with db.begin_nested():
db.execute( flood_row = (
text(""" db.execute(
text("""
SELECT COUNT(*) AS cnt SELECT COUNT(*) AS cnt
FROM cad_risk_zones FROM cad_risk_zones
WHERE ST_Intersects( WHERE ST_Intersects(
@ -2187,11 +2196,11 @@ def analyze_parcel(
AND (risk_type ILIKE '%flood%' OR risk_type ILIKE '%подтоп%' AND (risk_type ILIKE '%flood%' OR risk_type ILIKE '%подтоп%'
OR risk_type ILIKE '%затоп%') OR risk_type ILIKE '%затоп%')
"""), """),
{"wkt": geom_wkt}, {"wkt": geom_wkt},
)
.mappings()
.first()
) )
.mappings()
.first()
)
_flood = bool(flood_row and int(flood_row["cnt"]) > 0) _flood = bool(flood_row and int(flood_row["cnt"]) > 0)
# Geology proxy через hydrology flood_risk_flag (уже посчитан выше) # Geology proxy через hydrology flood_risk_flag (уже посчитан выше)
_geo_flood = hydrology.get("flood_risk_flag", False) if hydrology else False _geo_flood = hydrology.get("flood_risk_flag", False) if hydrology else False
@ -2214,9 +2223,10 @@ def analyze_parcel(
# 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев # 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев
market_trend: dict[str, Any] | None = None market_trend: dict[str, Any] | None = None
try: try:
trend_row = ( with db.begin_nested():
db.execute( trend_row = (
text(""" db.execute(
text("""
WITH district_deals AS ( WITH district_deals AS (
SELECT d.period_start_date AS deal_date, SELECT d.period_start_date AS deal_date,
d.price_per_sqm AS price_per_m2 d.price_per_sqm AS price_per_m2
@ -2251,11 +2261,11 @@ def analyze_parcel(
AS prior_n AS prior_n
FROM district_deals FROM district_deals
"""), """),
{"wkt": geom_wkt}, {"wkt": geom_wkt},
)
.mappings()
.first()
) )
.mappings()
.first()
)
if trend_row and trend_row["recent_avg"] and trend_row["prior_avg"]: if trend_row and trend_row["recent_avg"] and trend_row["prior_avg"]:
recent_p = float(trend_row["recent_avg"]) recent_p = float(trend_row["recent_avg"])
prior_p = float(trend_row["prior_avg"]) prior_p = float(trend_row["prior_avg"])
@ -2712,9 +2722,7 @@ def analyze_parcel(
if district_row and district_row["district_name"]: if district_row and district_row["district_name"]:
try: try:
with db.begin_nested(): with db.begin_nested():
saturation_block = compute_district_saturation( saturation_block = compute_district_saturation(db, district_row["district_name"])
db, district_row["district_name"]
)
except Exception as e: except Exception as e:
logger.warning( logger.warning(
"saturation block failed for %s (%s): %s", "saturation block failed for %s (%s): %s",
@ -2942,7 +2950,7 @@ def analyze_parcel(
response_model=ConnectionPointsResponse, response_model=ConnectionPointsResponse,
summary="Точки подключения к инженерным сетям + охранные зоны (issue #115)", summary="Точки подключения к инженерным сетям + охранные зоны (issue #115)",
) )
async def get_parcel_connection_points( def get_parcel_connection_points(
cad_num: str, cad_num: str,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
radius_m: Annotated[int, Query(ge=50, le=2000)] = 500, radius_m: Annotated[int, Query(ge=50, le=2000)] = 500,
@ -3106,7 +3114,7 @@ def get_isochrones(
response_model=PoiScoreResponse, response_model=PoiScoreResponse,
summary="POI weighted top-7 (B6)", summary="POI weighted top-7 (B6)",
) )
async def get_poi_score( def get_poi_score(
cad_num: str, cad_num: str,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
radius_m: Annotated[int, Query(ge=100, le=5000)] = 2000, radius_m: Annotated[int, Query(ge=100, le=5000)] = 2000,

View file

@ -9,7 +9,7 @@ from datetime import date, datetime
from typing import Literal from typing import Literal
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
class TradeInEstimateInput(BaseModel): class TradeInEstimateInput(BaseModel):
@ -23,6 +23,12 @@ class TradeInEstimateInput(BaseModel):
repair_state: Literal["needs_repair", "standard", "good", "excellent"] | None = None repair_state: Literal["needs_repair", "standard", "good", "excellent"] | None = None
has_balcony: bool | None = None has_balcony: bool | None = None
@model_validator(mode="after")
def _check_floor_within_total(self) -> TradeInEstimateInput:
if self.floor > self.total_floors:
raise ValueError("floor must be less than or equal to total_floors")
return self
class AnalogLot(BaseModel): class AnalogLot(BaseModel):
address: str address: str

View file

@ -24,6 +24,28 @@ def _f(value: Any) -> float | None:
return value return value
# UI шлёт английские классы (ClassLiteral = "Comfort"/"Comfort+"/"Business"/
# "Elite"), а domrf_kn_objects.obj_class и objective_corpus_room_month.class
# хранят русские названия из скрейпера (domrf_kn.py: "Элит"/"Бизнес"/"Комфорт"/
# "Стандарт"/"Типовой"). Без перевода точный матч obj_class = :cls в comparables/
# competitors/velocity/elasticity молча даёт ноль строк ("Comfort" != "Комфорт").
_CLASS_EN_TO_RU = {
"Comfort": "Комфорт",
"Comfort+": "Комфорт",
"Business": "Бизнес",
"Elite": "Элит",
}
def _class_to_db_vocab(target_class: str | None) -> str | None:
"""Перевод английского класса из UI в русский словарь БД (obj_class /
objective class). Неизвестные значения возвращаем как есть (уже могут быть
русскими, либо новый класс пусть фильтр решает естественно)."""
if target_class is None:
return None
return _CLASS_EN_TO_RU.get(target_class, target_class)
def market_pulse(db: Session, region_code: int = 66) -> list[dict[str, Any]]: def market_pulse(db: Session, region_code: int = 66) -> list[dict[str, Any]]:
rows = ( rows = (
db.execute( db.execute(
@ -121,7 +143,12 @@ def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[s
AND area > 0 AND deal_count > 0 AND area > 0 AND deal_count > 0
AND (area / deal_count) BETWEEN 15 AND 200 AND (area / deal_count) BETWEEN 15 AND 200
AND price_per_sqm > 0 AND price_per_sqm > 0
AND period_start_date >= '2025-07-01' -- #1384: скользящее окно вместо захардкоженной даты-пола
-- ('2025-07-01' расширял «recent»-окно каждую неделю по мере
-- доливки ETL новых report_months перекос в сторону всё
-- более длинной истории). Тот же фикс, что #1203 и _BUCKET_SQL.
AND period_start_date >= NOW()
- (:months_window || ' months')::INTERVAL
), ),
bucketed AS ( bucketed AS (
SELECT CASE SELECT CASE
@ -144,7 +171,9 @@ def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[s
ORDER BY bucket ORDER BY bucket
""" """
), ),
{"region_id": region_id}, # 12 мес — размер окна, эквивалентный исходному '2025-07-01' на момент
# написания (см. #1384); теперь окно скользит и не растёт со временем.
{"region_id": region_id, "months_window": 12},
) )
.mappings() .mappings()
.all() .all()
@ -993,7 +1022,15 @@ def object_flats_quartirography(db: Session, obj_id: int) -> list[dict[str, Any]
WHEN f.rooms = 3 THEN '3-комн.' WHEN f.rooms = 3 THEN '3-комн.'
ELSE (f.rooms::text || '-комн.') ELSE (f.rooms::text || '-комн.')
END AS room_label, END AS room_label,
COALESCE(f.rooms, -1) AS sort_key, -- #1358: sort_key должен совпадать с логическим room_label, а
-- не с сырым rooms. Нежилой юнит с flat_type LIKE '%нежил%' но
-- ненулевым rooms (напр. коммерция rooms=2) иначе уходил в
-- отдельную группу sort_key=2, дробя бакет 'Нежилые' на дубли.
CASE
WHEN f.rooms IS NULL OR LOWER(f.flat_type) LIKE '%нежил%'
OR LOWER(f.flat_type) LIKE '%nonliv%' THEN -1
ELSE f.rooms
END AS sort_key,
COUNT(*) AS total_count, COUNT(*) AS total_count,
COUNT(*) FILTER (WHERE LOWER(f.status) = 'free' COUNT(*) FILTER (WHERE LOWER(f.status) = 'free'
OR LOWER(f.status) LIKE '%свобод%') OR LOWER(f.status) LIKE '%свобод%')
@ -2450,6 +2487,10 @@ def recommend_mix(
"price_p75_per_m2": p75, "price_p75_per_m2": p75,
"units_planned": units_planned, "units_planned": units_planned,
"revenue_planned_rub": revenue_planned, "revenue_planned_rub": revenue_planned,
# #1359: сырое area_avg сохраняем, чтобы при success-boost
# перераспределить units/revenue под новые share_pct. Удаляется
# из ответа перед возвратом.
"_area_avg_raw": area_avg,
} }
) )
@ -2458,9 +2499,13 @@ def recommend_mix(
# 5b) Velocity baseline (apartments/month per ЖК) + price elasticity. # 5b) Velocity baseline (apartments/month per ЖК) + price elasticity.
# Both are required for the live "цена↔темп" calculator on the frontend. # Both are required for the live "цена↔темп" calculator on the frontend.
# Graceful: kn-API returns obj_class=NULL для всех ЖК Свердл (отдельный # Graceful: kn-API returns obj_class=NULL для всех ЖК Свердл (отдельный
# баг скрейпера). Если в районе нет ни одного НЕ-NULL obj_class # баг скрейпера). Если в районе нет ни одного ЖК ИМЕННО запрошенного класса
# игнорируем target_class фильтр на уровне velocity/elasticity/comparable # игнорируем target_class фильтр на уровне velocity/elasticity/comparable
# запросов, иначе obj_pool пустой и всё падает в fallback. # запросов, иначе obj_pool пустой и всё падает в fallback.
# #1375: БД хранит русские названия классов, UI шлёт английские — переводим
# перед матчем obj_class = :cls, иначе точный матч молча даёт ноль строк
# ("Comfort" != "Комфорт") и comparables/competitors тихо пустеют.
target_class_db = _class_to_db_vocab(target_class)
has_class_data = bool( has_class_data = bool(
db.execute( db.execute(
text( text(
@ -2469,18 +2514,20 @@ def recommend_mix(
WHERE region_cd = :rc WHERE region_cd = :rc
AND district_name = :dn AND district_name = :dn
AND obj_class IS NOT NULL AND obj_class IS NOT NULL
AND (CAST(:cls AS TEXT) IS NULL OR obj_class = :cls)
LIMIT 1 LIMIT 1
""" """
), ),
{"rc": region_code, "dn": district_row["district_name"]}, {"rc": region_code, "dn": district_row["district_name"], "cls": target_class_db},
).scalar() ).scalar()
) )
target_class_for_geo = target_class if has_class_data else None target_class_for_geo = target_class_db if has_class_data else None
if target_class and not has_class_data: if target_class and not has_class_data:
warnings.append( warnings.append(
f"obj_class не заполнен для ЖК района {district_row['district_name']}" f"Нет ЖК класса '{target_class}' среди размеченных в районе"
f" — фильтр по классу '{target_class}' игнорируется в velocity/comparable" f" {district_row['district_name']} — фильтр по классу игнорируется в"
" (но class_multiplier из yandex_realty_zk применяется к ценам)." " velocity/comparable (но class_multiplier из yandex_realty_zk"
" применяется к ценам)."
) )
vel = _velocity_baseline( vel = _velocity_baseline(
db, db,
@ -2739,6 +2786,41 @@ def recommend_mix(
if b["bucket"] != top_bucket_name: if b["bucket"] != top_bucket_name:
b["share_pct"] = round(b["share_pct"] * scale, 1) b["share_pct"] = round(b["share_pct"] * scale, 1)
# #1359: success-boost изменил share_pct → перераспределяем
# units/revenue/months_to_sellout и агрегаты под новые доли,
# иначе share_pct рассогласуется с units_planned/revenue/sellout
# (UI показывал бы 40% доли при units от старых 30%).
if area_total_m2:
total_units = 0
total_revenue = 0.0
for b in buckets:
area_avg = b["_area_avg_raw"]
if area_avg and area_avg > 0:
allocated = area_total_m2 * (b["share_pct"] / 100.0)
units = max(1, round(allocated / area_avg))
b["units_planned"] = units
# price_median_per_m2 уже с combined_price_factor —
# revenue считаем по скорректированной цене.
revenue = round(units * area_avg * b["price_median_per_m2"], 2)
b["revenue_planned_rub"] = revenue
total_revenue += revenue
bucket_velocity = b["velocity_per_month"] or 0.0
be_val = b.get("elasticity")
bpf = (
price_factor**be_val
if (be_val is not None and price_factor > 0)
else 1.0
)
adj_vel = bucket_velocity * bpf
b["months_to_sellout"] = (
round(units / adj_vel, 1) if adj_vel > 0 else None
)
total_units += units
# total_revenue/weighted_avg_price уже включают
# combined_price_factor (через per-bucket price_median_per_m2),
# повторно домножать НЕ нужно.
have_revenue = total_revenue > 0
# 5c) Inverse mode: target_months → required price_factor. # 5c) Inverse mode: target_months → required price_factor.
# Tier 3: используем weighted-by-units эластичность (per-bucket эластичности # Tier 3: используем weighted-by-units эластичность (per-bucket эластичности
# → агрегатная только когда нужна одна цифра). При smooth-buckets разница # → агрегатная только когда нужна одна цифра). При smooth-buckets разница
@ -2894,6 +2976,10 @@ def recommend_mix(
headline_parts.append(f"ликвидность {liquidity_24mo:.0f}/100") headline_parts.append(f"ликвидность {liquidity_24mo:.0f}/100")
headline = " · ".join(headline_parts) if headline_parts else None headline = " · ".join(headline_parts) if headline_parts else None
# #1359: убираем служебное поле перед возвратом (и до передачи в overlay).
for b in buckets:
b.pop("_area_avg_raw", None)
result: dict[str, Any] = { result: dict[str, Any] = {
"scope": { "scope": {
"district": district_row["district_name"], "district": district_row["district_name"],

View file

@ -16,6 +16,7 @@ ROBUST к частичному/пустому отчёту (отчёт може
from __future__ import annotations from __future__ import annotations
import math
import re import re
from typing import Any from typing import Any
@ -150,6 +151,8 @@ def _fmt_number(value: Any) -> str | None:
if isinstance(value, int): if isinstance(value, int):
return _fmt_thousands(value) return _fmt_thousands(value)
if isinstance(value, float): if isinstance(value, float):
if not math.isfinite(value): # NaN/Inf: int(value) бросил бы ValueError
return str(value)
if value == int(value): if value == int(value):
return _fmt_thousands(value) return _fmt_thousands(value)
# Точность отчёта сохраняем: repr float'а → '.'→','. (0.31 → '0,31'). # Точность отчёта сохраняем: repr float'а → '.'→','. (0.31 → '0,31').

View file

@ -26,8 +26,9 @@ explainability/прототипа, не основание для инвест-
КОНВЕРТ ШИРИТСЯ С ГОРИЗОНТОМ (сознательно, документируем): дальние горизонты КОНВЕРТ ШИРИТСЯ С ГОРИЗОНТОМ (сознательно, документируем): дальние горизонты
неопределённее, поэтому Δ ставки растёт линейно с горизонтом неопределённее, поэтому Δ ставки растёт линейно с горизонтом
delta_eff(h) = base_delta × (1 + _HORIZON_WIDEN_PER_YEAR × h/12). На h=12 мес delta_eff(h) = base_delta × (1 + _HORIZON_WIDEN_PER_YEAR × h/12). При
конверт = base_delta, на h=24 мес base_delta × (1 + 0.5×_HORIZON_WIDEN_PER_YEAR). _HORIZON_WIDEN_PER_YEAR=0.5: на h=12 мес конверт = base_delta × 1.5, на h=24 мес
= base_delta × 2.0 (на каждый год горизонта +50% к base_delta).
Держим ПРОСТО (один линейный множитель) и tunable. Ставка КЛАМПится 0 (нет Держим ПРОСТО (один линейный множитель) и tunable. Ставка КЛАМПится 0 (нет
отрицательной ключевой ставки) на дальнем агрессивном горизонте конверт может отрицательной ключевой ставки) на дальнем агрессивном горизонте конверт может
упереть ставку в 0. упереть ставку в 0.

View file

@ -30,6 +30,7 @@ from .provider import (
LLMProvider, LLMProvider,
LLMProviderError, LLMProviderError,
LLMRateLimitedError, LLMRateLimitedError,
LLMTimeoutError,
OpenAIProvider, OpenAIProvider,
ProviderResponse, ProviderResponse,
ToolCall, ToolCall,
@ -244,7 +245,7 @@ def _call_with_retries(
except LLMProviderError as e: except LLMProviderError as e:
# Таймаут (LLMTimeoutError) и прочие провайдер-ошибки: НЕ ретраим (таймаут уже # Таймаут (LLMTimeoutError) и прочие провайдер-ошибки: НЕ ретраим (таймаут уже
# «съел» бюджет времени; сетевые/4xx обычно не лечатся повтором тут). # «съел» бюджет времени; сетевые/4xx обычно не лечатся повтором тут).
reason = "timeout" if e.__class__.__name__ == "LLMTimeoutError" else "provider_error" reason = "timeout" if isinstance(e, LLMTimeoutError) else "provider_error"
logger.warning("llm: provider error (%s) → fallback: %s", reason, e) logger.warning("llm: provider error (%s) → fallback: %s", reason, e)
return LLMResult.fallback(reason) return LLMResult.fallback(reason)

View file

@ -83,10 +83,8 @@ def list_izyatie_documents(url: str = _IZYATIE_URL) -> list[dict[str, str]]:
# Берём только /file/<hash>-ссылки. # Берём только /file/<hash>-ссылки.
if not _RE_FILE_HREF.match(href): if not _RE_FILE_HREF.match(href):
continue continue
title: str = a_tag.get_text(strip=True) or href # Заголовок: текст якоря → title-атрибут → href как последний фолбэк.
# Если заголовок пустой — берём title-атрибут или href. title: str = a_tag.get_text(strip=True) or a_tag.get("title") or href
if not title:
title = a_tag.get("title", href)
absolute_url = urllib.parse.urljoin(_BASE_URL, href) absolute_url = urllib.parse.urljoin(_BASE_URL, href)
docs.append({"title": title, "url": absolute_url}) docs.append({"title": title, "url": absolute_url})

View file

@ -111,38 +111,91 @@ _COMPETITORS_IN_RADIUS_SQL = text("""
# :competitor_obj_ids — list[int] obj_id конкурентов в радиусе # :competitor_obj_ids — list[int] obj_id конкурентов в радиусе
# CAST(:window_interval AS interval) — psycopg v3 / SQLAlchemy 2.0 safe (не ::interval). # CAST(:window_interval AS interval) — psycopg v3 / SQLAlchemy 2.0 safe (не ::interval).
#
# Fix #1391: fan-out по objective_complex_mapping завышал SUM-метрики. UNIQUE-ключ
# mapping — (objective_complex_name, objective_group), поэтому один project_name может
# матчиться на N mapping-строк (разные группы / domrf_obj_id). Прямой
# JOIN crm × cm дублировал каждую crm-строку N раз → SUM(deals_total_count),
# weighted-avg числители/знаменатели и deals_window раздувались в N раз. DISTINCT-метрики
# (competitor_obj_ids / competitor_count) не страдали, но SUM — да.
# Решение: deals агрегируем per (project_name, room_bucket) в CTE `crm_agg`
# (без mapping-джойна → fan-out невозможен). Список project_name'ов в радиусе берём из
# CTE `mapped_projects` (DISTINCT по objective_complex_name) и джойним один-к-одному —
# каждый project_name вносит свои сделки ровно один раз. competitor_obj_ids /
# competitor_count считаем отдельно в `obj_per_bucket` (через unnest mapping-obj_id),
# чтобы fan-out по obj_id не попал в SUM-агрегаты, и приджойниваем по room_bucket.
_INLINE_VELOCITY_SQL = text(""" _INLINE_VELOCITY_SQL = text("""
SELECT WITH crm_agg AS (
CASE SELECT
WHEN crm.room_bucket = 'студия' THEN 'studio' crm.project_name,
ELSE crm.room_bucket CASE
END AS room_bucket, WHEN crm.room_bucket = 'студия' THEN 'studio'
SUM(crm.deals_total_count) AS deals_window, ELSE crm.room_bucket
COALESCE( END AS room_bucket,
SUM(crm.deals_total_count) AS deals_window,
SUM(crm.deals_total_avg_area_m2 * crm.deals_total_count) SUM(crm.deals_total_avg_area_m2 * crm.deals_total_count)
/ NULLIF(SUM(crm.deals_total_count), 0), AS area_weighted_sum,
0
)::numeric(10, 2) AS avg_area_m2,
COALESCE(
SUM(crm.deals_total_avg_price_thousand_rub_per_m2 * crm.deals_total_count) SUM(crm.deals_total_avg_price_thousand_rub_per_m2 * crm.deals_total_count)
/ NULLIF(SUM(crm.deals_total_count), 0), AS price_weighted_sum,
0 MIN(crm.report_month) AS window_start,
)::numeric(12, 2) * 1000.0 AS avg_price_per_m2_rub, MAX(crm.report_month) AS window_end
array_agg(DISTINCT cm.domrf_obj_id) AS competitor_obj_ids, FROM objective_corpus_room_month crm
COUNT(DISTINCT cm.domrf_obj_id) AS competitor_count, WHERE crm.report_month >= (NOW() - CAST(:window_interval AS interval))::date
MIN(crm.report_month) AS window_start, AND crm.room_bucket IS NOT NULL
MAX(crm.report_month) AS window_end GROUP BY crm.project_name,
FROM objective_corpus_room_month crm CASE
JOIN objective_complex_mapping cm WHEN crm.room_bucket = 'студия' THEN 'studio'
ON cm.objective_complex_name = crm.project_name ELSE crm.room_bucket
WHERE crm.report_month >= (NOW() - CAST(:window_interval AS interval))::date END
AND cm.domrf_obj_id = ANY(:competitor_obj_ids) ),
AND crm.room_bucket IS NOT NULL mapped_projects AS (
GROUP BY SELECT DISTINCT cm.objective_complex_name AS project_name
CASE FROM objective_complex_mapping cm
WHEN crm.room_bucket = 'студия' THEN 'studio' WHERE cm.domrf_obj_id = ANY(:competitor_obj_ids)
ELSE crm.room_bucket ),
END deals_per_bucket AS (
SELECT
a.room_bucket,
SUM(a.deals_window) AS deals_window,
COALESCE(
SUM(a.area_weighted_sum)
/ NULLIF(SUM(a.deals_window), 0),
0
)::numeric(10, 2) AS avg_area_m2,
COALESCE(
SUM(a.price_weighted_sum)
/ NULLIF(SUM(a.deals_window), 0),
0
)::numeric(12, 2) * 1000.0 AS avg_price_per_m2_rub,
MIN(a.window_start) AS window_start,
MAX(a.window_end) AS window_end
FROM crm_agg a
JOIN mapped_projects mp ON mp.project_name = a.project_name
GROUP BY a.room_bucket
),
obj_per_bucket AS (
SELECT
a.room_bucket,
array_agg(DISTINCT cm.domrf_obj_id) AS competitor_obj_ids,
COUNT(DISTINCT cm.domrf_obj_id) AS competitor_count
FROM crm_agg a
JOIN objective_complex_mapping cm
ON cm.objective_complex_name = a.project_name
WHERE cm.domrf_obj_id = ANY(:competitor_obj_ids)
GROUP BY a.room_bucket
)
SELECT
d.room_bucket AS room_bucket,
d.deals_window AS deals_window,
d.avg_area_m2 AS avg_area_m2,
d.avg_price_per_m2_rub AS avg_price_per_m2_rub,
o.competitor_obj_ids AS competitor_obj_ids,
o.competitor_count AS competitor_count,
d.window_start AS window_start,
d.window_end AS window_end
FROM deals_per_bucket d
JOIN obj_per_bucket o ON o.room_bucket = d.room_bucket
""") """)
# ── SQL: supply по (room_bucket, area_bin) за последний снимок ─────────────── # ── SQL: supply по (room_bucket, area_bin) за последний снимок ───────────────

View file

@ -96,12 +96,18 @@ def cad_exists_in_db(db: Session, cad_num: str) -> bool:
def find_active_on_demand_job(db: Session, cad_num: str) -> int | None: def find_active_on_demand_job(db: Session, cad_num: str) -> int | None:
"""Найти существующий on-demand job (queued/running) для этого cad. """Найти существующий on-demand job (queued/running/paused) для этого cad.
Возвращает job_id или None. Если в БД есть FAILED on-demand за последние 60 Возвращает job_id или None. Если в БД есть FAILED on-demand за последние 60
секунд тоже None (чтобы повторно пробовать). Если есть DONE job, но cad секунд тоже None (чтобы повторно пробовать). Если есть DONE job, но cad
отсутствует в БД (на NSPD не нашлось) тоже None, но caller через отсутствует в БД (на NSPD не нашлось) тоже None, но caller через
`fetch_status` отличит этот случай как `not_in_nspd`. `fetch_status` отличит этот случай как `not_in_nspd`.
NB (issue #1356): 'paused' тоже считается active. Job переходит в 'paused'
при WAF (consecutive>=8) или Celery soft_time_limit (6h) нетронутые targets
остаются 'pending', а job авто-резюмится через cleanup_zombies/worker_ready.
Это in-flight состояние, а не «не найдено»: без него paused job проваливался
бы в неверный ответ 'not_in_nspd' в `fetch_status`.
""" """
row = db.execute( row = db.execute(
text( text(
@ -110,7 +116,7 @@ def find_active_on_demand_job(db: Session, cad_num: str) -> int | None:
FROM nspd_geo_jobs j FROM nspd_geo_jobs j
JOIN nspd_geo_targets t ON t.job_id = j.job_id JOIN nspd_geo_targets t ON t.job_id = j.job_id
WHERE j.source_kind = :src WHERE j.source_kind = :src
AND j.status IN ('queued', 'running') AND j.status IN ('queued', 'running', 'paused')
AND t.cad_num = :c AND t.cad_num = :c
ORDER BY j.created_at DESC ORDER BY j.created_at DESC
LIMIT 1 LIMIT 1
@ -342,6 +348,18 @@ def fetch_status(db: Session, cad_num: str) -> dict:
or "НСПД временно недоступен. Попробуйте через минуту.", or "НСПД временно недоступен. Попробуйте через минуту.",
"eta_seconds": None, "eta_seconds": None,
} }
else:
# Non-terminal target (issue #1356): чаще всего 'pending' у paused/queued
# job, который ещё не дошёл до этого cad. Активный job обычно ловится выше
# в `find_active_on_demand_job`, но на гонке статусов сюда может прилететь
# job с pending-target — это in-flight, а НЕ 'not_in_nspd'. Консервативно
# отдаём 'fetching', чтобы frontend продолжил polling, а не показал 404.
return {
"status": "fetching",
"job_id": recent["job_id"],
"error_msg": None,
"eta_seconds": 15,
}
# No job at all — first request without prior history # No job at all — first request without prior history
return { return {

View file

@ -63,6 +63,7 @@ def _competitors_key(db: Session, cad_num: str, request: CompetitorsRequest) ->
tuple(sorted(request.exclude_obj_ids)), tuple(sorted(request.exclude_obj_ids)),
) )
# Маппинг time_window → число месяцев (float для деления velocity) # Маппинг time_window → число месяцев (float для деления velocity)
_TIME_WINDOW_MONTHS: dict[str, float] = { _TIME_WINDOW_MONTHS: dict[str, float] = {
"last_month": 1.0, "last_month": 1.0,
@ -433,10 +434,6 @@ _COMPETITORS_SQL = text("""
FROM distances d FROM distances d
LEFT JOIN velocity v ON v.obj_id = d.obj_id LEFT JOIN velocity v ON v.obj_id = d.obj_id
WHERE d.distance_m <= CAST(:radius_m AS float) WHERE d.distance_m <= CAST(:radius_m AS float)
AND (
CAST(:obj_class_filter AS text) IS NULL
OR d.obj_class = CAST(:obj_class_filter AS text)
)
ORDER BY d.distance_m ASC ORDER BY d.distance_m ASC
""") """)
@ -571,7 +568,6 @@ def get_competitors(
"radius_m": request.radius_km * 1000.0, "radius_m": request.radius_km * 1000.0,
"time_window_months": time_window_months, "time_window_months": time_window_months,
"window_interval": window_interval, "window_interval": window_interval,
"obj_class_filter": request.obj_class_filter,
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M, "velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
}, },
) )
@ -591,6 +587,24 @@ def get_competitors(
if exclude_set: if exclude_set:
rows = [r for r in rows if int(r["obj_id"]) not in exclude_set] rows = [r for r in rows if int(r["obj_id"]) not in exclude_set]
# ── 3b. Фильтр класса объекта (#1340) ────────────────────────────────────
# obj_class_filter из API приходит англ. (economy/comfort/business), а в БД
# domrf_kn_objects.obj_class хранит РУССКИЙ канон (Стандарт/Комфорт/Бизнес/…
# см. domrf_kn._OBJ_CLASS_PATTERNS). Прямое SQL-равенство 'comfort'='Комфорт'
# всегда false → пустой список. Нормализуем обе стороны через _normalize_class
# и сравниваем по индексу _CLASS_ORDER, поэтому economy (индекс 0) корректно
# матчит и 'Стандарт', и 'Типовой' (оба индекс 0).
if request.obj_class_filter is not None:
target_norm = _normalize_class(request.obj_class_filter)
target_order = _CLASS_ORDER.get(target_norm) if target_norm else None
if target_order is not None:
rows = [
r
for r in rows
if (norm := _normalize_class(r["obj_class"])) is not None
and _CLASS_ORDER[norm] == target_order
]
if not rows: if not rows:
return CompetitorsResponse( return CompetitorsResponse(
competitors=[], competitors=[],

View file

@ -160,14 +160,27 @@ def compute_gate_verdict(
# cad_zouit fallback path: classify by type_zone keywords (#232). # cad_zouit fallback path: classify by type_zone keywords (#232).
# subcategory = NULL в cad_zouit, поэтому subcategory-based logic не применяется. # subcategory = NULL в cad_zouit, поэтому subcategory-based logic не применяется.
type_zone_lower = (overlap.get("type_zone") or overlap.get("layer") or "").lower() type_zone_lower = (overlap.get("type_zone") or overlap.get("layer") or "").lower()
# #1070: «сетевое обременение» — охранная зона инж.сети — выделенный # #1070: классификация «сетевого обременения» (охранная зона инж.сети).
# blocker-код с видом сети (тепло/электро/…) в детали. Классификация # Флаг приходит из _get_cad_zouit_overlaps (is_network_zone/network_kind);
# пришла из _get_cad_zouit_overlaps (is_network_zone/network_kind); если # если overlap из старого пути без флага — определяем здесь же.
# overlap пришёл из старого пути без флага — определяем здесь же.
net_kind = overlap.get("network_kind") net_kind = overlap.get("network_kind")
if net_kind is None and overlap.get("is_network_zone") is None: if net_kind is None and overlap.get("is_network_zone") is None:
net_kind = classify_network_zone(overlap.get("type_zone") or overlap.get("layer")) net_kind = classify_network_zone(overlap.get("type_zone") or overlap.get("layer"))
if net_kind is not None: # Порядок веток: СЗЗ-warning ПЕРВЫМ (#1341) — маркер 'сзз'/'санитарно-защитная'
# специфичнее широких blocker-подстрок ('газ'/'электр'), СЗЗ газораспределительной
# станции/электроподстанции = ограничение, а не hard-blocker. Затем сетевое
# обременение (#1070, выделенный код раньше широких подстрок), затем общие
# blocker-keywords, иначе — generic warning.
if any(kw in type_zone_lower for kw in WARNING_TYPE_ZONE_KEYWORDS):
warnings.append(
Warning(
code="ZOUIT_CAD_SZZ",
detail=(
f"СЗЗ ({overlap.get('type_zone', '')}): " f"{overlap.get('name', '')}"
),
)
)
elif net_kind is not None:
kind_label = overlap.get("network_kind_label") or network_kind_label(net_kind) kind_label = overlap.get("network_kind_label") or network_kind_label(net_kind)
blockers.append( blockers.append(
Blocker( Blocker(
@ -188,15 +201,6 @@ def compute_gate_verdict(
), ),
) )
) )
elif any(kw in type_zone_lower for kw in WARNING_TYPE_ZONE_KEYWORDS):
warnings.append(
Warning(
code="ZOUIT_CAD_SZZ",
detail=(
f"СЗЗ ({overlap.get('type_zone', '')}): " f"{overlap.get('name', '')}"
),
)
)
else: else:
warnings.append( warnings.append(
Warning( Warning(
@ -209,8 +213,20 @@ def compute_gate_verdict(
) )
else: else:
# NSPD dump path: subcategory-based logic (backward-compat). # NSPD dump path: subcategory-based logic (backward-compat).
sub = overlap.get("subcategory") # subcategory из _get_zouit_overlaps = `subcategory or type_zone` → может быть
if isinstance(sub, int) and sub in BLOCKER_SUBCATEGORIES: # строкой ('17') или fallback на type_zone (#1360). Нормализуем к int как
# _get_zouit_engineering_overlaps, иначе blocker subcategory-17 тихо
# деградирует до warning.
sub_raw = overlap.get("subcategory")
sub: int | None
if isinstance(sub_raw, bool):
sub = None
else:
try:
sub = int(sub_raw) # type: ignore[arg-type]
except (ValueError, TypeError):
sub = None
if sub is not None and sub in BLOCKER_SUBCATEGORIES:
blockers.append( blockers.append(
Blocker( Blocker(
code=f"ZOUIT_OVERLAP_SUB{sub}", code=f"ZOUIT_OVERLAP_SUB{sub}",
@ -218,9 +234,17 @@ def compute_gate_verdict(
) )
) )
else: else:
# Сохраняем исходную метку (str/int) в warning-коде если нормализация
# не дала int — иначе теряется человекочитаемый ярлык subcategory.
if sub is not None:
sub_label: int | str = sub
elif sub_raw is not None:
sub_label = sub_raw
else:
sub_label = "unknown"
warnings.append( warnings.append(
Warning( Warning(
code=f"ZOUIT_SUB{sub if sub is not None else 'unknown'}", code=f"ZOUIT_SUB{sub_label}",
detail=f"ЗОУИТ {overlap.get('layer', '')}: {overlap.get('name', '')}", detail=f"ЗОУИТ {overlap.get('layer', '')}: {overlap.get('name', '')}",
) )
) )

View file

@ -108,10 +108,15 @@ async def fetch_overpass_noise() -> list[dict]:
all_elements: list[dict] = [] all_elements: list[dict] = []
async with httpx.AsyncClient(timeout=60, headers=_HEADERS) as client: async with httpx.AsyncClient(timeout=60, headers=_HEADERS) as client:
for key, value, source_type, road_class in _NOISE_QUERIES: for key, value, source_type, road_class in _NOISE_QUERIES:
# aerodrome — node; точки подключения инж.сетей — nwr (node+way); # aerodrome — nwr (node+way): в OSM аэродромы/аэропорты (Кольцово,
# IATA SVX) почти всегда замаплены как way-полигон, а не одиночный
# node. Запрос только по node возвращал ~0 элементов → авиашум молча
# терялся в скоринге (#1350). nwr ловит оба варианта; way-полигон
# граница уходит в _way_to_linestring_wkt и корректно участвует в
# ST_Distance-проверках. Точки подключения инж.сетей — тоже nwr;
# остальное (дороги/ж-д/трубы/ЛЭП-линии) — way. # остальное (дороги/ж-д/трубы/ЛЭП-линии) — way.
if key == "aeroway": if key == "aeroway":
el_type = "node" el_type = "nwr"
elif value in _UTILITY_POINT_VALUES: elif value in _UTILITY_POINT_VALUES:
el_type = "nwr" el_type = "nwr"
else: else:

View file

@ -98,7 +98,7 @@ _CURRENT_SQL = text(
SELECT o.* SELECT o.*
FROM domrf_kn_objects o FROM domrf_kn_objects o
WHERE o.dev_id IS NOT NULL WHERE o.dev_id IS NOT NULL
AND o.dev_id ~ '^[0-9]+' AND o.dev_id ~ '^[0-9]+(_|$)'
AND CAST(split_part(o.dev_id, '_', 1) AS integer) = ANY(CAST(:ids AS integer[])) AND CAST(split_part(o.dev_id, '_', 1) AS integer) = ANY(CAST(:ids AS integer[]))
), ),
latest AS ( latest AS (

View file

@ -92,6 +92,13 @@ async def fetch_overpass() -> list[dict]:
r.raise_for_status() r.raise_for_status()
elements: list[dict] = r.json().get("elements", []) elements: list[dict] = r.json().get("elements", [])
logger.info("Overpass: %s=%s (%s) → %d", key, value, category, len(elements)) logger.info("Overpass: %s=%s (%s) → %d", key, value, category, len(elements))
# Привязываем category именно к тому per-category запросу, под который
# элемент реально пришёл. Элемент с двумя целевыми тегами (например
# amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия
# несёт свою category. Иначе _classify по dict-порядку молча терял бы
# вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372.
for el in elements:
el["_gd_category"] = category
all_elements.extend(elements) all_elements.extend(elements)
except Exception as e: except Exception as e:
# Не падаем на одной категории — логируем и продолжаем # Не падаем на одной категории — логируем и продолжаем
@ -117,13 +124,17 @@ def sync_poi_to_db() -> dict[str, int]:
inserted = 0 inserted = 0
updated = 0 updated = 0
skipped_old = 0 skipped_old = 0
skipped = 0
fetched = len(elements) fetched = len(elements)
db = SessionLocal() db = SessionLocal()
try: try:
for el in elements: for el in elements:
tags: dict[str, str] = el.get("tags") or {} tags: dict[str, str] = el.get("tags") or {}
category = _classify(tags) # category проставлена в fetch_overpass под тот per-category запрос, под
# который элемент пришёл (multi-tag элемент приходит несколько раз, каждая
# копия со своей category). Fallback на _classify для прямых вызовов. См. #1372.
category = el.get("_gd_category") or _classify(tags)
if not category: if not category:
continue continue
@ -155,44 +166,59 @@ def sync_poi_to_db() -> dict[str, int]:
if last_edit and last_edit < two_years_ago: if last_edit and last_edit < two_years_ago:
skipped_old += 1 skipped_old += 1
result = db.execute( try:
text(""" with db.begin_nested(): # SAVEPOINT — откат только этой записи
INSERT INTO osm_poi_ekb result = db.execute(
(osm_id, osm_type, category, name, lat, lon, geom, text("""
last_osm_edit_date, tags, fetched_at) INSERT INTO osm_poi_ekb
VALUES (:osm_id, :osm_type, :category, :name, :lat, :lon, (osm_id, osm_type, category, name, lat, lon, geom,
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326), last_osm_edit_date, tags, fetched_at)
:last_edit, CAST(:tags AS jsonb), NOW()) VALUES (:osm_id, :osm_type, :category, :name, :lat, :lon,
ON CONFLICT (osm_type, osm_id, category) DO UPDATE ST_SetSRID(ST_MakePoint(:lon, :lat), 4326),
SET name = EXCLUDED.name, :last_edit, CAST(:tags AS jsonb), NOW())
lat = EXCLUDED.lat, ON CONFLICT (osm_type, osm_id, category) DO UPDATE
lon = EXCLUDED.lon, SET name = EXCLUDED.name,
geom = EXCLUDED.geom, lat = EXCLUDED.lat,
last_osm_edit_date = EXCLUDED.last_osm_edit_date, lon = EXCLUDED.lon,
tags = EXCLUDED.tags, geom = EXCLUDED.geom,
fetched_at = NOW() last_osm_edit_date = EXCLUDED.last_osm_edit_date,
RETURNING (xmax = 0) AS is_insert tags = EXCLUDED.tags,
"""), fetched_at = NOW()
{ RETURNING (xmax = 0) AS is_insert
"osm_id": osm_id, """),
"osm_type": osm_type, {
"category": category, "osm_id": osm_id,
"name": tags.get("name"), "osm_type": osm_type,
"lat": lat, "category": category,
"lon": lon, "name": tags.get("name"),
"last_edit": last_edit, "lat": lat,
"tags": json.dumps(tags, ensure_ascii=False), "lon": lon,
}, "last_edit": last_edit,
).scalar() "tags": json.dumps(tags, ensure_ascii=False),
},
if result: ).scalar()
inserted += 1 if result:
else: inserted += 1
updated += 1 else:
updated += 1
except Exception as e:
# Дефектный OSM-элемент (битый tags-jsonb, нарушение constraint, PostGIS-
# ошибка у way/relation без корректного center) не должен валить весь
# weekly-sync. SAVEPOINT откатывает только эту строку, продолжаем
# с остальными. См. #1343 и .claude/rules/backend.md (SAVEPOINT pattern).
logger.warning(
"poi_sync insert failed for %s/%s (category=%s): %s",
osm_type,
osm_id,
category,
e,
)
skipped += 1
db.commit() db.commit()
except Exception: except Exception as e:
db.rollback() db.rollback()
logger.exception("poi_sync: unexpected error, outer tx rolled back: %s", e)
raise raise
finally: finally:
db.close() db.close()
@ -202,4 +228,5 @@ def sync_poi_to_db() -> dict[str, int]:
"inserted": inserted, "inserted": inserted,
"updated": updated, "updated": updated,
"skipped_old": skipped_old, "skipped_old": skipped_old,
"skipped": skipped,
} }

View file

@ -939,11 +939,13 @@ def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dic
if parcel_wkt is None: if parcel_wkt is None:
raise ValueError(f"Участок {cad_num!r} не найден в БД") raise ValueError(f"Участок {cad_num!r} не найден в БД")
# Проверяем наличие дампа # Проверяем наличие дампа. harvest_error читаем чтобы не отдавать error-only
# строку как dump_available=True (#1371): _upsert_dump при частичном сбое
# пишет строку с harvest_error != NULL и нулевыми счётчиками.
dump_row = db.execute( dump_row = db.execute(
text( text(
""" """
SELECT fetched_at_utc, total_features SELECT fetched_at_utc, total_features, harvest_error
FROM nspd_quarter_dumps FROM nspd_quarter_dumps
WHERE quarter_cad = :q WHERE quarter_cad = :q
ORDER BY fetched_at_utc DESC ORDER BY fetched_at_utc DESC
@ -953,8 +955,7 @@ def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dic
{"q": quarter}, {"q": quarter},
).first() ).first()
if dump_row is None: def _empty_unavailable(fetched_at_iso: str | None) -> dict[str, Any]:
_trigger_harvest(quarter)
return { return {
"engineering_structures": [], "engineering_structures": [],
"zouit_engineering_overlaps": [], "zouit_engineering_overlaps": [],
@ -965,15 +966,32 @@ def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dic
"total_structures_in_radius": 0, "total_structures_in_radius": 0,
}, },
"dump_available": False, "dump_available": False,
"dump_fetched_at": None, "dump_fetched_at": fetched_at_iso,
} }
if dump_row is None:
_trigger_harvest(quarter)
return _empty_unavailable(None)
fetched_at = dump_row[0] fetched_at = dump_row[0]
if fetched_at is not None and getattr(fetched_at, "isoformat", None): if fetched_at is not None and getattr(fetched_at, "isoformat", None):
dump_fetched_at: str | None = fetched_at.isoformat() dump_fetched_at: str | None = fetched_at.isoformat()
else: else:
dump_fetched_at = str(fetched_at) if fetched_at is not None else None dump_fetched_at = str(fetched_at) if fetched_at is not None else None
# Error-only или устаревший дамп — отдаём dump_available=False и
# перезапускаем harvest (согласовано с get_quarter_dump_data, #1371).
harvest_error: str | None = dump_row[2]
is_stale = False
if fetched_at is not None and getattr(fetched_at, "tzinfo", "missing") != "missing":
fetched_at_aware = (
fetched_at if fetched_at.tzinfo is not None else fetched_at.replace(tzinfo=UTC)
)
is_stale = (datetime.now(UTC) - fetched_at_aware) > timedelta(days=_DUMP_MAX_AGE_DAYS)
if harvest_error is not None or is_stale:
_trigger_harvest(quarter)
return _empty_unavailable(dump_fetched_at)
structures = _get_engineering_structures_by_boundary(db, quarter, parcel_wkt, radius_m) structures = _get_engineering_structures_by_boundary(db, quarter, parcel_wkt, radius_m)
zouit_overlaps = _get_zouit_engineering_overlaps(db, quarter, parcel_wkt) zouit_overlaps = _get_zouit_engineering_overlaps(db, quarter, parcel_wkt)

View file

@ -377,6 +377,14 @@ def compute_velocity(
total_sqm = sum(float(r["total_sqm"] or 0.0) for r in mapped_sales_rows) total_sqm = sum(float(r["total_sqm"] or 0.0) for r in mapped_sales_rows)
months_observed = max((int(r["months_with_data"] or 0) for r in mapped_sales_rows), default=0) months_observed = max((int(r["months_with_data"] or 0) for r in mapped_sales_rows), default=0)
# Активные mapped-конкуренты: маппинг + реальные продажи в окне (months_with_data>0).
# LEFT JOIN на crm даёт строку has_mapping=TRUE с total_sqm=NULL→0, months=0 для
# замаппленных-но-непродающих ЖК — их НЕ считаем при делении/нормализации (#1354, #1382).
active_sales_rows = [
r
for r in mapped_sales_rows
if int(r["months_with_data"] or 0) > 0 and float(r["total_sqm"] or 0.0) > 0
]
period_start_dates = [r["period_start"] for r in mapped_sales_rows if r["period_start"]] period_start_dates = [r["period_start"] for r in mapped_sales_rows if r["period_start"]]
period_end_dates = [r["period_end"] for r in mapped_sales_rows if r["period_end"]] period_end_dates = [r["period_end"] for r in mapped_sales_rows if r["period_end"]]
period_start = min(period_start_dates).strftime("%Y-%m") if period_start_dates else "" period_start = min(period_start_dates).strftime("%Y-%m") if period_start_dates else ""
@ -429,17 +437,23 @@ def compute_velocity(
velocity_source="none", velocity_source="none",
) )
# Среднемесячный объём в расчёте: суммарный по всем конкурентам / месяцев. # Среднемесячный объём = Σ(объём_i / месяцев_i) по активным конкурентам (#1354).
# Чем больше конкурентов с данными — тем весомее результат. # Делить суммарный объём на max(месяцев) нельзя: при разнородных окнах
monthly_velocity = total_sqm / months_observed # (старые ЖК 6 мес, новые 1-2 мес) это размазывает объём новых по всему окну
# и занижает совокупную месячную скорость района.
monthly_velocity = sum(
float(r["total_sqm"] or 0.0) / int(r["months_with_data"]) for r in active_sales_rows
)
# ── Step 3: нормализация → score 0..1 ──────────────────────────────────── # ── Step 3: нормализация → score 0..1 ────────────────────────────────────
# Логика: сравниваем суммарный velocity радиуса с «нормой» одного ЖК. # Логика: сравниваем суммарный velocity радиуса с «нормой» одного ЖК.
# Если в радиусе продаётся N × ekb_median → рынок горячий. # Если в радиусе продаётся N × ekb_median → рынок горячий.
# Нормируем: score = min(1.0, total_velocity / (n_competitors × ekb_median × 2)) # Нормируем: score = min(1.0, total_velocity / (n_competitors × ekb_median × 2))
# Cap 2×median = «насыщен». Итоговый score 0..1. # Cap 2×median = «насыщен». Итоговый score 0..1.
# n_with_sales — только mapped конкуренты (у unmapped данных нет). # n_with_sales — только конкуренты с реальными продажами в окне (#1382).
n_with_sales = len(mapped_sales_rows) # mapped-но-непродающие ЖК (has_mapping=TRUE, total_sqm=0) исключены: иначе
# знаменатель раздувается, а числитель их не учитывает → systematic занижение.
n_with_sales = len(active_sales_rows)
denominator = n_with_sales * ekb_median * 2.0 if n_with_sales > 0 else ekb_median * 2.0 denominator = n_with_sales * ekb_median * 2.0 if n_with_sales > 0 else ekb_median * 2.0
velocity_score = min(1.0, max(0.0, monthly_velocity / denominator)) velocity_score = min(1.0, max(0.0, monthly_velocity / denominator))

View file

@ -27,8 +27,11 @@ forecast/climate до Phase A).
Все исключения httpx/json/KeyError ловятся `logger.warning` + negative-cache + None. Все исключения httpx/json/KeyError ловятся `logger.warning` + negative-cache + None.
THREAD-SAFETY: analyze_parcel sync def, под Uvicorn идёт в threadpool. Каждый кэш THREAD-SAFETY: analyze_parcel sync def, под Uvicorn идёт в threadpool. Каждый кэш
защищён своим `threading.Lock`. Single-flight: под lock'ом check-then-fetch-then-store, защищён своим `threading.Lock`, который удерживается ТОЛЬКО на время check/store сам
поэтому конкурентный cold-start на ОДИН ключ делает ровно один сетевой вызов. сетевой httpx-вызов идёт ВНЕ lock'а (#1370), иначе все analyze одного cache-типа
сериализуются на время вызова (lock per-cache-type, не per-key). Trade-off: конкурентный
cold-start на ОДИН ключ может породить несколько параллельных запросов (last-write wins),
что приемлемо вызовы идемпотентны, а TTL длинный.
time.monotonic используется вместо time.time устойчиво к скачкам системного времени time.monotonic используется вместо time.time устойчиво к скачкам системного времени
(NTP sync, ручной выставление). Тесты могут подменять `weather_cache._now` для проталки (NTP sync, ручной выставление). Тесты могут подменять `weather_cache._now` для проталки
@ -258,13 +261,16 @@ def get_weather_cached(lat: float, lon: float) -> dict[str, Any] | None:
entry = _FORECAST_CACHE.get(key) entry = _FORECAST_CACHE.get(key)
if entry is not None and entry[1] > now: if entry is not None and entry[1] > now:
return entry[0] return entry[0]
# MISS или истёк → внутри lock'а делаем сетевой вызов (single-flight: 16 # MISS или истёк → сетевой вызов ВНЕ lock'а, иначе все analyze одного cache-типа
# потоков на один ключ → один реальный запрос). # (даже для разных координат) сериализуются на время httpx-вызова (#1370). Ценой —
value = _fetch_weather_remote(lat, lon) # cold-start на ОДИН ключ может породить несколько параллельных запросов (last-write
ttl = _WEATHER_TTL_S if value is not None else _NEGATIVE_TTL_S # wins при store ниже), что приемлемо: вызовы идемпотентны, TTL длинный.
value = _fetch_weather_remote(lat, lon)
ttl = _WEATHER_TTL_S if value is not None else _NEGATIVE_TTL_S
with _FORECAST_LOCK:
_evict_one_if_full(_FORECAST_CACHE) _evict_one_if_full(_FORECAST_CACHE)
_FORECAST_CACHE[key] = (value, _now() + ttl) _FORECAST_CACHE[key] = (value, _now() + ttl)
return value return value
def get_seasonal_weather_cached(lat: float, lon: float) -> dict[str, Any] | None: def get_seasonal_weather_cached(lat: float, lon: float) -> dict[str, Any] | None:
@ -280,19 +286,22 @@ def get_seasonal_weather_cached(lat: float, lon: float) -> dict[str, Any] | None
entry = _CLIMATE_CACHE.get(key) entry = _CLIMATE_CACHE.get(key)
if entry is not None and entry[1] > now: if entry is not None and entry[1] > now:
return entry[0] return entry[0]
value = _fetch_seasonal_remote(lat, lon) # Сетевой вызов ВНЕ lock'а — не сериализуем разные ключи (#1370). См. get_weather_cached.
ttl = _SEASONAL_TTL_S if value is not None else _NEGATIVE_TTL_S value = _fetch_seasonal_remote(lat, lon)
ttl = _SEASONAL_TTL_S if value is not None else _NEGATIVE_TTL_S
with _CLIMATE_LOCK:
_evict_one_if_full(_CLIMATE_CACHE) _evict_one_if_full(_CLIMATE_CACHE)
_CLIMATE_CACHE[key] = (value, _now() + ttl) _CLIMATE_CACHE[key] = (value, _now() + ttl)
return value return value
def _fetch_air_quality_remote(lat: float, lon: float) -> dict[str, Any] | None: def _fetch_air_quality_remote(lat: float, lon: float) -> dict[str, Any] | None:
"""Open-Meteo Air Quality API — pm2_5/pm10/no2 текущего часа. """Open-Meteo Air Quality API — pm2_5/pm10/no2 текущего часа.
Перенесено из `app.api.v1.parcels._fetch_air_quality_sync` без изменения формата Использует `current=...` (bucket текущего часа), а не `hourly[0]` (=00:00 forecast-дня)
возвращаемого dict (фронт зависит). Любое исключение logger.warning + None docstring/UX обещают «качество воздуха сейчас» (#1377). Формат возвращаемого dict
(caller обернёт None в negative-cache на короткий TTL). (ключи pm2_5/pm10/no2/ts/source) сохранён фронт зависит. Любое исключение
logger.warning + None (caller обернёт None в negative-cache на короткий TTL).
""" """
try: try:
with httpx.Client(timeout=_AIR_TIMEOUT_S) as c: with httpx.Client(timeout=_AIR_TIMEOUT_S) as c:
@ -301,20 +310,21 @@ def _fetch_air_quality_remote(lat: float, lon: float) -> dict[str, Any] | None:
params={ params={
"latitude": lat, "latitude": lat,
"longitude": lon, "longitude": lon,
"hourly": "pm2_5,pm10,nitrogen_dioxide", # `current` отдаёт bucket текущего часа (а не hourly[0]=00:00) —
"forecast_days": 1, # docstring/UX обещают «качество воздуха сейчас». См. #1377.
"current": "pm2_5,pm10,nitrogen_dioxide",
}, },
) )
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
hourly = data.get("hourly", {}) current = data.get("current", {})
if not hourly.get("time"): if not current.get("time"):
return None return None
return { return {
"pm2_5": hourly["pm2_5"][0] if hourly.get("pm2_5") else None, "pm2_5": current.get("pm2_5"),
"pm10": hourly["pm10"][0] if hourly.get("pm10") else None, "pm10": current.get("pm10"),
"no2": hourly["nitrogen_dioxide"][0] if hourly.get("nitrogen_dioxide") else None, "no2": current.get("nitrogen_dioxide"),
"ts": hourly["time"][0], "ts": current["time"],
"source": "open-meteo", "source": "open-meteo",
} }
except Exception as e: except Exception as e:
@ -335,8 +345,10 @@ def get_air_quality_cached(lat: float, lon: float) -> dict[str, Any] | None:
entry = _AIR_CACHE.get(key) entry = _AIR_CACHE.get(key)
if entry is not None and entry[1] > now: if entry is not None and entry[1] > now:
return entry[0] return entry[0]
value = _fetch_air_quality_remote(lat, lon) # Сетевой вызов ВНЕ lock'а — не сериализуем разные ключи (#1370). См. get_weather_cached.
ttl = _AIR_TTL_S if value is not None else _NEGATIVE_TTL_S value = _fetch_air_quality_remote(lat, lon)
ttl = _AIR_TTL_S if value is not None else _NEGATIVE_TTL_S
with _AIR_LOCK:
_evict_one_if_full(_AIR_CACHE) _evict_one_if_full(_AIR_CACHE)
_AIR_CACHE[key] = (value, _now() + ttl) _AIR_CACHE[key] = (value, _now() + ttl)
return value return value

View file

@ -151,8 +151,11 @@ def _save_quarter(db: Session, payload: dict, cad_num: str) -> int:
-- cad_quarters_geom.geom = geometry(MultiPolygon,4326) NOT NULL (migration 58). -- cad_quarters_geom.geom = geometry(MultiPolygon,4326) NOT NULL (migration 58).
-- NSPD может вернуть одноконтурный Polygon ST_Multi coerce'ит в MultiPolygon, -- NSPD может вернуть одноконтурный Polygon ST_Multi coerce'ит в MultiPolygon,
-- иначе "Geometry type (Polygon) does not match column type (MultiPolygon)". -- иначе "Geometry type (Polygon) does not match column type (MultiPolygon)".
-- 38574326 transform как в upsert_quarter_geom_from_feature (Mercator). -- БЕЗ ST_Transform: rosreestr2coord отдаёт GeoJSON уже в WGS84
ST_Multi(ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:g), 3857), 4326)), -- (EPSG:4326, degrees) для всех area_type только ST_SetSRID.
-- ST_Transform из 3857 трактовал бы градусы как метры Mercator
-- коллапс к Null Island (issue #1336).
ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326)),
CAST(:props AS jsonb), NOW()) CAST(:props AS jsonb), NOW())
ON CONFLICT (cad_number) DO UPDATE ON CONFLICT (cad_number) DO UPDATE
SET geom = EXCLUDED.geom, SET geom = EXCLUDED.geom,
@ -200,7 +203,11 @@ def _save_building(db: Session, payload: dict, cad_num: str) -> int:
readable_address, area, floors, raw_props, source readable_address, area, floors, raw_props, source
) VALUES ( ) VALUES (
:cad, :qcad, :cad, :qcad,
ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:g), 3857), 4326), -- БЕЗ ST_Transform: rosreestr2coord отдаёт GeoJSON уже в WGS84
-- (EPSG:4326, degrees) для всех area_type только ST_SetSRID.
-- ST_Transform из 3857 трактовал бы градусы как метры Mercator
-- коллапс к Null Island (issue #1336).
ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326),
:purpose, :name, :addr, :area, :floors, CAST(:props AS jsonb), :source :purpose, :name, :addr, :area, :floors, CAST(:props AS jsonb), :source
) )
ON CONFLICT (cad_num) DO UPDATE ON CONFLICT (cad_num) DO UPDATE
@ -237,8 +244,9 @@ def _save_parcel(db: Session, payload: dict, cad_num: str) -> int:
NB: rosreestr2coord для area_type=1 (parcel) отдаёт GeoJSON уже в WGS84 NB: rosreestr2coord для area_type=1 (parcel) отдаёт GeoJSON уже в WGS84
(EPSG:4326, degrees). Просто ST_SetSRID без ST_Transform, иначе геометрия (EPSG:4326, degrees). Просто ST_SetSRID без ST_Transform, иначе геометрия
сжимается до точки в районе (0,0) на острове Null. Quarters/buildings сжимается до точки в районе (0,0) на острове Null. Quarters/buildings идут
другой path, у них Mercator (3857) и нужен ST_Transform. через тот же rosreestr2coord path и тоже WGS84 им ST_Transform тоже НЕ нужен
(issue #1336: раньше ошибочно трансформировались из 3857 → Null Island).
""" """
feats = (payload.get("data") or {}).get("features") or [] feats = (payload.get("data") or {}).get("features") or []
if not feats: if not feats:

View file

@ -326,7 +326,10 @@ def sync_objective_group(
use_dkp=params.get("use_dkp", False), use_dkp=params.get("use_dkp", False),
payload=payload, payload=payload,
) )
reports_ok += 1 # NB: reports_ok инкрементируем ТОЛЬКО ПОСЛЕ успешного
# parse+commit (зеркально lots_pf / issue #1220, #1369).
# Иначе сбой parse_corp_sum оставлял бы reports_failed==0 →
# _finish_run помечал бы run status='done' → silent failure.
try: try:
if kind == "corp_sum": if kind == "corp_sum":
n = parser_mod.parse_corp_sum( n = parser_mod.parse_corp_sum(
@ -341,8 +344,10 @@ def sync_objective_group(
{"n": n, "rid": raw_id}, {"n": n, "rid": raw_id},
) )
db.commit() db.commit()
reports_ok += 1
except Exception as parse_err: except Exception as parse_err:
db.rollback() db.rollback()
reports_failed += 1
logger.exception( logger.exception(
"sync_objective_group: parser failed for %s/%s/%s " "raw_id=%s: %s", "sync_objective_group: parser failed for %s/%s/%s " "raw_id=%s: %s",
section, section,

View file

@ -99,13 +99,13 @@ def _make_httpx_response(payload: dict[str, Any]) -> MagicMock:
def _make_air_response() -> dict[str, Any]: def _make_air_response() -> dict[str, Any]:
"""Минимальный валидный JSON от Open-Meteo Air Quality API (24 часа forecast).""" """Минимальный валидный JSON от Open-Meteo Air Quality API (current bucket, #1377)."""
return { return {
"hourly": { "current": {
"time": ["2026-06-12T00:00", "2026-06-12T01:00"], "time": "2026-06-12T14:00",
"pm2_5": [12.5, 13.0], "pm2_5": 12.5,
"pm10": [25.0, 26.0], "pm10": 25.0,
"nitrogen_dioxide": [15.0, 16.0], "nitrogen_dioxide": 15.0,
} }
} }

View file

@ -187,13 +187,17 @@ def test_save_quarter_sql_wraps_geom_in_st_multi() -> None:
assert "ST_Multi(" in sql, "SQL _save_quarter должен содержать ST_Multi для MultiPolygon schema" assert "ST_Multi(" in sql, "SQL _save_quarter должен содержать ST_Multi для MultiPolygon schema"
def test_save_quarter_sql_keeps_3857_to_4326_transform() -> None: def test_save_quarter_sql_no_transform_wgs84() -> None:
"""quarter GeoJSON в Mercator (3857) → ST_Transform к 4326 сохраняется (mirror bulk path).""" """quarter GeoJSON уже в WGS84 (rosreestr2coord) → ST_SetSRID(...,4326) БЕЗ ST_Transform
(#1336: ST_Transform из 3857 трактовал градусы как метры → коллапс к Null Island).
ST_Multi сохраняется колонка cad_quarters_geom.geom = MultiPolygon."""
db, captured = _capturing_db() db, captured = _capturing_db()
_save_quarter(db, _quarter_payload(), "66:41:0610029") _save_quarter(db, _quarter_payload(), "66:41:0610029")
sql, _params = captured[0] sql, _params = captured[0]
assert "ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:g), 3857), 4326)" in sql executable = _strip_sql_comments(sql)
assert "ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326))" in executable
assert "ST_Transform" not in executable
def test_save_parcel_empty_features_returns_zero() -> None: def test_save_parcel_empty_features_returns_zero() -> None:
@ -261,15 +265,18 @@ def test_save_building_derives_quarter_when_missing() -> None:
assert params["qcad"] == "66:41:0610029" assert params["qcad"] == "66:41:0610029"
def test_save_building_geom_transform_no_st_multi() -> None: def test_save_building_geom_no_transform_no_st_multi() -> None:
"""cad_buildings.geom = GEOMETRY(Geometry, 4326) полиморфный → ST_Multi НЕ нужен, """cad_buildings.geom = GEOMETRY(Geometry, 4326) полиморфный → ST_Multi НЕ нужен;
transform 38574326 сохраняется (mirror upsert_building).""" геометрия уже WGS84 (rosreestr2coord) ST_SetSRID(...,4326) БЕЗ ST_Transform
(#1336: ST_Transform из 3857 → Null Island)."""
db, captured = _capturing_db() db, captured = _capturing_db()
_save_building(db, _building_payload(), "66:41:0610029:83") _save_building(db, _building_payload(), "66:41:0610029:83")
sql, _params = captured[0] sql, _params = captured[0]
assert "ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:g), 3857), 4326)" in sql executable = _strip_sql_comments(sql)
assert "ST_Multi(" not in sql # полиморфная колонка — coerce не нужен assert "ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326)" in executable
assert "ST_Transform" not in executable
assert "ST_Multi(" not in executable # полиморфная колонка — coerce не нужен
def test_save_building_skips_feature_with_other_cad_num() -> None: def test_save_building_skips_feature_with_other_cad_num() -> None:
@ -562,9 +569,9 @@ def test_soft_time_limit_exceeded_flushes_heartbeat(monkeypatch: Any) -> None:
for sql, params in captured for sql, params in captured
if "heartbeat_at = NOW()" in sql and "targets_done" in sql if "heartbeat_at = NOW()" in sql and "targets_done" in sql
] ]
assert heartbeat_updates, ( assert (
"SoftTimeLimitExceeded handler должен flush'нуть heartbeat с counters перед raise" heartbeat_updates
) ), "SoftTimeLimitExceeded handler должен flush'нуть heartbeat с counters перед raise"
def test_soft_time_limit_exceeded_does_not_overwrite_paused_with_failed( def test_soft_time_limit_exceeded_does_not_overwrite_paused_with_failed(

View file

@ -33,8 +33,19 @@ ALTER TABLE domrf_kn_infrastructure
-- Step 3: add new unique constraint with NULLS NOT DISTINCT (PG15+) -- Step 3: add new unique constraint with NULLS NOT DISTINCT (PG15+)
-- so NULL poi_category / poi_address are treated as equal (not distinct) -- so NULL poi_category / poi_address are treated as equal (not distinct)
ALTER TABLE domrf_kn_infrastructure -- PostgreSQL has no ADD CONSTRAINT ... IF NOT EXISTS form, so guard via DO $$
ADD CONSTRAINT uq_infra_dedupe -- block (idempotency for DR-restore / re-apply under ON_ERROR_STOP).
UNIQUE NULLS NOT DISTINCT (obj_id, poi_category, poi_name, poi_address); DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'uq_infra_dedupe'
AND conrelid = 'domrf_kn_infrastructure'::regclass
) THEN
ALTER TABLE domrf_kn_infrastructure
ADD CONSTRAINT uq_infra_dedupe
UNIQUE NULLS NOT DISTINCT (obj_id, poi_category, poi_name, poi_address);
END IF;
END $$;
COMMIT; COMMIT;

View file

@ -36,7 +36,7 @@ CREATE TABLE IF NOT EXISTS own_planned_project (
-- Тайминг: планируемый месяц выхода в продажу. Хранится как ПЕРВОЕ число месяца -- Тайминг: планируемый месяц выхода в продажу. Хранится как ПЕРВОЕ число месяца
-- (YYYY-MM-01) — нормализуется на уровне сервиса/SQL; ось тайминга §25.3. -- (YYYY-MM-01) — нормализуется на уровне сервиса/SQL; ось тайминга §25.3.
planned_release_month DATE, planned_release_month DATE,
-- Цена ₽/м² (вилка). min/max независимы; оба ≥0 (guarded CHECK ниже). -- Цена ₽/м² (вилка). Оба ≥0; min ≤ max когда обе границы заданы (guarded CHECK ниже).
price_min_per_m2 NUMERIC, price_min_per_m2 NUMERIC,
price_max_per_m2 NUMERIC, price_max_per_m2 NUMERIC,
-- Квартирография: доли по форматам {"studio":0.3,"1k":0.4,"2k":0.2,"3k":0.1}. -- Квартирография: доли по форматам {"studio":0.3,"1k":0.4,"2k":0.2,"3k":0.1}.
@ -67,8 +67,8 @@ COMMENT ON COLUMN own_planned_project.obj_class IS
'Зеркалит class-вокабуляр forecasting SegmentSpec; матчинг в PR2 регистронезависимый.'; 'Зеркалит class-вокабуляр forecasting SegmentSpec; матчинг в PR2 регистронезависимый.';
COMMENT ON COLUMN own_planned_project.planned_release_month IS COMMENT ON COLUMN own_planned_project.planned_release_month IS
'Планируемый месяц выхода в продажу — ПЕРВОЕ число месяца (YYYY-MM-01). Ось тайминга §25.3.'; 'Планируемый месяц выхода в продажу — ПЕРВОЕ число месяца (YYYY-MM-01). Ось тайминга §25.3.';
COMMENT ON COLUMN own_planned_project.price_min_per_m2 IS 'Нижняя граница цены, ₽/м² (≥0).'; COMMENT ON COLUMN own_planned_project.price_min_per_m2 IS 'Нижняя граница цены, ₽/м² (≥0; ≤ price_max_per_m2 когда обе заданы).';
COMMENT ON COLUMN own_planned_project.price_max_per_m2 IS 'Верхняя граница цены, ₽/м² (≥0).'; COMMENT ON COLUMN own_planned_project.price_max_per_m2 IS 'Верхняя граница цены, ₽/м² (≥0; ≥ price_min_per_m2 когда обе заданы).';
COMMENT ON COLUMN own_planned_project.unit_mix IS COMMENT ON COLUMN own_planned_project.unit_mix IS
'Квартирография: JSONB долей по форматам {"studio":0.3,"1k":0.4,...}; каждая доля в [0,1].'; 'Квартирография: JSONB долей по форматам {"studio":0.3,"1k":0.4,...}; каждая доля в [0,1].';
COMMENT ON COLUMN own_planned_project.geom IS COMMENT ON COLUMN own_planned_project.geom IS
@ -90,6 +90,29 @@ BEGIN
END END
$$; $$;
-- ── 2b. CHECK на корректность вилки: min ≤ max (guarded — idempotent) ──────────
-- DB-уровневый backstop инварианта price-range (#1362). Pydantic-валидатор есть
-- только на OwnPlannedProjectCreate; PUT (OwnPlannedProjectUpdate) и partial-merge
-- в service-слое могут записать инвертированную вилку (min > max), которая молча
-- кормит §25.3 cannibalization бессмысленными ценовыми полосами. Этот CHECK ловит
-- инверсию независимо от пути записи (create / partial update / прямой SQL).
-- Срабатывает ТОЛЬКО когда обе границы заданы — частичная вилка (одна NULL) валидна.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ck_own_planned_project_price_range'
) THEN
ALTER TABLE own_planned_project
ADD CONSTRAINT ck_own_planned_project_price_range
CHECK (
price_min_per_m2 IS NULL
OR price_max_per_m2 IS NULL
OR price_min_per_m2 <= price_max_per_m2
);
END IF;
END
$$;
-- ── 3. CHECK на unit_mix: объект, все значения числовые в [0,1] (guarded) ────── -- ── 3. CHECK на unit_mix: объект, все значения числовые в [0,1] (guarded) ──────
-- Разрешаем NULL (не задано). Если задан — обязан быть JSON-объектом, и каждое -- Разрешаем NULL (не задано). Если задан — обязан быть JSON-объектом, и каждое
-- значение должно быть числом в [0,1] (доля). -- значение должно быть числом в [0,1] (доля).

View file

@ -0,0 +1,33 @@
-- 158_pzz_zones_ekb_nulls_not_distinct.sql
-- #1361 — применить UNIQUE NULLS NOT DISTINCT (rosreestr_id) на СУЩЕСТВУЮЩЕМ проде.
--
-- ПОЧЕМУ ОТДЕЛЬНАЯ МИГРАЦИЯ. Правка inline-констрейнта в 85_pzz_zones_ekb.sql
-- (CREATE TABLE IF NOT EXISTS) НЕ долетает до прода: 85 уже в _schema_migrations
-- → на деплое пропускается, таблица не пересоздаётся, констрейнт остаётся старым
-- `UNIQUE (rosreestr_id)` (NULLS DISTINCT). Эта миграция меняет констрейнт на живой
-- таблице, чтобы фикс #1361 реально вступил в силу (правка 85 — для fresh-install).
--
-- БАГ #1361. rosreestr_id nullable (PKK6 при f=geojson иногда кладёт OBJECTID в
-- feature-level id, а не в properties → loader получает NULL). При обычном UNIQUE
-- NULL != NULL → ON CONFLICT (rosreestr_id) НЕ матчит существующие NULL-строки →
-- каждый повторный sync копит дубли зон. NULLS NOT DISTINCT (PG15+) делает NULL
-- сопоставимым → ON CONFLICT матчит → дубли не копятся.
--
-- БЕЗОПАСНОСТЬ. Проверено на проде 2026-06-15: pzz_zones_ekb ПУСТА (0 строк),
-- текущий констрейнт = pzz_zones_ekb_rosreestr_id_key UNIQUE (rosreestr_id). На пустой
-- таблице DROP+ADD не теряет данных и не может упасть на дублях. Прецедент NULLS NOT
-- DISTINCT в репо: м.110 (uq_infra_dedupe), м.125, м.140. Prod = PG16.
-- Idempotent: DROP CONSTRAINT IF EXISTS + повторный ADD под gate _schema_migrations
-- (ровно один раз по NN-порядку).
-- Apply after: 157_analyze_hotpath_indexes.sql
BEGIN;
ALTER TABLE pzz_zones_ekb
DROP CONSTRAINT IF EXISTS pzz_zones_ekb_rosreestr_id_key;
ALTER TABLE pzz_zones_ekb
ADD CONSTRAINT pzz_zones_ekb_rosreestr_id_key
UNIQUE NULLS NOT DISTINCT (rosreestr_id);
COMMIT;

View file

@ -12,6 +12,12 @@ ALTER TABLE kn_scrape_runs
ADD COLUMN IF NOT EXISTS params JSONB, ADD COLUMN IF NOT EXISTS params JSONB,
ADD COLUMN IF NOT EXISTS resumed_from_run_id BIGINT REFERENCES kn_scrape_runs(run_id); ADD COLUMN IF NOT EXISTS resumed_from_run_id BIGINT REFERENCES kn_scrape_runs(run_id);
-- Reaper (admin_scrape.py) фильтрует по выражению COALESCE(heartbeat_at, started_at)
-- среди status='running'. B-tree по голому heartbeat_at не обслуживает range по
-- этому выражению → индекс был мёртв. Индексируем то же выражение и тот же
-- статус-предикат, что и в запросе. DROP IF EXISTS — определение индекса
-- меняется, а CREATE INDEX IF NOT EXISTS сверяет только имя (см. sql.md).
DROP INDEX IF EXISTS idx_kn_scrape_runs_running_heartbeat;
CREATE INDEX IF NOT EXISTS idx_kn_scrape_runs_running_heartbeat CREATE INDEX IF NOT EXISTS idx_kn_scrape_runs_running_heartbeat
ON kn_scrape_runs (heartbeat_at) ON kn_scrape_runs (COALESCE(heartbeat_at, started_at))
WHERE status IN ('running', 'resuming'); WHERE status = 'running';

View file

@ -5,7 +5,8 @@
-- Загружается через Celery task tasks.pzz_sync.sync_pzz_zones_ekb -- Загружается через Celery task tasks.pzz_sync.sync_pzz_zones_ekb
-- (ad-hoc: POST /api/v1/admin/scrape/pzz-sync). -- (ad-hoc: POST /api/v1/admin/scrape/pzz-sync).
-- --
-- Зависимости: PostGIS (уже установлен). -- Зависимости: PostGIS (уже установлен); PostgreSQL 15+ для UNIQUE NULLS NOT
-- DISTINCT на rosreestr_id (prod — PG16, postgis/postgis:16-3.4).
-- Применять: psql gendesign < data/sql/85_pzz_zones_ekb.sql -- Применять: psql gendesign < data/sql/85_pzz_zones_ekb.sql
BEGIN; BEGIN;
@ -19,7 +20,12 @@ CREATE TABLE IF NOT EXISTS pzz_zones_ekb (
raw_props JSONB DEFAULT '{}'::jsonb, raw_props JSONB DEFAULT '{}'::jsonb,
geom GEOMETRY(MultiPolygon, 4326) NOT NULL, geom GEOMETRY(MultiPolygon, 4326) NOT NULL,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(), fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(rosreestr_id) -- NULLS NOT DISTINCT (PG15+): rosreestr_id nullable (PKK6 при f=geojson иногда
-- кладёт OBJECTID в feature-level id, а не в properties → loader получает NULL).
-- Без этого NULL != NULL в обычном UNIQUE → ON CONFLICT (rosreestr_id) НЕ матчит
-- существующие NULL-строки → каждый повторный sync копит дубли зон (#1361).
-- Прецедент в репо: м.110 (uq_infra_dedupe), м.125, м.140.
UNIQUE NULLS NOT DISTINCT (rosreestr_id)
); );
CREATE INDEX IF NOT EXISTS pzz_zones_ekb_geom_idx ON pzz_zones_ekb USING GIST(geom); CREATE INDEX IF NOT EXISTS pzz_zones_ekb_geom_idx ON pzz_zones_ekb USING GIST(geom);

View file

@ -1,11 +1,16 @@
"""Reads JSON-encoded string from stdin (the kind MCP returns when wrapping a string), """Reads a JSON value from stdin (the kind MCP returns when wrapping data),
and writes the decoded value as UTF-8 to the given file.""" and writes the decoded value as UTF-8 to the given file.
If the JSON decodes to a plain string it is written verbatim; otherwise
(list/dict/number/etc.) it is re-serialised with json.dumps so f.write
always receives a str."""
import json, sys, os import json, sys, os
out_path = sys.argv[1] out_path = sys.argv[1]
raw = sys.stdin.read() raw = sys.stdin.read()
# raw is a JSON string like "[{\"name\":\"...\"}]" — already JSON-quoted # raw is JSON like "[{\"name\":\"...\"}]" or a JSON-quoted string — already JSON-encoded
decoded = json.loads(raw) decoded = json.loads(raw)
text = decoded if isinstance(decoded, str) else json.dumps(decoded, ensure_ascii=False)
os.makedirs(os.path.dirname(out_path) or '.', exist_ok=True) os.makedirs(os.path.dirname(out_path) or '.', exist_ok=True)
with open(out_path, 'w', encoding='utf-8') as f: with open(out_path, 'w', encoding='utf-8') as f:
f.write(decoded) f.write(text)
print(f'wrote {out_path} ({len(decoded)} chars)') print(f'wrote {out_path} ({len(text)} chars)')

View file

@ -71,6 +71,36 @@ export default function ScrapeAllAdminPage() {
"all" | "kn" | "nspd" | "objective" | "nspd_geo" "all" | "kn" | "nspd" | "objective" | "nspd_geo"
>("all"); >("all");
const [logsRunId, setLogsRunId] = useState<number | "">(""); const [logsRunId, setLogsRunId] = useState<number | "">("");
// scraper_type строки, по которой открыты логи: run_id не уникален между
// скраперами (независимые BIGSERIAL PK), поэтому фильтр логов обязан быть
// scoped на конкретный скрапер, иначе смешиваются прогоны с одинаковым run_id.
const [logsScraperType, setLogsScraperType] = useState<
"kn" | "nspd" | "nspd_geo" | ""
>("");
// Открыть логи конкретной строки: фиксируем и run_id, и scraper_type.
function openLogsFor(row: UnifiedRunRow) {
if (
row.scraper_type === "kn" ||
row.scraper_type === "nspd" ||
row.scraper_type === "nspd_geo"
) {
setLogsRunId(row.run_id);
setLogsScraperType(row.scraper_type);
}
}
// Сброс фильтра логов (кнопка «сбросить» и при смене таба).
function resetLogsFilter() {
setLogsRunId("");
setLogsScraperType("");
}
// Смена таба не должна тянуть стейл-фильтр логов чужого прогона.
function changeFilter(t: "all" | "kn" | "nspd" | "objective" | "nspd_geo") {
setFilter(t);
resetLogsFilter();
}
const runs = useQuery({ const runs = useQuery({
queryKey: ["unified-runs", filter], queryKey: ["unified-runs", filter],
@ -83,11 +113,17 @@ export default function ScrapeAllAdminPage() {
}); });
const logs = useQuery({ const logs = useQuery({
queryKey: ["unified-logs", filter, logsRunId], queryKey: ["unified-logs", filter, logsScraperType, logsRunId],
queryFn: () => { queryFn: () => {
const params = new URLSearchParams({ limit: "100" }); const params = new URLSearchParams({ limit: "100" });
if (filter !== "all") params.set("scraper_type", filter); // Когда открыт конкретный прогон — scope-им логи на его scraper_type
if (logsRunId !== "") params.set("run_id", String(logsRunId)); // (run_id не уникален между скраперами). Иначе используем таб-фильтр.
if (logsRunId !== "" && logsScraperType !== "") {
params.set("scraper_type", logsScraperType);
params.set("run_id", String(logsRunId));
} else if (filter !== "all") {
params.set("scraper_type", filter);
}
return apiFetch<UnifiedLogRow[]>( return apiFetch<UnifiedLogRow[]>(
`/api/v1/admin/scrape/all/logs?${params}`, `/api/v1/admin/scrape/all/logs?${params}`,
); );
@ -95,14 +131,19 @@ export default function ScrapeAllAdminPage() {
refetchInterval: 8000, refetchInterval: 8000,
}); });
// Сводка по statusам // Сводка по statusам в показанной выборке (top-30, НЕ глобальные тоталы).
// v_scrape_runs_unified отдаёт и статусы вне 4 плиток (nspd skipped,
// nspd_geo paused/queued) — собираем их в "other", чтобы сумма плиток
// совпадала с числом строк, а не молча теряла прогоны.
const summary = (() => { const summary = (() => {
const s = { running: 0, done: 0, failed: 0, zombie: 0 } as Record< const s = { running: 0, done: 0, failed: 0, zombie: 0, other: 0 } as Record<
string, string,
number number
>; >;
const known = new Set(["running", "done", "failed", "zombie"]);
runs.data?.forEach((r) => { runs.data?.forEach((r) => {
s[r.status] = (s[r.status] ?? 0) + 1; const key = known.has(r.status) ? r.status : "other";
s[key] = (s[key] ?? 0) + 1;
}); });
return s; return s;
})(); })();
@ -137,7 +178,7 @@ export default function ScrapeAllAdminPage() {
...cardStyle, ...cardStyle,
marginTop: 16, marginTop: 16,
display: "grid", display: "grid",
gridTemplateColumns: "repeat(4, 1fr)", gridTemplateColumns: "repeat(5, 1fr)",
gap: 16, gap: 16,
}} }}
> >
@ -149,6 +190,7 @@ export default function ScrapeAllAdminPage() {
<Stat label="✅ успешно" value={summary.done ?? 0} color="#16a34a" /> <Stat label="✅ успешно" value={summary.done ?? 0} color="#16a34a" />
<Stat label="❌ failed" value={summary.failed ?? 0} color="#b3261e" /> <Stat label="❌ failed" value={summary.failed ?? 0} color="#b3261e" />
<Stat label="🧟 zombie" value={summary.zombie ?? 0} color="#9333ea" /> <Stat label="🧟 zombie" value={summary.zombie ?? 0} color="#9333ea" />
<Stat label="• прочие" value={summary.other ?? 0} color="#6b7280" />
</section> </section>
{/* Фильтр + табы */} {/* Фильтр + табы */}
@ -160,7 +202,7 @@ export default function ScrapeAllAdminPage() {
<button <button
key={t} key={t}
type="button" type="button"
onClick={() => setFilter(t)} onClick={() => changeFilter(t)}
style={{ style={{
...filterTab, ...filterTab,
background: filter === t ? "#1d4ed8" : "#f3f4f6", background: filter === t ? "#1d4ed8" : "#f3f4f6",
@ -201,10 +243,13 @@ export default function ScrapeAllAdminPage() {
const heartbeatLag = r.heartbeat_at const heartbeatLag = r.heartbeat_at
? (Date.now() - new Date(r.heartbeat_at).getTime()) / 1000 ? (Date.now() - new Date(r.heartbeat_at).getTime()) / 1000
: null; : null;
// Порог выровнен с backend zombie-детектором (10 мин без
// heartbeat = точно мёртв, admin_scrape.py). Лаг 5-9 мин при
// долгом батче — нормальная живая работа, не "stale".
const isStale = const isStale =
r.status === "running" && r.status === "running" &&
heartbeatLag !== null && heartbeatLag !== null &&
heartbeatLag > 300; heartbeatLag > 600;
const badge = SCRAPER_BADGE[r.scraper_type]; const badge = SCRAPER_BADGE[r.scraper_type];
const statusColor = STATUS_COLOR[r.status] ?? "#6b7280"; const statusColor = STATUS_COLOR[r.status] ?? "#6b7280";
return ( return (
@ -292,7 +337,7 @@ export default function ScrapeAllAdminPage() {
r.scraper_type === "nspd_geo") && ( r.scraper_type === "nspd_geo") && (
<button <button
type="button" type="button"
onClick={() => setLogsRunId(r.run_id)} onClick={() => openLogsFor(r)}
style={{ style={{
...secondaryBtn, ...secondaryBtn,
padding: "4px 10px", padding: "4px 10px",
@ -342,10 +387,11 @@ export default function ScrapeAllAdminPage() {
📋 Логи (top 100){" "} 📋 Логи (top 100){" "}
{logsRunId !== "" && ( {logsRunId !== "" && (
<span style={{ fontSize: 13, fontWeight: 400, color: "#5b6066" }}> <span style={{ fontSize: 13, fontWeight: 400, color: "#5b6066" }}>
· фильтр run_id={logsRunId}{" "} · фильтр {logsScraperType && `${logsScraperType} `}run_id=
{logsRunId}{" "}
<button <button
type="button" type="button"
onClick={() => setLogsRunId("")} onClick={resetLogsFilter}
style={{ style={{
background: "none", background: "none",
border: "none", border: "none",

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { Fragment, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
@ -771,9 +771,8 @@ export default function CadastreScrapeAdminPage() {
</thead> </thead>
<tbody> <tbody>
{jobs.data?.map((job) => ( {jobs.data?.map((job) => (
<> <Fragment key={job.job_id}>
<tr <tr
key={job.job_id}
style={{ style={{
borderTop: "1px solid #f3f4f6", borderTop: "1px solid #f3f4f6",
cursor: "pointer", cursor: "pointer",
@ -859,7 +858,7 @@ export default function CadastreScrapeAdminPage() {
? expandedJob.data ? expandedJob.data
: undefined, : undefined,
)} )}
</> </Fragment>
))} ))}
{(jobs.data?.length ?? 0) === 0 && ( {(jobs.data?.length ?? 0) === 0 && (
<tr> <tr>

View file

@ -166,7 +166,7 @@ export default function ObjectDrillInPage() {
label="Нежилые" label="Нежилые"
value={nonliv?.total?.toString() ?? "—"} value={nonliv?.total?.toString() ?? "—"}
hint={ hint={
nonliv?.perc != null nonliv?.realised != null
? `${nonliv.realised} продано (${nonliv.perc}%)` ? `${nonliv.realised} продано (${nonliv.perc}%)`
: undefined : undefined
} }
@ -175,7 +175,7 @@ export default function ObjectDrillInPage() {
label="Машино-места" label="Машино-места"
value={parking?.total?.toString() ?? "—"} value={parking?.total?.toString() ?? "—"}
hint={ hint={
parking?.perc != null parking?.realised != null
? `${parking.realised} продано (${parking.perc}%)` ? `${parking.realised} продано (${parking.perc}%)`
: undefined : undefined
} }

View file

@ -40,6 +40,15 @@ export default function PrinzipPage() {
const d = detail.data; const d = detail.data;
const areaGap =
d?.avg_area_sqm != null ? Math.round(49 - d.avg_area_sqm) : null;
const areaDeltaValue =
areaGap == null
? "vs рынок 49 м²"
: `vs рынок 49 м² · разрыв ${
areaGap > 0 ? `-${areaGap}` : areaGap < 0 ? `+${-areaGap}` : "0"
} м²`;
return ( return (
<> <>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
@ -68,7 +77,7 @@ export default function PrinzipPage() {
value={d?.avg_area_sqm?.toFixed(1) ?? "—"} value={d?.avg_area_sqm?.toFixed(1) ?? "—"}
unit="м²" unit="м²"
delta={{ delta={{
value: `vs рынок 49 м² · разрыв -${d?.avg_area_sqm ? Math.round(49 - d.avg_area_sqm) : 0} м²`, value: areaDeltaValue,
positive: false, positive: false,
}} }}
/> />

View file

@ -73,12 +73,20 @@ export default function ConceptPage() {
const cadastreGeom = useCadastreGeom(); const cadastreGeom = useCadastreGeom();
const concept = useCreateConcept(); const concept = useCreateConcept();
// Any change to the parcel boundary invalidates a previous concept run:
// its variants/ТЭП/financials were computed for the OLD parcel, so showing
// them against the new outline would be inconsistent.
function handlePolygonChange(next: Polygon | null) {
setPolygon(next);
if (concept.data || concept.isError) {
concept.reset();
}
}
function handleCadSubmit(cad: string) { function handleCadSubmit(cad: string) {
cadastreGeom.mutate(cad, { cadastreGeom.mutate(cad, {
onSuccess: (geom) => { onSuccess: (geom) => {
setPolygon(geom); handlePolygonChange(geom);
// Fresh boundary invalidates a previous concept run.
concept.reset();
}, },
}); });
} }
@ -202,7 +210,7 @@ export default function ConceptPage() {
</div> </div>
)} )}
<ConceptDrawMap polygon={polygon} onChange={setPolygon} /> <ConceptDrawMap polygon={polygon} onChange={handlePolygonChange} />
<div <div
style={{ style={{

View file

@ -53,8 +53,8 @@ const SOURCES: DataSource[] = [
"Цены предложения и скорость продаж по жилым комплексам (sale-graph, velocity).", "Цены предложения и скорость продаж по жилым комплексам (sale-graph, velocity).",
license: "Коммерческий доступ по договору (платный тариф)", license: "Коммерческий доступ по договору (платный тариф)",
attribution: "Данные о ценах предложения предоставлены сервисом «Объектив»", attribution: "Данные о ценах предложения предоставлены сервисом «Объектив»",
href: "https://objced.ru", href: "https://objctv.ru",
hrefLabel: "objced.ru", hrefLabel: "objctv.ru",
}, },
{ {
name: "Open-Meteo", name: "Open-Meteo",

View file

@ -67,16 +67,21 @@ function isTabId(v: string | null | undefined): v is TabId {
type KpiColor = "neutral" | "amber" | "green" | "red" | "blue"; type KpiColor = "neutral" | "amber" | "green" | "red" | "blue";
// Пороги синхронизированы с backend market_trend.label (parcels.py:2250-2257):
// >8 «Сильный рост» (green), >0 «Умеренный рост» (blue),
// >-5 «Стагнация» (amber), иначе «Падение» (red). #1415.
function trendColor(pct: number): KpiColor { function trendColor(pct: number): KpiColor {
if (pct > 5) return "green"; if (pct > 8) return "green";
if (pct > 0) return "blue"; if (pct > 0) return "blue";
if (pct > -5) return "amber"; if (pct > -5) return "amber";
return "red"; return "red";
} }
// Пороги синхронизированы с backend noise.level (parcels.py:1809-1814):
// <50 «тихо» (green), <65 «умеренный» (amber), иначе «шумно» (red). #1416.
function noiseColor(db: number): KpiColor { function noiseColor(db: number): KpiColor {
if (db < 45) return "green"; if (db < 50) return "green";
if (db < 60) return "amber"; if (db < 65) return "amber";
return "red"; return "red";
} }
@ -195,8 +200,12 @@ function SiteFinderContent() {
: { weights }, : { weights },
}); });
// mutate is stable from useMutation — safe to omit from deps. // mutate is stable from useMutation — safe to omit from deps.
// data?.cad_num — dep, чтобы при завершении ПЕРВИЧНОГО analyze (cad_num
// переходит undefined→значение) ещё «висящий» pendingWeightsChange всё-таки
// применился, а не терялся (#1418). При повторном analyze того же участка
// cad_num не меняется → лишнего перезапуска/петли нет.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedWeightsChange]); }, [debouncedWeightsChange, data?.cad_num]);
function handleAnalyze(cadNum: string) { function handleAnalyze(cadNum: string) {
setIsochrones(undefined); setIsochrones(undefined);
@ -222,11 +231,12 @@ function SiteFinderContent() {
) { ) {
setCurrentWeights(weights); setCurrentWeights(weights);
setActiveProfileId(profileId); setActiveProfileId(profileId);
// Enqueue a debounced re-analyze if a parcel is loaded (#201 Phase 2). // Enqueue a debounced re-analyze (#201 Phase 2). The actual mutate fires
// The actual mutate fires after 300ms of silence via debouncedWeightsChange. // after 300ms of silence via debouncedWeightsChange. Не гардим по
if (data?.cad_num) { // data?.cad_num: если веса меняют пока первичный analyze ещё в полёте,
setPendingWeightsChange({ weights, profileId }); // pending всё равно ставится и применяется, когда data придёт — иначе
} // изменение молча теряется и score не соответствует ползункам (#1418).
setPendingWeightsChange({ weights, profileId });
} }
// Derive KPI values from data // Derive KPI values from data
@ -242,9 +252,15 @@ function SiteFinderContent() {
: "neutral"; : "neutral";
const districtValue = data?.district ? data.district.district_name : "—"; const districtValue = data?.district ? data.district.district_name : "—";
// Расстояние до ЦЕНТРА ЕКБ — location.distance_to_center_km (хаверсин до EKB_CENTER,
// уже в км). НЕ district.dist_to_center: то расстояние до полигона ближайшего района
// (≈0 внутри района), а не до центра города (#1414).
const distToCenterKm = data?.location?.distance_to_center_km;
const districtLabel = data?.district const districtLabel = data?.district
? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²` + ? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²` +
` · ${(data.district.dist_to_center / 1000).toFixed(1)} км от центра` (distToCenterKm != null
? ` · ${distToCenterKm.toFixed(1)} км от центра`
: "")
: "Район"; : "Район";
const trendPct = data?.market_trend?.delta_6m_pct; const trendPct = data?.market_trend?.delta_6m_pct;
@ -261,8 +277,10 @@ function SiteFinderContent() {
const noiseDb = data?.noise?.estimated_db; const noiseDb = data?.noise?.estimated_db;
const noiseValue = noiseDb != null ? `${Math.round(noiseDb)} dB` : "—"; const noiseValue = noiseDb != null ? `${Math.round(noiseDb)} dB` : "—";
const noiseLabel = data?.noise ? `Шум · ${data.noise.level}` : "Шум"; const noiseLabel = data?.noise ? `Шум · ${data.noise.level}` : "Шум";
// Цвет считаем от того же округлённого значения, что показано в KPI,
// чтобы число и окраска не расходились у границы порога (#1417).
const noiseKpiColor: KpiColor = const noiseKpiColor: KpiColor =
noiseDb != null ? noiseColor(noiseDb) : "neutral"; noiseDb != null ? noiseColor(Math.round(noiseDb)) : "neutral";
return ( return (
<main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}> <main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}>

View file

@ -44,10 +44,16 @@ export default function ComparePage() {
if ( if (
existing && existing &&
existing.status === col.status && existing.status === col.status &&
existing.score === col.score && existing.errorMsg === col.errorMsg &&
existing.districtName === col.districtName &&
existing.verdictLabel === col.verdictLabel && existing.verdictLabel === col.verdictLabel &&
existing.score === col.score &&
existing.scoreLabel === col.scoreLabel &&
existing.velocityScore === col.velocityScore && existing.velocityScore === col.velocityScore &&
existing.velocityDataAvailable === col.velocityDataAvailable &&
existing.medianPricePerM2 === col.medianPricePerM2 &&
existing.pipeline24mo === col.pipeline24mo && existing.pipeline24mo === col.pipeline24mo &&
existing.confidenceLabel === col.confidenceLabel &&
existing.confidenceValue === col.confidenceValue existing.confidenceValue === col.confidenceValue
) { ) {
return prev; return prev;

View file

@ -3,7 +3,7 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useRef, useState } from "react";
import { CadInput } from "@/components/site-finder/CadInput"; import { CadInput } from "@/components/site-finder/CadInput";
import { MapFilterBar } from "@/components/site-finder/entry/MapFilterBar"; import { MapFilterBar } from "@/components/site-finder/entry/MapFilterBar";
@ -57,15 +57,38 @@ interface FilterBarBridgeProps {
} }
function FilterBarBridge({ filters, onChange, bbox }: FilterBarBridgeProps) { function FilterBarBridge({ filters, onChange, bbox }: FilterBarBridgeProps) {
const { data: allInBbox } = useParcelsBboxQuery(bbox, {}); // useParcelsBboxQuery has no keepPreviousData (unlike useParcelAnalyzeQuery),
const { data: filtered } = useParcelsBboxQuery(bbox, filters); // and its queryKey carries the bbox — so on every pan/zoom both `data` go
// undefined mid-fetch. We hold the last fully-resolved pair so the counter
// doesn't flash "0 / 0" while fetching (#1419), and so totalCount/matchCount
// are always taken from the *same* committed frame — never a new bbox's
// matchCount over an old bbox's totalCount, which could read matchCount >
// totalCount and break the X ⊆ Y invariant (#1420).
const allQuery = useParcelsBboxQuery(bbox, {});
const filteredQuery = useParcelsBboxQuery(bbox, filters);
const lastCounts = useRef<{ totalCount: number; matchCount: number } | null>(
null,
);
// Only commit a new pair once BOTH queries have data for the current render;
// until then keep the previous pair (placeholderData: keepPreviousData
// equivalent, scoped to this bridge).
if (allQuery.data != null && filteredQuery.data != null) {
lastCounts.current = {
totalCount: allQuery.data.length,
matchCount: filteredQuery.data.length,
};
}
const counts = lastCounts.current ?? { totalCount: 0, matchCount: 0 };
return ( return (
<MapFilterBar <MapFilterBar
filters={filters} filters={filters}
onChange={onChange} onChange={onChange}
totalCount={allInBbox?.length ?? 0} totalCount={counts.totalCount}
matchCount={filtered?.length ?? 0} matchCount={counts.matchCount}
/> />
); );
} }

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import type { import type {
@ -12,19 +12,12 @@ import { EstimateForm } from "@/components/trade-in/EstimateForm";
import { EstimateResult } from "@/components/trade-in/EstimateResult"; import { EstimateResult } from "@/components/trade-in/EstimateResult";
import { EstimateProgress } from "@/components/trade-in/EstimateProgress"; import { EstimateProgress } from "@/components/trade-in/EstimateProgress";
// ── URL persistence helpers ────────────────────────────────────────────────────
function useEstimateId() {
// SSR guard: searchParams is only available in client
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
return params.get("id");
}
// ── Page ────────────────────────────────────────────────────────────────────── // ── Page ──────────────────────────────────────────────────────────────────────
export default function TradeInPage() { function TradeInInner() {
const router = useRouter(); const router = useRouter();
// App Router hook: reactive to URL changes and SSR-safe (no hydration mismatch).
const urlEstimateId = useSearchParams().get("id");
// Result from mutation (fresh) or from URL (restored) // Result from mutation (fresh) or from URL (restored)
const [freshResult, setFreshResult] = useState<{ const [freshResult, setFreshResult] = useState<{
@ -32,10 +25,14 @@ export default function TradeInPage() {
input: TradeInEstimateInput; input: TradeInEstimateInput;
} | null>(null); } | null>(null);
const urlEstimateId = useEstimateId(); // Drop the fresh result when the URL points at a different estimate so a
// Fetch from URL if no fresh result yet // soft-navigation to another shared link doesn't keep showing the old one.
const [freshFor, setFreshFor] = useState<string | null>(null);
const activeFreshResult = freshFor === urlEstimateId ? freshResult : null;
// Fetch from URL if no fresh result for the current id yet
const restoredEstimate = useEstimate( const restoredEstimate = useEstimate(
freshResult === null ? urlEstimateId : null, activeFreshResult === null ? urlEstimateId : null,
); );
const mutation = useEstimateMutation(); const mutation = useEstimateMutation();
@ -44,6 +41,9 @@ export default function TradeInPage() {
mutation.mutate(input, { mutation.mutate(input, {
onSuccess: (estimate) => { onSuccess: (estimate) => {
setFreshResult({ estimate, input }); setFreshResult({ estimate, input });
// Bind the fresh result to its id so it's only shown while the URL
// still points at this estimate (see activeFreshResult).
setFreshFor(estimate.estimate_id);
// Persist estimate_id in URL for shareable link // Persist estimate_id in URL for shareable link
router.replace(`/trade-in?id=${estimate.estimate_id}`, { router.replace(`/trade-in?id=${estimate.estimate_id}`, {
scroll: false, scroll: false,
@ -55,17 +55,24 @@ export default function TradeInPage() {
const apiError = mutation.error?.message ?? null; const apiError = mutation.error?.message ?? null;
// Determine what to render on the right side // Determine what to render on the right side
const restoreError = restoredEstimate.error?.message ?? null;
const resultData: { const resultData: {
estimate: AggregatedEstimate; estimate: AggregatedEstimate;
input: TradeInEstimateInput; input: TradeInEstimateInput;
} | null = } | null =
freshResult ?? activeFreshResult ??
(restoredEstimate.data (restoredEstimate.data
? { ? {
estimate: restoredEstimate.data, estimate: restoredEstimate.data,
// When restoring from URL we don't have the original input — synthesise a minimal one // When restoring from URL we don't have the original object parameters
// (GET /estimate/{id} returns only AggregatedEstimate, no input snapshot).
// Don't borrow the first analog's address or fabricate 0 m²/floor — those
// are someone else's listing. Pass an empty input; EstimateResult must
// hide the "Параметры объекта" block when the object params are unknown.
// TODO(TI): expose the stored input on the GET endpoint + render real params.
input: { input: {
address: restoredEstimate.data.analogs[0]?.address ?? "—", address: "",
area_m2: 0, area_m2: 0,
rooms: 0, rooms: 0,
floor: 0, floor: 0,
@ -131,6 +138,8 @@ export default function TradeInPage() {
/> />
) : restoredEstimate.isLoading ? ( ) : restoredEstimate.isLoading ? (
<EstimateProgress visible /> <EstimateProgress visible />
) : restoreError ? (
<RestoreError />
) : ( ) : (
<EmptyState /> <EmptyState />
)} )}
@ -152,6 +161,16 @@ export default function TradeInPage() {
); );
} }
// useSearchParams() в Next 15 App Router throw'ит при static rendering без
// <Suspense> boundary — поэтому оборачиваем page-level.
export default function TradeInPage() {
return (
<Suspense fallback={null}>
<TradeInInner />
</Suspense>
);
}
function EmptyState() { function EmptyState() {
return ( return (
<div <div
@ -181,3 +200,34 @@ function EmptyState() {
</div> </div>
); );
} }
function RestoreError() {
return (
<div
style={{
padding: "48px 24px",
textAlign: "center",
color: "#b91c1c",
background: "#fff",
border: "1px solid #fca5a5",
borderRadius: 12,
}}
>
<div style={{ fontSize: 40, marginBottom: 12 }}></div>
<div
style={{
fontSize: 16,
fontWeight: 600,
color: "#991b1b",
marginBottom: 6,
}}
>
Ссылка устарела или не найдена
</div>
<div style={{ fontSize: 14, color: "#6b7280" }}>
Оценка по этой ссылке недоступна срок её хранения истёк. Введите
параметры квартиры слева, чтобы рассчитать новую.
</div>
</div>
);
}

View file

@ -76,8 +76,12 @@ export function BulkGeoPanel() {
}, },
}); });
const parallelismValid =
Number.isInteger(parallelism) && parallelism >= 1 && parallelism <= 10;
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!parallelismValid) return;
bulkMutation.mutate(); bulkMutation.mutate();
}; };
@ -168,20 +172,23 @@ export function BulkGeoPanel() {
<span style={labelStyle}>Parallelism (110)</span> <span style={labelStyle}>Parallelism (110)</span>
<input <input
type="number" type="number"
value={parallelism} value={Number.isNaN(parallelism) ? "" : parallelism}
min={1} min={1}
max={10} max={10}
onChange={(e) => setParallelism(Number(e.target.value))} onChange={(e) => {
const next = Number(e.target.value);
setParallelism(e.target.value === "" ? NaN : next);
}}
style={{ ...numInput }} style={{ ...numInput }}
/> />
</label> </label>
<button <button
type="submit" type="submit"
disabled={bulkMutation.isPending} disabled={bulkMutation.isPending || !parallelismValid}
style={ style={
bulkMutation.isPending bulkMutation.isPending || !parallelismValid
? { ...triggerBtn, opacity: 0.6 } ? { ...triggerBtn, opacity: 0.6, cursor: "not-allowed" }
: triggerBtn : triggerBtn
} }
> >

View file

@ -68,6 +68,12 @@ export function JobSettingsPanel() {
const [drafts, setDrafts] = useState<Record<string, RowDraft>>({}); const [drafts, setDrafts] = useState<Record<string, RowDraft>>({});
const [expanded, setExpanded] = useState<Record<string, boolean>>({}); const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [toasts, setToasts] = useState<Toast[]>([]); const [toasts, setToasts] = useState<Toast[]>([]);
// Track in-flight saves per job_type — единственная updateMutation хранит
// только variables последнего mutate, поэтому при параллельных Save
// спиннер/opacity рассинхронизировались (issue #1429). Ведём множество.
const [pendingJobTypes, setPendingJobTypes] = useState<Set<string>>(
() => new Set<string>(),
);
const toastSeqRef = useRef(0); const toastSeqRef = useRef(0);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -132,6 +138,18 @@ export function JobSettingsPanel() {
"error", "error",
); );
}, },
onSettled: (
_data: JobSettingRead | undefined,
_err: unknown,
variables: { job_type: string; update: JobSettingUpdate },
) => {
setPendingJobTypes((prev: Set<string>) => {
if (!prev.has(variables.job_type)) return prev;
const next = new Set(prev);
next.delete(variables.job_type);
return next;
});
},
}); });
const patchDraft = (job_type: string, patch: Partial<RowDraft>) => { const patchDraft = (job_type: string, patch: Partial<RowDraft>) => {
@ -197,22 +215,27 @@ export function JobSettingsPanel() {
cron_schedule: draft.cron_schedule.trim() || null, cron_schedule: draft.cron_schedule.trim() || null,
rate_ms: draft.rate_ms !== "" ? Number(draft.rate_ms) : null, rate_ms: draft.rate_ms !== "" ? Number(draft.rate_ms) : null,
max_retries: Number(draft.max_retries), max_retries: Number(draft.max_retries),
max_concurrency: Number(draft.max_concurrency),
}; };
// Очистка max_concurrency давала Number("")===0, а backend требует ge=1 →
// 422 (issue #1430). При пустом поле не включаем его в update — сервер
// оставит прежнее значение, onSuccess вернёт его в draft.
if (draft.max_concurrency !== "") {
update.max_concurrency = Number(draft.max_concurrency);
}
if (extra_config !== undefined) { if (extra_config !== undefined) {
update.extra_config = extra_config; update.extra_config = extra_config;
} }
setPendingJobTypes((prev: Set<string>) => {
if (prev.has(job_type)) return prev;
const next = new Set(prev);
next.add(job_type);
return next;
});
updateMutation.mutate({ job_type, update }); updateMutation.mutate({ job_type, update });
}; };
const isPendingFor = (job_type: string) => const isPendingFor = (job_type: string) => pendingJobTypes.has(job_type);
updateMutation.isPending &&
(
updateMutation.variables as
| { job_type: string; update: JobSettingUpdate }
| undefined
)?.job_type === job_type;
return ( return (
<> <>

View file

@ -21,7 +21,7 @@ const COLS: {
{ {
key: "sverdl_sqm_th", key: "sverdl_sqm_th",
label: "тыс м²", label: "тыс м²",
format: (v) => (v ? v.toFixed(0) : "—"), format: (v) => (v != null ? v.toFixed(0) : "—"),
}, },
{ {
key: "sold_pct", key: "sold_pct",
@ -37,12 +37,12 @@ const COLS: {
{ {
key: "avg_area_sqm", key: "avg_area_sqm",
label: "ср. м²", label: "ср. м²",
format: (v) => (v ? v.toFixed(1) : "—"), format: (v) => (v != null ? v.toFixed(1) : "—"),
}, },
{ {
key: "pct_three_plus", key: "pct_three_plus",
label: "3-к+ %", label: "3-к+ %",
format: (v) => (v ? v.toFixed(0) : "—"), format: (v) => (v != null ? v.toFixed(0) : "—"),
}, },
]; ];

View file

@ -27,7 +27,6 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
const [activeCat, setActiveCat] = useState<string | null>(null); const [activeCat, setActiveCat] = useState<string | null>(null);
const [shown, setShown] = useState(PAGE_SIZE); const [shown, setShown] = useState(PAGE_SIZE);
const { data, isLoading, isFetching } = useObjectInfrastructure(objId, { const { data, isLoading, isFetching } = useObjectInfrastructure(objId, {
category: activeCat ?? undefined,
max_distance: maxDist, max_distance: maxDist,
}); });
@ -44,6 +43,14 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
return Object.entries(byCat).sort((a, b) => b[1] - a[1]); return Object.entries(byCat).sort((a, b) => b[1] - a[1]);
}, [data]); }, [data]);
const filtered = useMemo(
() =>
activeCat == null
? (data ?? [])
: (data ?? []).filter((p) => (p.poi_category ?? "—") === activeCat),
[data, activeCat],
);
return ( return (
<div> <div>
<div <div
@ -144,7 +151,7 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{(data ?? []).slice(0, shown).map((p, i) => ( {filtered.slice(0, shown).map((p, i) => (
<tr <tr
key={`${p.poi_name}-${p.lat}-${p.lon}`} key={`${p.poi_name}-${p.lat}-${p.lon}`}
style={{ style={{
@ -184,7 +191,7 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
))} ))}
</tbody> </tbody>
</table> </table>
{(data?.length ?? 0) > shown ? ( {filtered.length > shown ? (
<div <div
style={{ style={{
padding: 10, padding: 10,
@ -204,7 +211,7 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
cursor: "pointer", cursor: "pointer",
}} }}
> >
Показать ещё {Math.min(PAGE_SIZE, (data?.length ?? 0) - shown)} Показать ещё {Math.min(PAGE_SIZE, filtered.length - shown)}
</button> </button>
</div> </div>
) : null} ) : null}

View file

@ -11,6 +11,78 @@ export function PrinzipGapBar() {
const option = useMemo(() => { const option = useMemo(() => {
const rows = data?.key_gaps ?? []; const rows = data?.key_gaps ?? [];
// Метраж (м²) и доли (%) несравнимы по длине бара на общей шкале, поэтому
// строки разносятся по единице измерения на отдельные value-оси/гриды —
// длины баров сопоставимы только внутри своей единицы. Сохраняем исходный
// порядок строк внутри каждой группы.
const units = Array.from(new Set(rows.map((r) => r.unit)));
const groups = units.map((unit) => ({
unit,
rows: rows.filter((r) => r.unit === unit),
}));
const developers = [
{ key: "prinzip" as const, name: "PRINZIP", color: "#1d4ed8" },
{ key: "market" as const, name: "Рынок Свердл", color: "#94a3b8" },
{ key: "brusnika" as const, name: "Брусника", color: "#0a7a3a" },
{ key: "forum" as const, name: "Форум-групп", color: "#c2410c" },
];
// Вертикальная раскладка гридов: верхний отступ под легенду, нижний под ось,
// высота гридов пропорциональна числу строк в группе.
const TOP = 56;
const BOTTOM = 16;
const GAP = 48;
const totalRows = rows.length || 1;
const span = 100 - TOP - BOTTOM - GAP * Math.max(groups.length - 1, 0);
let cursor = TOP;
const grids: Record<string, unknown>[] = [];
const xAxes: Record<string, unknown>[] = [];
const yAxes: Record<string, unknown>[] = [];
const groupBounds = groups.map((g) => {
const height = (g.rows.length / totalRows) * span;
const top = cursor;
const bottom = 100 - (top + height);
cursor = top + height + GAP;
return { top, bottom };
});
groups.forEach((g, i) => {
const { top, bottom } = groupBounds[i];
grids.push({
left: 120,
right: 32,
top: `${top}%`,
bottom: `${bottom}%`,
containLabel: false,
});
xAxes.push({
type: "value",
gridIndex: i,
name: g.unit,
nameLocation: "end",
nameGap: 8,
});
yAxes.push({
type: "category",
gridIndex: i,
data: g.rows.map((r) => r.label),
});
});
const series = groups.flatMap((g, i) =>
developers.map((dev) => ({
name: dev.name,
type: "bar",
xAxisIndex: i,
yAxisIndex: i,
data: g.rows.map((r) => r[dev.key]),
itemStyle: { color: dev.color },
})),
);
return { return {
tooltip: { tooltip: {
trigger: "axis", trigger: "axis",
@ -33,36 +105,11 @@ export function PrinzipGapBar() {
].join("<br/>"); ].join("<br/>");
}, },
}, },
legend: { data: ["PRINZIP", "Рынок Свердл", "Брусника", "Форум-групп"] }, legend: { data: developers.map((d) => d.name) },
grid: { left: 120, right: 32, top: 40, bottom: 28 }, grid: grids,
xAxis: { type: "value" }, xAxis: xAxes,
yAxis: { type: "category", data: rows.map((r) => r.label) }, yAxis: yAxes,
series: [ series,
{
name: "PRINZIP",
type: "bar",
data: rows.map((r) => r.prinzip),
itemStyle: { color: "#1d4ed8" },
},
{
name: "Рынок Свердл",
type: "bar",
data: rows.map((r) => r.market),
itemStyle: { color: "#94a3b8" },
},
{
name: "Брусника",
type: "bar",
data: rows.map((r) => r.brusnika),
itemStyle: { color: "#0a7a3a" },
},
{
name: "Форум-групп",
type: "bar",
data: rows.map((r) => r.forum),
itemStyle: { color: "#c2410c" },
},
],
}; };
}, [data]); }, [data]);

View file

@ -23,7 +23,12 @@ export function QuartirographyChart() {
"3-к 60-80", "3-к 60-80",
"80+ м²", "80+ м²",
]; ];
const portfolioMap: Record<string, number> = { // DOM.РФ portfolio source (domrf_region_aggregates) has no studio segment
// (room_count_type only ONE/TWO/THREE/FOUR), so "Студии 15-30" has no
// counterpart here. Map it to null → ECharts draws no bar (N/A) instead of
// a misleading 0% grey bar.
const portfolioMap: Record<string, number | null> = {
"Студии 15-30": null,
"1-к 30-45": portfolioRows.find((r) => r.bucket === "1-к")?.percent ?? 0, "1-к 30-45": portfolioRows.find((r) => r.bucket === "1-к")?.percent ?? 0,
"2-к 45-60": portfolioRows.find((r) => r.bucket === "2-к")?.percent ?? 0, "2-к 45-60": portfolioRows.find((r) => r.bucket === "2-к")?.percent ?? 0,
"3-к 60-80": portfolioRows.find((r) => r.bucket === "3-к")?.percent ?? 0, "3-к 60-80": portfolioRows.find((r) => r.bucket === "3-к")?.percent ?? 0,
@ -32,13 +37,13 @@ export function QuartirographyChart() {
const dealsPercents = buckets.map( const dealsPercents = buckets.map(
(b) => dealsRows.find((r) => r.bucket === b)?.percent ?? 0, (b) => dealsRows.find((r) => r.bucket === b)?.percent ?? 0,
); );
const portfolioPercents = buckets.map((b) => portfolioMap[b] ?? 0); const portfolioPercents = buckets.map((b) => portfolioMap[b] ?? null);
return { return {
tooltip: { tooltip: {
trigger: "axis", trigger: "axis",
axisPointer: { type: "shadow" }, axisPointer: { type: "shadow" },
valueFormatter: (v: number) => `${v}%`, valueFormatter: (v: number | null) => (v == null ? "нет данных" : `${v}%`),
}, },
legend: { data: ["Что строится (портфель)", "Что покупают (ДДУ)"] }, legend: { data: ["Что строится (портфель)", "Что покупают (ДДУ)"] },
grid: { left: 110, right: 32, top: 40, bottom: 28 }, grid: { left: 110, right: 32, top: 40, bottom: 28 },

View file

@ -34,7 +34,7 @@ export function RecommendRevenueChart({ bucketLabels, revenuesRub }: Props) {
itemStyle: { color: COLORS[i % COLORS.length] }, itemStyle: { color: COLORS[i % COLORS.length] },
emphasis: { focus: "series" }, emphasis: { focus: "series" },
label: { label: {
show: (valuesMln[i] ?? 0) >= total * 0.05, show: total > 0 && (valuesMln[i] ?? 0) >= total * 0.05,
formatter: (p: { value: number; seriesName: string }) => formatter: (p: { value: number; seriesName: string }) =>
`${p.seriesName}\n${p.value.toFixed(1)} млн`, `${p.seriesName}\n${p.value.toFixed(1)} млн`,
color: "#fff", color: "#fff",

View file

@ -69,8 +69,13 @@ export function RecommendVelocityPanel({
const bucketPfPow = priceFactor > 0 ? priceFactor ** be : 1; const bucketPfPow = priceFactor > 0 ? priceFactor ** be : 1;
const adjustedV = v * bucketPfPow; const adjustedV = v * bucketPfPow;
adjustedVelocity += adjustedV * shareRatio; adjustedVelocity += adjustedV * shareRatio;
if (u > 0 && adjustedV > 0) { // Liquidity-24 KPI должен совпадать с точкой кривой
const months = u / adjustedV; // RecommendLiquidityChart на 24 мес (подпись секции обещает равенство).
// График считает sellout по ЕДИНОЙ scope-эластичности (v × pfPow),
// поэтому здесь тоже используем pfPow, а не per-bucket bucketPfPow.
const liquidityV = v * pfPow;
if (u > 0 && liquidityV > 0) {
const months = u / liquidityV;
const fracIn24 = Math.min(1, 24 / months); const fracIn24 = Math.min(1, 24 / months);
weightedSold24 += fracIn24 * u; weightedSold24 += fracIn24 * u;
} }
@ -492,8 +497,11 @@ export function RecommendVelocityPanel({
? ` (raw ×${scope.velocity_trend_ratio.toFixed(2)} clamp 0.7..2.0)` ? ` (raw ×${scope.velocity_trend_ratio.toFixed(2)} clamp 0.7..2.0)`
: ""}{" "} : ""}{" "}
· <strong>POI ×{scope.poi_factor.toFixed(2)}</strong> (на цену) ). При · <strong>POI ×{scope.poi_factor.toFixed(2)}</strong> (на цену) ). При
price ×{priceFactor.toFixed(2)} темп = базовый ×{" "} price ×{priceFactor.toFixed(2)} ценовой множитель темпа по общей
{priceFactor.toFixed(2)}^{elasticity} = ×{pfPow.toFixed(3)}. эластичности = {priceFactor.toFixed(2)}^{elasticity} = ×
{pfPow.toFixed(3)}. Показанный «Темп продаж» суммирует бакеты с их
собственной эластичностью (Tier 3) и текущим распределением долей,
поэтому может отличаться от базовый ×{pfPow.toFixed(3)}.
</div> </div>
</div> </div>
); );

View file

@ -64,12 +64,17 @@ export function RouteGuard({ children }: RouteGuardProps) {
// #799: pilot-only юзер (единственная доступная зона — trade-in) на // #799: pilot-only юзер (единственная доступная зона — trade-in) на
// запрещённом пути главного фронта (корень `/` и пр.) → авто-redirect в // запрещённом пути главного фронта (корень `/` и пр.) → авто-redirect в
// /trade-in/ вместо дед-энда «Доступа нет». Условия: // /trade-in/ вместо дед-энда «Доступа нет». Условия:
// - роль pilot (trade-in — её ЕДИНСТВЕННАЯ рабочая зона; paths scoped
// только на /trade-in/**). #1439: без этой проверки multi-zone роли
// (напр. analyst с paths="/**") на запрещённом /admin/* молча
// редиректились в /trade-in/ вместо честного NoAccessScreen;
// - текущий путь НЕ под /trade-in (иначе цикл; /trade-in — отдельный // - текущий путь НЕ под /trade-in (иначе цикл; /trade-in — отдельный
// bundle за Caddy, этот гард его не обслуживает, но guard на всякий); // bundle за Caddy, этот гард его не обслуживает, но guard на всякий);
// - у роли есть доступ к /trade-in/ (т.е. trade-in — её рабочая зона). // - у роли есть доступ к /trade-in/ (т.е. trade-in — её рабочая зона).
// admin / multi-scope с разрешённым `/` сюда не попадают (isPathAllowed=true). // admin / multi-scope с разрешённым `/` сюда не попадают (isPathAllowed=true).
if ( if (
typeof window !== "undefined" && typeof window !== "undefined" &&
data.role === "pilot" &&
!pathname.startsWith("/trade-in") && !pathname.startsWith("/trade-in") &&
isPathAllowed(data.allowed_paths, data.deny_paths, "/trade-in/") isPathAllowed(data.allowed_paths, data.deny_paths, "/trade-in/")
) { ) {

View file

@ -100,7 +100,11 @@ export function UserMenu() {
if (isLoading || error || !data) return null; if (isLoading || error || !data) return null;
const initial = data.username[0]?.toUpperCase() ?? "?"; const initial = data.username[0]?.toUpperCase() ?? "?";
const roleLabel = ROLE_LABELS[data.role]; // Runtime-fallback: TS гарантирует синхрон ROLE_LABELS только compile-time.
// Если backend добавит роль в roles.yaml и /me вернёт её до обновления фронта,
// ROLE_LABELS[data.role] === undefined → badge отрендерится пустым. Падаем
// на сырое значение роли, чтобы badge всегда был подписан.
const roleLabel = ROLE_LABELS[data.role] ?? data.role;
return ( return (
<div ref={containerRef} style={{ position: "relative" }}> <div ref={containerRef} style={{ position: "relative" }}>

View file

@ -54,8 +54,9 @@ function DrawLayer({
const onCreated = (e: L.LeafletEvent) => { const onCreated = (e: L.LeafletEvent) => {
const { layer } = e as L.DrawEvents.Created; const { layer } = e as L.DrawEvents.Created;
group.clearLayers(); // Don't add the layer to the FeatureGroup: the polygon is controlled by
group.addLayer(layer); // the parent via the `polygon` prop, which renders it as a <GeoJSON>.
// Adding it here would draw a second overlapping copy of the same ring.
const gj = (layer as L.Polygon).toGeoJSON(); const gj = (layer as L.Polygon).toGeoJSON();
if (gj.type === "Feature" && gj.geometry.type === "Polygon") { if (gj.type === "Feature" && gj.geometry.type === "Polygon") {
onPolygon(gj.geometry); onPolygon(gj.geometry);

View file

@ -77,6 +77,17 @@ export function ConceptExportButtons({ variant }: Props) {
); );
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// The parent renders one reused instance per tab (no `key`), so switching
// variants only changes this prop without remounting. Reset stale export
// state during render when the variant changes, otherwise an error message
// or in-flight disabled state would leak onto another variant's controls.
const [prevStrategy, setPrevStrategy] = useState(variant.strategy);
if (prevStrategy !== variant.strategy) {
setPrevStrategy(variant.strategy);
setServerLoading(null);
setError(null);
}
const fileStem = `gendesign_concept_${variant.strategy}_${today()}`; const fileStem = `gendesign_concept_${variant.strategy}_${today()}`;
function handleGeojson() { function handleGeojson() {

View file

@ -7,7 +7,7 @@
* Lazy-mounted (ssr:false) by the parent Leaflet touches `window`. * Lazy-mounted (ssr:false) by the parent Leaflet touches `window`.
*/ */
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet"; import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet";
import type { FeatureCollection, Polygon, Position } from "geojson"; import type { FeatureCollection, Polygon, Position } from "geojson";
import L from "leaflet"; import L from "leaflet";
@ -67,7 +67,13 @@ export function ConceptResultMap({
variantKey, variantKey,
height = 360, height = 360,
}: Props) { }: Props) {
const bounds = ringBounds(parcel.coordinates as Position[][]); // Memoize so FitBounds only re-fits when the parcel geometry actually
// changes — recomputing inline returns a new array each render, which would
// make the fitBounds effect fire on every re-render and discard manual zoom/pan.
const bounds = useMemo(
() => ringBounds(parcel.coordinates as Position[][]),
[parcel.coordinates],
);
const hasBuildings = buildings.features.length > 0; const hasBuildings = buildings.features.length > 0;
return ( return (

View file

@ -108,8 +108,13 @@ function VariantPanel({ parcel, variant }: PanelProps) {
color: "var(--fg-on-dark-muted)", color: "var(--fg-on-dark-muted)",
}} }}
> >
{STRATEGY_HINTS[variant.strategy]} IRR {formatPct(financial.irr)} · {STRATEGY_HINTS[variant.strategy]} IRR-proxy{" "}
плотность {teap.density.toLocaleString("ru-RU")} м²/га. {formatPct(financial.irr)} · плотность (FAR){" "}
{teap.density.toLocaleString("ru-RU", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
.
</div> </div>
</div> </div>
@ -158,9 +163,11 @@ function VariantPanel({ parcel, variant }: PanelProps) {
unit="шт" unit="шт"
/> />
<KpiCard <KpiCard
label="Плотность" label="Плотность (FAR)"
value={teap.density.toLocaleString("ru-RU")} value={teap.density.toLocaleString("ru-RU", {
unit="м²/га" minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
/> />
<KpiCard <KpiCard
label="Машино-мест" label="Машино-мест"
@ -196,16 +203,21 @@ function VariantPanel({ parcel, variant }: PanelProps) {
value={formatMoneyCompact(financial.gross_margin_rub)} value={formatMoneyCompact(financial.gross_margin_rub)}
delta={{ delta={{
value: value:
marginPositive === false marginPositive === true
? "Отрицательная маржа" ? "Положительная маржа"
: "Положительная маржа", : marginPositive === false
? "Отрицательная маржа"
: "Нулевая маржа",
positive: marginPositive, positive: marginPositive,
}} }}
/> />
<KpiCard <KpiCard
label="IRR" label="IRR-proxy"
value={formatPct(financial.irr)} value={formatPct(financial.irr)}
delta={{ value: "Внутренняя норма доходности", positive: null }} delta={{
value: "Упрощённая оценка: маржа на затраты, без дисконтирования",
positive: null,
}}
/> />
</div> </div>
</Section> </Section>

View file

@ -47,10 +47,17 @@ export const PILOT_EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
/** Derives a human-readable tracking ID from the UUID string returned by backend. /** Derives a human-readable tracking ID from the UUID string returned by backend.
* Example: "7f3a8c2e-..." "GD-7F3A8C2E" *
* Важно: показанный лиду номер ДОЛЖЕН находиться поддержкой в БД. Бэкенд
* (`backend/app/api/v1/pilot.py`) хранит и логирует полный uuid `id`, поэтому
* мы берём его реальный 8-символьный префикс БЕЗ изменения регистра он остаётся
* буквальным префиксом `pilot_requests.id`. Поддержка находит заявку прямым
* запросом: `SELECT * FROM pilot_requests WHERE id::text LIKE 'XXXXXXXX%'`.
* Префикс "GD-" только визуальный, в запрос НЕ входит.
* Example: "7f3a8c2e-..." "GD-7f3a8c2e"
*/ */
function deriveTrackingId(id: string): string { function deriveTrackingId(id: string): string {
return "GD-" + id.replace(/-/g, "").slice(0, 8).toUpperCase(); return "GD-" + id.replace(/-/g, "").slice(0, 8);
} }
/** /**

View file

@ -26,6 +26,14 @@ const TIME_WINDOW_LABELS: Record<TimeWindow, string> = {
last_year: "Последний год", last_year: "Последний год",
}; };
// Период «распроданности» для провенанса — соответствует выбранному time_window
// (backend считает sold_pct_of_supply по сделкам этого окна).
const TIME_WINDOW_OVERSOLD_PERIOD: Record<TimeWindow, string> = {
last_month: "за последний месяц",
last_quarter: "за последний квартал",
last_year: "за последний год",
};
const ROOM_BUCKET_LABELS: Record<string, string> = { const ROOM_BUCKET_LABELS: Record<string, string> = {
studio: "Студия", studio: "Студия",
"1": "1-комн.", "1": "1-комн.",
@ -138,7 +146,13 @@ function CompetitorDrillIn({
); );
} }
function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) { function TopLayoutsTable({
rows,
oversoldPeriodLabel,
}: {
rows: TopLayoutRow[];
oversoldPeriodLabel: string;
}) {
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set()); const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
if (rows.length === 0) { if (rows.length === 0) {
@ -333,7 +347,7 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
borderRadius: 4, borderRadius: 4,
whiteSpace: "nowrap", whiteSpace: "nowrap",
}} }}
title="Сделки за 24 мес превышают текущий snapshot supply — значение обрезано до 100%" title={`Сделки ${oversoldPeriodLabel} превышают текущий snapshot supply — значение обрезано до 100%`}
> >
&gt;100% &gt;100%
</span> </span>
@ -655,6 +669,11 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
const [minVelocity, setMinVelocity] = useState(0.5); const [minVelocity, setMinVelocity] = useState(0.5);
const [isPdfLoading, setIsPdfLoading] = useState(false); const [isPdfLoading, setIsPdfLoading] = useState(false);
const [pdfError, setPdfError] = useState<string | null>(null); const [pdfError, setPdfError] = useState<string | null>(null);
// Request that produced the currently shown `data` — PDF must use this,
// not live control state (controls may have changed without re-running «Рассчитать»).
const [lastRequest, setLastRequest] = useState<BestLayoutsRequest | null>(
null,
);
const { mutate, data, isPending, error } = useBestLayouts(cadNum); const { mutate, data, isPending, error } = useBestLayouts(cadNum);
@ -676,13 +695,17 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
} }
function handleCalculate() { function handleCalculate() {
mutate(buildRequest()); const req = buildRequest();
setLastRequest(req);
mutate(req);
} }
async function handleDownloadPdf() { async function handleDownloadPdf() {
// Use the request that produced the shown `data`, not live control state.
if (!lastRequest) return;
setIsPdfLoading(true); setIsPdfLoading(true);
try { try {
const req = buildRequest(); const req = lastRequest;
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/v1/parcels/${encodeURIComponent(cadNum)}/best-layouts/pdf`, `${API_BASE_URL}/api/v1/parcels/${encodeURIComponent(cadNum)}/best-layouts/pdf`,
{ {
@ -994,7 +1017,14 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
{data && !isPending && ( {data && !isPending && (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}> <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<DataQualityCard dq={data.data_quality} /> <DataQualityCard dq={data.data_quality} />
<TopLayoutsTable rows={data.top_layouts} /> <TopLayoutsTable
rows={data.top_layouts}
oversoldPeriodLabel={
TIME_WINDOW_OVERSOLD_PERIOD[
(lastRequest ?? buildRequest()).time_window
]
}
/>
<RecommendationCard rec={data.recommendation_for_tz} /> <RecommendationCard rec={data.recommendation_for_tz} />
</div> </div>
)} )}

View file

@ -48,7 +48,15 @@ export function CpLayerControlPanel({
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const totalCount = data?.engineering_structures.length ?? 0; const totalCount = data?.engineering_structures.length ?? 0;
const allVisible = visibleCategories.size === CP_ALL_CATEGORIES.length; // #1459 — чекбоксы рендерятся только для непустых категорий, поэтому
// «Показать все» должен отражать состояние именно доступных пользователю
// (непустых) категорий, а не всех CP_ALL_CATEGORIES (включая пустые).
const togglableCategories = CP_ALL_CATEGORIES.filter(
(cat) => (grouped.get(cat)?.length ?? 0) > 0,
);
const allVisible =
togglableCategories.length > 0 &&
togglableCategories.every((cat) => visibleCategories.has(cat));
const hasMarketLayers = (marketLayers?.length ?? 0) > 0; const hasMarketLayers = (marketLayers?.length ?? 0) > 0;
return ( return (

View file

@ -406,7 +406,7 @@ function WeatherBlock({
}} }}
title={weather.note} title={weather.note}
> >
{weather.source} · 7 дн. {weather.source} · {weather.forecast_days} дн.
</div> </div>
</div> </div>
); );

View file

@ -101,7 +101,7 @@ export function MarketTab({ data }: Props) {
}} }}
> >
<strong> <strong>
{activeCount} строящихся в {radiusKm}км {activeCount} активных в {radiusKm}км
</strong> </strong>
{" · велосити "} {" · велосити "}
<strong> <strong>
@ -147,7 +147,7 @@ export function MarketTab({ data }: Props) {
{activeCount} {activeCount}
</div> </div>
<div style={{ marginTop: 4, fontSize: 12, color: "#73767e" }}> <div style={{ marginTop: 4, fontSize: 12, color: "#73767e" }}>
строящихся в радиусе {radiusKm}км строящихся и недавно сданных в радиусе {radiusKm}км
</div> </div>
</div> </div>

View file

@ -82,7 +82,10 @@ export function MarketTrendBlock({ trend }: Props) {
const arrow = trendArrow(trend.delta_6m_pct); const arrow = trendArrow(trend.delta_6m_pct);
const arrowColor = deltaColor(trend.delta_6m_pct); const arrowColor = deltaColor(trend.delta_6m_pct);
const labelStyle = LABEL_COLORS[trend.label] ?? { // Backend кладёт длинный label ("Сильный рост — рынок растёт быстрее
// инфляции"); ключи LABEL_COLORS — короткие, поэтому нормализуем по префиксу.
const labelKey = trend.label.split(" — ")[0];
const labelStyle = LABEL_COLORS[labelKey] ?? {
bg: "#f3f4f6", bg: "#f3f4f6",
color: "#374151", color: "#374151",
}; };

View file

@ -13,6 +13,10 @@ interface MergedRow {
type: string | null; type: string | null;
distanceM: number; distanceM: number;
source: "analyze" | "connection-points"; source: "analyze" | "connection-points";
// Stable structure identifier (НСПД cad_num) when available — используется для
// дедупа между источниками, т.к. distanceM меряется до разных опорных точек
// (analyze — до центроида, connection-points — до границы участка).
cadNum: string | null;
} }
export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) { export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
@ -28,6 +32,7 @@ export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
type: item.type, type: item.type,
distanceM: item.distance_m, distanceM: item.distance_m,
source: "analyze", source: "analyze",
cadNum: null,
}); });
} }
} }
@ -39,14 +44,21 @@ export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
type: s.type, type: s.type,
distanceM: s.distance_to_boundary_m, distanceM: s.distance_to_boundary_m,
source: "connection-points", source: "connection-points",
cadNum: s.cad_num,
}); });
} }
} }
// Dedup by label+distance (simple check) // Dedup. distanceM меряется до разных опорных точек (analyze — до центроида,
// connection-points — до границы), поэтому label+round(distance) пропускает
// дубли одного физического сооружения. Дедупим по стабильному cad_num, когда он
// есть; иначе (analyze без cad_num) — по label + округлённому расстоянию.
const seen = new Set<string>(); const seen = new Set<string>();
const deduped = rows.filter((r) => { const deduped = rows.filter((r) => {
const key = `${r.label}|${Math.round(r.distanceM)}`; const key =
r.cadNum !== null
? `cad:${r.cadNum}`
: `lbl:${r.label}|${Math.round(r.distanceM)}`;
if (seen.has(key)) return false; if (seen.has(key)) return false;
seen.add(key); seen.add(key);
return true; return true;
@ -57,7 +69,7 @@ export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
if (deduped.length === 0 && !cpLoading) { if (deduped.length === 0 && !cpLoading) {
return ( return (
<div style={{ fontSize: 13, color: "#6b7280", fontStyle: "italic" }}> <div style={{ fontSize: 13, color: "#6b7280", fontStyle: "italic" }}>
Инженерных объектов в 200 м не найдено Инженерных объектов поблизости не найдено
</div> </div>
); );
} }

View file

@ -34,7 +34,11 @@ const CLASS_LABEL: Record<string, string> = {
function fmtMonth(iso: string | null): string { function fmtMonth(iso: string | null): string {
if (!iso) return "—"; if (!iso) return "—";
// Прочнее чем substring(0,7) — игнорирует timezone и datetime suffix // Regex-парс год/месяц напрямую — без таймзонных сюрпризов от Date()
// (new Date('2026-07-01') = UTC-полночь → в UTC-локали уезжает на 06.2026).
// Тот же приём, что в MarketLayers.formatReadyDt.
const m = /^(\d{4})-(\d{2})/.exec(iso);
if (m) return `${m[2]}.${m[1]}`;
const d = new Date(iso); const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso.substring(0, 7); if (Number.isNaN(d.getTime())) return iso.substring(0, 7);
return d.toLocaleDateString("ru-RU", { year: "numeric", month: "2-digit" }); return d.toLocaleDateString("ru-RU", { year: "numeric", month: "2-digit" });

View file

@ -311,7 +311,9 @@ export function ScoreBreakdownStackedBar({
color: "#6b7280", color: "#6b7280",
}} }}
> >
{aggregated.map((agg) => { {aggregated
.filter((a) => a.contribution !== 0)
.map((agg) => {
const color = agg.isCustom const color = agg.isCustom
? CATEGORY_COLORS.custom ? CATEGORY_COLORS.custom
: categoryColor(agg.category); : categoryColor(agg.category);
@ -373,11 +375,11 @@ export function ScoreBreakdownStackedBar({
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}> <div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
{sourceAgg.map(({ factor, contribution }) => { {sourceAgg.map(({ factor, contribution }) => {
const isCustom = customSet.has(factor); const isCustom = customSet.has(factor);
const maxAbs =
Math.max(...sourceAgg.map((s) => Math.abs(s.contribution))) || 1;
const barWidth = Math.min( const barWidth = Math.min(
100, 100,
(Math.abs(contribution) / (Math.abs(contribution) / maxAbs) * 100,
(Math.abs(sourceAgg[0]?.contribution) || 1)) *
100,
); );
const color = const color =
contribution >= 0 contribution >= 0

View file

@ -47,7 +47,7 @@ function SeasonCard({
{stats.avg_t_min_c}{stats.avg_t_max_c}°C {stats.avg_t_min_c}{stats.avg_t_max_c}°C
</div> </div>
<div style={{ fontSize: 11, color: "#6b7280" }}> <div style={{ fontSize: 11, color: "#6b7280" }}>
(макс {stats.min_t_c} / {stats.max_t_c}°C) (экстремумы {stats.min_t_c} / {stats.max_t_c}°C)
</div> </div>
<div style={{ fontSize: 12, color: "#374151" }}> <div style={{ fontSize: 12, color: "#374151" }}>
{stats.total_precip_mm} мм осадков {stats.total_precip_mm} мм осадков

View file

@ -3,7 +3,7 @@ import type { Velocity } from "@/types/site-finder";
import { SectionLabel } from "@/components/ui/SectionLabel"; import { SectionLabel } from "@/components/ui/SectionLabel";
import { EmptyState } from "@/components/ui/EmptyState"; import { EmptyState } from "@/components/ui/EmptyState";
const BUCKET_ORDER = ["студия", "1", "2", "3", "4+"] as const; const BUCKET_ORDER = ["студия", "1", "2", "3", "4", "5+"] as const;
type KnownBucket = (typeof BUCKET_ORDER)[number]; type KnownBucket = (typeof BUCKET_ORDER)[number];
const BUCKET_LABEL: Record<KnownBucket, string> = { const BUCKET_LABEL: Record<KnownBucket, string> = {
@ -11,7 +11,8 @@ const BUCKET_LABEL: Record<KnownBucket, string> = {
"1": "1-к", "1": "1-к",
"2": "2-к", "2": "2-к",
"3": "3-к", "3": "3-к",
"4+": "4+", "4": "4-к",
"5+": "5+",
}; };
interface VelocityBlockProps { interface VelocityBlockProps {
@ -253,7 +254,7 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
)} )}
{/* Top competitors */} {/* Top competitors */}
{velocity.sample_competitors.length > 0 && ( {dataAvailable && velocity.sample_competitors.length > 0 && (
<div> <div>
<SectionLabel style={{ marginBottom: 6 }}>Топ продавцов</SectionLabel> <SectionLabel style={{ marginBottom: 6 }}>Топ продавцов</SectionLabel>
<table <table

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useRef, useState } from "react"; import { useCallback, useState } from "react";
import { SectionLabel } from "@/components/ui/SectionLabel"; import { SectionLabel } from "@/components/ui/SectionLabel";
import { import {
@ -89,9 +89,6 @@ export function WeightProfilePanel({
const [saveName, setSaveName] = useState(""); const [saveName, setSaveName] = useState("");
const [saveDefault, setSaveDefault] = useState(false); const [saveDefault, setSaveDefault] = useState(false);
// Debounce timer ref
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Profiles query (only when userId + adminToken provided) // Profiles query (only when userId + adminToken provided)
const canUseCrud = !!userId && !!adminToken; const canUseCrud = !!userId && !!adminToken;
const profilesQuery = useWeightProfiles(userId ?? "", adminToken ?? ""); const profilesQuery = useWeightProfiles(userId ?? "", adminToken ?? "");
@ -102,11 +99,6 @@ export function WeightProfilePanel({
function handleSliderChange(cat: PoiCategoryKey, raw: string) { function handleSliderChange(cat: PoiCategoryKey, raw: string) {
const value = clamp(parseFloat(raw)); const value = clamp(parseFloat(raw));
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setDraft((prev) => ({ ...prev, [cat]: value }));
}, 300);
// Optimistic visual update (no debounce for the display value input itself)
setDraft((prev) => ({ ...prev, [cat]: value })); setDraft((prev) => ({ ...prev, [cat]: value }));
} }

View file

@ -205,11 +205,14 @@ export function ZouitLayer({ grouped, visibleSeverities }: LayerProps) {
return ( return (
<GeoJSON <GeoJSON
// react-leaflet GeoJSON кэширует слой и не реагирует на смену // react-leaflet GeoJSON кэширует слой и не реагирует на смену
// data prop — хеш в key форсирует remount при смене участка // data prop — геометрия в key форсирует remount при смене
// (тот же приём, что у изохрон/риск-зон в SiteMap/MarketLayers). // участка (тот же приём, что у изохрон/риск-зон в
key={`zouit-${sev}-${idx}-${ // SiteMap/MarketLayers). slice(0,24) GeoJSON-строки тут НЕ
overlap.geom_geojson?.slice(0, 24) ?? "" // годится: префикс `{"type":"Polygon","coord` константен и не
}`} // зависит от координат, поэтому при A→B с тем же числом
// полигонов в том же порядке ключи совпадали и старая
// геометрия оставалась на карте. Берём строку целиком.
key={`zouit-${sev}-${idx}-${overlap.geom_geojson ?? ""}`}
data={feature} data={feature}
style={{ style={{
color: style.color, color: style.color,

View file

@ -30,8 +30,20 @@ const NAV_SECTIONS: NavSection[] = [
{ id: "section-3-3", label: "3.3 Остатки и скорость" }, { id: "section-3-3", label: "3.3 Остатки и скорость" },
], ],
}, },
{ id: "section-4", label: "4. Инфраструктура" }, { id: "section-4", label: "4. Оценка" },
{ id: "section-5", label: "5. Свежесть" }, { id: "section-5", label: "5. Атмосфера" },
{
id: "section-6",
label: "6. Прогноз",
sub: [
{ id: "section-6-1", label: "6.1 Прогноз по горизонтам" },
{ id: "section-6-2", label: "6.2 Сценарии" },
{ id: "section-6-3", label: "6.3 Уверенность" },
{ id: "section-6-4", label: "6.4 Рекомендация по продукту" },
{ id: "section-6-5", label: "6.5 Прозрачность скоринга" },
{ id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" },
],
},
]; ];
// All section IDs in scroll order (for IntersectionObserver) // All section IDs in scroll order (for IntersectionObserver)

View file

@ -18,11 +18,9 @@ interface Row {
function formatDate(raw: string | null): string { function formatDate(raw: string | null): string {
if (!raw) return "—"; if (!raw) return "—";
try { const d = new Date(raw);
return new Date(raw).toLocaleDateString("ru-RU"); if (Number.isNaN(d.getTime())) return raw;
} catch { return d.toLocaleDateString("ru-RU");
return raw;
}
} }
function buildRows(data: ParcelEgrn): Row[] { function buildRows(data: ParcelEgrn): Row[] {

View file

@ -46,7 +46,8 @@ function factorLabel(key: string): string {
function formatFactorValue(value: number | null): string | null { function formatFactorValue(value: number | null): string | null {
if (value == null) return null; if (value == null) return null;
// Fractions in 0..1 read as percent (e.g. coverage); integers/large stay raw. // Fractions in 0..1 read as percent (e.g. coverage); integers/large stay raw.
if (value > 0 && value < 1) return `${Math.round(value * 100)}%`; // Upper bound is inclusive so full coverage (1.0) renders as «100%», not «1».
if (value > 0 && value <= 1) return `${Math.round(value * 100)}%`;
return Math.round(value).toLocaleString("ru"); return Math.round(value).toLocaleString("ru");
} }

View file

@ -70,6 +70,12 @@ interface Props {
// ── Component ───────────────────────────────────────────────────────────────── // ── Component ─────────────────────────────────────────────────────────────────
export function PoiList2Gis({ items, totalScore }: Props) { export function PoiList2Gis({ items, totalScore }: Props) {
// POI-weighted score is presented as «X / 100», so clamp to 0..100 defensively:
// the adapter sums round(weight*100) per POI без нормировки и при достаточном
// числе POI может выдать >100 → бессмысленное «137 / 100» (#1470). Корневую
// нормировку нужно чинить в site-finder-api.ts (useParcelPoiScoreQuery).
const displayScore = Math.min(100, Math.max(0, totalScore));
// Top-7, sorted by score_contribution desc // Top-7, sorted by score_contribution desc
const top7 = [...items] const top7 = [...items]
.sort((a, b) => b.score_contribution - a.score_contribution) .sort((a, b) => b.score_contribution - a.score_contribution)
@ -130,7 +136,7 @@ export function PoiList2Gis({ items, totalScore }: Props) {
fontVariantNumeric: "tabular-nums", fontVariantNumeric: "tabular-nums",
}} }}
> >
{totalScore.toFixed(0)} / 100 {displayScore.toFixed(0)} / 100
</span> </span>
</div> </div>

View file

@ -55,17 +55,25 @@ function pickHorizonForecast(
return [...forecasts].sort((a, b) => b.horizon_months - a.horizon_months)[0]; return [...forecasts].sort((a, b) => b.horizon_months - a.horizon_months)[0];
} }
interface ScenarioDeficit {
value: number | null;
/** Horizon (months) the value actually corresponds to, or null when unknown. */
horizon: number | null;
}
function deficitForScenario( function deficitForScenario(
key: ScenarioKey, key: ScenarioKey,
scenarios: ReportScenarios, scenarios: ReportScenarios,
scenariosSummary: ScenariosSummary | null, scenariosSummary: ScenariosSummary | null,
targetHorizon: number, targetHorizon: number,
): number | null { ): ScenarioDeficit {
const sc = scenarios.by_scenario[key]; const sc = scenarios.by_scenario[key];
const fc = sc ? pickHorizonForecast(sc.forecasts, targetHorizon) : null; const fc = sc ? pickHorizonForecast(sc.forecasts, targetHorizon) : null;
if (fc) return fc.deficit_index; if (fc) return { value: fc.deficit_index, horizon: fc.horizon_months };
if (scenariosSummary) return scenariosSummary[key]; // scenarios_summary is, by contract, the deficit_index at the target horizon.
return null; if (scenariosSummary)
return { value: scenariosSummary[key], horizon: targetHorizon };
return { value: null, horizon: null };
} }
export function ScenariosBlock({ export function ScenariosBlock({
@ -107,7 +115,8 @@ export function ScenariosBlock({
key={key} key={key}
name={SCENARIO_RU[key]} name={SCENARIO_RU[key]}
hint={SCENARIO_HINT[key]} hint={SCENARIO_HINT[key]}
deficitIndex={di} deficitIndex={di.value}
deficitHorizon={di.horizon}
ratePath={sc?.rate_path} ratePath={sc?.rate_path}
targetHorizon={targetHorizon} targetHorizon={targetHorizon}
/> />
@ -115,8 +124,10 @@ export function ScenariosBlock({
})} })}
</div> </div>
<p style={{ fontSize: 12, color: "var(--fg-tertiary)", margin: 0 }}> <p style={{ fontSize: 12, color: "var(--fg-tertiary)", margin: 0 }}>
Индекс дефицита приведён на целевом горизонте {targetHorizon} мес. Индекс дефицита приведён на целевом горизонте {targetHorizon} мес.; если
Конверт ставки путь ключевой ставки (% годовых) по горизонтам. ряд на этом горизонте отсутствует, используется самый длинный доступный
его горизонт указан в подписи карточки. Конверт ставки путь ключевой
ставки (% годовых) по горизонтам.
</p> </p>
</div> </div>
); );
@ -126,15 +137,20 @@ function ScenarioCard({
name, name,
hint, hint,
deficitIndex, deficitIndex,
deficitHorizon,
ratePath, ratePath,
targetHorizon, targetHorizon,
}: { }: {
name: string; name: string;
hint: string; hint: string;
deficitIndex: number | null; deficitIndex: number | null;
/** Horizon (months) the deficit value corresponds to; falls back to target. */
deficitHorizon: number | null;
ratePath: RatePath | undefined; ratePath: RatePath | undefined;
targetHorizon: number; targetHorizon: number;
}) { }) {
// Label the number with the horizon it actually represents, not the target.
const labelHorizon = deficitHorizon ?? targetHorizon;
const horizons = ratePath const horizons = ratePath
? Object.keys(ratePath) ? Object.keys(ratePath)
.map((h) => Number(h)) .map((h) => Number(h))
@ -163,7 +179,9 @@ function ScenarioCard({
> >
{name} {name}
</div> </div>
<div style={{ fontSize: 12, color: "var(--fg-tertiary)", marginTop: 2 }}> <div
style={{ fontSize: 12, color: "var(--fg-tertiary)", marginTop: 2 }}
>
{hint} {hint}
</div> </div>
</div> </div>
@ -179,7 +197,7 @@ function ScenarioCard({
color: "var(--fg-secondary)", color: "var(--fg-secondary)",
}} }}
> >
Индекс дефицита · {targetHorizon} мес. Индекс дефицита · {labelHorizon} мес.
</span> </span>
{deficitIndex != null ? ( {deficitIndex != null ? (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}> <div style={{ display: "flex", alignItems: "center", gap: 8 }}>

View file

@ -214,9 +214,12 @@ export function Section1ParcelInfo({ cad }: Props) {
// HeadlineBar texts // HeadlineBar texts
const headlineTitle = scoreVerdict(data.score, data.score_label); const headlineTitle = scoreVerdict(data.score, data.score_label);
// #1467: при отсутствии score_explanation (старый деплой / частичный мок /
// backend без поля) НЕ выдумываем дельту к району — формируем подзаголовок
// только из реально присутствующих полей (POI в радиусе 1 км).
const headlineSubtitle = data.score_explanation const headlineSubtitle = data.score_explanation
? data.score_explanation ? data.score_explanation
: `Score +12 vs район · ${data.poi_count} POI в радиусе 1 км`; : `${data.poi_count} POI в радиусе 1 км`;
return ( return (
<section <section
@ -299,20 +302,13 @@ export function Section1ParcelInfo({ cad }: Props) {
totalScore={poiData.poi_weighted_score} totalScore={poiData.poi_weighted_score}
/> />
) : ( ) : (
// Fallback: build from analyze score_breakdown // Fallback: B6 poi-score (useParcelPoiScoreQuery) ещё грузится или
<PoiList2Gis // упал. score_breakdown несёт только name/distance_m — без реального
items={Object.entries(data.score_breakdown).flatMap( // per-POI weight и без POI-weighted-score, поэтому НЕ подставляем
([cat, pois]) => // инвест-балл участка (data.score) как POI-оценку «X / 100» и НЕ
pois.slice(0, 2).map((poi) => ({ // фабрикуем weight/score_contribution (#1466). Показываем честный
category: cat, // пустой стейт «POI данные недоступны» до прихода B6.
name: poi.name ?? cat, <PoiList2Gis items={[]} totalScore={0} />
distance_m: poi.distance_m,
weight: 0.1,
score_contribution: 5,
})),
)}
totalScore={data.score}
/>
)} )}
</div> </div>
</div> </div>

View file

@ -36,6 +36,7 @@ interface UtilityDisplayConfig {
function getUtilityConfig(subtype: string): UtilityDisplayConfig { function getUtilityConfig(subtype: string): UtilityDisplayConfig {
switch (subtype) { switch (subtype) {
// ── Электричество ──
case "substation": case "substation":
return { return {
label: "Электричество (подстанция)", label: "Электричество (подстанция)",
@ -43,6 +44,13 @@ function getUtilityConfig(subtype: string): UtilityDisplayConfig {
<Zap size={16} strokeWidth={1.5} style={{ color: "var(--viz-4)" }} /> <Zap size={16} strokeWidth={1.5} style={{ color: "var(--viz-4)" }} />
), ),
}; };
case "transformer":
return {
label: "Электричество (трансформатор)",
icon: (
<Zap size={16} strokeWidth={1.5} style={{ color: "var(--viz-4)" }} />
),
};
case "power_line": case "power_line":
return { return {
label: "Электричество (ЛЭП)", label: "Электричество (ЛЭП)",
@ -50,7 +58,8 @@ function getUtilityConfig(subtype: string): UtilityDisplayConfig {
<Zap size={16} strokeWidth={1.5} style={{ color: "var(--viz-4)" }} /> <Zap size={16} strokeWidth={1.5} style={{ color: "var(--viz-4)" }} />
), ),
}; };
case "pipeline": // ── Газ ──
case "gas_pipeline":
return { return {
label: "Газ (газопровод)", label: "Газ (газопровод)",
icon: ( icon: (
@ -61,9 +70,21 @@ function getUtilityConfig(subtype: string): UtilityDisplayConfig {
/> />
), ),
}; };
case "water_intake": case "pipeline":
return { return {
label: "Водопровод", label: "Трубопровод (без вещества)",
icon: (
<Flame
size={16}
strokeWidth={1.5}
style={{ color: "var(--viz-4)" }}
/>
),
};
// ── Водопровод ──
case "water_main":
return {
label: "Водопровод (магистраль)",
icon: ( icon: (
<Droplet <Droplet
size={16} size={16}
@ -72,9 +93,43 @@ function getUtilityConfig(subtype: string): UtilityDisplayConfig {
/> />
), ),
}; };
case "pumping_station": case "water_works":
return { return {
label: "Канализация (насосная)", label: "Водопровод (водозабор)",
icon: (
<Droplet
size={16}
strokeWidth={1.5}
style={{ color: "var(--viz-2)" }}
/>
),
};
case "water_tower":
return {
label: "Водопровод (водонапорная башня)",
icon: (
<Droplet
size={16}
strokeWidth={1.5}
style={{ color: "var(--viz-2)" }}
/>
),
};
case "storage_tank":
return {
label: "Резервуар",
icon: (
<Droplet
size={16}
strokeWidth={1.5}
style={{ color: "var(--viz-2)" }}
/>
),
};
// ── Канализация ──
case "sewerage":
return {
label: "Канализация (коллектор)",
icon: ( icon: (
<Pipette <Pipette
size={16} size={16}
@ -83,6 +138,29 @@ function getUtilityConfig(subtype: string): UtilityDisplayConfig {
/> />
), ),
}; };
case "wastewater_plant":
return {
label: "Канализация (очистные)",
icon: (
<Pipette
size={16}
strokeWidth={1.5}
style={{ color: "var(--fg-secondary)" }}
/>
),
};
// ── Теплоснабжение ──
case "heat_substation":
return {
label: "Теплоснабжение (ЦТП)",
icon: (
<Flame
size={16}
strokeWidth={1.5}
style={{ color: "var(--viz-4)" }}
/>
),
};
default: default:
return { return {
label: subtype, label: subtype,
@ -135,14 +213,23 @@ function buildSubtitle(items: UtilitySummaryItem[]): string {
const parts: string[] = []; const parts: string[] = [];
const electric = items.find( const electric = items.find(
(i) => i.subtype === "substation" || i.subtype === "power_line", (i) =>
i.subtype === "substation" ||
i.subtype === "transformer" ||
i.subtype === "power_line",
);
const gas = items.find((i) => i.subtype === "gas_pipeline");
const water = items.find(
(i) =>
i.subtype === "water_main" ||
i.subtype === "water_works" ||
i.subtype === "water_tower",
); );
const gas = items.find((i) => i.subtype === "pipeline");
const water = items.find((i) => i.subtype === "water_intake");
if (electric) parts.push(`электричество ${electric.nearest_m} м`); if (electric)
if (gas) parts.push(`газ ${gas.nearest_m} м`); parts.push(`электричество ${electric.nearest_m.toLocaleString("ru")} м`);
if (water) parts.push(`водопровод ${water.nearest_m} м`); if (gas) parts.push(`газ ${gas.nearest_m.toLocaleString("ru")} м`);
if (water) parts.push(`водопровод ${water.nearest_m.toLocaleString("ru")} м`);
return parts.length > 0 return parts.length > 0
? parts.join(" · ") ? parts.join(" · ")

View file

@ -23,6 +23,10 @@ export interface CompareColumn {
score?: number | null; score?: number | null;
scoreLabel?: string | null; scoreLabel?: string | null;
velocityScore?: number | null; // 0..1 velocityScore?: number | null; // 0..1
// False → конкуренты есть, но данных velocity нет (velocity_score=0 — sentinel,
// не факт «рынок стоит»). Когда false, ячейка velocity = «—» и не участвует в
// подсветке «лучшее». undefined трактуется как «данные есть» (обратная совместимость).
velocityDataAvailable?: boolean | null;
medianPricePerM2?: number | null; medianPricePerM2?: number | null;
pipeline24mo?: number | null; // total flats in 24-month pipeline pipeline24mo?: number | null; // total flats in 24-month pipeline
confidenceLabel?: "high" | "medium" | "low" | null; confidenceLabel?: "high" | "medium" | "low" | null;
@ -117,15 +121,16 @@ const METRIC_ROWS: MetricRow[] = [
key: "velocity", key: "velocity",
label: "Скорость продаж", label: "Скорость продаж",
winner: "high", winner: "high",
value: (c) => c.velocityScore ?? null, // velocity_score=0 при velocity_data_available=false — это «нет данных», а не
// реальный 0, поэтому такая колонка не должна участвовать в сравнении/подсветке.
value: (c) =>
c.velocityDataAvailable === false ? null : c.velocityScore ?? null,
render: (c) => render: (c) =>
c.velocityScore != null && Number.isFinite(c.velocityScore) ? ( c.velocityDataAvailable !== false &&
c.velocityScore != null &&
Number.isFinite(c.velocityScore) ? (
<span style={{ fontVariantNumeric: "tabular-nums" }}> <span style={{ fontVariantNumeric: "tabular-nums" }}>
{Math.round(c.velocityScore * 100)} {Math.round(c.velocityScore * 100)}%
<span style={{ color: "var(--fg-tertiary)", fontSize: 12 }}>
{" "}
/ 100
</span>
</span> </span>
) : ( ) : (
DASH DASH

View file

@ -53,6 +53,10 @@ export function ParcelCompareLoader({ cad, onResult }: Props) {
score: a.score ?? null, score: a.score ?? null,
scoreLabel: a.score_label ?? null, scoreLabel: a.score_label ?? null,
velocityScore: a.velocity?.velocity_score ?? null, velocityScore: a.velocity?.velocity_score ?? null,
// SF#17 / issue #1422: explicit false (конкуренты есть, данных Objective
// нет → velocity_score=0 — sentinel) гасит ячейку и исключает колонку из
// подсветки «лучшее». undefined/null трактуется как «данные есть».
velocityDataAvailable: a.velocity?.velocity_data_available ?? null,
medianPricePerM2: a.district?.median_price_per_m2 ?? null, medianPricePerM2: a.district?.median_price_per_m2 ?? null,
pipeline24mo: a.pipeline_24mo?.flats_total ?? null, pipeline24mo: a.pipeline_24mo?.flats_total ?? null,
confidenceLabel: a.confidence_label ?? null, confidenceLabel: a.confidence_label ?? null,

View file

@ -106,7 +106,7 @@ function ParcelMarkers({
<div style={{ fontSize: 12 }}> <div style={{ fontSize: 12 }}>
<div style={{ fontWeight: 600 }}>{parcel.cad_num}</div> <div style={{ fontWeight: 600 }}>{parcel.cad_num}</div>
<div style={{ color: "var(--fg-secondary)" }}> <div style={{ color: "var(--fg-secondary)" }}>
{parcel.area_ha.toFixed(2)} га · {statusLabel} {parcel.area_ha != null ? parcel.area_ha.toFixed(2) : "—"} га · {statusLabel}
</div> </div>
<div style={{ color: "var(--fg-tertiary)", marginTop: 2 }}> <div style={{ color: "var(--fg-tertiary)", marginTop: 2 }}>
Нажмите, чтобы открыть анализ Нажмите, чтобы открыть анализ
@ -129,7 +129,7 @@ function ParcelMarkers({
<div <div
style={{ color: "var(--fg-secondary)", marginBottom: 8 }} style={{ color: "var(--fg-secondary)", marginBottom: 8 }}
> >
{parcel.area_ha.toFixed(2)} га · {statusLabel} {parcel.area_ha != null ? parcel.area_ha.toFixed(2) : "—"} га · {statusLabel}
</div> </div>
<button <button
type="button" type="button"
@ -244,10 +244,15 @@ export function EntryMap({
)} )}
</MapContainer> </MapContainer>
{/* Deselect on map click — overlay to capture clicks outside markers */} {/* Deselect on map click overlay to capture clicks outside markers.
zIndex must stay below ParcelDrawer (z20, sibling in .gd-map-shell):
neither this root nor .gd-map-shell creates a stacking context, so a
higher zIndex here would paint over the drawer and swallow its clicks
(X / "Открыть анализ" / ПКК link). z10 keeps the overlay above the
Leaflet map content but under the drawer card. */}
{selectedCad && ( {selectedCad && (
<div <div
style={{ position: "absolute", inset: 0, zIndex: 399 }} style={{ position: "absolute", inset: 0, zIndex: 10 }}
onClick={onParcelDeselect} onClick={onParcelDeselect}
aria-hidden="true" aria-hidden="true"
/> />

View file

@ -18,12 +18,16 @@ interface ChipProps {
label: string; label: string;
selected: boolean; selected: boolean;
onClick: () => void; onClick: () => void;
disabled?: boolean;
title?: string;
} }
function Chip({ label, selected, onClick }: ChipProps) { function Chip({ label, selected, onClick, disabled, title }: ChipProps) {
return ( return (
<button <button
onClick={onClick} onClick={onClick}
disabled={disabled}
title={title}
style={{ style={{
padding: "4px 12px", padding: "4px 12px",
borderRadius: 999, borderRadius: 999,
@ -34,7 +38,8 @@ function Chip({ label, selected, onClick }: ChipProps) {
color: selected ? "var(--accent)" : "var(--fg-secondary)", color: selected ? "var(--accent)" : "var(--fg-secondary)",
fontSize: 12, fontSize: 12,
fontWeight: selected ? 500 : 400, fontWeight: selected ? 500 : 400,
cursor: "pointer", cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.45 : 1,
whiteSpace: "nowrap", whiteSpace: "nowrap",
transition: "background 0.12s, color 0.12s, border-color 0.12s", transition: "background 0.12s, color 0.12s, border-color 0.12s",
}} }}
@ -44,6 +49,15 @@ function Chip({ label, selected, onClick }: ChipProps) {
); );
} }
// B1 by-bbox payload doesn't carry district/vri yet, and the endpoint ignores
// those query params — every parcel is adapted with placeholders
// (district:"—" / vri:"other"), so filtering on them either wipes the map or
// silently does nothing (#1475). Keep the controls visible but disabled until
// enrichment lands, so the UI doesn't pretend 2 of 3 filter axes work.
const DISTRICT_VRI_FILTERS_AVAILABLE = false;
const DISTRICT_VRI_DISABLED_HINT =
"Фильтр по району / ВРИ появится после обогащения данных участков";
const DISTRICTS = [ const DISTRICTS = [
"Ленинский", "Ленинский",
"Верх-Исетский", "Верх-Исетский",
@ -89,6 +103,7 @@ export function MapFilterBar({
} }
function toggleVri(v: ParcelVri) { function toggleVri(v: ParcelVri) {
if (!DISTRICT_VRI_FILTERS_AVAILABLE) return;
onChange({ ...filters, vri: filters.vri === v ? undefined : v }); onChange({ ...filters, vri: filters.vri === v ? undefined : v });
} }
@ -107,8 +122,8 @@ export function MapFilterBar({
const hasFilters = const hasFilters =
filters.status != null || filters.status != null ||
filters.district != null || (DISTRICT_VRI_FILTERS_AVAILABLE && filters.district != null) ||
filters.vri != null || (DISTRICT_VRI_FILTERS_AVAILABLE && filters.vri != null) ||
filters.min_area != null || filters.min_area != null ||
filters.max_area != null; filters.max_area != null;
@ -187,13 +202,19 @@ export function MapFilterBar({
}} }}
/> />
{/* VRI chips */} {/* VRI chips — disabled until B1 enrichment returns vri (#1475) */}
{VRI_OPTIONS.map((opt) => ( {VRI_OPTIONS.map((opt) => (
<Chip <Chip
key={opt.value} key={opt.value}
label={opt.label} label={opt.label}
selected={filters.vri === opt.value} selected={
DISTRICT_VRI_FILTERS_AVAILABLE && filters.vri === opt.value
}
onClick={() => toggleVri(opt.value)} onClick={() => toggleVri(opt.value)}
disabled={!DISTRICT_VRI_FILTERS_AVAILABLE}
title={
DISTRICT_VRI_FILTERS_AVAILABLE ? undefined : DISTRICT_VRI_DISABLED_HINT
}
/> />
))} ))}
@ -207,9 +228,15 @@ export function MapFilterBar({
}} }}
/> />
{/* District selector */} {/* District selector — disabled until B1 enrichment returns district (#1475) */}
<select <select
value={filters.district ?? ""} value={DISTRICT_VRI_FILTERS_AVAILABLE ? (filters.district ?? "") : ""}
disabled={!DISTRICT_VRI_FILTERS_AVAILABLE}
title={
DISTRICT_VRI_FILTERS_AVAILABLE
? undefined
: DISTRICT_VRI_DISABLED_HINT
}
onChange={(e: ChangeEvent<HTMLSelectElement>) => onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onChange({ ...filters, district: e.target.value || undefined }) onChange({ ...filters, district: e.target.value || undefined })
} }
@ -217,14 +244,20 @@ export function MapFilterBar({
fontSize: 12, fontSize: 12,
padding: "4px 8px", padding: "4px 8px",
borderRadius: 999, borderRadius: 999,
border: filters.district border:
? "1px solid var(--accent)" DISTRICT_VRI_FILTERS_AVAILABLE && filters.district
: "1px solid var(--border-card)", ? "1px solid var(--accent)"
background: filters.district : "1px solid var(--border-card)",
? "var(--accent-soft)" background:
: "var(--bg-card)", DISTRICT_VRI_FILTERS_AVAILABLE && filters.district
color: filters.district ? "var(--accent)" : "var(--fg-secondary)", ? "var(--accent-soft)"
cursor: "pointer", : "var(--bg-card)",
color:
DISTRICT_VRI_FILTERS_AVAILABLE && filters.district
? "var(--accent)"
: "var(--fg-secondary)",
cursor: DISTRICT_VRI_FILTERS_AVAILABLE ? "pointer" : "not-allowed",
opacity: DISTRICT_VRI_FILTERS_AVAILABLE ? 1 : 0.45,
}} }}
> >
<option value="">Все районы</option> <option value="">Все районы</option>

View file

@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { X, MapPin, Maximize2, ArrowRight } from "lucide-react"; import { X, MapPin, Maximize2, ArrowRight } from "lucide-react";
import type { ParcelBboxItem } from "@/lib/site-finder-api"; import type { ParcelBboxItem } from "@/lib/site-finder-api";
import { addLocalRecentParcel } from "@/lib/site-finder-api";
import { STATUS_COLORS, STATUS_LABELS } from "./ParcelLegend"; import { STATUS_COLORS, STATUS_LABELS } from "./ParcelLegend";
interface ParcelDrawerProps { interface ParcelDrawerProps {
@ -195,7 +196,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
fontVariantNumeric: "tabular-nums", fontVariantNumeric: "tabular-nums",
}} }}
> >
{parcel.area_ha.toFixed(2)}{" "} {parcel.area_ha != null ? parcel.area_ha.toFixed(2) : "—"}{" "}
<span <span
style={{ style={{
fontSize: 13, fontSize: 13,
@ -321,6 +322,16 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
> >
<Link <Link
href={`/site-finder/analysis/${encodeURIComponent(parcel.cad_num)}`} href={`/site-finder/analysis/${encodeURIComponent(parcel.cad_num)}`}
onClick={() => {
// Record the visit so this entry path also lands in RecentParcels (#1477).
addLocalRecentParcel({
cad_num: parcel.cad_num,
address: parcel.address,
area_ha: parcel.area_ha,
district: parcel.district,
visited_at: new Date().toISOString(),
});
}}
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",

View file

@ -1,14 +1,26 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Clock, MapPin } from "lucide-react"; import { Clock, MapPin } from "lucide-react";
import { useRecentParcels } from "@/lib/site-finder-api"; import { useRecentParcels } from "@/lib/site-finder-api";
const MAX_SHOWN = 5; const MAX_SHOWN = 5;
export function RecentParcels() { export function RecentParcels() {
const queryClient = useQueryClient();
const { data: parcels, isLoading } = useRecentParcels(); const { data: parcels, isLoading } = useRecentParcels();
// The visit is written to localStorage in page.handleParcelOpen, but the
// QueryClient survives client-side navigation and useRecentParcels has a 60s
// staleTime, so returning to /site-finder otherwise shows a stale snapshot
// without the just-opened parcel. Invalidate on mount to refetch the latest
// localStorage state (the component re-mounts on each return to the page).
useEffect(() => {
void queryClient.invalidateQueries({ queryKey: ["recent-parcels"] });
}, [queryClient]);
if (isLoading) { if (isLoading) {
return ( return (
<div <div

View file

@ -64,11 +64,12 @@ export function AnalogsTable({
} }
const sorted = [...rows].sort((a, b) => { const sorted = [...rows].sort((a, b) => {
const av = a[sortKey] ?? 0; const av = a[sortKey];
const bv = b[sortKey] ?? 0; const bv = b[sortKey];
return sortAsc // null ('неизвестно') всегда в конце, независимо от направления
? (av as number) - (bv as number) if (av === null) return bv === null ? 0 : 1;
: (bv as number) - (av as number); if (bv === null) return -1;
return sortAsc ? av - bv : bv - av;
}); });
function handleSort(key: SortKey) { function handleSort(key: SortKey) {

View file

@ -99,6 +99,12 @@ interface Props {
export function EstimateResult({ estimate, input }: Props) { export function EstimateResult({ estimate, input }: Props) {
const conf = CONFIDENCE_COLOR[estimate.confidence]; const conf = CONFIDENCE_COLOR[estimate.confidence];
// When restoring an estimate from a shared link (GET /estimate/{id}) the
// original object parameters are unknown — page.tsx passes an empty input
// (area_m2 = 0, total_floors = 0). Don't render the "Параметры объекта" block
// with placeholder garbage ("0 м²", "Студия", "Этаж 0 из 0") in that case.
const hasInputParams = Boolean(input.area_m2) || input.total_floors > 0;
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}> <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* Hero card */} {/* Hero card */}
@ -177,7 +183,7 @@ export function EstimateResult({ estimate, input }: Props) {
{conf.label} {conf.label}
</span> </span>
<span style={{ fontSize: 11, color: conf.fg }}> <span style={{ fontSize: 11, color: conf.fg }}>
{estimate.n_analogs} аналог{ending(estimate.n_analogs)} {estimate.n_analogs} объект{ending(estimate.n_analogs)} в выборке
</span> </span>
</div> </div>
</div> </div>
@ -202,98 +208,101 @@ export function EstimateResult({ estimate, input }: Props) {
</div> </div>
</Card> </Card>
{/* Section 1 — Cover / input snapshot */} {/* Section 1 — Cover / input snapshot.
<Card> Hidden when restoring from a shared link (params unknown). */}
<SectionHeader {hasInputParams && (
icon={ <Card>
<svg <SectionHeader
width="16" icon={
height="16" <svg
viewBox="0 0 24 24" width="16"
fill="none" height="16"
stroke="currentColor" viewBox="0 0 24 24"
strokeWidth="2" fill="none"
strokeLinecap="round" stroke="currentColor"
strokeLinejoin="round" strokeWidth="2"
aria-hidden="true" strokeLinecap="round"
> strokeLinejoin="round"
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17" /> aria-hidden="true"
<polyline points="16 7 22 7 22 13" />
</svg>
}
title="Параметры объекта"
/>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
gap: 10,
}}
>
{[
{ label: "Адрес", value: input.address },
{ label: "Площадь", value: `${input.area_m2} м²` },
{
label: "Комнат",
value: input.rooms === 0 ? "Студия" : String(input.rooms),
},
{
label: "Этаж",
value: `${input.floor} из ${input.total_floors}`,
},
...(input.year_built
? [{ label: "Год постройки", value: String(input.year_built) }]
: []),
...(input.house_type
? [
{
label: "Тип дома",
value:
HOUSE_TYPE_LABELS[input.house_type] ?? input.house_type,
},
]
: []),
...(input.repair_state
? [
{
label: "Ремонт",
value:
REPAIR_STATE_LABELS[input.repair_state] ??
input.repair_state,
},
]
: []),
{
label: "Балкон",
value: input.has_balcony ? "Есть" : "Нет",
},
].map(({ label, value }) => (
<div key={label}>
<div
style={{
fontSize: 11,
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: 0.3,
marginBottom: 2,
}}
> >
{label} <polyline points="22 7 13.5 15.5 8.5 10.5 2 17" />
<polyline points="16 7 22 7 22 13" />
</svg>
}
title="Параметры объекта"
/>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
gap: 10,
}}
>
{[
{ label: "Адрес", value: input.address },
{ label: "Площадь", value: `${input.area_m2} м²` },
{
label: "Комнат",
value: input.rooms === 0 ? "Студия" : String(input.rooms),
},
{
label: "Этаж",
value: `${input.floor} из ${input.total_floors}`,
},
...(input.year_built
? [{ label: "Год постройки", value: String(input.year_built) }]
: []),
...(input.house_type
? [
{
label: "Тип дома",
value:
HOUSE_TYPE_LABELS[input.house_type] ?? input.house_type,
},
]
: []),
...(input.repair_state
? [
{
label: "Ремонт",
value:
REPAIR_STATE_LABELS[input.repair_state] ??
input.repair_state,
},
]
: []),
{
label: "Балкон",
value: input.has_balcony ? "Есть" : "Нет",
},
].map(({ label, value }) => (
<div key={label}>
<div
style={{
fontSize: 11,
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: 0.3,
marginBottom: 2,
}}
>
{label}
</div>
<div
style={{
fontSize: 13,
color: "#1a1d23",
fontWeight: 500,
wordBreak: "break-word",
}}
>
{value}
</div>
</div> </div>
<div ))}
style={{ </div>
fontSize: 13, </Card>
color: "#1a1d23", )}
fontWeight: 500,
wordBreak: "break-word",
}}
>
{value}
</div>
</div>
))}
</div>
</Card>
{/* Section 2 — Listings (analogs) */} {/* Section 2 — Listings (analogs) */}
<Card> <Card>

View file

@ -44,7 +44,10 @@ export function Drawer({ open, onClose, side, width, children }: DrawerProps) {
}); });
return () => cancelAnimationFrame(id); return () => cancelAnimationFrame(id);
} else { } else {
if (previousFocusRef.current instanceof HTMLElement) { if (
previousFocusRef.current instanceof HTMLElement &&
document.contains(previousFocusRef.current)
) {
previousFocusRef.current.focus(); previousFocusRef.current.focus();
} }
} }
@ -128,7 +131,7 @@ export function Drawer({ open, onClose, side, width, children }: DrawerProps) {
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
width: "100%", width: resolvedWidth,
maxHeight: "85vh", maxHeight: "85vh",
background: "var(--bg-card)", background: "var(--bg-card)",
boxShadow: "0 -4px 32px rgba(0,0,0,0.14)", boxShadow: "0 -4px 32px rgba(0,0,0,0.14)",
@ -141,32 +144,20 @@ export function Drawer({ open, onClose, side, width, children }: DrawerProps) {
overflowY: "auto", overflowY: "auto",
}; };
if (!open) { // Keep the same DOM tree across open/closed so the panel element stays
// Keep in DOM for transition but aria-hide // mounted and the transform transition can interpolate on open→close.
return (
<div
aria-hidden="true"
style={{
position: "fixed",
inset: 0,
pointerEvents: "none",
zIndex: 199,
}}
>
<div ref={drawerRef} style={panelStyle} />
</div>
);
}
return ( return (
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-hidden={open ? undefined : "true"}
onClick={handleOverlayClick} onClick={handleOverlayClick}
style={{ style={{
position: "fixed", position: "fixed",
inset: 0, inset: 0,
background: "rgba(0,0,0,0.32)", background: open ? "rgba(0,0,0,0.32)" : "transparent",
pointerEvents: open ? "auto" : "none",
transition: "background 180ms ease",
zIndex: 199, zIndex: 199,
display: "flex", display: "flex",
alignItems: side === "bottom" ? "flex-end" : "stretch", alignItems: side === "bottom" ? "flex-end" : "stretch",

View file

@ -24,7 +24,11 @@ function sessionHeaders(): Record<string, string> {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function customPoisKey(parcelCad?: string | null): unknown[] { function customPoisKey(parcelCad?: string | null): unknown[] {
return ["custom-pois", parcelCad ?? null]; // Scope the cache by session: the list is filtered server-side via the
// X-Session-Id header (sessionHeaders()), so a session change must yield a
// distinct key to avoid serving a previous session's POIs (issue #1484).
// getOrCreateSessionId() is SSR-safe (returns "" when window is absent).
return ["custom-pois", getOrCreateSessionId(), parcelCad ?? null];
} }
// Canonical analyze key prefix is ["parcel-analyze", cad, horizon] (see // Canonical analyze key prefix is ["parcel-analyze", cad, horizon] (see

View file

@ -158,12 +158,20 @@ export function useSiteAnalysis() {
{ signal }, { signal },
); );
if (status.status === "ready") { if (status.status === "ready") {
// Re-trigger analyze — should be 200 now. // Re-trigger analyze — should be 200 now. Используем
// apiFetchWithStatus, чтобы НЕ принять 202-stub за готовый
// ParcelAnalysis (#1339): re-POST может вернуть 202 Accepted
// (геометрия ready, но analyze-воркер не закончил —
// задокументированная гонка). 202-stub не имеет score/
// score_breakdown → крашит рендер Секций 3/4. На 202 продолжаем
// polling (symmetry с первым запросом), как в useParcelAnalyzeQuery.
// setFetchingState(null) ПОСЛЕ await чтобы не было flicker: // setFetchingState(null) ПОСЛЕ await чтобы не было flicker:
// если очистить до await, mutation isPending=true но // если очистить до await, mutation isPending=true но
// fetchingState=null → пустой экран ~1 RTT. После await // fetchingState=null → пустой экран ~1 RTT. После await
// mutation сразу резолвится с data — render skipped. // mutation сразу резолвится с data — render skipped.
const second = await apiFetch<ParcelAnalysis>(analyzeUrl(cad), { const second = await apiFetchWithStatus<
ParcelAnalysis | AnalyzeAcceptedResponse
>(analyzeUrl(cad), {
method: "POST", method: "POST",
signal, signal,
...(bodyPayload ...(bodyPayload
@ -173,12 +181,16 @@ export function useSiteAnalysis() {
} }
: {}), : {}),
}); });
// Только если этот вызов всё ещё актуален — иначе зомби-цикл if (second.status === 200) {
// стирает баннер свежей мутации (#1242). // Только если этот вызов всё ещё актуален — иначе зомби-цикл
if (!signal.aborted) { // стирает баннер свежей мутации (#1242).
setFetchingState(null); if (!signal.aborted) {
setFetchingState(null);
}
return second.body as ParcelAnalysis;
} }
return second; // 202 race → fall through and poll /fetch-status again.
continue;
} }
if (status.status === "not_in_nspd") { if (status.status === "not_in_nspd") {
if (!signal.aborted) setFetchingState(null); if (!signal.aborted) setFetchingState(null);
@ -204,7 +216,16 @@ export function useSiteAnalysis() {
status.error_msg ?? "Неверный формат кадастрового номера", status.error_msg ?? "Неверный формат кадастрового номера",
); );
} }
// status === "fetching" → continue polling if (status.status === "fetching") {
// continue polling
continue;
}
// Неизвестный status (#1485): рассинхрон фронт/бэк — опечатка или
// новый/переименованный статус вне закрытого union FetchStatus.
// Бросаем явную ошибку вместо молчаливого polling до 2-мин таймаута
// (что вводило бы в заблуждение «загрузка слишком долгая»).
if (!signal.aborted) setFetchingState(null);
throw new Error(`Неизвестный статус загрузки: ${status.status}`);
} }
// Polling exhausted (2 min) // Polling exhausted (2 min)
if (!signal.aborted) setFetchingState(null); if (!signal.aborted) setFetchingState(null);

View file

@ -2932,6 +2932,11 @@ export interface components {
* @default rosreestr_pending * @default rosreestr_pending
*/ */
source: string; source: string;
/**
* Rate Ms
* @default 600
*/
rate_ms: number;
}; };
/** /**
* ChatAskRequest * ChatAskRequest

View file

@ -100,7 +100,7 @@ function profilesHeaders(adminToken: string): HeadersInit {
/** List all weight profiles for a given user_id. */ /** List all weight profiles for a given user_id. */
export function useWeightProfiles(userId: string, adminToken: string) { export function useWeightProfiles(userId: string, adminToken: string) {
return useQuery<WeightProfile[]>({ return useQuery<WeightProfile[]>({
queryKey: ["weight-profiles", userId, adminToken], queryKey: ["weight-profiles", userId],
queryFn: () => queryFn: () =>
apiFetch<WeightProfile[]>( apiFetch<WeightProfile[]>(
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}`, `${BASE_PATH}?user_id=${encodeURIComponent(userId)}`,
@ -153,7 +153,7 @@ export function useUpdateProfile(
}); });
} }
/** Delete a weight profile by id. Returns true if deleted. */ /** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */
export function useDeleteProfile(userId: string, adminToken: string) { export function useDeleteProfile(userId: string, adminToken: string) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation<void, Error, number>({ return useMutation<void, Error, number>({

View file

@ -16,6 +16,12 @@ export function triggerDownload(blob: Blob, filename: string): void {
a.download = filename; a.download = filename;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); // `a.click()` only queues the download — the browser still needs to read the
URL.revokeObjectURL(url); // blob via the object URL after this handler returns. Revoking synchronously
// frees the blob too early and intermittently cancels the download in Firefox
// and Safari, so defer cleanup to a later tick.
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 250);
} }

View file

@ -51,7 +51,9 @@ export type ParcelVri =
export interface ParcelBboxItem { export interface ParcelBboxItem {
cad_num: string; cad_num: string;
address: string; address: string;
area_ha: number; // #1421: nullable — the B1 by-bbox payload may omit area_m2; null означает
// «н/д», в отличие от реального 0 га. Потребители обязаны гейтить null.
area_ha: number | null;
status: ParcelStatus; status: ParcelStatus;
district: string; district: string;
vri: ParcelVri; vri: ParcelVri;
@ -77,7 +79,8 @@ export interface BboxCoords {
export interface RecentParcel { export interface RecentParcel {
cad_num: string; cad_num: string;
address: string; address: string;
area_ha: number; // #1421: nullable — площадь может быть неизвестна; null → «н/д», не «0 га».
area_ha: number | null;
district: string; district: string;
visited_at: string; // ISO string visited_at: string; // ISO string
} }
@ -121,8 +124,20 @@ function applyFilters(
filters: ParcelBboxFilters, filters: ParcelBboxFilters,
): ParcelBboxItem[] { ): ParcelBboxItem[] {
return parcels.filter((p) => { return parcels.filter((p) => {
if (filters.min_area != null && p.area_ha < filters.min_area) return false; // #1421: area_ha теперь nullable («н/д»). Площадной фильтр применяем только
if (filters.max_area != null && p.area_ha > filters.max_area) return false; // к участкам с известной площадью; null проходит мимо min/max гейтов.
if (
filters.min_area != null &&
p.area_ha != null &&
p.area_ha < filters.min_area
)
return false;
if (
filters.max_area != null &&
p.area_ha != null &&
p.area_ha > filters.max_area
)
return false;
if (filters.status != null && p.status !== filters.status) return false; if (filters.status != null && p.status !== filters.status) return false;
if ( if (
filters.district != null && filters.district != null &&
@ -209,17 +224,33 @@ export function useParcelsBboxQuery(
cad_num: p.cad_num, cad_num: p.cad_num,
lat: p.centroid_lat, lat: p.centroid_lat,
lon: p.centroid_lon, lon: p.centroid_lon,
area_ha: p.area_m2 != null ? p.area_m2 / 10000 : 0, // #1421: keep null when area_m2 is missing — не приземляем в 0, иначе
// карточка показывает «0 га» вместо «н/д».
area_ha: p.area_m2 != null ? p.area_m2 / 10000 : null,
status: p.status ?? "free", status: p.status ?? "free",
district: "—", district: "—",
vri: "other", vri: "other",
address: "", address: "",
})); }));
// Apply filters client-side until backend supports them (B1 follow-up) // Apply filters client-side until backend supports them (B1 follow-up).
return applyFilters(adapted, filters); // district/vri are NOT in the B1 by-bbox payload — every adapted parcel
// carries the placeholders district:"—" / vri:"other". Filtering on those
// client-side would wipe the whole map the moment the user picks any real
// district or VRI (none of the UI values equal "—"/"other"), so omit those
// two filter keys here. Площадь/статус — реальные данные, фильтруем их.
const areaStatusFilters: ParcelBboxFilters = {
min_area: filters.min_area,
max_area: filters.max_area,
status: filters.status,
};
return applyFilters(adapted, areaStatusFilters);
}, },
enabled: MOCK_PARCELS_BBOX || bbox != null, enabled: MOCK_PARCELS_BBOX || bbox != null,
staleTime: 30_000, staleTime: 30_000,
// #1419: bbox is part of the queryKey, so every pan/zoom is a fresh cache
// entry and `data` would blank to `undefined` mid-refetch → KPI flickers to
// «0 / 0». Hold the previous parcels on screen while the new bbox refetches.
placeholderData: keepPreviousData,
}); });
} }

View file

@ -2,11 +2,12 @@
""" """
Claude Code PostToolUse hook blocks new `print(` calls in backend/**/*.py. Claude Code PostToolUse hook blocks new `print(` calls in backend/**/*.py.
Wired in `.claude/settings.json` hooks.PostToolUse with matcher "Edit|Write". Wired in `.claude/settings.json` hooks.PostToolUse with matcher
"Edit|Write|MultiEdit".
Behavior: Behavior:
- Reads hook input as JSON from stdin (per Anthropic spec). - Reads hook input as JSON from stdin (per Anthropic spec).
- If tool is Edit/Write and file_path matches backend/**/*.py: - If tool is Edit/Write/MultiEdit and file_path matches backend/**/*.py:
- Scans new content for new `print(` calls. - Scans new content for new `print(` calls.
- Exits with code 2 to block (stderr is forwarded to Claude as feedback). - Exits with code 2 to block (stderr is forwarded to Claude as feedback).
- Otherwise exits 0 (no-op). - Otherwise exits 0 (no-op).
@ -29,15 +30,23 @@ ALLOW_PATH_PREFIXES = (
"backend/scripts/", "backend/scripts/",
) )
WATCH_PATH_PREFIX = "backend/" WATCH_PATH_PREFIX = "backend/"
PRINT_RE = re.compile(r"(?<!\w)print\s*\(") # Lookbehind `(?<![\w.])` rejects both word-char prefixes (e.g. `endprint(`)
# and method calls (e.g. `self.print(`, `logger.print(`), which are not the
# builtin `print`.
PRINT_RE = re.compile(r"(?<![\w.])print\s*\(")
def normalize_path(raw: str) -> str: def normalize_path(raw: str) -> str:
"""Convert absolute/relative path to repo-relative POSIX-like form.""" """Convert absolute/relative path to repo-relative POSIX-like form."""
p = Path(raw).as_posix() parts = Path(raw).parts
# Try to strip everything up to the last "backend/" occurrence. # Match the repo-root segment "backend" exactly (not a substring like
idx = p.rfind("backend/") # "services-backend"), and anchor on the FIRST such segment so a nested
return p[idx:] if idx >= 0 else p # "backend" dir does not drop the leading path components.
try:
idx = parts.index("backend")
except ValueError:
return Path(raw).as_posix()
return Path(*parts[idx:]).as_posix()
def main() -> int: def main() -> int:
@ -48,7 +57,7 @@ def main() -> int:
return 0 return 0
tool_name = payload.get("tool_name") or payload.get("tool") or "" tool_name = payload.get("tool_name") or payload.get("tool") or ""
if tool_name not in {"Edit", "Write"}: if tool_name not in {"Edit", "Write", "MultiEdit"}:
return 0 return 0
tool_input = payload.get("tool_input") or {} tool_input = payload.get("tool_input") or {}
@ -64,8 +73,17 @@ def main() -> int:
if not norm.endswith(".py"): if not norm.endswith(".py"):
return 0 return 0
# Pull the new content. Edit gives `new_string`, Write gives `content`. # Pull the new content. Edit gives `new_string`, Write gives `content`,
new_text = tool_input.get("new_string") or tool_input.get("content") or "" # MultiEdit gives an `edits` array of `{old_string, new_string}` entries.
edits = tool_input.get("edits")
if isinstance(edits, list):
new_text = "\n".join(
str(e.get("new_string") or "")
for e in edits
if isinstance(e, dict)
)
else:
new_text = tool_input.get("new_string") or tool_input.get("content") or ""
if not new_text: if not new_text:
return 0 return 0
@ -73,6 +91,10 @@ def main() -> int:
for lineno, line in enumerate(new_text.splitlines(), start=1): for lineno, line in enumerate(new_text.splitlines(), start=1):
if "# noqa" in line: if "# noqa" in line:
continue continue
# Skip whole-line comments (e.g. `# print(debug)`); regex on raw
# lines cannot see the surrounding syntax otherwise.
if line.lstrip().startswith("#"):
continue
if PRINT_RE.search(line): if PRINT_RE.search(line):
offenders.append((lineno, line.strip())) offenders.append((lineno, line.strip()))

View file

@ -75,7 +75,7 @@ def fetch_all_prs() -> dict[str, dict]:
page = 1 page = 1
api = f"{FORGEJO_URL}/api/v1/repos/{OWNER}/{REPO_NAME}/pulls" api = f"{FORGEJO_URL}/api/v1/repos/{OWNER}/{REPO_NAME}/pulls"
while page <= 30: while True:
url = f"{api}?state=all&limit=50&page={page}&sort=newest" url = f"{api}?state=all&limit=50&page={page}&sort=newest"
req = urllib.request.Request( req = urllib.request.Request(
url, headers={"Authorization": f"token {TOKEN}"} url, headers={"Authorization": f"token {TOKEN}"}

View file

@ -24,10 +24,33 @@ def main() -> None:
local.add(rel) local.add(rel)
print(f"Local files in target dirs: {len(local)}") print(f"Local files in target dirs: {len(local)}")
# 2. Fetch CouchDB docs # 2. Fetch ALL CouchDB docs (paginated — _all_docs усекается limit'ом,
r = requests.get(f"{BASE}/_all_docs", auth=AUTH, # а неполный alive-набор приведёт к удалению живых chunks как orphan).
params={"limit": 10000}, timeout=60) # Идём постранично через startkey_docid: last id страницы становится
rows = r.json()["rows"] # startkey следующей, дублирующая первая строка отбрасывается. Цикл
# завершается, когда страница вернула меньше page_size строк.
page_size = 10000
rows: list[dict] = []
startkey_docid: str | None = None
while True:
params: dict[str, object] = {"limit": page_size}
if startkey_docid is not None:
# startkey должен быть JSON-строкой
params["startkey"] = f'"{startkey_docid}"'
params["startkey_docid"] = startkey_docid
r = requests.get(f"{BASE}/_all_docs", auth=AUTH,
params=params, timeout=60)
page = r.json()["rows"]
if startkey_docid is not None and page and page[0]["id"] == startkey_docid:
# первая строка дублирует startkey предыдущей страницы — отбрасываем
page = page[1:]
rows.extend(page)
# Полная страница (page_size до отбрасывания дубля) => возможно есть ещё.
# Если строк меньше — это последняя страница.
if len(page) + (1 if startkey_docid is not None else 0) < page_size:
break
startkey_docid = rows[-1]["id"]
print(f"CouchDB docs fetched (paginated): {len(rows)}")
# 3. Identify ghosts # 3. Identify ghosts
ghosts: list[tuple[str, str]] = [] ghosts: list[tuple[str, str]] = []

View file

@ -84,13 +84,17 @@ _MONTH_MAP = {
} }
_DATE_PATTERNS = [ _DATE_PATTERNS = [
# Apr27_2026, Apr30_2026, Apr29_2026_Late # Apr27_2026, Apr30_2026, Apr29_2026_Late
re.compile(r"\b(?P<m>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?P<d>\d{1,2})_(?P<y>20\d{2})\b", re.IGNORECASE), # Ведущий lookbehind (?<![A-Za-z]) вместо \b: в именах entity месяц следует
# за '_' (Session_End_Apr30_2026), а '_' — это \w, поэтому \b между '_' и
# буквой не возникает. Завершающий (?![\dA-Za-z]) вместо \b — чтобы суффикс
# '_Late' (Apr29_2026_Late) не срывал совпадение.
re.compile(r"(?<![A-Za-z])(?P<m>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?P<d>\d{1,2})_(?P<y>20\d{2})(?![\dA-Za-z])", re.IGNORECASE),
# Apr26 (year implied 2026) # Apr26 (year implied 2026)
re.compile(r"\b(?P<m>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?P<d>\d{1,2})\b(?!_20)", re.IGNORECASE), re.compile(r"(?<![A-Za-z])(?P<m>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?P<d>\d{1,2})(?![\dA-Za-z])(?!_20)", re.IGNORECASE),
# Nov2025 # Nov2025
re.compile(r"\b(?P<m>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?P<y>20\d{2})\b", re.IGNORECASE), re.compile(r"(?<![A-Za-z])(?P<m>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?P<y>20\d{2})(?![\dA-Za-z])", re.IGNORECASE),
# Q1_2026, Q2_2026 — quarter→month # Q1_2026, Q2_2026 — quarter→month
re.compile(r"\bQ(?P<q>[1-4])_(?P<y>20\d{2})\b"), re.compile(r"(?<![A-Za-z])Q(?P<q>[1-4])_(?P<y>20\d{2})(?![\dA-Za-z])"),
] ]

View file

@ -7,6 +7,42 @@ OUT.mkdir(exist_ok=True)
PARCEL_ID = "parcel:66:41:0204016:10" PARCEL_ID = "parcel:66:41:0204016:10"
# Source of truth for scoring weights (mirrors 03_score.py WEIGHTS / scoring_weights table).
# economic=0.30 is the heaviest component — must be reported, not hidden.
DEFAULT_WEIGHTS = {
"education": 0.20,
"health": 0.10,
"retail": 0.15,
"transit": 0.15,
"leisure": 0.10,
"economic": 0.30,
}
WEIGHT_LABELS = {
"education": "образование",
"health": "здоровье",
"retail": "ритейл",
"transit": "транспорт",
"leisure": "досуг",
"economic": "экономика",
"market": "рынок",
}
def load_weights(conn):
"""Single source of truth for weights used in both JSON and HTML.
Prefer the scoring_weights table (written by 10_score_v2.py); fall back to
DEFAULT_WEIGHTS (== 03_score.py WEIGHTS). Includes economic (and market for v2).
"""
try:
rows = conn.execute("SELECT component, weight FROM scoring_weights").fetchall()
except sqlite3.OperationalError:
rows = None
if rows:
return {c: float(w) for c, w in rows}
return dict(DEFAULT_WEIGHTS)
def fetch_economics(conn, site_id): def fetch_economics(conn, site_id):
row = conn.execute("""SELECT sd.district, sd.method, sd.nearest_jk_dist_m, row = conn.execute("""SELECT sd.district, sd.method, sd.nearest_jk_dist_m,
de.n_projects, de.weighted_price_m2, de.median_price_m2, de.n_projects, de.weighted_price_m2, de.median_price_m2,
@ -65,6 +101,7 @@ def main():
conn = sqlite3.connect(DB) conn = sqlite3.connect(DB)
parcel = fetch_site_full(conn, PARCEL_ID) parcel = fetch_site_full(conn, PARCEL_ID)
n_total = conn.execute("SELECT count(*) FROM sites").fetchone()[0] n_total = conn.execute("SELECT count(*) FROM sites").fetchone()[0]
weights = load_weights(conn)
# Top-10 best-scoring ЖК + parcel position context # Top-10 best-scoring ЖК + parcel position context
rows = conn.execute("""SELECT s.site_id, s.name, s.district, s.developer, s.obj_class, rows = conn.execute("""SELECT s.site_id, s.name, s.district, s.developer, s.obj_class,
@ -126,7 +163,7 @@ def main():
"n_compared_jk": stats["n_jk"], "n_compared_jk": stats["n_jk"],
"weighted_score_distribution": stats, "weighted_score_distribution": stats,
"component_comparison": comp_stats, "component_comparison": comp_stats,
"weights_used": {"education":0.30,"health":0.15,"retail":0.20,"transit":0.20,"leisure":0.15}, "weights_used": weights,
"top10_best_jk_ekb": top10, "top10_best_jk_ekb": top10,
"10_closest_jk_to_parcel": closest, "10_closest_jk_to_parcel": closest,
} }
@ -169,6 +206,11 @@ def econ_block(e):
def build_html(d): def build_html(d):
p = d["parcel"] p = d["parcel"]
cs = d["component_comparison"] cs = d["component_comparison"]
# Single source of weights for the caption — identical to JSON weights_used.
weights_caption = " · ".join(
f"{WEIGHT_LABELS.get(c, c)} {w*100:.0f}%"
for c, w in d.get("weights_used", {}).items()
)
poi_table_rows = [] poi_table_rows = []
for cat, items in p["nearest_pois"].items(): for cat, items in p["nearest_pois"].items():
nearest = items[0] if items else None nearest = items[0] if items else None
@ -183,7 +225,7 @@ def build_html(d):
comp_rows = [] comp_rows = []
for c, v in cs.items(): for c, v in cs.items():
delta = v["parcel"] - v["median_jk"] delta = v["parcel"] - v["median_jk"]
cls = "g" if delta > 0 else ("r" if delta < 0 else "") cls = "g" if delta > 0 else ("neg" if delta < 0 else "")
comp_rows.append( comp_rows.append(
f"<tr><td>{c}</td><td class='r'>{v['parcel']}</td>" f"<tr><td>{c}</td><td class='r'>{v['parcel']}</td>"
f"<td class='r'>{v['median_jk']}</td><td class='r'>{v['p75_jk']}</td>" f"<td class='r'>{v['median_jk']}</td><td class='r'>{v['p75_jk']}</td>"
@ -222,7 +264,7 @@ th,td{{border:1px solid #ddd;padding:6px 10px;text-align:left}}
th{{background:#f5f7fa;font-weight:600}} th{{background:#f5f7fa;font-weight:600}}
.r{{text-align:right}} .r{{text-align:right}}
.g{{color:#0a6;font-weight:600}} .g{{color:#0a6;font-weight:600}}
.r.r{{color:#c33;font-weight:600}} .neg{{color:#c33;font-weight:600}}
.muted{{color:#888;font-size:12px}} .muted{{color:#888;font-size:12px}}
.note{{background:#fffbe6;border-left:4px solid #f0c000;padding:10px 14px;border-radius:4px;margin:12px 0}} .note{{background:#fffbe6;border-left:4px solid #f0c000;padding:10px 14px;border-radius:4px;margin:12px 0}}
</style></head><body> </style></head><body>
@ -245,9 +287,9 @@ th{{background:#f5f7fa;font-weight:600}}
<h2>Компоненты (взвешенные)</h2> <h2>Компоненты (взвешенные)</h2>
<table><thead><tr><th>Компонент</th><th class=r>Участок</th><th class=r>Медиана ЖК</th><th class=r>P75 ЖК</th><th class=r>Δ vs медиана</th></tr></thead> <table><thead><tr><th>Компонент</th><th class=r>Участок</th><th class=r>Медиана ЖК</th><th class=r>P75 ЖК</th><th class=r>Δ vs медиана</th></tr></thead>
<tbody>{"".join(comp_rows)}</tbody></table> <tbody>{"".join(comp_rows)}</tbody></table>
<div class=muted>Веса: образование 20% · здоровье 10% · ритейл 15% · транспорт 15% · досуг 10% · экономика 30%</div> <div class=muted>Веса: {weights_caption}</div>
<h2>Экономика района (Объектив API, последние 90 дней)</h2> <h2>Экономика района (Объектив API; скорость за 12 мес, доли продаж и цены накопительно за всё время)</h2>
{econ_block(p.get("economics"))} {econ_block(p.get("economics"))}
<h2>Ближайшие POI вокруг участка</h2> <h2>Ближайшие POI вокруг участка</h2>

View file

@ -52,7 +52,7 @@ def main():
conn = sqlite3.connect(DB) conn = sqlite3.connect(DB)
sites = conn.execute("SELECT site_id, lat, lon FROM sites").fetchall() sites = conn.execute("SELECT site_id, lat, lon FROM sites").fetchall()
lats=[s[1] for s in sites]; lons=[s[2] for s in sites] lats=[s[1] for s in sites]; lons=[s[2] for s in sites]
b = (min(lats)-0.005, min(lons)-0.005, max(lats)+0.005, max(lons)+0.005) b = (min(lats)-0.05, min(lons)-0.05, max(lats)+0.05, max(lons)+0.05)
for cat, filt in NEW_QUERIES: for cat, filt in NEW_QUERIES:
if cat in cache: if cat in cache:

View file

@ -609,10 +609,10 @@ def jk_full(site_id: str):
obj_id = s.get("obj_id") obj_id = s.get("obj_id")
if obj_id: if obj_id:
try: try:
import psycopg2 import psycopg
pg = psycopg2.connect(host="127.0.0.1", port=15432, user="gendesign", pg = psycopg.connect(host="127.0.0.1", port=15432, user="gendesign",
password="2J2SBPMKuS998fiwhtQqDhMI", password="2J2SBPMKuS998fiwhtQqDhMI",
dbname="gendesign", connect_timeout=2) dbname="gendesign", connect_timeout=2)
pcur = pg.cursor() pcur = pg.cursor()
pcur.execute("""SELECT photo_url, photo_dttm, period_dt, photo_name, ready_desc, thumb_path, hidden pcur.execute("""SELECT photo_url, photo_dttm, period_dt, photo_name, ready_desc, thumb_path, hidden
FROM domrf_kn_photos FROM domrf_kn_photos
@ -844,10 +844,13 @@ def developer_track_record(developer_name: str):
avg_score = sum(o["weighted"] or 0 for o in ojects if o["weighted"]) / max(sum(1 for o in ojects if o["weighted"]), 1) avg_score = sum(o["weighted"] or 0 for o in ojects if o["weighted"]) / max(sum(1 for o in ojects if o["weighted"]), 1)
total_flats = sum(o["flat_count"] or 0 for o in ojects) total_flats = sum(o["flat_count"] or 0 for o in ojects)
matched = [o for o in ojects if o["project"] and proj_data.get(o["project"])] matched = [o for o in ojects if o["project"] and proj_data.get(o["project"])]
total_lots = sum(proj_data[o["project"]]["total"] for o in matched) # Dedup by project: multiple sites can match the same Objective project,
sold_lots = sum(proj_data[o["project"]]["sold_n"] for o in matched) # so sum lot stats over unique projects to avoid double-counting.
avg_price = (sum(proj_data[o["project"]]["avg_price"] or 0 for o in matched) / len(matched)) if matched else None matched_projects = sorted({o["project"] for o in matched})
total_vel = sum(proj_data[o["project"]]["vel_6mo"] or 0 for o in matched) total_lots = sum(proj_data[p]["total"] for p in matched_projects)
sold_lots = sum(proj_data[p]["sold_n"] for p in matched_projects)
avg_price = (sum(proj_data[p]["avg_price"] or 0 for p in matched_projects) / len(matched_projects)) if matched_projects else None
total_vel = sum(proj_data[p]["vel_6mo"] or 0 for p in matched_projects)
portfolio = [] portfolio = []
for o in ojects: for o in ojects:
@ -994,7 +997,13 @@ def price_distribution(district: str):
vals.sort() vals.sort()
if not vals: continue if not vals: continue
n = len(vals) n = len(vals)
def q(p): return vals[max(0, min(n-1, int(p*n)))] def q(p):
# Linear-interpolated quantile at position p*(n-1) (avoids upward bias on small n).
pos = p * (n - 1)
lo = int(pos)
hi = min(lo + 1, n - 1)
frac = pos - lo
return vals[lo] + (vals[hi] - vals[lo]) * frac
out[k] = {"n": n, "min": vals[0], "p25": q(0.25), "p50": q(0.5), out[k] = {"n": n, "min": vals[0], "p25": q(0.25), "p50": q(0.5),
"p75": q(0.75), "max": vals[-1]} "p75": q(0.75), "max": vals[-1]}
return {"district": district, "by_class": out} return {"district": district, "by_class": out}
@ -1013,20 +1022,27 @@ def district_time_machine(district: str):
if m == 0: m = 12; y -= 1 if m == 0: m = 12; y -= 1
months.reverse() months.reverse()
series = [] series = []
import statistics
with _conn() as c: with _conn() as c:
for mo in months: for mo in months:
row = c.execute(""" row = c.execute("""
SELECT COUNT(*) AS deals, SELECT COUNT(*) AS deals,
AVG(CASE WHEN price_per_m2>0 THEN price_per_m2/1000.0 END) AS price,
SUM(CASE WHEN area_pd>0 THEN area_pd END) AS volume_m2, SUM(CASE WHEN area_pd>0 THEN area_pd END) AS volume_m2,
COUNT(DISTINCT project||'·'||corpus) AS corpuses COUNT(DISTINCT project||'·'||corpus) AS corpuses
FROM objective_lots FROM objective_lots
WHERE district=? AND substr(register_date,1,7)=? WHERE district=? AND substr(register_date,1,7)=?
""", (district, mo)).fetchone() """, (district, mo)).fetchone()
# True median price (SQLite has no MEDIAN aggregate) — compute in Python.
prices = [r["p"] for r in c.execute("""
SELECT price_per_m2/1000.0 AS p
FROM objective_lots
WHERE district=? AND substr(register_date,1,7)=? AND price_per_m2>0
""", (district, mo)).fetchall()]
median_price = statistics.median(prices) if prices else None
series.append({ series.append({
"month": mo, "month": mo,
"deals": row["deals"] or 0, "deals": row["deals"] or 0,
"median_price_kr": round(row["price"], 1) if row["price"] else None, "median_price_kr": round(median_price, 1) if median_price is not None else None,
"volume_m2": round(row["volume_m2"], 1) if row["volume_m2"] else 0, "volume_m2": round(row["volume_m2"], 1) if row["volume_m2"] else 0,
"active_corpuses": row["corpuses"] or 0, "active_corpuses": row["corpuses"] or 0,
}) })

View file

@ -411,6 +411,8 @@ async def test_cian_auth(
state = await cian_session_svc.verify_session(cookies) state = await cian_session_svc.verify_session(cookies)
if state is None: if state is None:
return {"authenticated": False, "userId": None, "reason": "session_expired_or_invalid"} return {"authenticated": False, "userId": None, "reason": "session_expired_or_invalid"}
if state.get("_ban"):
return {"authenticated": False, "userId": None, "reason": "banned_403"}
user_id = state.get("user", {}).get("userId") user_id = state.get("user", {}).get("userId")
return {"authenticated": True, "userId": user_id, "reason": None} return {"authenticated": True, "userId": user_id, "reason": None}

View file

@ -21,6 +21,18 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _map_row(row: object) -> dict[str, object]:
"""Маппит строку matview в поля SearchResultItem.
SQL отдаёт колонку `cadastral_number`, а в схеме поле зовётся
`kadastr_num` без явного маппинга значение молча теряется (#1514).
"""
data = dict(row)
if "cadastral_number" in data:
data["kadastr_num"] = data.pop("cadastral_number")
return data
@router.post("/search", response_model=SearchResponse) @router.post("/search", response_model=SearchResponse)
async def search( async def search(
params: SearchParams, params: SearchParams,
@ -37,7 +49,9 @@ async def search(
"search cache HIT key=%s page=%d size=%d", "search cache HIT key=%s page=%d size=%d",
cache_key[:24], params.page, params.page_size, cache_key[:24], params.page, params.page_size,
) )
return SearchResponse.model_validate(cached) resp = SearchResponse.model_validate(cached)
resp.cache_hit = True
return resp
sql, args = build_search_query(params) sql, args = build_search_query(params)
rows = db.execute(text(sql), args).mappings().all() rows = db.execute(text(sql), args).mappings().all()
@ -45,7 +59,7 @@ async def search(
count_sql, count_args = build_count_query(params) count_sql, count_args = build_count_query(params)
total = int(db.execute(text(count_sql), count_args).scalar() or 0) total = int(db.execute(text(count_sql), count_args).scalar() or 0)
items = [SearchResultItem.model_validate(dict(r)) for r in rows] items = [SearchResultItem.model_validate(_map_row(r)) for r in rows]
elapsed_ms = (time.monotonic() - started) * 1000.0 elapsed_ms = (time.monotonic() - started) * 1000.0
response = SearchResponse( response = SearchResponse(
items=items, items=items,

View file

@ -5,6 +5,7 @@
from __future__ import annotations from __future__ import annotations
import calendar
import logging import logging
from datetime import UTC, date, datetime, timedelta from datetime import UTC, date, datetime, timedelta
from typing import Annotated, Any from typing import Annotated, Any
@ -396,7 +397,10 @@ def estimate_pdf(
confidence=row.confidence, confidence=row.confidence,
confidence_explanation=row.confidence_explanation, confidence_explanation=row.confidence_explanation,
n_analogs=row.n_analogs, n_analogs=row.n_analogs,
period_months=24, # #1351: окно сделок — 12 мес (estimator.DEALS_PERIOD_MONTHS), как в POST
# /estimate и GET /estimate/{id}. Раньше PDF-ветка хардкодила 24 →
# экспортёр рисовал ложный ~2-летний диапазон в клиентском документе.
period_months=12,
analogs=analogs, analogs=analogs,
actual_deals=actual_deals, actual_deals=actual_deals,
expires_at=row.expires_at, expires_at=row.expires_at,
@ -1445,7 +1449,15 @@ def get_street_deals(
from app.services.estimator import _deal_to_analog, _percentile, extract_street_name from app.services.estimator import _deal_to_analog, _percentile, extract_street_name
now = datetime.now(tz=UTC) now = datetime.now(tz=UTC)
period_from: date = (now - timedelta(days=period_months * 30)).date() # #1381: отображаемое окно должно совпадать с SQL-фильтром ниже, который
# использует календарный interval PostgreSQL (NOW() - N months), а не
# фиксированные 30-дневные месяцы. Считаем cutoff календарной арифметикой.
_from_month0 = (now.year * 12 + (now.month - 1)) - period_months
_from_year, _from_month_idx = divmod(_from_month0, 12)
_from_month = _from_month_idx + 1
# Клампим день для коротких месяцев (как делает PostgreSQL interval).
_from_day = min(now.day, calendar.monthrange(_from_year, _from_month)[1])
period_from: date = date(_from_year, _from_month, _from_day)
period_to: date = now.date() period_to: date = now.date()
def _empty() -> StreetDealsResponse: def _empty() -> StreetDealsResponse:

View file

@ -60,10 +60,36 @@ class SearchParams(BaseModel):
raise ValueError("lat and lon must be provided together") raise ValueError("lat and lon must be provided together")
if self.sort == "dist_asc" and self.lat is None: if self.sort == "dist_asc" and self.lat is None:
raise ValueError("sort=dist_asc requires lat+lon") raise ValueError("sort=dist_asc requires lat+lon")
if self.price_rub_min and self.price_rub_max and self.price_rub_min > self.price_rub_max: if (
self.price_rub_min is not None
and self.price_rub_max is not None
and self.price_rub_min > self.price_rub_max
):
raise ValueError("price_rub_min > price_rub_max") raise ValueError("price_rub_min > price_rub_max")
if self.area_m2_min and self.area_m2_max and self.area_m2_min > self.area_m2_max: if (
self.area_m2_min is not None
and self.area_m2_max is not None
and self.area_m2_min > self.area_m2_max
):
raise ValueError("area_m2_min > area_m2_max") raise ValueError("area_m2_min > area_m2_max")
if (
self.floor_min is not None
and self.floor_max is not None
and self.floor_min > self.floor_max
):
raise ValueError("floor_min > floor_max")
if (
self.year_built_min is not None
and self.year_built_max is not None
and self.year_built_min > self.year_built_max
):
raise ValueError("year_built_min > year_built_max")
if (
self.floors_total_min is not None
and self.floors_total_max is not None
and self.floors_total_min > self.floors_total_max
):
raise ValueError("floors_total_min > floors_total_max")
return self return self
@property @property

View file

@ -126,8 +126,26 @@ async def backfill_cian_price_history(
result.skipped += 1 result.skipped += 1
else: else:
try: try:
# Count rows actually inserted: save_detail_enrichment skips
# changes without change_time/price_rub and uses ON CONFLICT
# DO NOTHING, so len(price_changes) overcounts on invalid
# elements or idempotent re-runs. Diff the row count instead.
before = db.execute(
text(
"SELECT COUNT(*) FROM offer_price_history "
"WHERE listing_id = CAST(:lid AS bigint)"
),
{"lid": lid},
).scalar_one()
save_detail_enrichment(db, lid, enrichment) save_detail_enrichment(db, lid, enrichment)
result.saved += len(enrichment.price_changes) after = db.execute(
text(
"SELECT COUNT(*) FROM offer_price_history "
"WHERE listing_id = CAST(:lid AS bigint)"
),
{"lid": lid},
).scalar_one()
result.saved += max(0, int(after) - int(before))
except Exception as exc: except Exception as exc:
logger.warning("cian_price_history: save failed listing_id=%s: %s", lid, exc) logger.warning("cian_price_history: save failed listing_id=%s: %s", lid, exc)
result.errors += 1 result.errors += 1

Some files were not shown because too many files have changed in this diff Show more