From 86e9ea2937c382f4174d088f5762b59c7da8b710 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Mon, 15 Jun 2026 20:21:11 +0500 Subject: [PATCH] =?UTF-8?q?fix(week-review):=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=D1=8B=20=D0=BA=D0=BE=D0=B4-=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=8C=D1=8E=20=E2=80=94=20169=20issue=20(label=20?= =?UTF-8?q?=C2=ABweek=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=201=C2=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Многоагентный аудит + имплементация: один воркер на файл, точечные правки. Верификация: 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 --- backend/app/api/v1/admin_scrape.py | 17 ++- backend/app/api/v1/parcels.py | 142 ++++++++++-------- backend/app/schemas/trade_in.py | 8 +- backend/app/services/analytics_queries.py | 104 +++++++++++-- backend/app/services/chat/intents.py | 3 + backend/app/services/forecasting/scenarios.py | 5 +- backend/app/services/llm/client.py | 3 +- .../app/services/scrapers/izyatie_client.py | 6 +- .../app/services/site_finder/best_layouts.py | 111 ++++++++++---- .../services/site_finder/cadastre_fetch.py | 22 ++- .../app/services/site_finder/competitors.py | 23 ++- .../app/services/site_finder/gate_verdict.py | 49 ++++-- .../app/services/site_finder/noise_loader.py | 9 +- .../app/services/site_finder/own_portfolio.py | 2 +- .../app/services/site_finder/poi_loader.py | 99 +++++++----- .../site_finder/quarter_dump_lookup.py | 28 +++- backend/app/services/site_finder/velocity.py | 24 ++- backend/app/services/weather_cache.py | 60 +++++--- backend/app/workers/tasks/nspd_geo.py | 18 ++- backend/app/workers/tasks/scrape_objective.py | 7 +- data/sql/110_22j_dedup_infrastructure.sql | 17 ++- data/sql/148_own_planned_project.sql | 29 +++- data/sql/55_schema_scrape_resume.sql | 10 +- data/sql/85_pzz_zones_ekb.sql | 10 +- data/sql/decode_to_file.py | 15 +- frontend/src/app/admin/scrape/all/page.tsx | 70 +++++++-- .../src/app/admin/scrape/cadastre/page.tsx | 7 +- .../src/app/analytics/objects/[id]/page.tsx | 4 +- frontend/src/app/analytics/prinzip/page.tsx | 11 +- frontend/src/app/concept/page.tsx | 16 +- frontend/src/app/data-sources/page.tsx | 4 +- frontend/src/app/legacy/site-finder/page.tsx | 40 +++-- frontend/src/app/site-finder/compare/page.tsx | 8 +- frontend/src/app/site-finder/page.tsx | 33 +++- frontend/src/app/trade-in/page.tsx | 84 ++++++++--- .../src/components/admin/BulkGeoPanel.tsx | 17 ++- .../src/components/admin/JobSettingsPanel.tsx | 39 ++++- .../analytics/DeveloperLeaderboard.tsx | 6 +- .../components/analytics/ObjectInfraMap.tsx | 15 +- .../components/analytics/PrinzipGapBar.tsx | 107 +++++++++---- .../analytics/QuartirographyChart.tsx | 11 +- .../analytics/RecommendRevenueChart.tsx | 2 +- .../analytics/RecommendVelocityPanel.tsx | 16 +- frontend/src/components/auth/RouteGuard.tsx | 5 + frontend/src/components/auth/UserMenu.tsx | 6 +- .../src/components/concept/ConceptDrawMap.tsx | 5 +- .../concept/ConceptExportButtons.tsx | 11 ++ .../components/concept/ConceptResultMap.tsx | 10 +- .../concept/ConceptVariantsResult.tsx | 32 ++-- .../components/landing/PilotRequestModal.tsx | 11 +- .../site-finder/BestLayoutsBlock.tsx | 40 ++++- .../site-finder/CpLayerControlPanel.tsx | 10 +- .../components/site-finder/EnvironmentTab.tsx | 2 +- .../src/components/site-finder/MarketTab.tsx | 4 +- .../site-finder/MarketTrendBlock.tsx | 5 +- .../NspdEngineeringNearbyBlock.tsx | 18 ++- .../site-finder/Pipeline24moBlock.tsx | 6 +- .../site-finder/ScoreBreakdownStackedBar.tsx | 10 +- .../site-finder/SeasonalWeatherBlock.tsx | 2 +- .../components/site-finder/VelocityBlock.tsx | 7 +- .../site-finder/WeightProfilePanel.tsx | 10 +- .../src/components/site-finder/ZouitLayer.tsx | 13 +- .../site-finder/analysis/AnalysisSidebar.tsx | 16 +- .../analysis/EgrnPropertyTable.tsx | 8 +- .../analysis/ForecastConfidenceBlock.tsx | 3 +- .../site-finder/analysis/PoiList2Gis.tsx | 8 +- .../site-finder/analysis/ScenariosBlock.tsx | 36 +++-- .../analysis/Section1ParcelInfo.tsx | 26 ++-- .../analysis/Section2NetworksUtilities.tsx | 109 ++++++++++++-- .../site-finder/compare/CompareTable.tsx | 19 ++- .../components/site-finder/entry/EntryMap.tsx | 9 +- .../site-finder/entry/MapFilterBar.tsx | 65 ++++++-- .../site-finder/entry/ParcelDrawer.tsx | 11 ++ .../site-finder/entry/RecentParcels.tsx | 12 ++ .../src/components/trade-in/AnalogsTable.tsx | 11 +- .../components/trade-in/EstimateResult.tsx | 2 +- frontend/src/components/ui/Drawer.tsx | 31 ++-- frontend/src/hooks/useCustomPois.ts | 6 +- frontend/src/hooks/useSiteAnalysis.ts | 37 ++++- frontend/src/lib/api/weightProfiles.ts | 4 +- frontend/src/lib/download.ts | 10 +- frontend/src/lib/site-finder-api.ts | 14 +- scripts/claude-hooks/check-no-print.py | 42 ++++-- scripts/cleanup-worktrees-aggressive.py | 2 +- scripts/cleanup_ghosts.py | 31 +++- scripts/migrate_kg_to_obsidian.py | 12 +- site-finder/04_report.py | 52 ++++++- site-finder/12_more_pois.py | 2 +- site-finder/server.py | 38 +++-- tradein-mvp/backend/app/api/v1/admin.py | 2 + tradein-mvp/backend/app/api/v1/search.py | 18 ++- tradein-mvp/backend/app/api/v1/trade_in.py | 16 +- tradein-mvp/backend/app/schemas/search.py | 30 +++- .../app/services/cian_price_history.py | 20 ++- tradein-mvp/backend/app/services/dadata.py | 10 +- tradein-mvp/backend/app/services/estimator.py | 30 +++- .../app/services/exporters/trade_in_pdf.py | 27 ++-- tradein-mvp/backend/app/services/geocoder.py | 12 +- .../app/services/house_imv_backfill.py | 31 +++- .../backend/app/services/image_sanitizer.py | 6 +- .../services/matching/conflict_resolution.py | 29 +++- .../backend/app/services/matching/houses.py | 17 +++ .../app/services/matching/normalize.py | 21 ++- .../backend/app/services/sber_index.py | 125 +++++++++------ tradein-mvp/backend/app/services/scheduler.py | 43 +++++- .../backend/app/services/scrape_pipeline.py | 38 ++++- .../backend/app/services/scrapers/avito.py | 31 ++++ .../backend/app/services/scrapers/cian.py | 9 +- .../data/sql/062_clean_avito_addresses.sql | 4 +- .../data/sql/080_asking_to_sold_ratios.sql | 6 + 110 files changed, 2025 insertions(+), 634 deletions(-) diff --git a/backend/app/api/v1/admin_scrape.py b/backend/app/api/v1/admin_scrape.py index 52307e74..6dad9e82 100644 --- a/backend/app/api/v1/admin_scrape.py +++ b/backend/app/api/v1/admin_scrape.py @@ -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) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 77d09276..ab3a4ee1 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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, diff --git a/backend/app/schemas/trade_in.py b/backend/app/schemas/trade_in.py index de21322a..f4d43b96 100644 --- a/backend/app/schemas/trade_in.py +++ b/backend/app/schemas/trade_in.py @@ -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 diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py index ce9657ee..2083ed3a 100644 --- a/backend/app/services/analytics_queries.py +++ b/backend/app/services/analytics_queries.py @@ -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"], diff --git a/backend/app/services/chat/intents.py b/backend/app/services/chat/intents.py index 221b1829..59c2ff75 100644 --- a/backend/app/services/chat/intents.py +++ b/backend/app/services/chat/intents.py @@ -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'). diff --git a/backend/app/services/forecasting/scenarios.py b/backend/app/services/forecasting/scenarios.py index ae802c0a..f0b3eb0e 100644 --- a/backend/app/services/forecasting/scenarios.py +++ b/backend/app/services/forecasting/scenarios.py @@ -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. diff --git a/backend/app/services/llm/client.py b/backend/app/services/llm/client.py index 71c960e5..86e0c85b 100644 --- a/backend/app/services/llm/client.py +++ b/backend/app/services/llm/client.py @@ -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) diff --git a/backend/app/services/scrapers/izyatie_client.py b/backend/app/services/scrapers/izyatie_client.py index 765d9f84..fb33e7df 100644 --- a/backend/app/services/scrapers/izyatie_client.py +++ b/backend/app/services/scrapers/izyatie_client.py @@ -83,10 +83,8 @@ def list_izyatie_documents(url: str = _IZYATIE_URL) -> list[dict[str, str]]: # Берём только /file/-ссылки. 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}) diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py index 86226793..a26332f0 100644 --- a/backend/app/services/site_finder/best_layouts.py +++ b/backend/app/services/site_finder/best_layouts.py @@ -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) за последний снимок ─────────────── diff --git a/backend/app/services/site_finder/cadastre_fetch.py b/backend/app/services/site_finder/cadastre_fetch.py index 9c81c0a4..14e76479 100644 --- a/backend/app/services/site_finder/cadastre_fetch.py +++ b/backend/app/services/site_finder/cadastre_fetch.py @@ -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 { diff --git a/backend/app/services/site_finder/competitors.py b/backend/app/services/site_finder/competitors.py index 3173d75d..c735d2d1 100644 --- a/backend/app/services/site_finder/competitors.py +++ b/backend/app/services/site_finder/competitors.py @@ -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=[], diff --git a/backend/app/services/site_finder/gate_verdict.py b/backend/app/services/site_finder/gate_verdict.py index 8b854cb3..64c9f24d 100644 --- a/backend/app/services/site_finder/gate_verdict.py +++ b/backend/app/services/site_finder/gate_verdict.py @@ -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', '')}", ) ) diff --git a/backend/app/services/site_finder/noise_loader.py b/backend/app/services/site_finder/noise_loader.py index 666b089b..32755c83 100644 --- a/backend/app/services/site_finder/noise_loader.py +++ b/backend/app/services/site_finder/noise_loader.py @@ -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: diff --git a/backend/app/services/site_finder/own_portfolio.py b/backend/app/services/site_finder/own_portfolio.py index 5841fcf0..3c983cc1 100644 --- a/backend/app/services/site_finder/own_portfolio.py +++ b/backend/app/services/site_finder/own_portfolio.py @@ -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 ( diff --git a/backend/app/services/site_finder/poi_loader.py b/backend/app/services/site_finder/poi_loader.py index e3b341e9..709943ec 100644 --- a/backend/app/services/site_finder/poi_loader.py +++ b/backend/app/services/site_finder/poi_loader.py @@ -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, } diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index 10ed6210..a6ae41e0 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -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) diff --git a/backend/app/services/site_finder/velocity.py b/backend/app/services/site_finder/velocity.py index a7e266a6..e391dee3 100644 --- a/backend/app/services/site_finder/velocity.py +++ b/backend/app/services/site_finder/velocity.py @@ -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)) diff --git a/backend/app/services/weather_cache.py b/backend/app/services/weather_cache.py index f5cf9bf7..a037924f 100644 --- a/backend/app/services/weather_cache.py +++ b/backend/app/services/weather_cache.py @@ -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 diff --git a/backend/app/workers/tasks/nspd_geo.py b/backend/app/workers/tasks/nspd_geo.py index 418c576f..3d0c1b74 100644 --- a/backend/app/workers/tasks/nspd_geo.py +++ b/backend/app/workers/tasks/nspd_geo.py @@ -151,8 +151,11 @@ def _save_quarter(db: Session, payload: dict, cad_num: str) -> int: -- cad_quarters_geom.geom = geometry(MultiPolygon,4326) NOT NULL (migration 58). -- NSPD может вернуть одноконтурный Polygon → ST_Multi coerce'ит в MultiPolygon, -- иначе "Geometry type (Polygon) does not match column type (MultiPolygon)". - -- 3857→4326 transform как в upsert_quarter_geom_from_feature (Mercator). - ST_Multi(ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:g), 3857), 4326)), + -- БЕЗ ST_Transform: rosreestr2coord отдаёт GeoJSON уже в WGS84 + -- (EPSG:4326, degrees) для всех area_type — только ST_SetSRID. + -- ST_Transform из 3857 трактовал бы градусы как метры Mercator → + -- коллапс к Null Island (issue #1336). + ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326)), CAST(:props AS jsonb), NOW()) ON CONFLICT (cad_number) DO UPDATE SET geom = EXCLUDED.geom, @@ -200,7 +203,11 @@ def _save_building(db: Session, payload: dict, cad_num: str) -> int: readable_address, area, floors, raw_props, source ) VALUES ( :cad, :qcad, - ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:g), 3857), 4326), + -- БЕЗ ST_Transform: rosreestr2coord отдаёт GeoJSON уже в WGS84 + -- (EPSG:4326, degrees) для всех area_type — только ST_SetSRID. + -- ST_Transform из 3857 трактовал бы градусы как метры Mercator → + -- коллапс к Null Island (issue #1336). + ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326), :purpose, :name, :addr, :area, :floors, CAST(:props AS jsonb), :source ) ON CONFLICT (cad_num) DO UPDATE @@ -237,8 +244,9 @@ def _save_parcel(db: Session, payload: dict, cad_num: str) -> int: NB: rosreestr2coord для area_type=1 (parcel) отдаёт GeoJSON уже в WGS84 (EPSG:4326, degrees). Просто ST_SetSRID — без ST_Transform, иначе геометрия - сжимается до точки в районе (0,0) на острове Null. Quarters/buildings — - другой path, у них Mercator (3857) и нужен ST_Transform. + сжимается до точки в районе (0,0) на острове Null. Quarters/buildings идут + через тот же rosreestr2coord path и тоже WGS84 — им ST_Transform тоже НЕ нужен + (issue #1336: раньше ошибочно трансформировались из 3857 → Null Island). """ feats = (payload.get("data") or {}).get("features") or [] if not feats: diff --git a/backend/app/workers/tasks/scrape_objective.py b/backend/app/workers/tasks/scrape_objective.py index 7098da54..1cfa2fca 100644 --- a/backend/app/workers/tasks/scrape_objective.py +++ b/backend/app/workers/tasks/scrape_objective.py @@ -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, diff --git a/data/sql/110_22j_dedup_infrastructure.sql b/data/sql/110_22j_dedup_infrastructure.sql index 5b6d1ba4..4a167bf1 100644 --- a/data/sql/110_22j_dedup_infrastructure.sql +++ b/data/sql/110_22j_dedup_infrastructure.sql @@ -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; diff --git a/data/sql/148_own_planned_project.sql b/data/sql/148_own_planned_project.sql index 797e8ba2..85a39c4d 100644 --- a/data/sql/148_own_planned_project.sql +++ b/data/sql/148_own_planned_project.sql @@ -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] (доля). diff --git a/data/sql/55_schema_scrape_resume.sql b/data/sql/55_schema_scrape_resume.sql index 08524406..702e8088 100644 --- a/data/sql/55_schema_scrape_resume.sql +++ b/data/sql/55_schema_scrape_resume.sql @@ -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'; diff --git a/data/sql/85_pzz_zones_ekb.sql b/data/sql/85_pzz_zones_ekb.sql index c8ff80e5..dac4d730 100644 --- a/data/sql/85_pzz_zones_ekb.sql +++ b/data/sql/85_pzz_zones_ekb.sql @@ -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); diff --git a/data/sql/decode_to_file.py b/data/sql/decode_to_file.py index 2207ff59..d9a6c7fd 100644 --- a/data/sql/decode_to_file.py +++ b/data/sql/decode_to_file.py @@ -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)') diff --git a/frontend/src/app/admin/scrape/all/page.tsx b/frontend/src/app/admin/scrape/all/page.tsx index d3635112..337aa431 100644 --- a/frontend/src/app/admin/scrape/all/page.tsx +++ b/frontend/src/app/admin/scrape/all/page.tsx @@ -71,6 +71,36 @@ export default function ScrapeAllAdminPage() { "all" | "kn" | "nspd" | "objective" | "nspd_geo" >("all"); const [logsRunId, setLogsRunId] = useState(""); + // 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( `/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() { + {/* Фильтр + табы */} @@ -160,7 +202,7 @@ export default function ScrapeAllAdminPage() { ) : null} diff --git a/frontend/src/components/analytics/PrinzipGapBar.tsx b/frontend/src/components/analytics/PrinzipGapBar.tsx index 1dd232ec..c6900409 100644 --- a/frontend/src/components/analytics/PrinzipGapBar.tsx +++ b/frontend/src/components/analytics/PrinzipGapBar.tsx @@ -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[] = []; + const xAxes: Record[] = []; + const yAxes: Record[] = []; + 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("
"); }, }, - 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]); diff --git a/frontend/src/components/analytics/QuartirographyChart.tsx b/frontend/src/components/analytics/QuartirographyChart.tsx index d456baac..833815f0 100644 --- a/frontend/src/components/analytics/QuartirographyChart.tsx +++ b/frontend/src/components/analytics/QuartirographyChart.tsx @@ -23,7 +23,12 @@ export function QuartirographyChart() { "3-к 60-80", "80+ м²", ]; - const portfolioMap: Record = { + // 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 = { + "Студии 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 }, diff --git a/frontend/src/components/analytics/RecommendRevenueChart.tsx b/frontend/src/components/analytics/RecommendRevenueChart.tsx index 7f6496bc..2e6121de 100644 --- a/frontend/src/components/analytics/RecommendRevenueChart.tsx +++ b/frontend/src/components/analytics/RecommendRevenueChart.tsx @@ -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", diff --git a/frontend/src/components/analytics/RecommendVelocityPanel.tsx b/frontend/src/components/analytics/RecommendVelocityPanel.tsx index d1c84063..34a08931 100644 --- a/frontend/src/components/analytics/RecommendVelocityPanel.tsx +++ b/frontend/src/components/analytics/RecommendVelocityPanel.tsx @@ -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)` : ""}{" "} · POI ×{scope.poi_factor.toFixed(2)} (на цену) ). При - 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)}. ); diff --git a/frontend/src/components/auth/RouteGuard.tsx b/frontend/src/components/auth/RouteGuard.tsx index 2d8ae184..66f8c743 100644 --- a/frontend/src/components/auth/RouteGuard.tsx +++ b/frontend/src/components/auth/RouteGuard.tsx @@ -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/") ) { diff --git a/frontend/src/components/auth/UserMenu.tsx b/frontend/src/components/auth/UserMenu.tsx index abea2f62..d30443cb 100644 --- a/frontend/src/components/auth/UserMenu.tsx +++ b/frontend/src/components/auth/UserMenu.tsx @@ -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 (
diff --git a/frontend/src/components/concept/ConceptDrawMap.tsx b/frontend/src/components/concept/ConceptDrawMap.tsx index 421a2073..d64f355a 100644 --- a/frontend/src/components/concept/ConceptDrawMap.tsx +++ b/frontend/src/components/concept/ConceptDrawMap.tsx @@ -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 . + // 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); diff --git a/frontend/src/components/concept/ConceptExportButtons.tsx b/frontend/src/components/concept/ConceptExportButtons.tsx index e07453f3..1cd67405 100644 --- a/frontend/src/components/concept/ConceptExportButtons.tsx +++ b/frontend/src/components/concept/ConceptExportButtons.tsx @@ -77,6 +77,17 @@ export function ConceptExportButtons({ variant }: Props) { ); const [error, setError] = useState(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() { diff --git a/frontend/src/components/concept/ConceptResultMap.tsx b/frontend/src/components/concept/ConceptResultMap.tsx index efd0daab..0d50990e 100644 --- a/frontend/src/components/concept/ConceptResultMap.tsx +++ b/frontend/src/components/concept/ConceptResultMap.tsx @@ -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 ( diff --git a/frontend/src/components/concept/ConceptVariantsResult.tsx b/frontend/src/components/concept/ConceptVariantsResult.tsx index 29ef3e16..97fa2c9c 100644 --- a/frontend/src/components/concept/ConceptVariantsResult.tsx +++ b/frontend/src/components/concept/ConceptVariantsResult.tsx @@ -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, + })} + .
@@ -158,9 +163,11 @@ function VariantPanel({ parcel, variant }: PanelProps) { unit="шт" /> diff --git a/frontend/src/components/landing/PilotRequestModal.tsx b/frontend/src/components/landing/PilotRequestModal.tsx index aab73461..b0eb5027 100644 --- a/frontend/src/components/landing/PilotRequestModal.tsx +++ b/frontend/src/components/landing/PilotRequestModal.tsx @@ -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); } /** diff --git a/frontend/src/components/site-finder/BestLayoutsBlock.tsx b/frontend/src/components/site-finder/BestLayoutsBlock.tsx index d27fe6c7..dc9909ee 100644 --- a/frontend/src/components/site-finder/BestLayoutsBlock.tsx +++ b/frontend/src/components/site-finder/BestLayoutsBlock.tsx @@ -26,6 +26,14 @@ const TIME_WINDOW_LABELS: Record = { last_year: "Последний год", }; +// Период «распроданности» для провенанса — соответствует выбранному time_window +// (backend считает sold_pct_of_supply по сделкам этого окна). +const TIME_WINDOW_OVERSOLD_PERIOD: Record = { + last_month: "за последний месяц", + last_quarter: "за последний квартал", + last_year: "за последний год", +}; + const ROOM_BUCKET_LABELS: Record = { 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>(new Set()); if (rows.length === 0) { @@ -333,7 +347,7 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) { borderRadius: 4, whiteSpace: "nowrap", }} - title="Сделки за 24 мес превышают текущий snapshot supply — значение обрезано до 100%" + title={`Сделки ${oversoldPeriodLabel} превышают текущий snapshot supply — значение обрезано до 100%`} > >100% @@ -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(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( + 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 && (
- +
)} diff --git a/frontend/src/components/site-finder/CpLayerControlPanel.tsx b/frontend/src/components/site-finder/CpLayerControlPanel.tsx index 49f12064..e461ab9b 100644 --- a/frontend/src/components/site-finder/CpLayerControlPanel.tsx +++ b/frontend/src/components/site-finder/CpLayerControlPanel.tsx @@ -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 ( diff --git a/frontend/src/components/site-finder/EnvironmentTab.tsx b/frontend/src/components/site-finder/EnvironmentTab.tsx index fd447ed8..4d4a90fa 100644 --- a/frontend/src/components/site-finder/EnvironmentTab.tsx +++ b/frontend/src/components/site-finder/EnvironmentTab.tsx @@ -406,7 +406,7 @@ function WeatherBlock({ }} title={weather.note} > - {weather.source} · 7 дн. + {weather.source} · {weather.forecast_days} дн. ); diff --git a/frontend/src/components/site-finder/MarketTab.tsx b/frontend/src/components/site-finder/MarketTab.tsx index c6b4868e..60461e68 100644 --- a/frontend/src/components/site-finder/MarketTab.tsx +++ b/frontend/src/components/site-finder/MarketTab.tsx @@ -101,7 +101,7 @@ export function MarketTab({ data }: Props) { }} > - {activeCount} строящихся в {radiusKm}км + {activeCount} активных в {radiusKm}км {" · велосити "} @@ -147,7 +147,7 @@ export function MarketTab({ data }: Props) { {activeCount}
- строящихся в радиусе {radiusKm}км + строящихся и недавно сданных в радиусе {radiusKm}км
diff --git a/frontend/src/components/site-finder/MarketTrendBlock.tsx b/frontend/src/components/site-finder/MarketTrendBlock.tsx index 1c744c08..c4af54c0 100644 --- a/frontend/src/components/site-finder/MarketTrendBlock.tsx +++ b/frontend/src/components/site-finder/MarketTrendBlock.tsx @@ -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", }; diff --git a/frontend/src/components/site-finder/NspdEngineeringNearbyBlock.tsx b/frontend/src/components/site-finder/NspdEngineeringNearbyBlock.tsx index f7dc76cb..b650e8c3 100644 --- a/frontend/src/components/site-finder/NspdEngineeringNearbyBlock.tsx +++ b/frontend/src/components/site-finder/NspdEngineeringNearbyBlock.tsx @@ -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(); 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 (
- Инженерных объектов в 200 м не найдено + Инженерных объектов поблизости не найдено
); } diff --git a/frontend/src/components/site-finder/Pipeline24moBlock.tsx b/frontend/src/components/site-finder/Pipeline24moBlock.tsx index c0c12838..e6c466ba 100644 --- a/frontend/src/components/site-finder/Pipeline24moBlock.tsx +++ b/frontend/src/components/site-finder/Pipeline24moBlock.tsx @@ -34,7 +34,11 @@ const CLASS_LABEL: Record = { 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" }); diff --git a/frontend/src/components/site-finder/ScoreBreakdownStackedBar.tsx b/frontend/src/components/site-finder/ScoreBreakdownStackedBar.tsx index bbbe723e..f5ff2f45 100644 --- a/frontend/src/components/site-finder/ScoreBreakdownStackedBar.tsx +++ b/frontend/src/components/site-finder/ScoreBreakdownStackedBar.tsx @@ -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({
{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 diff --git a/frontend/src/components/site-finder/SeasonalWeatherBlock.tsx b/frontend/src/components/site-finder/SeasonalWeatherBlock.tsx index 9a1d1268..828614e5 100644 --- a/frontend/src/components/site-finder/SeasonalWeatherBlock.tsx +++ b/frontend/src/components/site-finder/SeasonalWeatherBlock.tsx @@ -47,7 +47,7 @@ function SeasonCard({ {stats.avg_t_min_c}…{stats.avg_t_max_c}°C
- (макс ↓{stats.min_t_c} / ↑{stats.max_t_c}°C) + (экстремумы ↓{stats.min_t_c} / ↑{stats.max_t_c}°C)
{stats.total_precip_mm} мм осадков diff --git a/frontend/src/components/site-finder/VelocityBlock.tsx b/frontend/src/components/site-finder/VelocityBlock.tsx index 615fe7fa..1178c832 100644 --- a/frontend/src/components/site-finder/VelocityBlock.tsx +++ b/frontend/src/components/site-finder/VelocityBlock.tsx @@ -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 = { @@ -11,7 +11,8 @@ const BUCKET_LABEL: Record = { "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 && (
Топ продавцов | 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 })); } diff --git a/frontend/src/components/site-finder/ZouitLayer.tsx b/frontend/src/components/site-finder/ZouitLayer.tsx index cef47b43..44344e92 100644 --- a/frontend/src/components/site-finder/ZouitLayer.tsx +++ b/frontend/src/components/site-finder/ZouitLayer.tsx @@ -205,11 +205,14 @@ export function ZouitLayer({ grouped, visibleSeverities }: LayerProps) { return ( 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"); } diff --git a/frontend/src/components/site-finder/analysis/PoiList2Gis.tsx b/frontend/src/components/site-finder/analysis/PoiList2Gis.tsx index 0cd5f54f..c90c2d8b 100644 --- a/frontend/src/components/site-finder/analysis/PoiList2Gis.tsx +++ b/frontend/src/components/site-finder/analysis/PoiList2Gis.tsx @@ -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 diff --git a/frontend/src/components/site-finder/analysis/ScenariosBlock.tsx b/frontend/src/components/site-finder/analysis/ScenariosBlock.tsx index 60f14488..35d2455b 100644 --- a/frontend/src/components/site-finder/analysis/ScenariosBlock.tsx +++ b/frontend/src/components/site-finder/analysis/ScenariosBlock.tsx @@ -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({ })}

- Индекс дефицита приведён на целевом горизонте {targetHorizon} мес. - Конверт ставки — путь ключевой ставки (% годовых) по горизонтам. + Индекс дефицита приведён на целевом горизонте {targetHorizon} мес.; если + ряд на этом горизонте отсутствует, используется самый длинный доступный + — его горизонт указан в подписи карточки. Конверт ставки — путь ключевой + ставки (% годовых) по горизонтам.

); @@ -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} -
+
{hint}
@@ -179,7 +197,7 @@ function ScenarioCard({ color: "var(--fg-secondary)", }} > - Индекс дефицита · {targetHorizon} мес. + Индекс дефицита · {labelHorizon} мес. {deficitIndex != null ? (
diff --git a/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx b/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx index 8d07294c..a0c6c1b1 100644 --- a/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx +++ b/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx @@ -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 (
) : ( - // Fallback: build from analyze score_breakdown - - 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. + )}
diff --git a/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx b/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx index 4c59745d..84662cd6 100644 --- a/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx +++ b/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx @@ -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 { ), }; + case "transformer": + return { + label: "Электричество (трансформатор)", + icon: ( + + ), + }; case "power_line": return { label: "Электричество (ЛЭП)", @@ -50,7 +58,8 @@ function getUtilityConfig(subtype: string): UtilityDisplayConfig { ), }; - 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: ( + + ), + }; + // ── Водопровод ── + case "water_main": + return { + label: "Водопровод (магистраль)", icon: ( ), }; - case "pumping_station": + case "water_works": return { - label: "Канализация (насосная)", + label: "Водопровод (водозабор)", + icon: ( + + ), + }; + case "water_tower": + return { + label: "Водопровод (водонапорная башня)", + icon: ( + + ), + }; + case "storage_tank": + return { + label: "Резервуар", + icon: ( + + ), + }; + // ── Канализация ── + case "sewerage": + return { + label: "Канализация (коллектор)", icon: ( ), }; + case "wastewater_plant": + return { + label: "Канализация (очистные)", + icon: ( + + ), + }; + // ── Теплоснабжение ── + case "heat_substation": + return { + label: "Теплоснабжение (ЦТП)", + icon: ( + + ), + }; 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(" · ") diff --git a/frontend/src/components/site-finder/compare/CompareTable.tsx b/frontend/src/components/site-finder/compare/CompareTable.tsx index 9aa86266..53d2c956 100644 --- a/frontend/src/components/site-finder/compare/CompareTable.tsx +++ b/frontend/src/components/site-finder/compare/CompareTable.tsx @@ -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) ? ( - {Math.round(c.velocityScore * 100)} - - {" "} - / 100 - + {Math.round(c.velocityScore * 100)}% ) : ( DASH diff --git a/frontend/src/components/site-finder/entry/EntryMap.tsx b/frontend/src/components/site-finder/entry/EntryMap.tsx index 5bc77d1a..95830385 100644 --- a/frontend/src/components/site-finder/entry/EntryMap.tsx +++ b/frontend/src/components/site-finder/entry/EntryMap.tsx @@ -244,10 +244,15 @@ export function EntryMap({ )} - {/* 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 && (
" f"" @@ -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}} @@ -245,9 +287,9 @@ th{{background:#f5f7fa;font-weight:600}}

Компоненты (взвешенные)

{c}{v['parcel']}{v['median_jk']}{v['p75_jk']}
{"".join(comp_rows)}
КомпонентУчастокМедиана ЖКP75 ЖКΔ vs медиана
-
Веса: образование 20% · здоровье 10% · ритейл 15% · транспорт 15% · досуг 10% · экономика 30%
+
Веса: {weights_caption}
-

Экономика района (Объектив API, последние 90 дней)

+

Экономика района (Объектив API; скорость — за 12 мес, доли продаж и цены — накопительно за всё время)

{econ_block(p.get("economics"))}

Ближайшие POI вокруг участка

diff --git a/site-finder/12_more_pois.py b/site-finder/12_more_pois.py index 8e6b9ca2..26875d5b 100644 --- a/site-finder/12_more_pois.py +++ b/site-finder/12_more_pois.py @@ -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: diff --git a/site-finder/server.py b/site-finder/server.py index 4b437103..e4274f89 100644 --- a/site-finder/server.py +++ b/site-finder/server.py @@ -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, }) diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index f587db5b..3f40e9cf 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -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} diff --git a/tradein-mvp/backend/app/api/v1/search.py b/tradein-mvp/backend/app/api/v1/search.py index 99b8b7f0..9580c2af 100644 --- a/tradein-mvp/backend/app/api/v1/search.py +++ b/tradein-mvp/backend/app/api/v1/search.py @@ -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, diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index bcf8f141..17067741 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -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: diff --git a/tradein-mvp/backend/app/schemas/search.py b/tradein-mvp/backend/app/schemas/search.py index 6d51182e..bf797ab0 100644 --- a/tradein-mvp/backend/app/schemas/search.py +++ b/tradein-mvp/backend/app/schemas/search.py @@ -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 diff --git a/tradein-mvp/backend/app/services/cian_price_history.py b/tradein-mvp/backend/app/services/cian_price_history.py index 5e1c2baf..ec43af46 100644 --- a/tradein-mvp/backend/app/services/cian_price_history.py +++ b/tradein-mvp/backend/app/services/cian_price_history.py @@ -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 diff --git a/tradein-mvp/backend/app/services/dadata.py b/tradein-mvp/backend/app/services/dadata.py index 8f414476..c665353f 100644 --- a/tradein-mvp/backend/app/services/dadata.py +++ b/tradein-mvp/backend/app/services/dadata.py @@ -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" diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 00f3b7e6..4eb4a9ed 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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, diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py index ae4fee39..0ad4e170 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -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 - -
Количество объявлений по аналогичным объектам {n_total} шт.
Количество объявлений по аналогичным объектам
- (с учётом ремонта)
{n_with_repair} шт.
Источники данных
{sources_html}
@@ -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( "" f"{addr_cell}" @@ -784,7 +784,7 @@ def _examples_rows(lots: list[AnalogLot]) -> str: f"{price_cell}" f"{lot.days_on_market or '—'}" + f"font-variant-numeric:tabular-nums;'>{days_cell}" "" ) 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] diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 4b2b35e2..f97b8ca3 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -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 ) diff --git a/tradein-mvp/backend/app/services/house_imv_backfill.py b/tradein-mvp/backend/app/services/house_imv_backfill.py index 5515bd4a..d944916c 100644 --- a/tradein-mvp/backend/app/services/house_imv_backfill.py +++ b/tradein-mvp/backend/app/services/house_imv_backfill.py @@ -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() diff --git a/tradein-mvp/backend/app/services/image_sanitizer.py b/tradein-mvp/backend/app/services/image_sanitizer.py index f2df5841..24816332 100644 --- a/tradein-mvp/backend/app/services/image_sanitizer.py +++ b/tradein-mvp/backend/app/services/image_sanitizer.py @@ -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") diff --git a/tradein-mvp/backend/app/services/matching/conflict_resolution.py b/tradein-mvp/backend/app/services/matching/conflict_resolution.py index e1d2ac5c..42c00b23 100644 --- a/tradein-mvp/backend/app/services/matching/conflict_resolution.py +++ b/tradein-mvp/backend/app/services/matching/conflict_resolution.py @@ -9,8 +9,14 @@ cian (serp/bti/detail/stats/valuation), yandex (serp/detail/realty_nb/valuation) from __future__ import annotations +import logging from typing import Any +logger = logging.getLogger(__name__) + +# cross_validate: warn if spread between sources exceeds this fraction of the median. +_CROSS_VALIDATE_DIVERGENCE_THRESHOLD = 0.10 + # --------------------------------------------------------------------------- # HOUSE_FIELD_PRIORITY — vault sec 3.5 # --------------------------------------------------------------------------- @@ -149,12 +155,31 @@ def _resolve( non_null = [v for v in candidates.values() if v is not None] return min(non_null) if non_null else None if rule == "cross_validate": - # Caller decides — return median by default + # Return the median; flag divergence (vault sec 4.5: warn if diff > 10%). nums = [v for v in candidates.values() if isinstance(v, int | float)] if not nums: return None nums.sort() - return nums[len(nums) // 2] + n = len(nums) + if n % 2: + median = nums[n // 2] + else: + # True median for even n: mean of the two central values. + median = (nums[n // 2 - 1] + nums[n // 2]) / 2 + if median and (max(nums) - min(nums)) > _CROSS_VALIDATE_DIVERGENCE_THRESHOLD * abs( + median + ): + logger.warning( + "cross_validate divergence for field %r: min=%s max=%s median=%s " + "(spread exceeds %.0f%% of median); candidates=%r", + field, + min(nums), + max(nums), + median, + _CROSS_VALIDATE_DIVERGENCE_THRESHOLD * 100, + candidates, + ) + return median # Unknown rule return next((v for v in candidates.values() if v is not None), None) diff --git a/tradein-mvp/backend/app/services/matching/houses.py b/tradein-mvp/backend/app/services/matching/houses.py index 7d91f822..d2ff0373 100644 --- a/tradein-mvp/backend/app/services/matching/houses.py +++ b/tradein-mvp/backend/app/services/matching/houses.py @@ -86,6 +86,10 @@ def match_or_create_house( method="cadastr_exact", confidence=1.0, ) + # Register fingerprint/normalized_address alias so a later scrape of the same + # house from a cadastr-less source (typical SERP) hits Tier 2a/2b instead of + # falling through to a duplicate New INSERT when geo jitter exceeds 30 m. + _insert_alias(db, house_id=house_id, address=address, fp=fp, source=ext_source) logger.info("house match cadastr_exact house_id=%s cad=%s", house_id, cad) return (house_id, 1.0, "cadastr_exact") @@ -348,6 +352,19 @@ def _upsert_house_source( ) ON CONFLICT (ext_source, ext_id) DO UPDATE SET confidence = GREATEST(EXCLUDED.confidence, house_sources.confidence), + -- Keep provenance consistent with the winning confidence: only adopt the + -- new method/timestamp when the incoming match is more confident than the + -- stored one (e.g. geo_proximity/0.7 later upgraded to cadastr_exact/1.0). + matched_method = CASE + WHEN EXCLUDED.confidence > house_sources.confidence + THEN EXCLUDED.matched_method + ELSE house_sources.matched_method + END, + matched_at = CASE + WHEN EXCLUDED.confidence > house_sources.confidence + THEN EXCLUDED.matched_at + ELSE house_sources.matched_at + END, last_seen_at = NOW() """), {"hid": house_id, "s": ext_source, "e": str(ext_id), "c": confidence, "m": method}, diff --git a/tradein-mvp/backend/app/services/matching/normalize.py b/tradein-mvp/backend/app/services/matching/normalize.py index 53c6c994..9d4d0503 100644 --- a/tradein-mvp/backend/app/services/matching/normalize.py +++ b/tradein-mvp/backend/app/services/matching/normalize.py @@ -13,6 +13,15 @@ import unicodedata _PUNCT = re.compile(r'[^\w\s\-]', flags=re.UNICODE) _WS = re.compile(r'\s+') +# House/building markers ('дом'/'д'/'корпус'/'корп'/'к') are dropped to a single +# canonical form: only the number is kept. This makes the output source-independent — +# SERP writes a bare number ('Ленина 5') while detail/БТИ write a marker ('д. 5' -> 'д 5') +# and both must yield the same normalized_address / fingerprint (bug #1534). +# The marker is removed ONLY when immediately followed by a number, so street-name +# initials like 'К. Либкнехта' / 'Д. Зверева' (marker followed by a word) are left +# untouched and not mis-expanded into 'корпус'/'дом' (bug #1535). +_HOUSE_MARKER = re.compile(r'\b(?:дом|корпус|корп|д|к)\s+(?=\d)', flags=re.UNICODE) + # Abbreviation expansion — applied after lowercasing, bounded by spaces. # Order matters: longer/more-specific patterns first to avoid partial overlap. # Hyphenated forms ('пр-кт', 'б-р', 'пр-д') come first so they match before @@ -34,8 +43,10 @@ _ABBREV = [ (' туп ', ' тупик '), (' стр ', ' строение '), (' корп ', ' корпус '), - (' д ', ' дом '), - (' к ', ' корпус '), + # NOTE: house/building markers ('д'/'дом'/'к'/'корп'/'корпус') are NOT expanded here. + # They are handled separately by _HOUSE_MARKER below — stripped to a single canonical + # form (number only) and only when followed by a number — to avoid asymmetric keys + # (#1534) and mis-expanding street-name initials like 'К. Либкнехта' (#1535). ] @@ -49,6 +60,8 @@ def normalize_address(text: str | None) -> str: 4. Collapse whitespace 5. Expand common Russian address abbreviations (hyphenated forms first) 6. Collapse remaining hyphens to spaces + 7. Drop house/building markers ('дом'/'д'/'корпус'/'корп'/'к') that precede a + number, keeping only the number — a single canonical form across sources """ if not text: return "" @@ -61,6 +74,10 @@ def normalize_address(text: str | None) -> str: s = s.replace(short, full) # Collapse remaining hyphens (e.g. standalone '-' separators or numeric ranges) s = s.replace('-', ' ') + s = _WS.sub(' ', s).strip() + # Drop house/building markers that precede a number ('д 5'/'дом 5'/'корпус 5' -> '5'), + # leaving street-name initials ('к либкнехта') untouched. Re-collapse whitespace after. + s = _HOUSE_MARKER.sub('', s) return _WS.sub(' ', s).strip() diff --git a/tradein-mvp/backend/app/services/sber_index.py b/tradein-mvp/backend/app/services/sber_index.py index 564ccfe1..e0b0942b 100644 --- a/tradein-mvp/backend/app/services/sber_index.py +++ b/tradein-mvp/backend/app/services/sber_index.py @@ -32,6 +32,7 @@ REF_AREA codes (sberindex internal region IDs, not ОКАТО/ISO): from __future__ import annotations +import asyncio import base64 import json import logging @@ -281,6 +282,68 @@ async def fetch_sber_index( # --------------------------------------------------------------------------- +def _upsert_rows_sync( + db: Session, rows_to_upsert: list[tuple[str, date, str, str, float]] +) -> None: + """Synchronous per-row UPSERT into sber_price_index + commit. + + #1348: blocking psycopg work — must run via asyncio.to_thread, never directly + on the event loop. Idempotent ON CONFLICT(city, period_month, dashboard). + """ + for city_label, period_month, segment, dash, value in rows_to_upsert: + db.execute( + text( + """ + INSERT INTO sber_price_index + (city, period_month, segment, dashboard, + index_value_rub_m2, source, fetched_at) + VALUES ( + CAST(:city AS text), + CAST(:period_month AS date), + CAST(:segment AS text), + CAST(:dashboard AS text), + CAST(:index_value AS double precision), + 'sberindex', + now() + ) + ON CONFLICT (city, period_month, dashboard) + DO UPDATE SET + index_value_rub_m2 = EXCLUDED.index_value_rub_m2, + segment = EXCLUDED.segment, + fetched_at = now() + """ + ), + { + "city": city_label, + "period_month": period_month.isoformat(), + "segment": segment, + "dashboard": dash, + "index_value": value, + }, + ) + + db.commit() + + +def _query_our_median_sync(db: Session) -> float | None: + """Synchronous benchmark SELECT — scrape-median asking ppm² (ЕКБ вторичка). + + #1348: blocking psycopg work — must run via asyncio.to_thread. + """ + return db.execute( + text( + # novostroyki guard (#1186): NULL = legacy вторичка до м.011 + "SELECT percentile_cont(0.5)" + " WITHIN GROUP (ORDER BY price_per_m2) AS m" + " FROM listings" + " WHERE is_active = true" + " AND price_per_m2 BETWEEN 50000 AND 800000" + " AND (listing_segment IS NULL" + " OR listing_segment = 'vtorichka')" + ) + ).scalar() + + async def pull_sber_indices( db: Session, *, @@ -333,41 +396,13 @@ async def pull_sber_indices( counters["skipped"] += 1 continue - # Idempotent upsert — ON CONFLICT(city, period_month, dashboard) - for city_label, period_month, segment, dash, value in rows_to_upsert: - db.execute( - text( - """ - INSERT INTO sber_price_index - (city, period_month, segment, dashboard, - index_value_rub_m2, source, fetched_at) - VALUES ( - CAST(:city AS text), - CAST(:period_month AS date), - CAST(:segment AS text), - CAST(:dashboard AS text), - CAST(:index_value AS double precision), - 'sberindex', - now() - ) - ON CONFLICT (city, period_month, dashboard) - DO UPDATE SET - index_value_rub_m2 = EXCLUDED.index_value_rub_m2, - segment = EXCLUDED.segment, - fetched_at = now() - """ - ), - { - "city": city_label, - "period_month": period_month.isoformat(), - "segment": segment, - "dashboard": dash, - "index_value": value, - }, - ) - counters["upserted"] += 1 + # Idempotent upsert — ON CONFLICT(city, period_month, dashboard). + # #1348: the blocking psycopg UPSERT loop + commit run off the + # event loop via asyncio.to_thread (sync Session is fine here — + # access is sequential, never concurrent). + await asyncio.to_thread(_upsert_rows_sync, db, rows_to_upsert) + counters["upserted"] += len(rows_to_upsert) - db.commit() logger.info( "sber_index: upserted %d rows for dashboard=%s ref_area=%s", len(rows_to_upsert), @@ -392,18 +427,10 @@ async def pull_sber_indices( if ref_area == "66": # Task B: best-effort reconciliation to our scrape-median asking ppm² try: - our_median = db.execute( - text( - # novostroyki guard (#1186): NULL = legacy вторичка до м.011 - "SELECT percentile_cont(0.5)" - " WITHIN GROUP (ORDER BY price_per_m2) AS m" - " FROM listings" - " WHERE is_active = true" - " AND price_per_m2 BETWEEN 50000 AND 800000" - " AND (listing_segment IS NULL" - " OR listing_segment = 'vtorichka')" - ) - ).scalar() + # #1348: blocking benchmark SELECT off the event loop + our_median = await asyncio.to_thread( + _query_our_median_sync, db + ) if our_median is not None and latest_val: sber_latest = latest_val divergence_pct = (our_median - sber_latest) / sber_latest * 100 @@ -437,6 +464,12 @@ async def pull_sber_indices( ) counters["errors"] += 1 except Exception: + # #1345: a failed db.execute leaves the shared Session in an + # aborted-transaction state; without rollback every remaining + # series cascades into InFailedSqlTransaction. Roll back so the + # next (city, dashboard) iteration starts on a clean transaction. + # #1348: rollback is blocking psycopg work — keep it off the loop. + await asyncio.to_thread(db.rollback) logger.exception( "sber_index: unexpected error for dashboard=%s ref_area=%s — skip", dashboard.slug, diff --git a/tradein-mvp/backend/app/services/scheduler.py b/tradein-mvp/backend/app/services/scheduler.py index 0ae23c35..63307da5 100644 --- a/tradein-mvp/backend/app/services/scheduler.py +++ b/tradein-mvp/backend/app/services/scheduler.py @@ -209,6 +209,37 @@ def _claim_run(db: Session, schedule_row: dict[str, Any]) -> int | None: return run_id +def _defer_next_run_at(db: Session, schedule_row: dict[str, Any]) -> None: + """Сдвинуть next_run_at на следующее окно БЕЗ создания run (#1522). + + Используется когда trigger делает early-return (например cian: отсутствуют/протухли + cookies) ДО _claim_run. Без этого get_due_schedules переотбирает schedule на каждом + тике (SCHEDULER_TICK_SEC), и pre-check (verify_session → реальный HTTP к Cian) гоняется + раз в минуту круглосуточно. Сдвиг откладывает следующую попытку до завтрашнего окна. + """ + source = schedule_row["source"] + next_at = compute_next_run_at( + schedule_row["window_start_hour"], + schedule_row["window_end_hour"], + ) + db.execute( + text( + """ + UPDATE scrape_schedules + SET next_run_at = :next_at, updated_at = NOW() + WHERE source = :source + """ + ), + {"next_at": next_at, "source": source}, + ) + db.commit() + logger.info( + "scheduler: deferred next_run_at source=%s next_run_at=%s (no run claimed)", + source, + next_at.isoformat(), + ) + + async def trigger_avito_city_sweep_run(db: Session, schedule_row: dict[str, Any]) -> int | None: """Создать новый scrape_runs + launch run_avito_city_sweep в asyncio.create_task. @@ -330,6 +361,11 @@ async def trigger_cian_backfill_run(db: Session, schedule_row: dict[str, Any]) - cookies = load_session(db) if cookies is None: logger.warning("scheduler: cian_history_backfill skipped — no valid session cookies in DB") + # #1522: всё равно сдвигаем next_run_at на следующее окно, иначе get_due_schedules + # переотбирает этот schedule на КАЖДОМ тике (60с) и verify_session долбит Cian + # круглосуточно, пока оператор вручную не зальёт свежие cookies — burn proxy-квоты + # + риск TLS/bot-бана. + _defer_next_run_at(db, schedule_row) return None state = await verify_session(cookies) @@ -345,6 +381,9 @@ async def trigger_cian_backfill_run(db: Session, schedule_row: dict[str, Any]) - ) except Exception: pass # sentry_sdk not initialised in dev + # #1522: cookies протухли — сдвигаем next_run_at (см. комментарий выше), чтобы + # verify_session не бил по Cian каждые 60с до ручной перезаливки cookies. + _defer_next_run_at(db, schedule_row) return None run_id = _claim_run(db, schedule_row) @@ -863,7 +902,9 @@ def import_rosreestr_dkp( round(area, 2) AS area_m2, LEAST( NULLIF( - substring(floor FROM '[0-9]+'), '' + -- #1525: '-?[0-9]+' сохраняет знак минус, иначе цоколь/подвал + -- '-1' импортируется как 1. Для '5/9' по-прежнему берётся 5. + substring(floor FROM '-?[0-9]+'), '' )::int, 100 ) AS floor_num, diff --git a/tradein-mvp/backend/app/services/scrape_pipeline.py b/tradein-mvp/backend/app/services/scrape_pipeline.py index 6a658bfc..5da1b716 100644 --- a/tradein-mvp/backend/app/services/scrape_pipeline.py +++ b/tradein-mvp/backend/app/services/scrape_pipeline.py @@ -251,6 +251,14 @@ async def run_avito_pipeline( logger.warning("pipeline:house_enrich failed for %s: %s", house_path, e) counters.houses_failed += 1 counters.errors.append(f"house {house_path}: {e}") + # #1368: save_house_catalog_enrichment не делает внутренний rollback, + # поэтому упавший промежуточный statement оставляет общий Session в + # InFailedSqlTransaction. Без сброса — каскад «current transaction is + # aborted» на все оставшиеся дома/listings этого якоря. + try: + db.rollback() + except Exception: + pass if idx < len(house_paths_list) - 1: await asyncio.sleep(detail_delay) @@ -339,7 +347,19 @@ async def run_avito_pipeline( counters.detail_failed += 1 counters.errors.append(f"detail {source_url}: {e}") logger.warning("pipeline:detail_enrich failed for %s: %s", source_url, e) - consecutive_blocks = 0 + # #1386: НЕ сбрасываем consecutive_blocks здесь. Транзиентная + # сетевая/parse-ошибка (5xx, timeout, connection reset) — правдоподобный + # побочный эффект активного IP-блока (Avito не отдаёт 403/429 единообразно). + # Сброс маскировал бы продолжающийся блок (block, block, timeout→reset, ...) + # и не давал бы сработать early-abort на 3 consecutive blocks. + # Сброс на успехе (см. выше) остаётся. + # #1368: save_detail_enrichment без внутреннего rollback мог оставить + # общий Session в aborted-state — сбрасываем, иначе каскад на оставшиеся + # detail-строки и Step 5b. + try: + db.rollback() + except Exception: + pass if idx < len(priority_rows) - 1: jitter = random.uniform(0.8, 1.2) @@ -373,6 +393,12 @@ async def run_avito_pipeline( ) counters.houses_failed += 1 counters.errors.append(f"house(detail) {house_path}: {e}") + # #1368: см. Step 4 — сбрасываем aborted-state общего Session, + # иначе каскад на оставшиеся дома detail-фазы. + try: + db.rollback() + except Exception: + pass if h_idx < len(nh_list) - 1: await asyncio.sleep(detail_delay) @@ -597,6 +623,16 @@ async def run_avito_city_sweep( exc, ) counters.errors_count += 1 + # #1521: IMV-фаза могла оставить Session в aborted-state + # (необёрнутый eligibility SELECT / _mark_status на отравленной + # сессии). update_heartbeat и mark_done — единственные финализаторы + # БЕЗ defensive rollback, поэтому сбрасываем txn-state сами. Иначе + # PendingRollbackError пролетает мимо внутреннего try во внешний + # except (mark_failed) → успешный sweep ложно помечается 'failed'. + try: + db.rollback() + except Exception: + pass scrape_runs.update_heartbeat(db, run_id, counters.to_dict()) scrape_runs.mark_done(db, run_id, counters.to_dict()) diff --git a/tradein-mvp/backend/app/services/scrapers/avito.py b/tradein-mvp/backend/app/services/scrapers/avito.py index ae49f725..b500752f 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito.py +++ b/tradein-mvp/backend/app/services/scrapers/avito.py @@ -68,6 +68,23 @@ _UNIT_DAYS: dict[str, int] = { "месяц": 30, "год": 365, } +# #1387: Avito SERP часто показывает давность в единственном числе БЕЗ ведущего +# числа — «час назад», «минуту назад», «день назад», «неделю назад», «месяц +# назад», «год назад». Числовой `_REL_DATE_RE` их не матчит (требует `\d+`), +# поэтому отдельная ветка с неявным n=1. Юниты в винительном падеже («минуту», +# «неделю») плюс именительный («час», «день»). +_REL_DATE_SINGULAR_RE = re.compile( + r"(?P" + r"секунду|" + r"минуту|" + r"час|" + r"день|" + r"неделю|" + r"месяц|" + r"год" + r")\s+назад", + flags=re.I, +) # Russian month names → number. Avito показывает абсолютные даты как «18 мая». _RU_MONTHS: dict[str, int] = { @@ -124,6 +141,20 @@ def _parse_relative_date(s: str | None) -> date | None: if unit_raw.startswith(prefix): return date.today() - timedelta(days=n * days) + # #1387: Relative singular без числа («час назад», «неделю назад»). Неявное + # n=1; те же _UNIT_DAYS. Винительный падеж юнита нормализуем к ключу-префиксу. + m = _REL_DATE_SINGULAR_RE.search(s) + if m: + unit_raw = m["unit"].lower() + # «минуту»→«минут», «неделю»→«недел», «секунду»→«секунд»; остальные уже + # совпадают с префиксами _UNIT_DAYS. + unit_key = {"минуту": "минут", "неделю": "недел", "секунду": "секунд"}.get( + unit_raw, unit_raw + ) + for prefix, days in _UNIT_DAYS.items(): + if unit_key.startswith(prefix): + return date.today() - timedelta(days=days) + # Absolute («18 мая», «5 июня в 12:30»). Year — current; если месяц # в будущем относительно сегодня (например сейчас декабрь, увидели # «5 января») — откатываем к прошлому году. diff --git a/tradein-mvp/backend/app/services/scrapers/cian.py b/tradein-mvp/backend/app/services/scrapers/cian.py index 8dc2d0e3..71f82133 100644 --- a/tradein-mvp/backend/app/services/scrapers/cian.py +++ b/tradein-mvp/backend/app/services/scrapers/cian.py @@ -802,7 +802,14 @@ class CianScraper(BaseScraper): has_balcony = loggias_count > 0 # Мебель и repair_state - has_furniture: bool | None = offer.get("hasFurniture") or offer.get("isSoldFurnished") + # hasFurniture (мебель вообще) и isSoldFurnished (продаётся с мебелью) — + # разная семантика, нельзя OR-склеивать: явный False у hasFurniture + # затирался бы коалесингом. Берём hasFurniture; только если он None, + # падаем на isSoldFurnished как fallback (#1388). + _has_furniture = offer.get("hasFurniture") + has_furniture: bool | None = ( + _has_furniture if _has_furniture is not None else offer.get("isSoldFurnished") + ) # decoration — для новостроек (отделка); repair_state — для вторички # Cian SERP: offer.decoration содержит raw значения (without/cosmetic/euro/design/...) # Нормализуем до enum needs_repair/standard/good/excellent при инgesте. diff --git a/tradein-mvp/backend/data/sql/062_clean_avito_addresses.sql b/tradein-mvp/backend/data/sql/062_clean_avito_addresses.sql index 2f1819e0..dc8bbe15 100644 --- a/tradein-mvp/backend/data/sql/062_clean_avito_addresses.sql +++ b/tradein-mvp/backend/data/sql/062_clean_avito_addresses.sql @@ -7,9 +7,9 @@ UPDATE listings SET address = trim(both ' ,.' FROM regexp_replace( regexp_replace(address, '\.?css-[a-z0-9_-]+\s*\{[^}]*\}', '', 'gi'), - '\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+).*$', '', 'i' + '\s*(Площадь \d+(\.\d+)?\s?(м²|м2|кв\.?\s?м)|от \d+\s?мин\.|css-[a-z0-9_-]+).*$', '', 'i' )) WHERE source = 'avito' - AND (address ~* 'css-[a-z0-9_-]+' OR address ~* 'Площадь \d|от \d+\s?мин\.'); + AND (address ~* 'css-[a-z0-9_-]+' OR address ~* 'Площадь \d+(\.\d+)?\s?(м²|м2|кв\.?\s?м)|от \d+\s?мин\.'); COMMIT; diff --git a/tradein-mvp/backend/data/sql/080_asking_to_sold_ratios.sql b/tradein-mvp/backend/data/sql/080_asking_to_sold_ratios.sql index 12a008b7..1c50e88d 100644 --- a/tradein-mvp/backend/data/sql/080_asking_to_sold_ratios.sql +++ b/tradein-mvp/backend/data/sql/080_asking_to_sold_ratios.sql @@ -111,6 +111,9 @@ ask_side AS ( WHERE is_active AND rooms IS NOT NULL AND price_per_m2 BETWEEN 30000 AND 600000 + -- novostroyki guard (#1364/#1186): NULL = legacy вторичка до м.011. + -- Secondary-only asking — sold-сторона ДКП вторичка; новостройки исказили бы ratio. + AND (listing_segment IS NULL OR listing_segment = 'vtorichka') GROUP BY LEAST(GREATEST(rooms, 0), 4) ), -- Per-rooms строки: только бакеты с обеими сторонами, прошедшие порог 30/30 и ask>0. @@ -155,6 +158,9 @@ ask_global AS ( WHERE is_active AND rooms IS NOT NULL AND price_per_m2 BETWEEN 30000 AND 600000 + -- novostroyki guard (#1364/#1186): NULL = legacy вторичка до м.011. + -- Secondary-only asking — sold-сторона ДКП вторичка; новостройки исказили бы ratio. + AND (listing_segment IS NULL OR listing_segment = 'vtorichka') ), -- Global fallback строка rooms_bucket=-1 (пишется всегда, если ask>0). global_row AS (