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
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:
commit
8d1a13ee5e
118 changed files with 2438 additions and 757 deletions
|
|
@ -11,6 +11,7 @@ Returns the Celery task id; poll /api/v1/admin/scrape/runs/{run_id} to track DB-
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
|
|
@ -22,6 +23,8 @@ from sqlalchemy.orm import Session
|
|||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
|
|
@ -69,7 +72,7 @@ def trigger_kn_sweep(
|
|||
"task_id": result.id,
|
||||
"region_code": payload.region_code,
|
||||
"developers": payload.developers,
|
||||
"queued_at": result.date_done.isoformat() if result.date_done else None,
|
||||
"queued_at": "now",
|
||||
"force": payload.force,
|
||||
"lock_was_released": lock_released,
|
||||
}
|
||||
|
|
@ -175,9 +178,16 @@ def queue_status(
|
|||
return out
|
||||
|
||||
def _safe(fn):
|
||||
# Деградация (return None при недоступном broker) намеренна для UI-poll
|
||||
# эндпоинта, но логируем чтобы устойчиво сломанный broker был виден в логах.
|
||||
try:
|
||||
return fn()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"queue_status: celery inspect call %s failed",
|
||||
getattr(fn, "__name__", fn),
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
deadline = time.monotonic() + 0.8
|
||||
|
|
@ -203,6 +213,9 @@ def queue_status(
|
|||
with conn.channel() as channel:
|
||||
queue_depth = channel.client.llen("celery")
|
||||
except Exception:
|
||||
# Намеренная деградация для UI-poll; логируем чтобы недоступный broker
|
||||
# не был невидим в логах (см. .claude/rules/backend.md).
|
||||
logger.warning("queue_status: broker queue_depth probe failed", exc_info=True)
|
||||
queue_depth = None
|
||||
|
||||
return {
|
||||
|
|
@ -769,6 +782,7 @@ class BulkGeoEnqueueRequest(BaseModel):
|
|||
"all_in_region — UNION всех cad из rosreestr_deals + cad_buildings + complexes"
|
||||
),
|
||||
)
|
||||
rate_ms: int = Field(default=600, ge=100, le=10000)
|
||||
|
||||
|
||||
# Маппинг thematic_id → таблица проверки существования + колонка + job_kind-метка
|
||||
|
|
@ -929,6 +943,7 @@ def bulk_enqueue_geo(
|
|||
"source": payload.source,
|
||||
},
|
||||
cad_nums_with_thematic=cad_with_thematic,
|
||||
rate_ms=payload.rate_ms,
|
||||
triggered_by="bulk_admin",
|
||||
)
|
||||
process_nspd_geo_job.apply_async(args=[job_id], queue=geo_queue)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from app.schemas.concept import ConceptInput, ConceptOutput
|
||||
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.
|
||||
"""
|
||||
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:
|
||||
logger.warning("concept generation rejected parcel: %s", exc)
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
|
|
|||
|
|
@ -1457,7 +1457,8 @@ def analyze_parcel(
|
|||
) AS dist_to_center
|
||||
FROM ekb_districts d
|
||||
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(
|
||||
d.geom::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||
|
|
@ -1841,9 +1842,10 @@ def analyze_parcel(
|
|||
# 9c) Hydrology — водоёмы и реки в радиусе 2 км из osm_noise_sources_ekb
|
||||
hydrology: dict[str, Any] | None = None
|
||||
try:
|
||||
hydro_rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
with db.begin_nested():
|
||||
hydro_rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT source_type, road_class, name,
|
||||
ST_Distance(
|
||||
n.geom::geography,
|
||||
|
|
@ -1859,11 +1861,11 @@ def analyze_parcel(
|
|||
ORDER BY distance_m ASC
|
||||
LIMIT 10
|
||||
"""),
|
||||
{"wkt": geom_wkt},
|
||||
{"wkt": geom_wkt},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
hydrology = {
|
||||
"nearest": [
|
||||
{
|
||||
|
|
@ -1890,9 +1892,10 @@ def analyze_parcel(
|
|||
# 9d) Utilities — power lines + pipelines из OSM (магистральные сети)
|
||||
utilities: dict[str, Any] | None = None
|
||||
try:
|
||||
util_rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
with db.begin_nested():
|
||||
util_rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT road_class, name,
|
||||
ST_Distance(
|
||||
n.geom::geography,
|
||||
|
|
@ -1908,11 +1911,11 @@ def analyze_parcel(
|
|||
ORDER BY distance_m ASC
|
||||
LIMIT 10
|
||||
"""),
|
||||
{"wkt": geom_wkt},
|
||||
{"wkt": geom_wkt},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
# Группировка по типу для compactness. util_rows отсортированы по
|
||||
# distance_m ASC → первый встреченный road_class = ближайший.
|
||||
by_subtype: dict[str, dict[str, Any]] = {}
|
||||
|
|
@ -1974,9 +1977,10 @@ def analyze_parcel(
|
|||
# 9f) Parcel meta — ВРИ и кадастровые метаданные из cad_parcels (#29 G2)
|
||||
parcel_meta: ParcelMeta | None = None
|
||||
try:
|
||||
pm_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
with db.begin_nested():
|
||||
pm_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT permitted_use_established_by_document AS permitted_use,
|
||||
land_record_category_type AS land_category,
|
||||
land_record_subtype AS land_subtype,
|
||||
|
|
@ -1985,11 +1989,11 @@ def analyze_parcel(
|
|||
WHERE cad_num = CAST(:c AS text)
|
||||
LIMIT 1
|
||||
"""),
|
||||
{"c": cad_num},
|
||||
{"c": cad_num},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
if pm_row:
|
||||
parcel_meta = ParcelMeta(
|
||||
permitted_use=pm_row["permitted_use"],
|
||||
|
|
@ -2003,9 +2007,10 @@ def analyze_parcel(
|
|||
# B5-1) EGRN block — расширенные данные из cad_parcels (SF-B5)
|
||||
egrn_block: dict[str, Any] = {}
|
||||
try:
|
||||
egrn_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
with db.begin_nested():
|
||||
egrn_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT cost_value AS cadastral_value_rub,
|
||||
cost_index AS cost_index_per_m2,
|
||||
land_record_category_type AS land_category,
|
||||
|
|
@ -2021,11 +2026,11 @@ def analyze_parcel(
|
|||
WHERE cad_num = CAST(:c AS text)
|
||||
LIMIT 1
|
||||
"""),
|
||||
{"c": cad_num},
|
||||
{"c": cad_num},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
if egrn_row:
|
||||
_cad_val = (
|
||||
float(egrn_row["cadastral_value_rub"])
|
||||
|
|
@ -2070,19 +2075,20 @@ def analyze_parcel(
|
|||
"zouit_count": 0,
|
||||
}
|
||||
try:
|
||||
zouit_rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
with db.begin_nested():
|
||||
zouit_rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT type_zone, name_by_doc
|
||||
FROM cad_zouit
|
||||
WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326))
|
||||
ORDER BY id
|
||||
"""),
|
||||
{"wkt": geom_wkt},
|
||||
{"wkt": geom_wkt},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
if zouit_rows:
|
||||
_zouit_types = list({r["type_zone"] for r in zouit_rows if r["type_zone"]})
|
||||
encumbrance_block = {
|
||||
|
|
@ -2096,9 +2102,10 @@ def analyze_parcel(
|
|||
# B5-3) Red lines block — пересечение с cad_red_lines (SF-B5)
|
||||
red_lines_block: dict[str, Any] = {"intersects": False, "count": 0}
|
||||
try:
|
||||
rl_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
with db.begin_nested():
|
||||
rl_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM cad_red_lines
|
||||
WHERE ST_Intersects(
|
||||
|
|
@ -2106,11 +2113,11 @@ def analyze_parcel(
|
|||
ST_GeomFromText(:wkt, 4326)
|
||||
)
|
||||
"""),
|
||||
{"wkt": geom_wkt},
|
||||
{"wkt": geom_wkt},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
if rl_row:
|
||||
_rl_cnt = int(rl_row["cnt"])
|
||||
red_lines_block = {
|
||||
|
|
@ -2132,9 +2139,10 @@ def analyze_parcel(
|
|||
}
|
||||
if district_row and district_row["district_name"]:
|
||||
try:
|
||||
dp_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
with db.begin_nested():
|
||||
dp_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
MIN(price_per_m2_rub) AS price_min,
|
||||
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 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:
|
||||
district_price_block = {
|
||||
"district_price_per_m2_min": (
|
||||
|
|
@ -2175,9 +2183,10 @@ def analyze_parcel(
|
|||
"geology_risk_label": None,
|
||||
}
|
||||
try:
|
||||
flood_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
with db.begin_nested():
|
||||
flood_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM cad_risk_zones
|
||||
WHERE ST_Intersects(
|
||||
|
|
@ -2187,11 +2196,11 @@ def analyze_parcel(
|
|||
AND (risk_type ILIKE '%flood%' 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)
|
||||
# Geology proxy через hydrology flood_risk_flag (уже посчитан выше)
|
||||
_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 месяцев
|
||||
market_trend: dict[str, Any] | None = None
|
||||
try:
|
||||
trend_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
with db.begin_nested():
|
||||
trend_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
WITH district_deals AS (
|
||||
SELECT d.period_start_date AS deal_date,
|
||||
d.price_per_sqm AS price_per_m2
|
||||
|
|
@ -2251,11 +2261,11 @@ def analyze_parcel(
|
|||
AS prior_n
|
||||
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"]:
|
||||
recent_p = float(trend_row["recent_avg"])
|
||||
prior_p = float(trend_row["prior_avg"])
|
||||
|
|
@ -2712,9 +2722,7 @@ def analyze_parcel(
|
|||
if district_row and district_row["district_name"]:
|
||||
try:
|
||||
with db.begin_nested():
|
||||
saturation_block = compute_district_saturation(
|
||||
db, district_row["district_name"]
|
||||
)
|
||||
saturation_block = compute_district_saturation(db, district_row["district_name"])
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"saturation block failed for %s (%s): %s",
|
||||
|
|
@ -2942,7 +2950,7 @@ def analyze_parcel(
|
|||
response_model=ConnectionPointsResponse,
|
||||
summary="Точки подключения к инженерным сетям + охранные зоны (issue #115)",
|
||||
)
|
||||
async def get_parcel_connection_points(
|
||||
def get_parcel_connection_points(
|
||||
cad_num: str,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
radius_m: Annotated[int, Query(ge=50, le=2000)] = 500,
|
||||
|
|
@ -3106,7 +3114,7 @@ def get_isochrones(
|
|||
response_model=PoiScoreResponse,
|
||||
summary="POI weighted top-7 (B6)",
|
||||
)
|
||||
async def get_poi_score(
|
||||
def get_poi_score(
|
||||
cad_num: str,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
radius_m: Annotated[int, Query(ge=100, le=5000)] = 2000,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from datetime import date, datetime
|
|||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class TradeInEstimateInput(BaseModel):
|
||||
|
|
@ -23,6 +23,12 @@ class TradeInEstimateInput(BaseModel):
|
|||
repair_state: Literal["needs_repair", "standard", "good", "excellent"] | 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):
|
||||
address: str
|
||||
|
|
|
|||
|
|
@ -24,6 +24,28 @@ def _f(value: Any) -> float | None:
|
|||
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]]:
|
||||
rows = (
|
||||
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 / deal_count) BETWEEN 15 AND 200
|
||||
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 (
|
||||
SELECT CASE
|
||||
|
|
@ -144,7 +171,9 @@ def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[s
|
|||
ORDER BY bucket
|
||||
"""
|
||||
),
|
||||
{"region_id": region_id},
|
||||
# 12 мес — размер окна, эквивалентный исходному '2025-07-01' на момент
|
||||
# написания (см. #1384); теперь окно скользит и не растёт со временем.
|
||||
{"region_id": region_id, "months_window": 12},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
|
|
@ -993,7 +1022,15 @@ def object_flats_quartirography(db: Session, obj_id: int) -> list[dict[str, Any]
|
|||
WHEN f.rooms = 3 THEN '3-комн.'
|
||||
ELSE (f.rooms::text || '-комн.')
|
||||
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(*) FILTER (WHERE LOWER(f.status) = 'free'
|
||||
OR LOWER(f.status) LIKE '%свобод%')
|
||||
|
|
@ -2450,6 +2487,10 @@ def recommend_mix(
|
|||
"price_p75_per_m2": p75,
|
||||
"units_planned": units_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.
|
||||
# Both are required for the live "цена↔темп" calculator on the frontend.
|
||||
# Graceful: kn-API returns obj_class=NULL для всех ЖК Свердл (отдельный
|
||||
# баг скрейпера). Если в районе нет ни одного НЕ-NULL obj_class —
|
||||
# баг скрейпера). Если в районе нет ни одного ЖК ИМЕННО запрошенного класса —
|
||||
# игнорируем target_class фильтр на уровне velocity/elasticity/comparable
|
||||
# запросов, иначе obj_pool пустой и всё падает в fallback.
|
||||
# #1375: БД хранит русские названия классов, UI шлёт английские — переводим
|
||||
# перед матчем obj_class = :cls, иначе точный матч молча даёт ноль строк
|
||||
# ("Comfort" != "Комфорт") и comparables/competitors тихо пустеют.
|
||||
target_class_db = _class_to_db_vocab(target_class)
|
||||
has_class_data = bool(
|
||||
db.execute(
|
||||
text(
|
||||
|
|
@ -2469,18 +2514,20 @@ def recommend_mix(
|
|||
WHERE region_cd = :rc
|
||||
AND district_name = :dn
|
||||
AND obj_class IS NOT NULL
|
||||
AND (CAST(:cls AS TEXT) IS NULL OR obj_class = :cls)
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"rc": region_code, "dn": district_row["district_name"]},
|
||||
{"rc": region_code, "dn": district_row["district_name"], "cls": target_class_db},
|
||||
).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:
|
||||
warnings.append(
|
||||
f"obj_class не заполнен для ЖК района {district_row['district_name']}"
|
||||
f" — фильтр по классу '{target_class}' игнорируется в velocity/comparable"
|
||||
" (но class_multiplier из yandex_realty_zk применяется к ценам)."
|
||||
f"Нет ЖК класса '{target_class}' среди размеченных в районе"
|
||||
f" {district_row['district_name']} — фильтр по классу игнорируется в"
|
||||
" velocity/comparable (но class_multiplier из yandex_realty_zk"
|
||||
" применяется к ценам)."
|
||||
)
|
||||
vel = _velocity_baseline(
|
||||
db,
|
||||
|
|
@ -2739,6 +2786,41 @@ def recommend_mix(
|
|||
if b["bucket"] != top_bucket_name:
|
||||
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.
|
||||
# Tier 3: используем weighted-by-units эластичность (per-bucket эластичности
|
||||
# → агрегатная только когда нужна одна цифра). При smooth-buckets разница
|
||||
|
|
@ -2894,6 +2976,10 @@ def recommend_mix(
|
|||
headline_parts.append(f"ликвидность {liquidity_24mo:.0f}/100")
|
||||
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] = {
|
||||
"scope": {
|
||||
"district": district_row["district_name"],
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ ROBUST к частичному/пустому отчёту (отчёт може
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -150,6 +151,8 @@ def _fmt_number(value: Any) -> str | None:
|
|||
if isinstance(value, int):
|
||||
return _fmt_thousands(value)
|
||||
if isinstance(value, float):
|
||||
if not math.isfinite(value): # NaN/Inf: int(value) бросил бы ValueError
|
||||
return str(value)
|
||||
if value == int(value):
|
||||
return _fmt_thousands(value)
|
||||
# Точность отчёта сохраняем: repr float'а → '.'→','. (0.31 → '0,31').
|
||||
|
|
|
|||
|
|
@ -26,8 +26,9 @@ explainability/прототипа, не основание для инвест-
|
|||
|
||||
КОНВЕРТ ШИРИТСЯ С ГОРИЗОНТОМ (сознательно, документируем): дальние горизонты
|
||||
неопределённее, поэтому Δ ставки растёт линейно с горизонтом —
|
||||
delta_eff(h) = base_delta × (1 + _HORIZON_WIDEN_PER_YEAR × h/12). На h=12 мес
|
||||
конверт = base_delta, на h=24 мес ≈ base_delta × (1 + 0.5×_HORIZON_WIDEN_PER_YEAR).
|
||||
delta_eff(h) = base_delta × (1 + _HORIZON_WIDEN_PER_YEAR × h/12). При
|
||||
_HORIZON_WIDEN_PER_YEAR=0.5: на h=12 мес конверт = base_delta × 1.5, на h=24 мес
|
||||
= base_delta × 2.0 (на каждый год горизонта +50% к base_delta).
|
||||
Держим ПРОСТО (один линейный множитель) и tunable. Ставка КЛАМПится ≥ 0 (нет
|
||||
отрицательной ключевой ставки) — на дальнем агрессивном горизонте конверт может
|
||||
упереть ставку в 0.
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from .provider import (
|
|||
LLMProvider,
|
||||
LLMProviderError,
|
||||
LLMRateLimitedError,
|
||||
LLMTimeoutError,
|
||||
OpenAIProvider,
|
||||
ProviderResponse,
|
||||
ToolCall,
|
||||
|
|
@ -244,7 +245,7 @@ def _call_with_retries(
|
|||
except LLMProviderError as e:
|
||||
# Таймаут (LLMTimeoutError) и прочие провайдер-ошибки: НЕ ретраим (таймаут уже
|
||||
# «съел» бюджет времени; сетевые/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)
|
||||
return LLMResult.fallback(reason)
|
||||
|
||||
|
|
|
|||
|
|
@ -83,10 +83,8 @@ def list_izyatie_documents(url: str = _IZYATIE_URL) -> list[dict[str, str]]:
|
|||
# Берём только /file/<hash>-ссылки.
|
||||
if not _RE_FILE_HREF.match(href):
|
||||
continue
|
||||
title: str = a_tag.get_text(strip=True) or href
|
||||
# Если заголовок пустой — берём title-атрибут или href.
|
||||
if not title:
|
||||
title = a_tag.get("title", href)
|
||||
# Заголовок: текст якоря → title-атрибут → href как последний фолбэк.
|
||||
title: str = a_tag.get_text(strip=True) or a_tag.get("title") or href
|
||||
absolute_url = urllib.parse.urljoin(_BASE_URL, href)
|
||||
docs.append({"title": title, "url": absolute_url})
|
||||
|
||||
|
|
|
|||
|
|
@ -111,38 +111,91 @@ _COMPETITORS_IN_RADIUS_SQL = text("""
|
|||
# :competitor_obj_ids — list[int] obj_id конкурентов в радиусе
|
||||
# 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("""
|
||||
SELECT
|
||||
CASE
|
||||
WHEN crm.room_bucket = 'студия' THEN 'studio'
|
||||
ELSE crm.room_bucket
|
||||
END AS room_bucket,
|
||||
SUM(crm.deals_total_count) AS deals_window,
|
||||
COALESCE(
|
||||
WITH crm_agg AS (
|
||||
SELECT
|
||||
crm.project_name,
|
||||
CASE
|
||||
WHEN crm.room_bucket = 'студия' THEN 'studio'
|
||||
ELSE crm.room_bucket
|
||||
END AS room_bucket,
|
||||
SUM(crm.deals_total_count) AS deals_window,
|
||||
SUM(crm.deals_total_avg_area_m2 * crm.deals_total_count)
|
||||
/ NULLIF(SUM(crm.deals_total_count), 0),
|
||||
0
|
||||
)::numeric(10, 2) AS avg_area_m2,
|
||||
COALESCE(
|
||||
AS area_weighted_sum,
|
||||
SUM(crm.deals_total_avg_price_thousand_rub_per_m2 * crm.deals_total_count)
|
||||
/ NULLIF(SUM(crm.deals_total_count), 0),
|
||||
0
|
||||
)::numeric(12, 2) * 1000.0 AS avg_price_per_m2_rub,
|
||||
array_agg(DISTINCT cm.domrf_obj_id) AS competitor_obj_ids,
|
||||
COUNT(DISTINCT cm.domrf_obj_id) AS competitor_count,
|
||||
MIN(crm.report_month) AS window_start,
|
||||
MAX(crm.report_month) AS window_end
|
||||
FROM objective_corpus_room_month crm
|
||||
JOIN objective_complex_mapping cm
|
||||
ON cm.objective_complex_name = crm.project_name
|
||||
WHERE crm.report_month >= (NOW() - CAST(:window_interval AS interval))::date
|
||||
AND cm.domrf_obj_id = ANY(:competitor_obj_ids)
|
||||
AND crm.room_bucket IS NOT NULL
|
||||
GROUP BY
|
||||
CASE
|
||||
WHEN crm.room_bucket = 'студия' THEN 'studio'
|
||||
ELSE crm.room_bucket
|
||||
END
|
||||
AS price_weighted_sum,
|
||||
MIN(crm.report_month) AS window_start,
|
||||
MAX(crm.report_month) AS window_end
|
||||
FROM objective_corpus_room_month crm
|
||||
WHERE crm.report_month >= (NOW() - CAST(:window_interval AS interval))::date
|
||||
AND crm.room_bucket IS NOT NULL
|
||||
GROUP BY crm.project_name,
|
||||
CASE
|
||||
WHEN crm.room_bucket = 'студия' THEN 'studio'
|
||||
ELSE crm.room_bucket
|
||||
END
|
||||
),
|
||||
mapped_projects AS (
|
||||
SELECT DISTINCT cm.objective_complex_name AS project_name
|
||||
FROM objective_complex_mapping cm
|
||||
WHERE cm.domrf_obj_id = ANY(:competitor_obj_ids)
|
||||
),
|
||||
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) за последний снимок ───────────────
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""Найти существующий on-demand job (queued/running) для этого cad.
|
||||
"""Найти существующий on-demand job (queued/running/paused) для этого cad.
|
||||
|
||||
Возвращает job_id или None. Если в БД есть FAILED on-demand за последние 60
|
||||
секунд — тоже None (чтобы повторно пробовать). Если есть DONE job, но cad
|
||||
отсутствует в БД (на NSPD не нашлось) — тоже None, но caller через
|
||||
`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(
|
||||
text(
|
||||
|
|
@ -110,7 +116,7 @@ def find_active_on_demand_job(db: Session, cad_num: str) -> int | None:
|
|||
FROM nspd_geo_jobs j
|
||||
JOIN nspd_geo_targets t ON t.job_id = j.job_id
|
||||
WHERE j.source_kind = :src
|
||||
AND j.status IN ('queued', 'running')
|
||||
AND j.status IN ('queued', 'running', 'paused')
|
||||
AND t.cad_num = :c
|
||||
ORDER BY j.created_at DESC
|
||||
LIMIT 1
|
||||
|
|
@ -342,6 +348,18 @@ def fetch_status(db: Session, cad_num: str) -> dict:
|
|||
or "НСПД временно недоступен. Попробуйте через минуту.",
|
||||
"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
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ def _competitors_key(db: Session, cad_num: str, request: CompetitorsRequest) ->
|
|||
tuple(sorted(request.exclude_obj_ids)),
|
||||
)
|
||||
|
||||
|
||||
# Маппинг time_window → число месяцев (float для деления velocity)
|
||||
_TIME_WINDOW_MONTHS: dict[str, float] = {
|
||||
"last_month": 1.0,
|
||||
|
|
@ -433,10 +434,6 @@ _COMPETITORS_SQL = text("""
|
|||
FROM distances d
|
||||
LEFT JOIN velocity v ON v.obj_id = d.obj_id
|
||||
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
|
||||
""")
|
||||
|
||||
|
|
@ -571,7 +568,6 @@ def get_competitors(
|
|||
"radius_m": request.radius_km * 1000.0,
|
||||
"time_window_months": time_window_months,
|
||||
"window_interval": window_interval,
|
||||
"obj_class_filter": request.obj_class_filter,
|
||||
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
|
||||
},
|
||||
)
|
||||
|
|
@ -591,6 +587,24 @@ def get_competitors(
|
|||
if 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:
|
||||
return CompetitorsResponse(
|
||||
competitors=[],
|
||||
|
|
|
|||
|
|
@ -160,14 +160,27 @@ def compute_gate_verdict(
|
|||
# cad_zouit fallback path: classify by type_zone keywords (#232).
|
||||
# subcategory = NULL в cad_zouit, поэтому subcategory-based logic не применяется.
|
||||
type_zone_lower = (overlap.get("type_zone") or overlap.get("layer") or "").lower()
|
||||
# #1070: «сетевое обременение» — охранная зона инж.сети — выделенный
|
||||
# blocker-код с видом сети (тепло/электро/…) в детали. Классификация
|
||||
# пришла из _get_cad_zouit_overlaps (is_network_zone/network_kind); если
|
||||
# overlap пришёл из старого пути без флага — определяем здесь же.
|
||||
# #1070: классификация «сетевого обременения» (охранная зона инж.сети).
|
||||
# Флаг приходит из _get_cad_zouit_overlaps (is_network_zone/network_kind);
|
||||
# если overlap из старого пути без флага — определяем здесь же.
|
||||
net_kind = overlap.get("network_kind")
|
||||
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"))
|
||||
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)
|
||||
blockers.append(
|
||||
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:
|
||||
warnings.append(
|
||||
Warning(
|
||||
|
|
@ -209,8 +213,20 @@ def compute_gate_verdict(
|
|||
)
|
||||
else:
|
||||
# NSPD dump path: subcategory-based logic (backward-compat).
|
||||
sub = overlap.get("subcategory")
|
||||
if isinstance(sub, int) and sub in BLOCKER_SUBCATEGORIES:
|
||||
# subcategory из _get_zouit_overlaps = `subcategory or type_zone` → может быть
|
||||
# строкой ('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(
|
||||
Blocker(
|
||||
code=f"ZOUIT_OVERLAP_SUB{sub}",
|
||||
|
|
@ -218,9 +234,17 @@ def compute_gate_verdict(
|
|||
)
|
||||
)
|
||||
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(
|
||||
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', '')}",
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -108,10 +108,15 @@ async def fetch_overpass_noise() -> list[dict]:
|
|||
all_elements: list[dict] = []
|
||||
async with httpx.AsyncClient(timeout=60, headers=_HEADERS) as client:
|
||||
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.
|
||||
if key == "aeroway":
|
||||
el_type = "node"
|
||||
el_type = "nwr"
|
||||
elif value in _UTILITY_POINT_VALUES:
|
||||
el_type = "nwr"
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ _CURRENT_SQL = text(
|
|||
SELECT o.*
|
||||
FROM domrf_kn_objects o
|
||||
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[]))
|
||||
),
|
||||
latest AS (
|
||||
|
|
|
|||
|
|
@ -92,6 +92,13 @@ async def fetch_overpass() -> list[dict]:
|
|||
r.raise_for_status()
|
||||
elements: list[dict] = r.json().get("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)
|
||||
except Exception as e:
|
||||
# Не падаем на одной категории — логируем и продолжаем
|
||||
|
|
@ -117,13 +124,17 @@ def sync_poi_to_db() -> dict[str, int]:
|
|||
inserted = 0
|
||||
updated = 0
|
||||
skipped_old = 0
|
||||
skipped = 0
|
||||
fetched = len(elements)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for el in elements:
|
||||
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:
|
||||
continue
|
||||
|
||||
|
|
@ -155,44 +166,59 @@ def sync_poi_to_db() -> dict[str, int]:
|
|||
if last_edit and last_edit < two_years_ago:
|
||||
skipped_old += 1
|
||||
|
||||
result = db.execute(
|
||||
text("""
|
||||
INSERT INTO osm_poi_ekb
|
||||
(osm_id, osm_type, category, name, lat, lon, geom,
|
||||
last_osm_edit_date, tags, fetched_at)
|
||||
VALUES (:osm_id, :osm_type, :category, :name, :lat, :lon,
|
||||
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326),
|
||||
:last_edit, CAST(:tags AS jsonb), NOW())
|
||||
ON CONFLICT (osm_type, osm_id, category) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
lat = EXCLUDED.lat,
|
||||
lon = EXCLUDED.lon,
|
||||
geom = EXCLUDED.geom,
|
||||
last_osm_edit_date = EXCLUDED.last_osm_edit_date,
|
||||
tags = EXCLUDED.tags,
|
||||
fetched_at = NOW()
|
||||
RETURNING (xmax = 0) AS is_insert
|
||||
"""),
|
||||
{
|
||||
"osm_id": osm_id,
|
||||
"osm_type": osm_type,
|
||||
"category": category,
|
||||
"name": tags.get("name"),
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"last_edit": last_edit,
|
||||
"tags": json.dumps(tags, ensure_ascii=False),
|
||||
},
|
||||
).scalar()
|
||||
|
||||
if result:
|
||||
inserted += 1
|
||||
else:
|
||||
updated += 1
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT — откат только этой записи
|
||||
result = db.execute(
|
||||
text("""
|
||||
INSERT INTO osm_poi_ekb
|
||||
(osm_id, osm_type, category, name, lat, lon, geom,
|
||||
last_osm_edit_date, tags, fetched_at)
|
||||
VALUES (:osm_id, :osm_type, :category, :name, :lat, :lon,
|
||||
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326),
|
||||
:last_edit, CAST(:tags AS jsonb), NOW())
|
||||
ON CONFLICT (osm_type, osm_id, category) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
lat = EXCLUDED.lat,
|
||||
lon = EXCLUDED.lon,
|
||||
geom = EXCLUDED.geom,
|
||||
last_osm_edit_date = EXCLUDED.last_osm_edit_date,
|
||||
tags = EXCLUDED.tags,
|
||||
fetched_at = NOW()
|
||||
RETURNING (xmax = 0) AS is_insert
|
||||
"""),
|
||||
{
|
||||
"osm_id": osm_id,
|
||||
"osm_type": osm_type,
|
||||
"category": category,
|
||||
"name": tags.get("name"),
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"last_edit": last_edit,
|
||||
"tags": json.dumps(tags, ensure_ascii=False),
|
||||
},
|
||||
).scalar()
|
||||
if result:
|
||||
inserted += 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()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("poi_sync: unexpected error, outer tx rolled back: %s", e)
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -202,4 +228,5 @@ def sync_poi_to_db() -> dict[str, int]:
|
|||
"inserted": inserted,
|
||||
"updated": updated,
|
||||
"skipped_old": skipped_old,
|
||||
"skipped": skipped,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -939,11 +939,13 @@ def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dic
|
|||
if parcel_wkt is None:
|
||||
raise ValueError(f"Участок {cad_num!r} не найден в БД")
|
||||
|
||||
# Проверяем наличие дампа
|
||||
# Проверяем наличие дампа. harvest_error читаем чтобы не отдавать error-only
|
||||
# строку как dump_available=True (#1371): _upsert_dump при частичном сбое
|
||||
# пишет строку с harvest_error != NULL и нулевыми счётчиками.
|
||||
dump_row = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT fetched_at_utc, total_features
|
||||
SELECT fetched_at_utc, total_features, harvest_error
|
||||
FROM nspd_quarter_dumps
|
||||
WHERE quarter_cad = :q
|
||||
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},
|
||||
).first()
|
||||
|
||||
if dump_row is None:
|
||||
_trigger_harvest(quarter)
|
||||
def _empty_unavailable(fetched_at_iso: str | None) -> dict[str, Any]:
|
||||
return {
|
||||
"engineering_structures": [],
|
||||
"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,
|
||||
},
|
||||
"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]
|
||||
if fetched_at is not None and getattr(fetched_at, "isoformat", None):
|
||||
dump_fetched_at: str | None = fetched_at.isoformat()
|
||||
else:
|
||||
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)
|
||||
zouit_overlaps = _get_zouit_engineering_overlaps(db, quarter, parcel_wkt)
|
||||
|
||||
|
|
|
|||
|
|
@ -377,6 +377,14 @@ def compute_velocity(
|
|||
|
||||
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)
|
||||
# Активные 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_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 ""
|
||||
|
|
@ -429,17 +437,23 @@ def compute_velocity(
|
|||
velocity_source="none",
|
||||
)
|
||||
|
||||
# Среднемесячный объём в расчёте: суммарный по всем конкурентам / месяцев.
|
||||
# Чем больше конкурентов с данными — тем весомее результат.
|
||||
monthly_velocity = total_sqm / months_observed
|
||||
# Среднемесячный объём = Σ(объём_i / месяцев_i) по активным конкурентам (#1354).
|
||||
# Делить суммарный объём на max(месяцев) нельзя: при разнородных окнах
|
||||
# (старые ЖК 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 ────────────────────────────────────
|
||||
# Логика: сравниваем суммарный velocity радиуса с «нормой» одного ЖК.
|
||||
# Если в радиусе продаётся N × ekb_median → рынок горячий.
|
||||
# Нормируем: score = min(1.0, total_velocity / (n_competitors × ekb_median × 2))
|
||||
# Cap 2×median = «насыщен». Итоговый score 0..1.
|
||||
# n_with_sales — только mapped конкуренты (у unmapped данных нет).
|
||||
n_with_sales = len(mapped_sales_rows)
|
||||
# n_with_sales — только конкуренты с реальными продажами в окне (#1382).
|
||||
# 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
|
||||
velocity_score = min(1.0, max(0.0, monthly_velocity / denominator))
|
||||
|
||||
|
|
|
|||
|
|
@ -27,8 +27,11 @@ forecast/climate до Phase A).
|
|||
Все исключения httpx/json/KeyError ловятся → `logger.warning` + negative-cache + None.
|
||||
|
||||
THREAD-SAFETY: analyze_parcel — sync def, под Uvicorn идёт в threadpool. Каждый кэш
|
||||
защищён своим `threading.Lock`. Single-flight: под lock'ом check-then-fetch-then-store,
|
||||
поэтому конкурентный cold-start на ОДИН ключ делает ровно один сетевой вызов.
|
||||
защищён своим `threading.Lock`, который удерживается ТОЛЬКО на время check/store — сам
|
||||
сетевой httpx-вызов идёт ВНЕ lock'а (#1370), иначе все analyze одного cache-типа
|
||||
сериализуются на время вызова (lock — per-cache-type, не per-key). Trade-off: конкурентный
|
||||
cold-start на ОДИН ключ может породить несколько параллельных запросов (last-write wins),
|
||||
что приемлемо — вызовы идемпотентны, а TTL длинный.
|
||||
|
||||
time.monotonic используется вместо time.time — устойчиво к скачкам системного времени
|
||||
(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)
|
||||
if entry is not None and entry[1] > now:
|
||||
return entry[0]
|
||||
# MISS или истёк → внутри lock'а делаем сетевой вызов (single-flight: 16
|
||||
# потоков на один ключ → один реальный запрос).
|
||||
value = _fetch_weather_remote(lat, lon)
|
||||
ttl = _WEATHER_TTL_S if value is not None else _NEGATIVE_TTL_S
|
||||
# MISS или истёк → сетевой вызов ВНЕ lock'а, иначе все analyze одного cache-типа
|
||||
# (даже для разных координат) сериализуются на время httpx-вызова (#1370). Ценой —
|
||||
# cold-start на ОДИН ключ может породить несколько параллельных запросов (last-write
|
||||
# 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)
|
||||
_FORECAST_CACHE[key] = (value, _now() + ttl)
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
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)
|
||||
if entry is not None and entry[1] > now:
|
||||
return entry[0]
|
||||
value = _fetch_seasonal_remote(lat, lon)
|
||||
ttl = _SEASONAL_TTL_S if value is not None else _NEGATIVE_TTL_S
|
||||
# Сетевой вызов ВНЕ lock'а — не сериализуем разные ключи (#1370). См. get_weather_cached.
|
||||
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)
|
||||
_CLIMATE_CACHE[key] = (value, _now() + ttl)
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def _fetch_air_quality_remote(lat: float, lon: float) -> dict[str, Any] | None:
|
||||
"""Open-Meteo Air Quality API — pm2_5/pm10/no2 текущего часа.
|
||||
|
||||
Перенесено из `app.api.v1.parcels._fetch_air_quality_sync` без изменения формата
|
||||
возвращаемого dict (фронт зависит). Любое исключение → logger.warning + None
|
||||
(caller обернёт None в negative-cache на короткий TTL).
|
||||
Использует `current=...` (bucket текущего часа), а не `hourly[0]` (=00:00 forecast-дня) —
|
||||
docstring/UX обещают «качество воздуха сейчас» (#1377). Формат возвращаемого dict
|
||||
(ключи pm2_5/pm10/no2/ts/source) сохранён — фронт зависит. Любое исключение →
|
||||
logger.warning + None (caller обернёт None в negative-cache на короткий TTL).
|
||||
"""
|
||||
try:
|
||||
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={
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"hourly": "pm2_5,pm10,nitrogen_dioxide",
|
||||
"forecast_days": 1,
|
||||
# `current` отдаёт bucket текущего часа (а не hourly[0]=00:00) —
|
||||
# docstring/UX обещают «качество воздуха сейчас». См. #1377.
|
||||
"current": "pm2_5,pm10,nitrogen_dioxide",
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
hourly = data.get("hourly", {})
|
||||
if not hourly.get("time"):
|
||||
current = data.get("current", {})
|
||||
if not current.get("time"):
|
||||
return None
|
||||
return {
|
||||
"pm2_5": hourly["pm2_5"][0] if hourly.get("pm2_5") else None,
|
||||
"pm10": hourly["pm10"][0] if hourly.get("pm10") else None,
|
||||
"no2": hourly["nitrogen_dioxide"][0] if hourly.get("nitrogen_dioxide") else None,
|
||||
"ts": hourly["time"][0],
|
||||
"pm2_5": current.get("pm2_5"),
|
||||
"pm10": current.get("pm10"),
|
||||
"no2": current.get("nitrogen_dioxide"),
|
||||
"ts": current["time"],
|
||||
"source": "open-meteo",
|
||||
}
|
||||
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)
|
||||
if entry is not None and entry[1] > now:
|
||||
return entry[0]
|
||||
value = _fetch_air_quality_remote(lat, lon)
|
||||
ttl = _AIR_TTL_S if value is not None else _NEGATIVE_TTL_S
|
||||
# Сетевой вызов ВНЕ lock'а — не сериализуем разные ключи (#1370). См. get_weather_cached.
|
||||
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)
|
||||
_AIR_CACHE[key] = (value, _now() + ttl)
|
||||
return value
|
||||
return value
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
-- NSPD может вернуть одноконтурный Polygon → ST_Multi coerce'ит в MultiPolygon,
|
||||
-- иначе "Geometry type (Polygon) does not match column type (MultiPolygon)".
|
||||
-- 3857→4326 transform как в upsert_quarter_geom_from_feature (Mercator).
|
||||
ST_Multi(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_Multi(ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326)),
|
||||
CAST(:props AS jsonb), NOW())
|
||||
ON CONFLICT (cad_number) DO UPDATE
|
||||
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
|
||||
) VALUES (
|
||||
: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
|
||||
)
|
||||
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
|
||||
(EPSG:4326, degrees). Просто ST_SetSRID — без ST_Transform, иначе геометрия
|
||||
сжимается до точки в районе (0,0) на острове Null. Quarters/buildings —
|
||||
другой path, у них Mercator (3857) и нужен ST_Transform.
|
||||
сжимается до точки в районе (0,0) на острове Null. Quarters/buildings идут
|
||||
через тот же rosreestr2coord path и тоже WGS84 — им ST_Transform тоже НЕ нужен
|
||||
(issue #1336: раньше ошибочно трансформировались из 3857 → Null Island).
|
||||
"""
|
||||
feats = (payload.get("data") or {}).get("features") or []
|
||||
if not feats:
|
||||
|
|
|
|||
|
|
@ -326,7 +326,10 @@ def sync_objective_group(
|
|||
use_dkp=params.get("use_dkp", False),
|
||||
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:
|
||||
if kind == "corp_sum":
|
||||
n = parser_mod.parse_corp_sum(
|
||||
|
|
@ -341,8 +344,10 @@ def sync_objective_group(
|
|||
{"n": n, "rid": raw_id},
|
||||
)
|
||||
db.commit()
|
||||
reports_ok += 1
|
||||
except Exception as parse_err:
|
||||
db.rollback()
|
||||
reports_failed += 1
|
||||
logger.exception(
|
||||
"sync_objective_group: parser failed for %s/%s/%s " "raw_id=%s: %s",
|
||||
section,
|
||||
|
|
|
|||
|
|
@ -99,13 +99,13 @@ def _make_httpx_response(payload: dict[str, Any]) -> MagicMock:
|
|||
|
||||
|
||||
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 {
|
||||
"hourly": {
|
||||
"time": ["2026-06-12T00:00", "2026-06-12T01:00"],
|
||||
"pm2_5": [12.5, 13.0],
|
||||
"pm10": [25.0, 26.0],
|
||||
"nitrogen_dioxide": [15.0, 16.0],
|
||||
"current": {
|
||||
"time": "2026-06-12T14:00",
|
||||
"pm2_5": 12.5,
|
||||
"pm10": 25.0,
|
||||
"nitrogen_dioxide": 15.0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
def test_save_quarter_sql_keeps_3857_to_4326_transform() -> None:
|
||||
"""quarter GeoJSON в Mercator (3857) → ST_Transform к 4326 сохраняется (mirror bulk path)."""
|
||||
def test_save_quarter_sql_no_transform_wgs84() -> None:
|
||||
"""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()
|
||||
_save_quarter(db, _quarter_payload(), "66:41:0610029")
|
||||
|
||||
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:
|
||||
|
|
@ -261,15 +265,18 @@ def test_save_building_derives_quarter_when_missing() -> None:
|
|||
assert params["qcad"] == "66:41:0610029"
|
||||
|
||||
|
||||
def test_save_building_geom_transform_no_st_multi() -> None:
|
||||
"""cad_buildings.geom = GEOMETRY(Geometry, 4326) полиморфный → ST_Multi НЕ нужен,
|
||||
transform 3857→4326 сохраняется (mirror upsert_building)."""
|
||||
def test_save_building_geom_no_transform_no_st_multi() -> None:
|
||||
"""cad_buildings.geom = GEOMETRY(Geometry, 4326) полиморфный → ST_Multi НЕ нужен;
|
||||
геометрия уже WGS84 (rosreestr2coord) → ST_SetSRID(...,4326) БЕЗ ST_Transform
|
||||
(#1336: ST_Transform из 3857 → Null Island)."""
|
||||
db, captured = _capturing_db()
|
||||
_save_building(db, _building_payload(), "66:41:0610029:83")
|
||||
|
||||
sql, _params = captured[0]
|
||||
assert "ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:g), 3857), 4326)" in sql
|
||||
assert "ST_Multi(" not in sql # полиморфная колонка — coerce не нужен
|
||||
executable = _strip_sql_comments(sql)
|
||||
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:
|
||||
|
|
@ -562,9 +569,9 @@ def test_soft_time_limit_exceeded_flushes_heartbeat(monkeypatch: Any) -> None:
|
|||
for sql, params in captured
|
||||
if "heartbeat_at = NOW()" in sql and "targets_done" in sql
|
||||
]
|
||||
assert heartbeat_updates, (
|
||||
"SoftTimeLimitExceeded handler должен flush'нуть heartbeat с counters перед raise"
|
||||
)
|
||||
assert (
|
||||
heartbeat_updates
|
||||
), "SoftTimeLimitExceeded handler должен flush'нуть heartbeat с counters перед raise"
|
||||
|
||||
|
||||
def test_soft_time_limit_exceeded_does_not_overwrite_paused_with_failed(
|
||||
|
|
|
|||
|
|
@ -33,8 +33,19 @@ ALTER TABLE domrf_kn_infrastructure
|
|||
|
||||
-- Step 3: add new unique constraint with NULLS NOT DISTINCT (PG15+)
|
||||
-- so NULL poi_category / poi_address are treated as equal (not distinct)
|
||||
ALTER TABLE domrf_kn_infrastructure
|
||||
ADD CONSTRAINT uq_infra_dedupe
|
||||
UNIQUE NULLS NOT DISTINCT (obj_id, poi_category, poi_name, poi_address);
|
||||
-- PostgreSQL has no ADD CONSTRAINT ... IF NOT EXISTS form, so guard via DO $$
|
||||
-- block (idempotency for DR-restore / re-apply under ON_ERROR_STOP).
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ CREATE TABLE IF NOT EXISTS own_planned_project (
|
|||
-- Тайминг: планируемый месяц выхода в продажу. Хранится как ПЕРВОЕ число месяца
|
||||
-- (YYYY-MM-01) — нормализуется на уровне сервиса/SQL; ось тайминга §25.3.
|
||||
planned_release_month DATE,
|
||||
-- Цена ₽/м² (вилка). min/max независимы; оба ≥0 (guarded CHECK ниже).
|
||||
-- Цена ₽/м² (вилка). Оба ≥0; min ≤ max когда обе границы заданы (guarded CHECK ниже).
|
||||
price_min_per_m2 NUMERIC,
|
||||
price_max_per_m2 NUMERIC,
|
||||
-- Квартирография: доли по форматам {"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 регистронезависимый.';
|
||||
COMMENT ON COLUMN own_planned_project.planned_release_month IS
|
||||
'Планируемый месяц выхода в продажу — ПЕРВОЕ число месяца (YYYY-MM-01). Ось тайминга §25.3.';
|
||||
COMMENT ON COLUMN own_planned_project.price_min_per_m2 IS 'Нижняя граница цены, ₽/м² (≥0).';
|
||||
COMMENT ON COLUMN own_planned_project.price_max_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; ≥ price_min_per_m2 когда обе заданы).';
|
||||
COMMENT ON COLUMN own_planned_project.unit_mix IS
|
||||
'Квартирография: JSONB долей по форматам {"studio":0.3,"1k":0.4,...}; каждая доля в [0,1].';
|
||||
COMMENT ON COLUMN own_planned_project.geom IS
|
||||
|
|
@ -90,6 +90,29 @@ BEGIN
|
|||
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) ──────
|
||||
-- Разрешаем NULL (не задано). Если задан — обязан быть JSON-объектом, и каждое
|
||||
-- значение должно быть числом в [0,1] (доля).
|
||||
|
|
|
|||
33
data/sql/158_pzz_zones_ekb_nulls_not_distinct.sql
Normal file
33
data/sql/158_pzz_zones_ekb_nulls_not_distinct.sql
Normal 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;
|
||||
|
|
@ -12,6 +12,12 @@ ALTER TABLE kn_scrape_runs
|
|||
ADD COLUMN IF NOT EXISTS params JSONB,
|
||||
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
|
||||
ON kn_scrape_runs (heartbeat_at)
|
||||
WHERE status IN ('running', 'resuming');
|
||||
ON kn_scrape_runs (COALESCE(heartbeat_at, started_at))
|
||||
WHERE status = 'running';
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
-- Загружается через Celery task tasks.pzz_sync.sync_pzz_zones_ekb
|
||||
-- (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
|
||||
|
||||
BEGIN;
|
||||
|
|
@ -19,7 +20,12 @@ CREATE TABLE IF NOT EXISTS pzz_zones_ekb (
|
|||
raw_props JSONB DEFAULT '{}'::jsonb,
|
||||
geom GEOMETRY(MultiPolygon, 4326) NOT NULL,
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
"""Reads JSON-encoded string from stdin (the kind MCP returns when wrapping a string),
|
||||
and writes the decoded value as UTF-8 to the given file."""
|
||||
"""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.
|
||||
|
||||
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
|
||||
out_path = sys.argv[1]
|
||||
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)
|
||||
text = decoded if isinstance(decoded, str) else json.dumps(decoded, ensure_ascii=False)
|
||||
os.makedirs(os.path.dirname(out_path) or '.', exist_ok=True)
|
||||
with open(out_path, 'w', encoding='utf-8') as f:
|
||||
f.write(decoded)
|
||||
print(f'wrote {out_path} ({len(decoded)} chars)')
|
||||
f.write(text)
|
||||
print(f'wrote {out_path} ({len(text)} chars)')
|
||||
|
|
|
|||
|
|
@ -71,6 +71,36 @@ export default function ScrapeAllAdminPage() {
|
|||
"all" | "kn" | "nspd" | "objective" | "nspd_geo"
|
||||
>("all");
|
||||
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({
|
||||
queryKey: ["unified-runs", filter],
|
||||
|
|
@ -83,11 +113,17 @@ export default function ScrapeAllAdminPage() {
|
|||
});
|
||||
|
||||
const logs = useQuery({
|
||||
queryKey: ["unified-logs", filter, logsRunId],
|
||||
queryKey: ["unified-logs", filter, logsScraperType, logsRunId],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ limit: "100" });
|
||||
if (filter !== "all") params.set("scraper_type", filter);
|
||||
if (logsRunId !== "") params.set("run_id", String(logsRunId));
|
||||
// Когда открыт конкретный прогон — scope-им логи на его scraper_type
|
||||
// (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[]>(
|
||||
`/api/v1/admin/scrape/all/logs?${params}`,
|
||||
);
|
||||
|
|
@ -95,14 +131,19 @@ export default function ScrapeAllAdminPage() {
|
|||
refetchInterval: 8000,
|
||||
});
|
||||
|
||||
// Сводка по statusам
|
||||
// Сводка по statusам в показанной выборке (top-30, НЕ глобальные тоталы).
|
||||
// v_scrape_runs_unified отдаёт и статусы вне 4 плиток (nspd skipped,
|
||||
// nspd_geo paused/queued) — собираем их в "other", чтобы сумма плиток
|
||||
// совпадала с числом строк, а не молча теряла прогоны.
|
||||
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,
|
||||
number
|
||||
>;
|
||||
const known = new Set(["running", "done", "failed", "zombie"]);
|
||||
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;
|
||||
})();
|
||||
|
|
@ -137,7 +178,7 @@ export default function ScrapeAllAdminPage() {
|
|||
...cardStyle,
|
||||
marginTop: 16,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gridTemplateColumns: "repeat(5, 1fr)",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
|
|
@ -149,6 +190,7 @@ export default function ScrapeAllAdminPage() {
|
|||
<Stat label="✅ успешно" value={summary.done ?? 0} color="#16a34a" />
|
||||
<Stat label="❌ failed" value={summary.failed ?? 0} color="#b3261e" />
|
||||
<Stat label="🧟 zombie" value={summary.zombie ?? 0} color="#9333ea" />
|
||||
<Stat label="• прочие" value={summary.other ?? 0} color="#6b7280" />
|
||||
</section>
|
||||
|
||||
{/* Фильтр + табы */}
|
||||
|
|
@ -160,7 +202,7 @@ export default function ScrapeAllAdminPage() {
|
|||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setFilter(t)}
|
||||
onClick={() => changeFilter(t)}
|
||||
style={{
|
||||
...filterTab,
|
||||
background: filter === t ? "#1d4ed8" : "#f3f4f6",
|
||||
|
|
@ -201,10 +243,13 @@ export default function ScrapeAllAdminPage() {
|
|||
const heartbeatLag = r.heartbeat_at
|
||||
? (Date.now() - new Date(r.heartbeat_at).getTime()) / 1000
|
||||
: null;
|
||||
// Порог выровнен с backend zombie-детектором (10 мин без
|
||||
// heartbeat = точно мёртв, admin_scrape.py). Лаг 5-9 мин при
|
||||
// долгом батче — нормальная живая работа, не "stale".
|
||||
const isStale =
|
||||
r.status === "running" &&
|
||||
heartbeatLag !== null &&
|
||||
heartbeatLag > 300;
|
||||
heartbeatLag > 600;
|
||||
const badge = SCRAPER_BADGE[r.scraper_type];
|
||||
const statusColor = STATUS_COLOR[r.status] ?? "#6b7280";
|
||||
return (
|
||||
|
|
@ -292,7 +337,7 @@ export default function ScrapeAllAdminPage() {
|
|||
r.scraper_type === "nspd_geo") && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLogsRunId(r.run_id)}
|
||||
onClick={() => openLogsFor(r)}
|
||||
style={{
|
||||
...secondaryBtn,
|
||||
padding: "4px 10px",
|
||||
|
|
@ -342,10 +387,11 @@ export default function ScrapeAllAdminPage() {
|
|||
📋 Логи (top 100){" "}
|
||||
{logsRunId !== "" && (
|
||||
<span style={{ fontSize: 13, fontWeight: 400, color: "#5b6066" }}>
|
||||
· фильтр run_id={logsRunId}{" "}
|
||||
· фильтр {logsScraperType && `${logsScraperType} `}run_id=
|
||||
{logsRunId}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLogsRunId("")}
|
||||
onClick={resetLogsFilter}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
|
@ -771,9 +771,8 @@ export default function CadastreScrapeAdminPage() {
|
|||
</thead>
|
||||
<tbody>
|
||||
{jobs.data?.map((job) => (
|
||||
<>
|
||||
<Fragment key={job.job_id}>
|
||||
<tr
|
||||
key={job.job_id}
|
||||
style={{
|
||||
borderTop: "1px solid #f3f4f6",
|
||||
cursor: "pointer",
|
||||
|
|
@ -859,7 +858,7 @@ export default function CadastreScrapeAdminPage() {
|
|||
? expandedJob.data
|
||||
: undefined,
|
||||
)}
|
||||
</>
|
||||
</Fragment>
|
||||
))}
|
||||
{(jobs.data?.length ?? 0) === 0 && (
|
||||
<tr>
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ export default function ObjectDrillInPage() {
|
|||
label="Нежилые"
|
||||
value={nonliv?.total?.toString() ?? "—"}
|
||||
hint={
|
||||
nonliv?.perc != null
|
||||
nonliv?.realised != null
|
||||
? `${nonliv.realised} продано (${nonliv.perc}%)`
|
||||
: undefined
|
||||
}
|
||||
|
|
@ -175,7 +175,7 @@ export default function ObjectDrillInPage() {
|
|||
label="Машино-места"
|
||||
value={parking?.total?.toString() ?? "—"}
|
||||
hint={
|
||||
parking?.perc != null
|
||||
parking?.realised != null
|
||||
? `${parking.realised} продано (${parking.perc}%)`
|
||||
: undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,15 @@ export default function PrinzipPage() {
|
|||
|
||||
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 (
|
||||
<>
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||
|
|
@ -68,7 +77,7 @@ export default function PrinzipPage() {
|
|||
value={d?.avg_area_sqm?.toFixed(1) ?? "—"}
|
||||
unit="м²"
|
||||
delta={{
|
||||
value: `vs рынок 49 м² · разрыв -${d?.avg_area_sqm ? Math.round(49 - d.avg_area_sqm) : 0} м²`,
|
||||
value: areaDeltaValue,
|
||||
positive: false,
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -73,12 +73,20 @@ export default function ConceptPage() {
|
|||
const cadastreGeom = useCadastreGeom();
|
||||
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) {
|
||||
cadastreGeom.mutate(cad, {
|
||||
onSuccess: (geom) => {
|
||||
setPolygon(geom);
|
||||
// Fresh boundary invalidates a previous concept run.
|
||||
concept.reset();
|
||||
handlePolygonChange(geom);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -202,7 +210,7 @@ export default function ConceptPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<ConceptDrawMap polygon={polygon} onChange={setPolygon} />
|
||||
<ConceptDrawMap polygon={polygon} onChange={handlePolygonChange} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -53,8 +53,8 @@ const SOURCES: DataSource[] = [
|
|||
"Цены предложения и скорость продаж по жилым комплексам (sale-graph, velocity).",
|
||||
license: "Коммерческий доступ по договору (платный тариф)",
|
||||
attribution: "Данные о ценах предложения предоставлены сервисом «Объектив»",
|
||||
href: "https://objced.ru",
|
||||
hrefLabel: "objced.ru",
|
||||
href: "https://objctv.ru",
|
||||
hrefLabel: "objctv.ru",
|
||||
},
|
||||
{
|
||||
name: "Open-Meteo",
|
||||
|
|
|
|||
|
|
@ -67,16 +67,21 @@ function isTabId(v: string | null | undefined): v is TabId {
|
|||
|
||||
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 {
|
||||
if (pct > 5) return "green";
|
||||
if (pct > 8) return "green";
|
||||
if (pct > 0) return "blue";
|
||||
if (pct > -5) return "amber";
|
||||
return "red";
|
||||
}
|
||||
|
||||
// Пороги синхронизированы с backend noise.level (parcels.py:1809-1814):
|
||||
// <50 «тихо» (green), <65 «умеренный» (amber), иначе «шумно» (red). #1416.
|
||||
function noiseColor(db: number): KpiColor {
|
||||
if (db < 45) return "green";
|
||||
if (db < 60) return "amber";
|
||||
if (db < 50) return "green";
|
||||
if (db < 65) return "amber";
|
||||
return "red";
|
||||
}
|
||||
|
||||
|
|
@ -195,8 +200,12 @@ function SiteFinderContent() {
|
|||
: { weights },
|
||||
});
|
||||
// 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
|
||||
}, [debouncedWeightsChange]);
|
||||
}, [debouncedWeightsChange, data?.cad_num]);
|
||||
|
||||
function handleAnalyze(cadNum: string) {
|
||||
setIsochrones(undefined);
|
||||
|
|
@ -222,11 +231,12 @@ function SiteFinderContent() {
|
|||
) {
|
||||
setCurrentWeights(weights);
|
||||
setActiveProfileId(profileId);
|
||||
// Enqueue a debounced re-analyze if a parcel is loaded (#201 Phase 2).
|
||||
// The actual mutate fires after 300ms of silence via debouncedWeightsChange.
|
||||
if (data?.cad_num) {
|
||||
setPendingWeightsChange({ weights, profileId });
|
||||
}
|
||||
// Enqueue a debounced re-analyze (#201 Phase 2). The actual mutate fires
|
||||
// after 300ms of silence via debouncedWeightsChange. Не гардим по
|
||||
// data?.cad_num: если веса меняют пока первичный analyze ещё в полёте,
|
||||
// pending всё равно ставится и применяется, когда data придёт — иначе
|
||||
// изменение молча теряется и score не соответствует ползункам (#1418).
|
||||
setPendingWeightsChange({ weights, profileId });
|
||||
}
|
||||
|
||||
// Derive KPI values from data
|
||||
|
|
@ -242,9 +252,15 @@ function SiteFinderContent() {
|
|||
: "neutral";
|
||||
|
||||
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
|
||||
? `${(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;
|
||||
|
|
@ -261,8 +277,10 @@ function SiteFinderContent() {
|
|||
const noiseDb = data?.noise?.estimated_db;
|
||||
const noiseValue = noiseDb != null ? `${Math.round(noiseDb)} dB` : "—";
|
||||
const noiseLabel = data?.noise ? `Шум · ${data.noise.level}` : "Шум";
|
||||
// Цвет считаем от того же округлённого значения, что показано в KPI,
|
||||
// чтобы число и окраска не расходились у границы порога (#1417).
|
||||
const noiseKpiColor: KpiColor =
|
||||
noiseDb != null ? noiseColor(noiseDb) : "neutral";
|
||||
noiseDb != null ? noiseColor(Math.round(noiseDb)) : "neutral";
|
||||
|
||||
return (
|
||||
<main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}>
|
||||
|
|
|
|||
|
|
@ -44,10 +44,16 @@ export default function ComparePage() {
|
|||
if (
|
||||
existing &&
|
||||
existing.status === col.status &&
|
||||
existing.score === col.score &&
|
||||
existing.errorMsg === col.errorMsg &&
|
||||
existing.districtName === col.districtName &&
|
||||
existing.verdictLabel === col.verdictLabel &&
|
||||
existing.score === col.score &&
|
||||
existing.scoreLabel === col.scoreLabel &&
|
||||
existing.velocityScore === col.velocityScore &&
|
||||
existing.velocityDataAvailable === col.velocityDataAvailable &&
|
||||
existing.medianPricePerM2 === col.medianPricePerM2 &&
|
||||
existing.pipeline24mo === col.pipeline24mo &&
|
||||
existing.confidenceLabel === col.confidenceLabel &&
|
||||
existing.confidenceValue === col.confidenceValue
|
||||
) {
|
||||
return prev;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { CadInput } from "@/components/site-finder/CadInput";
|
||||
import { MapFilterBar } from "@/components/site-finder/entry/MapFilterBar";
|
||||
|
|
@ -57,15 +57,38 @@ interface FilterBarBridgeProps {
|
|||
}
|
||||
|
||||
function FilterBarBridge({ filters, onChange, bbox }: FilterBarBridgeProps) {
|
||||
const { data: allInBbox } = useParcelsBboxQuery(bbox, {});
|
||||
const { data: filtered } = useParcelsBboxQuery(bbox, filters);
|
||||
// useParcelsBboxQuery has no keepPreviousData (unlike useParcelAnalyzeQuery),
|
||||
// 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 (
|
||||
<MapFilterBar
|
||||
filters={filters}
|
||||
onChange={onChange}
|
||||
totalCount={allInBbox?.length ?? 0}
|
||||
matchCount={filtered?.length ?? 0}
|
||||
totalCount={counts.totalCount}
|
||||
matchCount={counts.matchCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Suspense, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import type {
|
||||
|
|
@ -12,19 +12,12 @@ import { EstimateForm } from "@/components/trade-in/EstimateForm";
|
|||
import { EstimateResult } from "@/components/trade-in/EstimateResult";
|
||||
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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function TradeInPage() {
|
||||
function TradeInInner() {
|
||||
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)
|
||||
const [freshResult, setFreshResult] = useState<{
|
||||
|
|
@ -32,10 +25,14 @@ export default function TradeInPage() {
|
|||
input: TradeInEstimateInput;
|
||||
} | null>(null);
|
||||
|
||||
const urlEstimateId = useEstimateId();
|
||||
// Fetch from URL if no fresh result yet
|
||||
// Drop the fresh result when the URL points at a different estimate so a
|
||||
// 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(
|
||||
freshResult === null ? urlEstimateId : null,
|
||||
activeFreshResult === null ? urlEstimateId : null,
|
||||
);
|
||||
|
||||
const mutation = useEstimateMutation();
|
||||
|
|
@ -44,6 +41,9 @@ export default function TradeInPage() {
|
|||
mutation.mutate(input, {
|
||||
onSuccess: (estimate) => {
|
||||
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
|
||||
router.replace(`/trade-in?id=${estimate.estimate_id}`, {
|
||||
scroll: false,
|
||||
|
|
@ -55,17 +55,24 @@ export default function TradeInPage() {
|
|||
const apiError = mutation.error?.message ?? null;
|
||||
|
||||
// Determine what to render on the right side
|
||||
const restoreError = restoredEstimate.error?.message ?? null;
|
||||
|
||||
const resultData: {
|
||||
estimate: AggregatedEstimate;
|
||||
input: TradeInEstimateInput;
|
||||
} | null =
|
||||
freshResult ??
|
||||
activeFreshResult ??
|
||||
(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: {
|
||||
address: restoredEstimate.data.analogs[0]?.address ?? "—",
|
||||
address: "",
|
||||
area_m2: 0,
|
||||
rooms: 0,
|
||||
floor: 0,
|
||||
|
|
@ -131,6 +138,8 @@ export default function TradeInPage() {
|
|||
/>
|
||||
) : restoredEstimate.isLoading ? (
|
||||
<EstimateProgress visible />
|
||||
) : restoreError ? (
|
||||
<RestoreError />
|
||||
) : (
|
||||
<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() {
|
||||
return (
|
||||
<div
|
||||
|
|
@ -181,3 +200,34 @@ function EmptyState() {
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,8 +76,12 @@ export function BulkGeoPanel() {
|
|||
},
|
||||
});
|
||||
|
||||
const parallelismValid =
|
||||
Number.isInteger(parallelism) && parallelism >= 1 && parallelism <= 10;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!parallelismValid) return;
|
||||
bulkMutation.mutate();
|
||||
};
|
||||
|
||||
|
|
@ -168,20 +172,23 @@ export function BulkGeoPanel() {
|
|||
<span style={labelStyle}>Parallelism (1–10)</span>
|
||||
<input
|
||||
type="number"
|
||||
value={parallelism}
|
||||
value={Number.isNaN(parallelism) ? "" : parallelism}
|
||||
min={1}
|
||||
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 }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={bulkMutation.isPending}
|
||||
disabled={bulkMutation.isPending || !parallelismValid}
|
||||
style={
|
||||
bulkMutation.isPending
|
||||
? { ...triggerBtn, opacity: 0.6 }
|
||||
bulkMutation.isPending || !parallelismValid
|
||||
? { ...triggerBtn, opacity: 0.6, cursor: "not-allowed" }
|
||||
: triggerBtn
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -68,6 +68,12 @@ export function JobSettingsPanel() {
|
|||
const [drafts, setDrafts] = useState<Record<string, RowDraft>>({});
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
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 queryClient = useQueryClient();
|
||||
|
||||
|
|
@ -132,6 +138,18 @@ export function JobSettingsPanel() {
|
|||
"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>) => {
|
||||
|
|
@ -197,22 +215,27 @@ export function JobSettingsPanel() {
|
|||
cron_schedule: draft.cron_schedule.trim() || null,
|
||||
rate_ms: draft.rate_ms !== "" ? Number(draft.rate_ms) : null,
|
||||
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) {
|
||||
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 });
|
||||
};
|
||||
|
||||
const isPendingFor = (job_type: string) =>
|
||||
updateMutation.isPending &&
|
||||
(
|
||||
updateMutation.variables as
|
||||
| { job_type: string; update: JobSettingUpdate }
|
||||
| undefined
|
||||
)?.job_type === job_type;
|
||||
const isPendingFor = (job_type: string) => pendingJobTypes.has(job_type);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const COLS: {
|
|||
{
|
||||
key: "sverdl_sqm_th",
|
||||
label: "тыс м²",
|
||||
format: (v) => (v ? v.toFixed(0) : "—"),
|
||||
format: (v) => (v != null ? v.toFixed(0) : "—"),
|
||||
},
|
||||
{
|
||||
key: "sold_pct",
|
||||
|
|
@ -37,12 +37,12 @@ const COLS: {
|
|||
{
|
||||
key: "avg_area_sqm",
|
||||
label: "ср. м²",
|
||||
format: (v) => (v ? v.toFixed(1) : "—"),
|
||||
format: (v) => (v != null ? v.toFixed(1) : "—"),
|
||||
},
|
||||
{
|
||||
key: "pct_three_plus",
|
||||
label: "3-к+ %",
|
||||
format: (v) => (v ? v.toFixed(0) : "—"),
|
||||
format: (v) => (v != null ? v.toFixed(0) : "—"),
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
|
|||
const [activeCat, setActiveCat] = useState<string | null>(null);
|
||||
const [shown, setShown] = useState(PAGE_SIZE);
|
||||
const { data, isLoading, isFetching } = useObjectInfrastructure(objId, {
|
||||
category: activeCat ?? undefined,
|
||||
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]);
|
||||
}, [data]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
activeCat == null
|
||||
? (data ?? [])
|
||||
: (data ?? []).filter((p) => (p.poi_category ?? "—") === activeCat),
|
||||
[data, activeCat],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
|
|
@ -144,7 +151,7 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data ?? []).slice(0, shown).map((p, i) => (
|
||||
{filtered.slice(0, shown).map((p, i) => (
|
||||
<tr
|
||||
key={`${p.poi_name}-${p.lat}-${p.lon}`}
|
||||
style={{
|
||||
|
|
@ -184,7 +191,7 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
|
|||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{(data?.length ?? 0) > shown ? (
|
||||
{filtered.length > shown ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
|
|
@ -204,7 +211,7 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
|
|||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Показать ещё {Math.min(PAGE_SIZE, (data?.length ?? 0) - shown)}
|
||||
Показать ещё {Math.min(PAGE_SIZE, filtered.length - shown)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,78 @@ export function PrinzipGapBar() {
|
|||
|
||||
const option = useMemo(() => {
|
||||
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 {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
|
|
@ -33,36 +105,11 @@ export function PrinzipGapBar() {
|
|||
].join("<br/>");
|
||||
},
|
||||
},
|
||||
legend: { data: ["PRINZIP", "Рынок Свердл", "Брусника", "Форум-групп"] },
|
||||
grid: { left: 120, right: 32, top: 40, bottom: 28 },
|
||||
xAxis: { type: "value" },
|
||||
yAxis: { type: "category", data: rows.map((r) => r.label) },
|
||||
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" },
|
||||
},
|
||||
],
|
||||
legend: { data: developers.map((d) => d.name) },
|
||||
grid: grids,
|
||||
xAxis: xAxes,
|
||||
yAxis: yAxes,
|
||||
series,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,12 @@ export function QuartirographyChart() {
|
|||
"3-к 60-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,
|
||||
"2-к 45-60": portfolioRows.find((r) => r.bucket === "2-к")?.percent ?? 0,
|
||||
"3-к 60-80": portfolioRows.find((r) => r.bucket === "3-к")?.percent ?? 0,
|
||||
|
|
@ -32,13 +37,13 @@ export function QuartirographyChart() {
|
|||
const dealsPercents = buckets.map(
|
||||
(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 {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "shadow" },
|
||||
valueFormatter: (v: number) => `${v}%`,
|
||||
valueFormatter: (v: number | null) => (v == null ? "нет данных" : `${v}%`),
|
||||
},
|
||||
legend: { data: ["Что строится (портфель)", "Что покупают (ДДУ)"] },
|
||||
grid: { left: 110, right: 32, top: 40, bottom: 28 },
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export function RecommendRevenueChart({ bucketLabels, revenuesRub }: Props) {
|
|||
itemStyle: { color: COLORS[i % COLORS.length] },
|
||||
emphasis: { focus: "series" },
|
||||
label: {
|
||||
show: (valuesMln[i] ?? 0) >= total * 0.05,
|
||||
show: total > 0 && (valuesMln[i] ?? 0) >= total * 0.05,
|
||||
formatter: (p: { value: number; seriesName: string }) =>
|
||||
`${p.seriesName}\n${p.value.toFixed(1)} млн`,
|
||||
color: "#fff",
|
||||
|
|
|
|||
|
|
@ -69,8 +69,13 @@ export function RecommendVelocityPanel({
|
|||
const bucketPfPow = priceFactor > 0 ? priceFactor ** be : 1;
|
||||
const adjustedV = v * bucketPfPow;
|
||||
adjustedVelocity += adjustedV * shareRatio;
|
||||
if (u > 0 && adjustedV > 0) {
|
||||
const months = u / adjustedV;
|
||||
// Liquidity-24 KPI должен совпадать с точкой кривой
|
||||
// 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);
|
||||
weightedSold24 += fracIn24 * u;
|
||||
}
|
||||
|
|
@ -492,8 +497,11 @@ export function RecommendVelocityPanel({
|
|||
? ` (raw ×${scope.velocity_trend_ratio.toFixed(2)} clamp 0.7..2.0)`
|
||||
: ""}{" "}
|
||||
· <strong>POI ×{scope.poi_factor.toFixed(2)}</strong> (на цену) ). При
|
||||
price ×{priceFactor.toFixed(2)} темп = базовый ×{" "}
|
||||
{priceFactor.toFixed(2)}^{elasticity} = ×{pfPow.toFixed(3)}.
|
||||
price ×{priceFactor.toFixed(2)} ценовой множитель темпа по общей
|
||||
эластичности = {priceFactor.toFixed(2)}^{elasticity} = ×
|
||||
{pfPow.toFixed(3)}. Показанный «Темп продаж» суммирует бакеты с их
|
||||
собственной эластичностью (Tier 3) и текущим распределением долей,
|
||||
поэтому может отличаться от базовый ×{pfPow.toFixed(3)}.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -64,12 +64,17 @@ export function RouteGuard({ children }: RouteGuardProps) {
|
|||
// #799: pilot-only юзер (единственная доступная зона — trade-in) на
|
||||
// запрещённом пути главного фронта (корень `/` и пр.) → авто-redirect в
|
||||
// /trade-in/ вместо дед-энда «Доступа нет». Условия:
|
||||
// - роль pilot (trade-in — её ЕДИНСТВЕННАЯ рабочая зона; paths scoped
|
||||
// только на /trade-in/**). #1439: без этой проверки multi-zone роли
|
||||
// (напр. analyst с paths="/**") на запрещённом /admin/* молча
|
||||
// редиректились в /trade-in/ вместо честного NoAccessScreen;
|
||||
// - текущий путь НЕ под /trade-in (иначе цикл; /trade-in — отдельный
|
||||
// bundle за Caddy, этот гард его не обслуживает, но guard на всякий);
|
||||
// - у роли есть доступ к /trade-in/ (т.е. trade-in — её рабочая зона).
|
||||
// admin / multi-scope с разрешённым `/` сюда не попадают (isPathAllowed=true).
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
data.role === "pilot" &&
|
||||
!pathname.startsWith("/trade-in") &&
|
||||
isPathAllowed(data.allowed_paths, data.deny_paths, "/trade-in/")
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -100,7 +100,11 @@ export function UserMenu() {
|
|||
if (isLoading || error || !data) return null;
|
||||
|
||||
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 (
|
||||
<div ref={containerRef} style={{ position: "relative" }}>
|
||||
|
|
|
|||
|
|
@ -54,8 +54,9 @@ function DrawLayer({
|
|||
|
||||
const onCreated = (e: L.LeafletEvent) => {
|
||||
const { layer } = e as L.DrawEvents.Created;
|
||||
group.clearLayers();
|
||||
group.addLayer(layer);
|
||||
// Don't add the layer to the FeatureGroup: the polygon is controlled by
|
||||
// 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();
|
||||
if (gj.type === "Feature" && gj.geometry.type === "Polygon") {
|
||||
onPolygon(gj.geometry);
|
||||
|
|
|
|||
|
|
@ -77,6 +77,17 @@ export function ConceptExportButtons({ variant }: Props) {
|
|||
);
|
||||
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()}`;
|
||||
|
||||
function handleGeojson() {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
* 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 type { FeatureCollection, Polygon, Position } from "geojson";
|
||||
import L from "leaflet";
|
||||
|
|
@ -67,7 +67,13 @@ export function ConceptResultMap({
|
|||
variantKey,
|
||||
height = 360,
|
||||
}: 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;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -108,8 +108,13 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
|||
color: "var(--fg-on-dark-muted)",
|
||||
}}
|
||||
>
|
||||
{STRATEGY_HINTS[variant.strategy]} IRR {formatPct(financial.irr)} ·
|
||||
плотность {teap.density.toLocaleString("ru-RU")} м²/га.
|
||||
{STRATEGY_HINTS[variant.strategy]} IRR-proxy{" "}
|
||||
{formatPct(financial.irr)} · плотность (FAR){" "}
|
||||
{teap.density.toLocaleString("ru-RU", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -158,9 +163,11 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
|||
unit="шт"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Плотность"
|
||||
value={teap.density.toLocaleString("ru-RU")}
|
||||
unit="м²/га"
|
||||
label="Плотность (FAR)"
|
||||
value={teap.density.toLocaleString("ru-RU", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Машино-мест"
|
||||
|
|
@ -196,16 +203,21 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
|||
value={formatMoneyCompact(financial.gross_margin_rub)}
|
||||
delta={{
|
||||
value:
|
||||
marginPositive === false
|
||||
? "Отрицательная маржа"
|
||||
: "Положительная маржа",
|
||||
marginPositive === true
|
||||
? "Положительная маржа"
|
||||
: marginPositive === false
|
||||
? "Отрицательная маржа"
|
||||
: "Нулевая маржа",
|
||||
positive: marginPositive,
|
||||
}}
|
||||
/>
|
||||
<KpiCard
|
||||
label="IRR"
|
||||
label="IRR-proxy"
|
||||
value={formatPct(financial.irr)}
|
||||
delta={{ value: "Внутренняя норма доходности", positive: null }}
|
||||
delta={{
|
||||
value: "Упрощённая оценка: маржа на затраты, без дисконтирования",
|
||||
positive: null,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
|
|
|||
|
|
@ -47,10 +47,17 @@ export const PILOT_EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 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 {
|
||||
return "GD-" + id.replace(/-/g, "").slice(0, 8).toUpperCase();
|
||||
return "GD-" + id.replace(/-/g, "").slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@ const TIME_WINDOW_LABELS: Record<TimeWindow, string> = {
|
|||
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> = {
|
||||
studio: "Студия",
|
||||
"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());
|
||||
|
||||
if (rows.length === 0) {
|
||||
|
|
@ -333,7 +347,7 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
|
|||
borderRadius: 4,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title="Сделки за 24 мес превышают текущий snapshot supply — значение обрезано до 100%"
|
||||
title={`Сделки ${oversoldPeriodLabel} превышают текущий snapshot supply — значение обрезано до 100%`}
|
||||
>
|
||||
>100%
|
||||
</span>
|
||||
|
|
@ -655,6 +669,11 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
|
|||
const [minVelocity, setMinVelocity] = useState(0.5);
|
||||
const [isPdfLoading, setIsPdfLoading] = useState(false);
|
||||
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);
|
||||
|
||||
|
|
@ -676,13 +695,17 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
|
|||
}
|
||||
|
||||
function handleCalculate() {
|
||||
mutate(buildRequest());
|
||||
const req = buildRequest();
|
||||
setLastRequest(req);
|
||||
mutate(req);
|
||||
}
|
||||
|
||||
async function handleDownloadPdf() {
|
||||
// Use the request that produced the shown `data`, not live control state.
|
||||
if (!lastRequest) return;
|
||||
setIsPdfLoading(true);
|
||||
try {
|
||||
const req = buildRequest();
|
||||
const req = lastRequest;
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/parcels/${encodeURIComponent(cadNum)}/best-layouts/pdf`,
|
||||
{
|
||||
|
|
@ -994,7 +1017,14 @@ export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
|
|||
{data && !isPending && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<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} />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,15 @@ export function CpLayerControlPanel({
|
|||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -406,7 +406,7 @@ function WeatherBlock({
|
|||
}}
|
||||
title={weather.note}
|
||||
>
|
||||
{weather.source} · 7 дн.
|
||||
{weather.source} · {weather.forecast_days} дн.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ export function MarketTab({ data }: Props) {
|
|||
}}
|
||||
>
|
||||
<strong>
|
||||
{activeCount} строящихся в {radiusKm}км
|
||||
{activeCount} активных в {radiusKm}км
|
||||
</strong>
|
||||
{" · велосити "}
|
||||
<strong>
|
||||
|
|
@ -147,7 +147,7 @@ export function MarketTab({ data }: Props) {
|
|||
{activeCount}
|
||||
</div>
|
||||
<div style={{ marginTop: 4, fontSize: 12, color: "#73767e" }}>
|
||||
строящихся в радиусе {radiusKm}км
|
||||
строящихся и недавно сданных в радиусе {radiusKm}км
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -82,7 +82,10 @@ export function MarketTrendBlock({ trend }: Props) {
|
|||
|
||||
const arrow = trendArrow(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",
|
||||
color: "#374151",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ interface MergedRow {
|
|||
type: string | null;
|
||||
distanceM: number;
|
||||
source: "analyze" | "connection-points";
|
||||
// Stable structure identifier (НСПД cad_num) when available — используется для
|
||||
// дедупа между источниками, т.к. distanceM меряется до разных опорных точек
|
||||
// (analyze — до центроида, connection-points — до границы участка).
|
||||
cadNum: string | null;
|
||||
}
|
||||
|
||||
export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
|
||||
|
|
@ -28,6 +32,7 @@ export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
|
|||
type: item.type,
|
||||
distanceM: item.distance_m,
|
||||
source: "analyze",
|
||||
cadNum: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -39,14 +44,21 @@ export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
|
|||
type: s.type,
|
||||
distanceM: s.distance_to_boundary_m,
|
||||
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 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;
|
||||
seen.add(key);
|
||||
return true;
|
||||
|
|
@ -57,7 +69,7 @@ export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
|
|||
if (deduped.length === 0 && !cpLoading) {
|
||||
return (
|
||||
<div style={{ fontSize: 13, color: "#6b7280", fontStyle: "italic" }}>
|
||||
Инженерных объектов в 200 м не найдено
|
||||
Инженерных объектов поблизости не найдено
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,11 @@ const CLASS_LABEL: Record<string, string> = {
|
|||
|
||||
function fmtMonth(iso: string | null): string {
|
||||
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);
|
||||
if (Number.isNaN(d.getTime())) return iso.substring(0, 7);
|
||||
return d.toLocaleDateString("ru-RU", { year: "numeric", month: "2-digit" });
|
||||
|
|
|
|||
|
|
@ -311,7 +311,9 @@ export function ScoreBreakdownStackedBar({
|
|||
color: "#6b7280",
|
||||
}}
|
||||
>
|
||||
{aggregated.map((agg) => {
|
||||
{aggregated
|
||||
.filter((a) => a.contribution !== 0)
|
||||
.map((agg) => {
|
||||
const color = agg.isCustom
|
||||
? CATEGORY_COLORS.custom
|
||||
: categoryColor(agg.category);
|
||||
|
|
@ -373,11 +375,11 @@ export function ScoreBreakdownStackedBar({
|
|||
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||
{sourceAgg.map(({ factor, contribution }) => {
|
||||
const isCustom = customSet.has(factor);
|
||||
const maxAbs =
|
||||
Math.max(...sourceAgg.map((s) => Math.abs(s.contribution))) || 1;
|
||||
const barWidth = Math.min(
|
||||
100,
|
||||
(Math.abs(contribution) /
|
||||
(Math.abs(sourceAgg[0]?.contribution) || 1)) *
|
||||
100,
|
||||
(Math.abs(contribution) / maxAbs) * 100,
|
||||
);
|
||||
const color =
|
||||
contribution >= 0
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ function SeasonCard({
|
|||
{stats.avg_t_min_c}…{stats.avg_t_max_c}°C
|
||||
</div>
|
||||
<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 style={{ fontSize: 12, color: "#374151" }}>
|
||||
{stats.total_precip_mm} мм осадков
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Velocity } from "@/types/site-finder";
|
|||
import { SectionLabel } from "@/components/ui/SectionLabel";
|
||||
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];
|
||||
|
||||
const BUCKET_LABEL: Record<KnownBucket, string> = {
|
||||
|
|
@ -11,7 +11,8 @@ const BUCKET_LABEL: Record<KnownBucket, string> = {
|
|||
"1": "1-к",
|
||||
"2": "2-к",
|
||||
"3": "3-к",
|
||||
"4+": "4+",
|
||||
"4": "4-к",
|
||||
"5+": "5+",
|
||||
};
|
||||
|
||||
interface VelocityBlockProps {
|
||||
|
|
@ -253,7 +254,7 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
|
|||
)}
|
||||
|
||||
{/* Top competitors */}
|
||||
{velocity.sample_competitors.length > 0 && (
|
||||
{dataAvailable && velocity.sample_competitors.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel style={{ marginBottom: 6 }}>Топ продавцов</SectionLabel>
|
||||
<table
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { SectionLabel } from "@/components/ui/SectionLabel";
|
||||
import {
|
||||
|
|
@ -89,9 +89,6 @@ export function WeightProfilePanel({
|
|||
const [saveName, setSaveName] = useState("");
|
||||
const [saveDefault, setSaveDefault] = useState(false);
|
||||
|
||||
// Debounce timer ref
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Profiles query (only when userId + adminToken provided)
|
||||
const canUseCrud = !!userId && !!adminToken;
|
||||
const profilesQuery = useWeightProfiles(userId ?? "", adminToken ?? "");
|
||||
|
|
@ -102,11 +99,6 @@ export function WeightProfilePanel({
|
|||
|
||||
function handleSliderChange(cat: PoiCategoryKey, raw: string) {
|
||||
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 }));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -205,11 +205,14 @@ export function ZouitLayer({ grouped, visibleSeverities }: LayerProps) {
|
|||
return (
|
||||
<GeoJSON
|
||||
// react-leaflet GeoJSON кэширует слой и не реагирует на смену
|
||||
// data prop — хеш в key форсирует remount при смене участка
|
||||
// (тот же приём, что у изохрон/риск-зон в SiteMap/MarketLayers).
|
||||
key={`zouit-${sev}-${idx}-${
|
||||
overlap.geom_geojson?.slice(0, 24) ?? ""
|
||||
}`}
|
||||
// data prop — геометрия в key форсирует remount при смене
|
||||
// участка (тот же приём, что у изохрон/риск-зон в
|
||||
// SiteMap/MarketLayers). slice(0,24) GeoJSON-строки тут НЕ
|
||||
// годится: префикс `{"type":"Polygon","coord` константен и не
|
||||
// зависит от координат, поэтому при A→B с тем же числом
|
||||
// полигонов в том же порядке ключи совпадали и старая
|
||||
// геометрия оставалась на карте. Берём строку целиком.
|
||||
key={`zouit-${sev}-${idx}-${overlap.geom_geojson ?? ""}`}
|
||||
data={feature}
|
||||
style={{
|
||||
color: style.color,
|
||||
|
|
|
|||
|
|
@ -30,8 +30,20 @@ const NAV_SECTIONS: NavSection[] = [
|
|||
{ id: "section-3-3", label: "3.3 Остатки и скорость" },
|
||||
],
|
||||
},
|
||||
{ id: "section-4", label: "4. Инфраструктура" },
|
||||
{ id: "section-5", label: "5. Свежесть" },
|
||||
{ id: "section-4", label: "4. Оценка" },
|
||||
{ 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)
|
||||
|
|
|
|||
|
|
@ -18,11 +18,9 @@ interface Row {
|
|||
|
||||
function formatDate(raw: string | null): string {
|
||||
if (!raw) return "—";
|
||||
try {
|
||||
return new Date(raw).toLocaleDateString("ru-RU");
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
const d = new Date(raw);
|
||||
if (Number.isNaN(d.getTime())) return raw;
|
||||
return d.toLocaleDateString("ru-RU");
|
||||
}
|
||||
|
||||
function buildRows(data: ParcelEgrn): Row[] {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ function factorLabel(key: string): string {
|
|||
function formatFactorValue(value: number | null): string | null {
|
||||
if (value == null) return null;
|
||||
// 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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,12 @@ interface Props {
|
|||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
const top7 = [...items]
|
||||
.sort((a, b) => b.score_contribution - a.score_contribution)
|
||||
|
|
@ -130,7 +136,7 @@ export function PoiList2Gis({ items, totalScore }: Props) {
|
|||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{totalScore.toFixed(0)} / 100
|
||||
{displayScore.toFixed(0)} / 100
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -55,17 +55,25 @@ function pickHorizonForecast(
|
|||
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(
|
||||
key: ScenarioKey,
|
||||
scenarios: ReportScenarios,
|
||||
scenariosSummary: ScenariosSummary | null,
|
||||
targetHorizon: number,
|
||||
): number | null {
|
||||
): ScenarioDeficit {
|
||||
const sc = scenarios.by_scenario[key];
|
||||
const fc = sc ? pickHorizonForecast(sc.forecasts, targetHorizon) : null;
|
||||
if (fc) return fc.deficit_index;
|
||||
if (scenariosSummary) return scenariosSummary[key];
|
||||
return null;
|
||||
if (fc) return { value: fc.deficit_index, horizon: fc.horizon_months };
|
||||
// scenarios_summary is, by contract, the deficit_index at the target horizon.
|
||||
if (scenariosSummary)
|
||||
return { value: scenariosSummary[key], horizon: targetHorizon };
|
||||
return { value: null, horizon: null };
|
||||
}
|
||||
|
||||
export function ScenariosBlock({
|
||||
|
|
@ -107,7 +115,8 @@ export function ScenariosBlock({
|
|||
key={key}
|
||||
name={SCENARIO_RU[key]}
|
||||
hint={SCENARIO_HINT[key]}
|
||||
deficitIndex={di}
|
||||
deficitIndex={di.value}
|
||||
deficitHorizon={di.horizon}
|
||||
ratePath={sc?.rate_path}
|
||||
targetHorizon={targetHorizon}
|
||||
/>
|
||||
|
|
@ -115,8 +124,10 @@ export function ScenariosBlock({
|
|||
})}
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: "var(--fg-tertiary)", margin: 0 }}>
|
||||
Индекс дефицита приведён на целевом горизонте {targetHorizon} мес.
|
||||
Конверт ставки — путь ключевой ставки (% годовых) по горизонтам.
|
||||
Индекс дефицита приведён на целевом горизонте {targetHorizon} мес.; если
|
||||
ряд на этом горизонте отсутствует, используется самый длинный доступный
|
||||
— его горизонт указан в подписи карточки. Конверт ставки — путь ключевой
|
||||
ставки (% годовых) по горизонтам.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -126,15 +137,20 @@ function ScenarioCard({
|
|||
name,
|
||||
hint,
|
||||
deficitIndex,
|
||||
deficitHorizon,
|
||||
ratePath,
|
||||
targetHorizon,
|
||||
}: {
|
||||
name: string;
|
||||
hint: string;
|
||||
deficitIndex: number | null;
|
||||
/** Horizon (months) the deficit value corresponds to; falls back to target. */
|
||||
deficitHorizon: number | null;
|
||||
ratePath: RatePath | undefined;
|
||||
targetHorizon: number;
|
||||
}) {
|
||||
// Label the number with the horizon it actually represents, not the target.
|
||||
const labelHorizon = deficitHorizon ?? targetHorizon;
|
||||
const horizons = ratePath
|
||||
? Object.keys(ratePath)
|
||||
.map((h) => Number(h))
|
||||
|
|
@ -163,7 +179,9 @@ function ScenarioCard({
|
|||
>
|
||||
{name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--fg-tertiary)", marginTop: 2 }}>
|
||||
<div
|
||||
style={{ fontSize: 12, color: "var(--fg-tertiary)", marginTop: 2 }}
|
||||
>
|
||||
{hint}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -179,7 +197,7 @@ function ScenarioCard({
|
|||
color: "var(--fg-secondary)",
|
||||
}}
|
||||
>
|
||||
Индекс дефицита · {targetHorizon} мес.
|
||||
Индекс дефицита · {labelHorizon} мес.
|
||||
</span>
|
||||
{deficitIndex != null ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
|
|
|
|||
|
|
@ -214,9 +214,12 @@ export function Section1ParcelInfo({ cad }: Props) {
|
|||
|
||||
// HeadlineBar texts
|
||||
const headlineTitle = scoreVerdict(data.score, data.score_label);
|
||||
// #1467: при отсутствии score_explanation (старый деплой / частичный мок /
|
||||
// backend без поля) НЕ выдумываем дельту к району — формируем подзаголовок
|
||||
// только из реально присутствующих полей (POI в радиусе 1 км).
|
||||
const headlineSubtitle = data.score_explanation
|
||||
? data.score_explanation
|
||||
: `Score +12 vs район · ${data.poi_count} POI в радиусе 1 км`;
|
||||
: `${data.poi_count} POI в радиусе 1 км`;
|
||||
|
||||
return (
|
||||
<section
|
||||
|
|
@ -299,20 +302,13 @@ export function Section1ParcelInfo({ cad }: Props) {
|
|||
totalScore={poiData.poi_weighted_score}
|
||||
/>
|
||||
) : (
|
||||
// Fallback: build from analyze score_breakdown
|
||||
<PoiList2Gis
|
||||
items={Object.entries(data.score_breakdown).flatMap(
|
||||
([cat, pois]) =>
|
||||
pois.slice(0, 2).map((poi) => ({
|
||||
category: cat,
|
||||
name: poi.name ?? cat,
|
||||
distance_m: poi.distance_m,
|
||||
weight: 0.1,
|
||||
score_contribution: 5,
|
||||
})),
|
||||
)}
|
||||
totalScore={data.score}
|
||||
/>
|
||||
// Fallback: B6 poi-score (useParcelPoiScoreQuery) ещё грузится или
|
||||
// упал. score_breakdown несёт только name/distance_m — без реального
|
||||
// per-POI weight и без POI-weighted-score, поэтому НЕ подставляем
|
||||
// инвест-балл участка (data.score) как POI-оценку «X / 100» и НЕ
|
||||
// фабрикуем weight/score_contribution (#1466). Показываем честный
|
||||
// пустой стейт «POI данные недоступны» до прихода B6.
|
||||
<PoiList2Gis items={[]} totalScore={0} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ interface UtilityDisplayConfig {
|
|||
|
||||
function getUtilityConfig(subtype: string): UtilityDisplayConfig {
|
||||
switch (subtype) {
|
||||
// ── Электричество ──
|
||||
case "substation":
|
||||
return {
|
||||
label: "Электричество (подстанция)",
|
||||
|
|
@ -43,6 +44,13 @@ function getUtilityConfig(subtype: string): UtilityDisplayConfig {
|
|||
<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":
|
||||
return {
|
||||
label: "Электричество (ЛЭП)",
|
||||
|
|
@ -50,7 +58,8 @@ function getUtilityConfig(subtype: string): UtilityDisplayConfig {
|
|||
<Zap size={16} strokeWidth={1.5} style={{ color: "var(--viz-4)" }} />
|
||||
),
|
||||
};
|
||||
case "pipeline":
|
||||
// ── Газ ──
|
||||
case "gas_pipeline":
|
||||
return {
|
||||
label: "Газ (газопровод)",
|
||||
icon: (
|
||||
|
|
@ -61,9 +70,21 @@ function getUtilityConfig(subtype: string): UtilityDisplayConfig {
|
|||
/>
|
||||
),
|
||||
};
|
||||
case "water_intake":
|
||||
case "pipeline":
|
||||
return {
|
||||
label: "Водопровод",
|
||||
label: "Трубопровод (без вещества)",
|
||||
icon: (
|
||||
<Flame
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
style={{ color: "var(--viz-4)" }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
// ── Водопровод ──
|
||||
case "water_main":
|
||||
return {
|
||||
label: "Водопровод (магистраль)",
|
||||
icon: (
|
||||
<Droplet
|
||||
size={16}
|
||||
|
|
@ -72,9 +93,43 @@ function getUtilityConfig(subtype: string): UtilityDisplayConfig {
|
|||
/>
|
||||
),
|
||||
};
|
||||
case "pumping_station":
|
||||
case "water_works":
|
||||
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: (
|
||||
<Pipette
|
||||
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:
|
||||
return {
|
||||
label: subtype,
|
||||
|
|
@ -135,14 +213,23 @@ function buildSubtitle(items: UtilitySummaryItem[]): string {
|
|||
const parts: string[] = [];
|
||||
|
||||
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 (gas) parts.push(`газ ${gas.nearest_m} м`);
|
||||
if (water) parts.push(`водопровод ${water.nearest_m} м`);
|
||||
if (electric)
|
||||
parts.push(`электричество ${electric.nearest_m.toLocaleString("ru")} м`);
|
||||
if (gas) parts.push(`газ ${gas.nearest_m.toLocaleString("ru")} м`);
|
||||
if (water) parts.push(`водопровод ${water.nearest_m.toLocaleString("ru")} м`);
|
||||
|
||||
return parts.length > 0
|
||||
? parts.join(" · ")
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ export interface CompareColumn {
|
|||
score?: number | null;
|
||||
scoreLabel?: string | null;
|
||||
velocityScore?: number | null; // 0..1
|
||||
// False → конкуренты есть, но данных velocity нет (velocity_score=0 — sentinel,
|
||||
// не факт «рынок стоит»). Когда false, ячейка velocity = «—» и не участвует в
|
||||
// подсветке «лучшее». undefined трактуется как «данные есть» (обратная совместимость).
|
||||
velocityDataAvailable?: boolean | null;
|
||||
medianPricePerM2?: number | null;
|
||||
pipeline24mo?: number | null; // total flats in 24-month pipeline
|
||||
confidenceLabel?: "high" | "medium" | "low" | null;
|
||||
|
|
@ -117,15 +121,16 @@ const METRIC_ROWS: MetricRow[] = [
|
|||
key: "velocity",
|
||||
label: "Скорость продаж",
|
||||
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) =>
|
||||
c.velocityScore != null && Number.isFinite(c.velocityScore) ? (
|
||||
c.velocityDataAvailable !== false &&
|
||||
c.velocityScore != null &&
|
||||
Number.isFinite(c.velocityScore) ? (
|
||||
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{Math.round(c.velocityScore * 100)}
|
||||
<span style={{ color: "var(--fg-tertiary)", fontSize: 12 }}>
|
||||
{" "}
|
||||
/ 100
|
||||
</span>
|
||||
{Math.round(c.velocityScore * 100)}%
|
||||
</span>
|
||||
) : (
|
||||
DASH
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ export function ParcelCompareLoader({ cad, onResult }: Props) {
|
|||
score: a.score ?? null,
|
||||
scoreLabel: a.score_label ?? 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,
|
||||
pipeline24mo: a.pipeline_24mo?.flats_total ?? null,
|
||||
confidenceLabel: a.confidence_label ?? null,
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ function ParcelMarkers({
|
|||
<div style={{ fontSize: 12 }}>
|
||||
<div style={{ fontWeight: 600 }}>{parcel.cad_num}</div>
|
||||
<div style={{ color: "var(--fg-secondary)" }}>
|
||||
{parcel.area_ha.toFixed(2)} га · {statusLabel}
|
||||
{parcel.area_ha != null ? parcel.area_ha.toFixed(2) : "—"} га · {statusLabel}
|
||||
</div>
|
||||
<div style={{ color: "var(--fg-tertiary)", marginTop: 2 }}>
|
||||
Нажмите, чтобы открыть анализ
|
||||
|
|
@ -129,7 +129,7 @@ function ParcelMarkers({
|
|||
<div
|
||||
style={{ color: "var(--fg-secondary)", marginBottom: 8 }}
|
||||
>
|
||||
{parcel.area_ha.toFixed(2)} га · {statusLabel}
|
||||
{parcel.area_ha != null ? parcel.area_ha.toFixed(2) : "—"} га · {statusLabel}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -244,10 +244,15 @@ export function EntryMap({
|
|||
)}
|
||||
</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 && (
|
||||
<div
|
||||
style={{ position: "absolute", inset: 0, zIndex: 399 }}
|
||||
style={{ position: "absolute", inset: 0, zIndex: 10 }}
|
||||
onClick={onParcelDeselect}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -18,12 +18,16 @@ interface ChipProps {
|
|||
label: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
function Chip({ label, selected, onClick }: ChipProps) {
|
||||
function Chip({ label, selected, onClick, disabled, title }: ChipProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
borderRadius: 999,
|
||||
|
|
@ -34,7 +38,8 @@ function Chip({ label, selected, onClick }: ChipProps) {
|
|||
color: selected ? "var(--accent)" : "var(--fg-secondary)",
|
||||
fontSize: 12,
|
||||
fontWeight: selected ? 500 : 400,
|
||||
cursor: "pointer",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.45 : 1,
|
||||
whiteSpace: "nowrap",
|
||||
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 = [
|
||||
"Ленинский",
|
||||
"Верх-Исетский",
|
||||
|
|
@ -89,6 +103,7 @@ export function MapFilterBar({
|
|||
}
|
||||
|
||||
function toggleVri(v: ParcelVri) {
|
||||
if (!DISTRICT_VRI_FILTERS_AVAILABLE) return;
|
||||
onChange({ ...filters, vri: filters.vri === v ? undefined : v });
|
||||
}
|
||||
|
||||
|
|
@ -107,8 +122,8 @@ export function MapFilterBar({
|
|||
|
||||
const hasFilters =
|
||||
filters.status != null ||
|
||||
filters.district != null ||
|
||||
filters.vri != null ||
|
||||
(DISTRICT_VRI_FILTERS_AVAILABLE && filters.district != null) ||
|
||||
(DISTRICT_VRI_FILTERS_AVAILABLE && filters.vri != null) ||
|
||||
filters.min_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) => (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
label={opt.label}
|
||||
selected={filters.vri === opt.value}
|
||||
selected={
|
||||
DISTRICT_VRI_FILTERS_AVAILABLE && filters.vri === 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
|
||||
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({ ...filters, district: e.target.value || undefined })
|
||||
}
|
||||
|
|
@ -217,14 +244,20 @@ export function MapFilterBar({
|
|||
fontSize: 12,
|
||||
padding: "4px 8px",
|
||||
borderRadius: 999,
|
||||
border: filters.district
|
||||
? "1px solid var(--accent)"
|
||||
: "1px solid var(--border-card)",
|
||||
background: filters.district
|
||||
? "var(--accent-soft)"
|
||||
: "var(--bg-card)",
|
||||
color: filters.district ? "var(--accent)" : "var(--fg-secondary)",
|
||||
cursor: "pointer",
|
||||
border:
|
||||
DISTRICT_VRI_FILTERS_AVAILABLE && filters.district
|
||||
? "1px solid var(--accent)"
|
||||
: "1px solid var(--border-card)",
|
||||
background:
|
||||
DISTRICT_VRI_FILTERS_AVAILABLE && filters.district
|
||||
? "var(--accent-soft)"
|
||||
: "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>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import Link from "next/link";
|
||||
import { X, MapPin, Maximize2, ArrowRight } from "lucide-react";
|
||||
import type { ParcelBboxItem } from "@/lib/site-finder-api";
|
||||
import { addLocalRecentParcel } from "@/lib/site-finder-api";
|
||||
import { STATUS_COLORS, STATUS_LABELS } from "./ParcelLegend";
|
||||
|
||||
interface ParcelDrawerProps {
|
||||
|
|
@ -195,7 +196,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
|
|||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{parcel.area_ha.toFixed(2)}{" "}
|
||||
{parcel.area_ha != null ? parcel.area_ha.toFixed(2) : "—"}{" "}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
|
|
@ -321,6 +322,16 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
|
|||
>
|
||||
<Link
|
||||
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={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
|
|
|||
|
|
@ -1,14 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Clock, MapPin } from "lucide-react";
|
||||
import { useRecentParcels } from "@/lib/site-finder-api";
|
||||
|
||||
const MAX_SHOWN = 5;
|
||||
|
||||
export function RecentParcels() {
|
||||
const queryClient = useQueryClient();
|
||||
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) {
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -64,11 +64,12 @@ export function AnalogsTable({
|
|||
}
|
||||
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
const av = a[sortKey] ?? 0;
|
||||
const bv = b[sortKey] ?? 0;
|
||||
return sortAsc
|
||||
? (av as number) - (bv as number)
|
||||
: (bv as number) - (av as number);
|
||||
const av = a[sortKey];
|
||||
const bv = b[sortKey];
|
||||
// null ('неизвестно') всегда в конце, независимо от направления
|
||||
if (av === null) return bv === null ? 0 : 1;
|
||||
if (bv === null) return -1;
|
||||
return sortAsc ? av - bv : bv - av;
|
||||
});
|
||||
|
||||
function handleSort(key: SortKey) {
|
||||
|
|
|
|||
|
|
@ -99,6 +99,12 @@ interface Props {
|
|||
export function EstimateResult({ estimate, input }: Props) {
|
||||
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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{/* Hero card */}
|
||||
|
|
@ -177,7 +183,7 @@ export function EstimateResult({ estimate, input }: Props) {
|
|||
{conf.label}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: conf.fg }}>
|
||||
{estimate.n_analogs} аналог{ending(estimate.n_analogs)}
|
||||
{estimate.n_analogs} объект{ending(estimate.n_analogs)} в выборке
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -202,98 +208,101 @@ export function EstimateResult({ estimate, input }: Props) {
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Section 1 — Cover / input snapshot */}
|
||||
<Card>
|
||||
<SectionHeader
|
||||
icon={
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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,
|
||||
}}
|
||||
{/* Section 1 — Cover / input snapshot.
|
||||
Hidden when restoring from a shared link (params unknown). */}
|
||||
{hasInputParams && (
|
||||
<Card>
|
||||
<SectionHeader
|
||||
icon={
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{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
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "#1a1d23",
|
||||
fontWeight: 500,
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Section 2 — Listings (analogs) */}
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -44,7 +44,10 @@ export function Drawer({ open, onClose, side, width, children }: DrawerProps) {
|
|||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
} else {
|
||||
if (previousFocusRef.current instanceof HTMLElement) {
|
||||
if (
|
||||
previousFocusRef.current instanceof HTMLElement &&
|
||||
document.contains(previousFocusRef.current)
|
||||
) {
|
||||
previousFocusRef.current.focus();
|
||||
}
|
||||
}
|
||||
|
|
@ -128,7 +131,7 @@ export function Drawer({ open, onClose, side, width, children }: DrawerProps) {
|
|||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: "100%",
|
||||
width: resolvedWidth,
|
||||
maxHeight: "85vh",
|
||||
background: "var(--bg-card)",
|
||||
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",
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
// Keep in DOM for transition but aria-hide
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
zIndex: 199,
|
||||
}}
|
||||
>
|
||||
<div ref={drawerRef} style={panelStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Keep the same DOM tree across open/closed so the panel element stays
|
||||
// mounted and the transform transition can interpolate on open→close.
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-hidden={open ? undefined : "true"}
|
||||
onClick={handleOverlayClick}
|
||||
style={{
|
||||
position: "fixed",
|
||||
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,
|
||||
display: "flex",
|
||||
alignItems: side === "bottom" ? "flex-end" : "stretch",
|
||||
|
|
|
|||
|
|
@ -24,7 +24,11 @@ function sessionHeaders(): Record<string, string> {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -158,12 +158,20 @@ export function useSiteAnalysis() {
|
|||
{ signal },
|
||||
);
|
||||
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:
|
||||
// если очистить до await, mutation isPending=true но
|
||||
// fetchingState=null → пустой экран ~1 RTT. После await
|
||||
// mutation сразу резолвится с data — render skipped.
|
||||
const second = await apiFetch<ParcelAnalysis>(analyzeUrl(cad), {
|
||||
const second = await apiFetchWithStatus<
|
||||
ParcelAnalysis | AnalyzeAcceptedResponse
|
||||
>(analyzeUrl(cad), {
|
||||
method: "POST",
|
||||
signal,
|
||||
...(bodyPayload
|
||||
|
|
@ -173,12 +181,16 @@ export function useSiteAnalysis() {
|
|||
}
|
||||
: {}),
|
||||
});
|
||||
// Только если этот вызов всё ещё актуален — иначе зомби-цикл
|
||||
// стирает баннер свежей мутации (#1242).
|
||||
if (!signal.aborted) {
|
||||
setFetchingState(null);
|
||||
if (second.status === 200) {
|
||||
// Только если этот вызов всё ещё актуален — иначе зомби-цикл
|
||||
// стирает баннер свежей мутации (#1242).
|
||||
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 (!signal.aborted) setFetchingState(null);
|
||||
|
|
@ -204,7 +216,16 @@ export function useSiteAnalysis() {
|
|||
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)
|
||||
if (!signal.aborted) setFetchingState(null);
|
||||
|
|
|
|||
|
|
@ -2932,6 +2932,11 @@ export interface components {
|
|||
* @default rosreestr_pending
|
||||
*/
|
||||
source: string;
|
||||
/**
|
||||
* Rate Ms
|
||||
* @default 600
|
||||
*/
|
||||
rate_ms: number;
|
||||
};
|
||||
/**
|
||||
* ChatAskRequest
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ function profilesHeaders(adminToken: string): HeadersInit {
|
|||
/** List all weight profiles for a given user_id. */
|
||||
export function useWeightProfiles(userId: string, adminToken: string) {
|
||||
return useQuery<WeightProfile[]>({
|
||||
queryKey: ["weight-profiles", userId, adminToken],
|
||||
queryKey: ["weight-profiles", userId],
|
||||
queryFn: () =>
|
||||
apiFetch<WeightProfile[]>(
|
||||
`${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) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, Error, number>({
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ export function triggerDownload(blob: Blob, filename: string): void {
|
|||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
// `a.click()` only queues the download — the browser still needs to read the
|
||||
// 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,9 @@ export type ParcelVri =
|
|||
export interface ParcelBboxItem {
|
||||
cad_num: 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;
|
||||
district: string;
|
||||
vri: ParcelVri;
|
||||
|
|
@ -77,7 +79,8 @@ export interface BboxCoords {
|
|||
export interface RecentParcel {
|
||||
cad_num: string;
|
||||
address: string;
|
||||
area_ha: number;
|
||||
// #1421: nullable — площадь может быть неизвестна; null → «н/д», не «0 га».
|
||||
area_ha: number | null;
|
||||
district: string;
|
||||
visited_at: string; // ISO string
|
||||
}
|
||||
|
|
@ -121,8 +124,20 @@ function applyFilters(
|
|||
filters: ParcelBboxFilters,
|
||||
): ParcelBboxItem[] {
|
||||
return parcels.filter((p) => {
|
||||
if (filters.min_area != null && p.area_ha < filters.min_area) return false;
|
||||
if (filters.max_area != null && p.area_ha > filters.max_area) return false;
|
||||
// #1421: area_ha теперь nullable («н/д»). Площадной фильтр применяем только
|
||||
// к участкам с известной площадью; 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.district != null &&
|
||||
|
|
@ -209,17 +224,33 @@ export function useParcelsBboxQuery(
|
|||
cad_num: p.cad_num,
|
||||
lat: p.centroid_lat,
|
||||
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",
|
||||
district: "—",
|
||||
vri: "other",
|
||||
address: "",
|
||||
}));
|
||||
// Apply filters client-side until backend supports them (B1 follow-up)
|
||||
return applyFilters(adapted, filters);
|
||||
// Apply filters client-side until backend supports them (B1 follow-up).
|
||||
// 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,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
"""
|
||||
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:
|
||||
- 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.
|
||||
- Exits with code 2 to block (stderr is forwarded to Claude as feedback).
|
||||
- Otherwise exits 0 (no-op).
|
||||
|
|
@ -29,15 +30,23 @@ ALLOW_PATH_PREFIXES = (
|
|||
"backend/scripts/",
|
||||
)
|
||||
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:
|
||||
"""Convert absolute/relative path to repo-relative POSIX-like form."""
|
||||
p = Path(raw).as_posix()
|
||||
# Try to strip everything up to the last "backend/" occurrence.
|
||||
idx = p.rfind("backend/")
|
||||
return p[idx:] if idx >= 0 else p
|
||||
parts = Path(raw).parts
|
||||
# Match the repo-root segment "backend" exactly (not a substring like
|
||||
# "services-backend"), and anchor on the FIRST such segment so a nested
|
||||
# "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:
|
||||
|
|
@ -48,7 +57,7 @@ def main() -> int:
|
|||
return 0
|
||||
|
||||
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
|
||||
|
||||
tool_input = payload.get("tool_input") or {}
|
||||
|
|
@ -64,8 +73,17 @@ def main() -> int:
|
|||
if not norm.endswith(".py"):
|
||||
return 0
|
||||
|
||||
# Pull the new content. Edit gives `new_string`, Write gives `content`.
|
||||
new_text = tool_input.get("new_string") or tool_input.get("content") or ""
|
||||
# Pull the new content. Edit gives `new_string`, Write gives `content`,
|
||||
# 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:
|
||||
return 0
|
||||
|
||||
|
|
@ -73,6 +91,10 @@ def main() -> int:
|
|||
for lineno, line in enumerate(new_text.splitlines(), start=1):
|
||||
if "# noqa" in line:
|
||||
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):
|
||||
offenders.append((lineno, line.strip()))
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ def fetch_all_prs() -> dict[str, dict]:
|
|||
page = 1
|
||||
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"
|
||||
req = urllib.request.Request(
|
||||
url, headers={"Authorization": f"token {TOKEN}"}
|
||||
|
|
|
|||
|
|
@ -24,10 +24,33 @@ def main() -> None:
|
|||
local.add(rel)
|
||||
print(f"Local files in target dirs: {len(local)}")
|
||||
|
||||
# 2. Fetch CouchDB docs
|
||||
r = requests.get(f"{BASE}/_all_docs", auth=AUTH,
|
||||
params={"limit": 10000}, timeout=60)
|
||||
rows = r.json()["rows"]
|
||||
# 2. Fetch ALL CouchDB docs (paginated — _all_docs усекается limit'ом,
|
||||
# а неполный alive-набор приведёт к удалению живых chunks как orphan).
|
||||
# Идём постранично через startkey_docid: last id страницы становится
|
||||
# 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
|
||||
ghosts: list[tuple[str, str]] = []
|
||||
|
|
|
|||
|
|
@ -84,13 +84,17 @@ _MONTH_MAP = {
|
|||
}
|
||||
_DATE_PATTERNS = [
|
||||
# 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)
|
||||
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
|
||||
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
|
||||
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])"),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,42 @@ OUT.mkdir(exist_ok=True)
|
|||
|
||||
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):
|
||||
row = conn.execute("""SELECT sd.district, sd.method, sd.nearest_jk_dist_m,
|
||||
de.n_projects, de.weighted_price_m2, de.median_price_m2,
|
||||
|
|
@ -65,6 +101,7 @@ def main():
|
|||
conn = sqlite3.connect(DB)
|
||||
parcel = fetch_site_full(conn, PARCEL_ID)
|
||||
n_total = conn.execute("SELECT count(*) FROM sites").fetchone()[0]
|
||||
weights = load_weights(conn)
|
||||
|
||||
# Top-10 best-scoring ЖК + parcel position context
|
||||
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"],
|
||||
"weighted_score_distribution": 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,
|
||||
"10_closest_jk_to_parcel": closest,
|
||||
}
|
||||
|
|
@ -169,6 +206,11 @@ def econ_block(e):
|
|||
def build_html(d):
|
||||
p = d["parcel"]
|
||||
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 = []
|
||||
for cat, items in p["nearest_pois"].items():
|
||||
nearest = items[0] if items else None
|
||||
|
|
@ -183,7 +225,7 @@ def build_html(d):
|
|||
comp_rows = []
|
||||
for c, v in cs.items():
|
||||
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(
|
||||
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>"
|
||||
|
|
@ -222,7 +264,7 @@ th,td{{border:1px solid #ddd;padding:6px 10px;text-align:left}}
|
|||
th{{background:#f5f7fa;font-weight:600}}
|
||||
.r{{text-align:right}}
|
||||
.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}}
|
||||
.note{{background:#fffbe6;border-left:4px solid #f0c000;padding:10px 14px;border-radius:4px;margin:12px 0}}
|
||||
</style></head><body>
|
||||
|
|
@ -245,9 +287,9 @@ th{{background:#f5f7fa;font-weight:600}}
|
|||
<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>
|
||||
<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"))}
|
||||
|
||||
<h2>Ближайшие POI вокруг участка</h2>
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ def main():
|
|||
conn = sqlite3.connect(DB)
|
||||
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]
|
||||
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:
|
||||
if cat in cache:
|
||||
|
|
|
|||
|
|
@ -609,10 +609,10 @@ def jk_full(site_id: str):
|
|||
obj_id = s.get("obj_id")
|
||||
if obj_id:
|
||||
try:
|
||||
import psycopg2
|
||||
pg = psycopg2.connect(host="127.0.0.1", port=15432, user="gendesign",
|
||||
password="2J2SBPMKuS998fiwhtQqDhMI",
|
||||
dbname="gendesign", connect_timeout=2)
|
||||
import psycopg
|
||||
pg = psycopg.connect(host="127.0.0.1", port=15432, user="gendesign",
|
||||
password="2J2SBPMKuS998fiwhtQqDhMI",
|
||||
dbname="gendesign", connect_timeout=2)
|
||||
pcur = pg.cursor()
|
||||
pcur.execute("""SELECT photo_url, photo_dttm, period_dt, photo_name, ready_desc, thumb_path, hidden
|
||||
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)
|
||||
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"])]
|
||||
total_lots = sum(proj_data[o["project"]]["total"] for o in matched)
|
||||
sold_lots = sum(proj_data[o["project"]]["sold_n"] for o in matched)
|
||||
avg_price = (sum(proj_data[o["project"]]["avg_price"] or 0 for o in matched) / len(matched)) if matched else None
|
||||
total_vel = sum(proj_data[o["project"]]["vel_6mo"] or 0 for o in matched)
|
||||
# Dedup by project: multiple sites can match the same Objective project,
|
||||
# so sum lot stats over unique projects to avoid double-counting.
|
||||
matched_projects = sorted({o["project"] 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 = []
|
||||
for o in ojects:
|
||||
|
|
@ -994,7 +997,13 @@ def price_distribution(district: str):
|
|||
vals.sort()
|
||||
if not vals: continue
|
||||
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),
|
||||
"p75": q(0.75), "max": vals[-1]}
|
||||
return {"district": district, "by_class": out}
|
||||
|
|
@ -1013,20 +1022,27 @@ def district_time_machine(district: str):
|
|||
if m == 0: m = 12; y -= 1
|
||||
months.reverse()
|
||||
series = []
|
||||
import statistics
|
||||
with _conn() as c:
|
||||
for mo in months:
|
||||
row = c.execute("""
|
||||
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,
|
||||
COUNT(DISTINCT project||'·'||corpus) AS corpuses
|
||||
FROM objective_lots
|
||||
WHERE district=? AND substr(register_date,1,7)=?
|
||||
""", (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({
|
||||
"month": mo,
|
||||
"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,
|
||||
"active_corpuses": row["corpuses"] or 0,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -411,6 +411,8 @@ async def test_cian_auth(
|
|||
state = await cian_session_svc.verify_session(cookies)
|
||||
if state is None:
|
||||
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")
|
||||
return {"authenticated": True, "userId": user_id, "reason": None}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,18 @@ logger = logging.getLogger(__name__)
|
|||
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)
|
||||
async def search(
|
||||
params: SearchParams,
|
||||
|
|
@ -37,7 +49,9 @@ async def search(
|
|||
"search cache HIT key=%s page=%d size=%d",
|
||||
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)
|
||||
rows = db.execute(text(sql), args).mappings().all()
|
||||
|
|
@ -45,7 +59,7 @@ async def search(
|
|||
count_sql, count_args = build_count_query(params)
|
||||
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
|
||||
response = SearchResponse(
|
||||
items=items,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import logging
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from typing import Annotated, Any
|
||||
|
|
@ -396,7 +397,10 @@ def estimate_pdf(
|
|||
confidence=row.confidence,
|
||||
confidence_explanation=row.confidence_explanation,
|
||||
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,
|
||||
actual_deals=actual_deals,
|
||||
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
|
||||
|
||||
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()
|
||||
|
||||
def _empty() -> StreetDealsResponse:
|
||||
|
|
|
|||
|
|
@ -60,10 +60,36 @@ class SearchParams(BaseModel):
|
|||
raise ValueError("lat and lon must be provided together")
|
||||
if self.sort == "dist_asc" and self.lat is None:
|
||||
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")
|
||||
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")
|
||||
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
|
||||
|
||||
@property
|
||||
|
|
|
|||
|
|
@ -126,8 +126,26 @@ async def backfill_cian_price_history(
|
|||
result.skipped += 1
|
||||
else:
|
||||
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)
|
||||
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:
|
||||
logger.warning("cian_price_history: save failed listing_id=%s: %s", lid, exc)
|
||||
result.errors += 1
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue