fix(week-review): автофиксы код-ревью — 169 issue (label «week ревью 1»)

Многоагентный аудит + имплементация: один воркер на файл, точечные правки.
Верификация: py_compile (47/47 .py) + tsc --noEmit (0 ошибок). Unit-тесты
не прогонялись (окружение не поднято: rollup native dep / нет pytest-venv).

Полностью исправлено (169): #1336, #1337, #1339, #1340, #1341, #1342, #1343, #1345, #1346, #1348, #1349, #1350, #1351, #1354, #1356, #1358, #1359, #1360, #1362, #1364, #1365, #1366, #1367, #1368, #1369, #1370, #1371, #1372, #1373, #1374, #1375, #1376, #1377, #1378, #1379, #1380, #1381, #1382, #1384, #1385, #1386, #1387, #1388, #1389, #1390, #1391, #1392, #1394, #1395, #1396, #1397, #1399, #1400, #1401, #1402, #1403, #1404, #1408, #1409, #1410, #1411, #1412, #1413, #1414, #1415, #1416, #1417, #1418, #1420, #1423, #1425, #1426, #1427, #1428, #1429, #1430, #1431, #1432, #1433, #1434, #1435, #1437, #1438, #1439, #1440, #1441, #1442, #1443, #1444, #1445, #1446, #1447, #1448, #1449, #1450, #1451, #1452, #1453, #1454, #1455, #1456, #1457, #1458, #1459, #1460, #1461, #1462, #1463, #1464, #1465, #1466, #1467, #1468, #1469, #1471, #1472, #1473, #1474, #1476, #1478, #1479, #1481, #1482, #1483, #1484, #1485, #1487, #1488, #1489, #1490, #1491, #1492, #1493, #1494, #1495, #1496, #1497, #1499, #1500, #1501, #1502, #1504, #1505, #1506, #1507, #1510, #1514, #1515, #1516, #1517, #1518, #1519, #1521, #1522, #1523, #1524, #1525, #1526, #1527, #1528, #1529, #1531, #1532, #1533, #1534, #1535, #1536, #1537, #1538

Частично (9, in-file часть, остаток cross-file): #1361, #1419, #1422, #1424, #1470, #1475, #1477, #1480, #1498
Требуют cross-file (3, не тронуты): #1338, #1363, #1421
Пропущено (1): #1539

Не входило в партию: 22 needs-Leha issue (нужны решения владельца).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Light1YT 2026-06-15 20:21:11 +05:00
parent 2c22c3f7ea
commit 86e9ea2937
110 changed files with 2025 additions and 634 deletions

View file

@ -11,6 +11,7 @@ Returns the Celery task id; poll /api/v1/admin/scrape/runs/{run_id} to track DB-
from __future__ import annotations
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)

View file

@ -1455,7 +1455,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,
@ -1827,9 +1828,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,
@ -1845,11 +1847,11 @@ def analyze_parcel(
ORDER BY distance_m ASC
LIMIT 10
"""),
{"wkt": geom_wkt},
{"wkt": geom_wkt},
)
.mappings()
.all()
)
.mappings()
.all()
)
hydrology = {
"nearest": [
{
@ -1876,9 +1878,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,
@ -1894,11 +1897,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]] = {}
@ -1960,9 +1963,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,
@ -1971,11 +1975,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"],
@ -1989,9 +1993,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,
@ -2007,11 +2012,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"])
@ -2056,19 +2061,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 = {
@ -2082,9 +2088,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(
@ -2092,11 +2099,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 = {
@ -2118,9 +2125,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,
@ -2133,11 +2141,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": (
@ -2161,9 +2169,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(
@ -2173,11 +2182,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
@ -2200,9 +2209,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
@ -2237,11 +2247,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"])
@ -2906,7 +2916,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,
@ -3070,7 +3080,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,

View file

@ -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

View file

@ -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"],

View file

@ -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').

View file

@ -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.

View file

@ -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)

View file

@ -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})

View file

@ -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) за последний снимок ───────────────

View file

@ -96,12 +96,18 @@ def cad_exists_in_db(db: Session, cad_num: str) -> bool:
def find_active_on_demand_job(db: Session, cad_num: str) -> int | None:
"""Найти существующий 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 {

View file

@ -420,10 +420,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
""")
@ -527,7 +523,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,
},
)
@ -547,6 +542,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=[],

View file

@ -155,7 +155,19 @@ 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()
if any(kw in type_zone_lower for kw in BLOCKER_TYPE_ZONE_KEYWORDS):
# СЗЗ-маркер специфичнее широких blocker-подстрок ('газ'/'электр'),
# поэтому проверяем warning-keywords ПЕРВЫМИ (#1341). СЗЗ газораспределительной
# станции/электроподстанции — ограничение, а не hard-blocker.
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 any(kw in type_zone_lower for kw in BLOCKER_TYPE_ZONE_KEYWORDS):
blockers.append(
Blocker(
code="ZOUIT_CAD_BLOCKER",
@ -165,15 +177,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(
@ -186,8 +189,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}",
@ -195,9 +210,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', '')}",
)
)

View file

@ -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:

View file

@ -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 (

View file

@ -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,
}

View file

@ -924,11 +924,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
@ -938,8 +940,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": [],
@ -950,15 +951,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)

View file

@ -377,6 +377,14 @@ def compute_velocity(
total_sqm = sum(float(r["total_sqm"] or 0.0) for r in mapped_sales_rows)
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))

View file

@ -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

View file

@ -151,8 +151,11 @@ def _save_quarter(db: Session, payload: dict, cad_num: str) -> int:
-- cad_quarters_geom.geom = geometry(MultiPolygon,4326) NOT NULL (migration 58).
-- NSPD может вернуть одноконтурный Polygon ST_Multi coerce'ит в MultiPolygon,
-- иначе "Geometry type (Polygon) does not match column type (MultiPolygon)".
-- 38574326 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:

View file

@ -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,

View file

@ -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;

View file

@ -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] (доля).

View file

@ -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';

View file

@ -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);

View file

@ -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)')

View file

@ -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",

View file

@ -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>

View file

@ -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
}

View file

@ -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,
}}
/>

View file

@ -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={{

View file

@ -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",

View file

@ -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" }}>

View file

@ -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;

View file

@ -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}
/>
);
}

View file

@ -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>
);
}

View file

@ -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 (110)</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
}
>

View file

@ -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 (
<>

View file

@ -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) : "—"),
},
];

View file

@ -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}

View file

@ -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]);

View file

@ -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 },

View file

@ -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",

View file

@ -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>
);

View file

@ -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/")
) {

View file

@ -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" }}>

View file

@ -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);

View file

@ -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() {

View file

@ -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 (

View file

@ -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>

View file

@ -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);
}
/**

View file

@ -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%`}
>
&gt;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>
)}

View file

@ -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 (

View file

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

View file

@ -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>

View file

@ -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",
};

View file

@ -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>
);
}

View file

@ -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" });

View file

@ -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

View file

@ -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} мм осадков

View file

@ -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

View file

@ -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 }));
}

View file

@ -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,

View file

@ -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)

View file

@ -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[] {

View file

@ -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");
}

View file

@ -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>

View file

@ -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 }}>

View file

@ -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>

View file

@ -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(" · ")

View file

@ -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

View file

@ -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"
/>

View file

@ -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>

View file

@ -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 {
@ -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",

View file

@ -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

View file

@ -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) {

View file

@ -177,7 +177,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>

View file

@ -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",

View file

@ -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

View file

@ -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);

View file

@ -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>({

View file

@ -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);
}

View file

@ -215,8 +215,18 @@ export function useParcelsBboxQuery(
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,

View file

@ -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()))

View file

@ -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}"}

View file

@ -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]] = []

View file

@ -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])"),
]

View file

@ -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>

View file

@ -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:

View file

@ -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,
})

View file

@ -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}

View file

@ -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,

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -232,7 +232,8 @@ class DadataSuggestion:
house номер дома если distinguished (строкой: «30» / «30к2»).
street название улицы.
city locality.
kind `house` (fias_level=8) / `street` (=7) / `city` (<=6).
kind `house` (fias_level=8/9) / `street` (=7) / `city` (<=6) /
`plot` (=65 planning, =75 plot).
Маппится на GeocodeSuggestion.kind в geocoder.py.
"""
@ -258,7 +259,12 @@ def _classify_kind(fias_level: Any) -> str:
level = _coerce_int(fias_level)
if level is None:
return "city" # safe default
if level >= 8:
if level in (65, 75):
# 65=planning structure / 75=земельный участок — не дом.
# geocoder._DADATA_KIND_MAP не знает 'plot' → fallback на 'locality'.
return "plot"
if level in (8, 9):
# 8=house, 9=flat — оба привязаны к конкретному дому.
return "house"
if level == 7:
return "street"

View file

@ -2230,8 +2230,11 @@ async def estimate_quality(
# иначе он вылетает за диапазон. range_low — RAW comp min (asking-space):
# headline теперь asking, band-haircut больше не применяется (sold-скидка —
# единственным механизмом per-rooms ratio ниже).
spread_low = int(anchor["comp_min_ppm2"] * payload.area_m2)
spread_high = int(anchor["comp_max_ppm2"] * payload.area_m2)
# #1518: repair_coef применён к point (new_ppm2), поэтому границы спреда
# тоже нормируем на ремонт — иначе центр и края в разных масштабах
# (для needs_repair coef<1 верх несимметрично завышен, для excellent — занижен).
spread_low = int(anchor["comp_min_ppm2"] * repair_coef * payload.area_m2)
spread_high = int(anchor["comp_max_ppm2"] * repair_coef * payload.area_m2)
new_range_low = min(new_range_low, spread_low, point)
new_range_high = max(new_range_high, spread_high, point)
@ -2462,8 +2465,15 @@ async def estimate_quality(
same_quarter_count = sum(
1 for lq in lot_quarters_for_guard2 if lq == target_quarter
)
# #1385: знаменатель — ТОЛЬКО классифицируемые аналоги (с разрешённым
# кварталом), как и числитель. Аналоги без кадастра (типичные анонимные
# Avito) не классифицируемы и не должны разбавлять ratio — иначе при
# низком покрытии кадастром ratio структурно занижен, Guard-2 не
# срабатывает, и quarter-index применяется к медиане повторно.
same_quarter_ratio = (
same_quarter_count / len(listings_clean) if listings_clean else 0.0
same_quarter_count / len(lot_quarters_for_guard2)
if lot_quarters_for_guard2
else 0.0
)
if same_quarter_ratio > settings.estimate_quarter_match_skip_ratio:
logger.info(
@ -2644,10 +2654,16 @@ async def estimate_quality(
# (другие/дешевле/пусто → premium headline «не подтверждён» аналогами).
if anchor_tier is not None and anchor_comps_used:
analogs_lots = [_anchor_comp_to_analog(c) for c in anchor_comps_used[:10]]
# #1519: при сработавшем якоре метаданные (freshness/last_scraped_at/
# days_on_market) считаем по ПОКАЗАННЫМ комплам, а не по радиусной выборке —
# иначе «обновлено N мин назад»/дата парсинга/срок продажи относятся к другому
# набору (или = None при пустом listings_clean, хотя у комплов данные есть).
metadata_lots = anchor_comps_used
else:
analogs_lots = [_listing_to_analog(lot) for lot in listings_clean[:10]]
metadata_lots = listings_clean
deals_lots = [_deal_to_analog(d) for d in deals[:10]]
freshness_pre = _compute_freshness_minutes(listings_clean)
freshness_pre = _compute_freshness_minutes(metadata_lots)
# DaData enrichment (PR Q1) — заполняется только если service отработал.
# При DaData = None все колонки идут в DB как NULL (graceful).
dadata_metro_json = (
@ -2785,8 +2801,8 @@ async def estimate_quality(
sources_used = sorted(set(sources_used) | {"yandex_valuation"})
if cian_val is not None and cian_val.sale_price_rub:
sources_used = sorted(set(sources_used) | {"cian_valuation"})
freshness_min = _compute_freshness_minutes(listings_clean)
last_scraped_at = _compute_last_scraped_at(listings_clean)
freshness_min = _compute_freshness_minutes(metadata_lots)
last_scraped_at = _compute_last_scraped_at(metadata_lots)
# Месячный ₽/м² тренд целевого дома (web TREND chart) — best-effort, None если нет данных.
price_trend_raw = _fetch_price_trend(db, target_house_id=target_house_id)
price_trend = (
@ -2815,7 +2831,7 @@ async def estimate_quality(
data_freshness_minutes=freshness_min,
last_scraped_at=last_scraped_at,
price_trend=price_trend,
est_days_on_market=_estimate_days_on_market(listings_clean, deals),
est_days_on_market=_estimate_days_on_market(metadata_lots, deals),
cian_valuation=(
CianValuationSummary(
sale_price_rub=int(cian_val.sale_price_rub) if cian_val.sale_price_rub else None,

View file

@ -624,9 +624,10 @@ def _deals_range(deals: list[AnalogLot], fallback: tuple[int, int]) -> tuple[int
def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
n_total = estimate.n_analogs
# Используем n_analogs — estimator уже фильтрует по repair_state при поиске.
# Fabricated "~40%" удалён (#33): показываем тот же count, что прошёл фильтр.
n_with_repair = n_total
# #1531: убрана строка-дубль «(с учётом ремонта)». Estimator НЕ фильтрует
# аналоги по repair_state (coverage listings.repair_state ~2%, см. estimator.py:160),
# а лишь применяет ценовой коэффициент к медиане/диапазону — поэтому отдельного
# count «с учётом ремонта» не существует, второе число было идентично n_total.
# Source logos (pseudo) — берём из estimate.sources_used (не захардкоженный список).
sources_to_show = estimate.sources_used or []
@ -701,9 +702,6 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4pt 0;">Количество объявлений по аналогичным объектам</td>
<td class="bold" style="text-align:right;">{n_total} шт.</td></tr>
<tr><td style="padding:4pt 0;">Количество объявлений по аналогичным объектам<br>
<span style="font-size:8pt;color:#6b7280;">(с учётом ремонта)</span></td>
<td class="bold" style="text-align:right;">{n_with_repair} шт.</td></tr>
</table>
<div style="margin-top:14pt;font-size:9pt;color:#6b7280;">Источники данных</div>
<div style="margin-top:6pt;">{sources_html}</div>
@ -774,6 +772,8 @@ def _examples_rows(lots: list[AnalogLot]) -> str:
addr_cell = addr
ppm2_cell = _fmt_ppm2(lot.price_per_m2) if lot.price_per_m2 is not None else ""
price_cell = _fmt_ppm2(lot.price_rub) if lot.price_rub is not None else ""
# #1533: 0 — валидное значение (лот размещён сегодня), `0 or '—'` теряло бы его.
days_cell = lot.days_on_market if lot.days_on_market is not None else ""
rows.append(
"<tr>"
f"<td style='padding:7pt 4pt;border-bottom:1px solid #f3f4f6;'>{addr_cell}</td>"
@ -784,7 +784,7 @@ def _examples_rows(lots: list[AnalogLot]) -> str:
f"<td style='padding:7pt 4pt;border-bottom:1px solid #f3f4f6;text-align:right;"
f"font-variant-numeric:tabular-nums;'>{price_cell}</td>"
f"<td style='padding:7pt 4pt;border-bottom:1px solid #f3f4f6;text-align:right;"
f"font-variant-numeric:tabular-nums;'>{lot.days_on_market or ''}</td>"
f"font-variant-numeric:tabular-nums;'>{days_cell}</td>"
"</tr>"
)
return "".join(rows)
@ -813,11 +813,15 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
year_built = input_snapshot.get("year_built")
year_range = f"{year_built - 2}{year_built + 2}" if year_built else ""
house_type = input_snapshot.get("house_type")
# #1532: маппинг идентичен странице «Листинги» (включая monolith_brick/other),
# иначе один и тот же «Тип дома» рассинхронизирован между стр.2 и стр.3.
house_label = (
{
"panel": "Эконом до 2000 года",
"brick": "Кирпич до 2000",
"monolith": "Современный (монолит)",
"monolith_brick": "Современный (м-к)",
"other": "Любой",
}.get(house_type, "Любой")
if house_type
else "Любой"
@ -827,14 +831,15 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
deals_low, deals_high = _deals_range(
estimate.actual_deals, fallback=(estimate.range_low_rub, estimate.range_high_rub)
)
days_min, days_max = _days_on_market_range(estimate.actual_deals)
# #1366: НЕ показываем срок экспозиции для бара сделок. actual_deals — это
# зарегистрированные ДКП Росреестра без days_on_market (поле None), поэтому
# _days_on_market_range вернул бы хардкод-fallback (4, 118) — выдуманные сроки
# под заголовком «Диапазон цен по фактическим сделкам». Cover-страница так же
# намеренно не рисует days для бара сделок (show_days по умолчанию False).
range_bar = _price_range_bar(
deals_low,
deals_high,
sub_label="Рынок",
days_min=days_min,
days_max=days_max,
show_days=True,
)
top5 = estimate.actual_deals[:5]

View file

@ -622,8 +622,8 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
addr_norm = normalize_address(address)
# 1. Cache
cached = _cache_get(db, addr_norm)
# 1. Cache (sync DB-IO → offload в threadpool, чтобы не блокировать event loop)
cached = await asyncio.to_thread(_cache_get, db, addr_norm)
if cached is not None:
logger.info("geocode cache hit: %s", addr_norm)
return cached
@ -639,7 +639,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
provider="nominatim", # treat as "local" — same confidence as nominatim
confidence="exact",
)
_cache_put(db, addr_norm, result)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info(
"geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon
)
@ -656,7 +656,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
and 56.65 <= result.lat <= 56.95
)
if result is not None and in_ekb:
_cache_put(db, addr_norm, result)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
return result
# Tier 2: typo-variants
@ -668,7 +668,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
if result is None:
continue
if 60.40 <= result.lon <= 60.85 and 56.65 <= result.lat <= 56.95:
_cache_put(db, addr_norm, result)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info(
"geocode yandex typo-fixed: %s%s → (%.5f, %.5f)",
addr_norm, variant, result.lat, result.lon,
@ -681,7 +681,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
try:
result = await _nominatim_lookup(address)
if result is not None:
_cache_put(db, addr_norm, result)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info(
"geocode nominatim: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon
)

View file

@ -134,11 +134,18 @@ def pick_lot_params(db: Session, house_id: int) -> dict:
)
rooms_raw = int(row["rooms"])
floor = int(row["floor"] or 1)
floor_at_home = int(row["total_floors"] or (house and house["total_floors"]) or 9)
# floor и floor_at_home считаются независимо (медиана vs default-9). Если у
# всех listings и у дома total_floors=NULL, floor_at_home молча падает на 9,
# а медианный floor может быть выше → Avito IMV получает кв. на этаже выше дома.
# Консервативный clamp: floor не может превышать этажность дома.
floor = min(floor, floor_at_home)
return {
"rooms": max(rooms_raw, 1), # Avito IMV отклоняет rooms=0 (студия)
"area_m2": float(row["area_m2"]),
"floor": int(row["floor"] or 1),
"floor_at_home": int(row["total_floors"] or (house and house["total_floors"]) or 9),
"floor": floor,
"floor_at_home": floor_at_home,
"house_type": _map_house_type(row["house_type"] or (house and house["house_type"])),
"renovation_type": "cosmetic",
"has_balcony": True,
@ -594,6 +601,26 @@ async def _process_one_house(db: Session, house: dict) -> str:
logger.error("house_imv: unexpected error house=%d: %r", hid, exc)
return "error"
# Адрес найден, но рыночной цены нет: avito_imv возвращает recommended_price=0
# ("нет оценки"). Не считаем это успехом — иначе дом залипает в imv_status='ok'
# и больше не переоценивается (backfill_house_imv фильтрует pending,
# process_houses_imv_batch — != 'ok', estimator — recommended_price>0).
# Помечаем not_found (адрес есть, но usable-оценки нет) — дом остаётся
# доступным для повторной обработки sweep'ом (imv_status != 'ok').
if eval_result.recommended_price <= 0:
logger.info(
"house_imv: empty IMV (no market price) house=%d market_count=%r",
hid,
eval_result.market_count,
)
_mark_status(
db,
hid,
"not_found",
f"imv_empty: recommended_price=0 market_count={eval_result.market_count}"[:200],
)
return "not_found"
try:
save_imv_result(db, hid, params, eval_result)
db.commit()

View file

@ -16,7 +16,7 @@ from __future__ import annotations
import io
import logging
from PIL import Image, UnidentifiedImageError
from PIL import Image, ImageOps, UnidentifiedImageError
logger = logging.getLogger(__name__)
@ -51,6 +51,10 @@ def sanitize_image(content: bytes) -> tuple[bytes, str]:
try:
with Image.open(io.BytesIO(content)) as img:
img.load() # force full decode; surfaces UnidentifiedImageError early
# Bake EXIF Orientation into pixels BEFORE stripping EXIF, else phone
# portrait photos (Orientation=6/8) end up rotated 90/270° once the tag
# is gone. No-op when there is no orientation tag.
img = ImageOps.exif_transpose(img)
# Convert any mode (RGBA, P, CMYK, ...) to RGB for JPEG output.
if img.mode != "RGB":
img = img.convert("RGB")

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