- {[
- { label: "Адрес", value: input.address },
- { label: "Площадь", value: `${input.area_m2} м²` },
- {
- label: "Комнат",
- value: input.rooms === 0 ? "Студия" : String(input.rooms),
- },
- {
- label: "Этаж",
- value: `${input.floor} из ${input.total_floors}`,
- },
- ...(input.year_built
- ? [{ label: "Год постройки", value: String(input.year_built) }]
- : []),
- ...(input.house_type
- ? [
- {
- label: "Тип дома",
- value:
- HOUSE_TYPE_LABELS[input.house_type] ?? input.house_type,
- },
- ]
- : []),
- ...(input.repair_state
- ? [
- {
- label: "Ремонт",
- value:
- REPAIR_STATE_LABELS[input.repair_state] ??
- input.repair_state,
- },
- ]
- : []),
- {
- label: "Балкон",
- value: input.has_balcony ? "Есть" : "Нет",
- },
- ].map(({ label, value }) => (
-
-
+
- {label}
+
+
+
+ }
+ title="Параметры объекта"
+ />
+
+ {[
+ { label: "Адрес", value: input.address },
+ { label: "Площадь", value: `${input.area_m2} м²` },
+ {
+ label: "Комнат",
+ value: input.rooms === 0 ? "Студия" : String(input.rooms),
+ },
+ {
+ label: "Этаж",
+ value: `${input.floor} из ${input.total_floors}`,
+ },
+ ...(input.year_built
+ ? [{ label: "Год постройки", value: String(input.year_built) }]
+ : []),
+ ...(input.house_type
+ ? [
+ {
+ label: "Тип дома",
+ value:
+ HOUSE_TYPE_LABELS[input.house_type] ?? input.house_type,
+ },
+ ]
+ : []),
+ ...(input.repair_state
+ ? [
+ {
+ label: "Ремонт",
+ value:
+ REPAIR_STATE_LABELS[input.repair_state] ??
+ input.repair_state,
+ },
+ ]
+ : []),
+ {
+ label: "Балкон",
+ value: input.has_balcony ? "Есть" : "Нет",
+ },
+ ].map(({ label, value }) => (
+
+
+ {label}
+
+
+ {value}
+
-
- {value}
-
-
- ))}
-
-
+ ))}
+
+
+ )}
{/* Section 2 — Listings (analogs) */}
diff --git a/frontend/src/lib/site-finder-api.ts b/frontend/src/lib/site-finder-api.ts
index dca9dfc2..ec80dfb8 100644
--- a/frontend/src/lib/site-finder-api.ts
+++ b/frontend/src/lib/site-finder-api.ts
@@ -51,7 +51,9 @@ export type ParcelVri =
export interface ParcelBboxItem {
cad_num: string;
address: string;
- area_ha: number;
+ // #1421: nullable — the B1 by-bbox payload may omit area_m2; null означает
+ // «н/д», в отличие от реального 0 га. Потребители обязаны гейтить null.
+ area_ha: number | null;
status: ParcelStatus;
district: string;
vri: ParcelVri;
@@ -77,7 +79,8 @@ export interface BboxCoords {
export interface RecentParcel {
cad_num: string;
address: string;
- area_ha: number;
+ // #1421: nullable — площадь может быть неизвестна; null → «н/д», не «0 га».
+ area_ha: number | null;
district: string;
visited_at: string; // ISO string
}
@@ -121,8 +124,20 @@ function applyFilters(
filters: ParcelBboxFilters,
): ParcelBboxItem[] {
return parcels.filter((p) => {
- if (filters.min_area != null && p.area_ha < filters.min_area) return false;
- if (filters.max_area != null && p.area_ha > filters.max_area) return false;
+ // #1421: area_ha теперь nullable («н/д»). Площадной фильтр применяем только
+ // к участкам с известной площадью; null проходит мимо min/max гейтов.
+ if (
+ filters.min_area != null &&
+ p.area_ha != null &&
+ p.area_ha < filters.min_area
+ )
+ return false;
+ if (
+ filters.max_area != null &&
+ p.area_ha != null &&
+ p.area_ha > filters.max_area
+ )
+ return false;
if (filters.status != null && p.status !== filters.status) return false;
if (
filters.district != null &&
@@ -209,7 +224,9 @@ export function useParcelsBboxQuery(
cad_num: p.cad_num,
lat: p.centroid_lat,
lon: p.centroid_lon,
- area_ha: p.area_m2 != null ? p.area_m2 / 10000 : 0,
+ // #1421: keep null when area_m2 is missing — не приземляем в 0, иначе
+ // карточка показывает «0 га» вместо «н/д».
+ area_ha: p.area_m2 != null ? p.area_m2 / 10000 : null,
status: p.status ?? "free",
district: "—",
vri: "other",
@@ -230,6 +247,10 @@ export function useParcelsBboxQuery(
},
enabled: MOCK_PARCELS_BBOX || bbox != null,
staleTime: 30_000,
+ // #1419: bbox is part of the queryKey, so every pan/zoom is a fresh cache
+ // entry and `data` would blank to `undefined` mid-refetch → KPI flickers to
+ // «0 / 0». Hold the previous parcels on screen while the new bbox refetches.
+ placeholderData: keepPreviousData,
});
}
diff --git a/tradein-mvp/backend/app/services/house_imv_backfill.py b/tradein-mvp/backend/app/services/house_imv_backfill.py
index d944916c..11a35e4d 100644
--- a/tradein-mvp/backend/app/services/house_imv_backfill.py
+++ b/tradein-mvp/backend/app/services/house_imv_backfill.py
@@ -21,6 +21,7 @@ import asyncio
import json
import logging
import time
+from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Literal
@@ -37,6 +38,12 @@ from app.services.scrapers.avito_imv import (
logger = logging.getLogger(__name__)
+# Каждые N домов дёргаем heartbeat-колбэк (если передан) внутри длинного per-house
+# цикла. Без этого долгая IMV-фаза (десятки/сотни домов × request_delay_sec) не
+# обновляет scrape_runs.heartbeat_at дольше ZOMBIE_THRESHOLD_HOURS, и scheduler
+# ложно помечает живой run 'zombie' (после чего терминальный mark_done — no-op). См. #1363.
+_HEARTBEAT_EVERY_N_HOUSES = 5
+
# ── house_type normalisation ─────────────────────────────────────────────────
_HOUSE_TYPE_TO_IMV: dict[str, str] = {
@@ -351,6 +358,16 @@ class HouseIMVBackfillResult:
status_counts: dict[str, int] = field(default_factory=dict)
+def _beat(heartbeat: Callable[[], None] | None) -> None:
+ """Best-effort вызов heartbeat-колбэка. Сбой heartbeat не должен ронять backfill."""
+ if heartbeat is None:
+ return
+ try:
+ heartbeat()
+ except Exception:
+ logger.warning("house_imv_backfill: heartbeat callback failed (ignored)", exc_info=True)
+
+
async def backfill_house_imv(
db: Session,
*,
@@ -358,6 +375,7 @@ async def backfill_house_imv(
request_delay_sec: float = 5.0,
only_status: str = "pending",
house_id: int | None = None,
+ heartbeat: Callable[[], None] | None = None,
) -> HouseIMVBackfillResult:
"""Run Avito IMV evaluation for each house in scope, save results.
@@ -368,6 +386,10 @@ async def backfill_house_imv(
only_status: process houses with this imv_status (default 'pending').
Use 'transient_error' to retry failures.
house_id: process a single specific house (debug).
+ heartbeat: optional callback дёргается каждые _HEARTBEAT_EVERY_N_HOUSES
+ домов — caller обновляет scrape_runs.heartbeat_at, чтобы reap_zombies
+ не пометил живой долгий run 'zombie' (#1363). Best-effort: исключения
+ из колбэка логируются и не прерывают backfill.
Returns:
HouseIMVBackfillResult with aggregate counters.
@@ -442,6 +464,11 @@ async def backfill_house_imv(
status_str,
)
+ # Периодический heartbeat внутри длинного цикла — иначе reap_zombies
+ # может пометить живой run 'zombie' за время IMV-фазы (#1363).
+ if (i + 1) % _HEARTBEAT_EVERY_N_HOUSES == 0:
+ _beat(heartbeat)
+
if i < len(rows) - 1:
if status_str == "auth_error":
logger.warning("IMV auth error — extra delay 60s before next house")
@@ -467,6 +494,7 @@ async def process_houses_imv_batch(
house_ids: set[int],
*,
request_delay_sec: float = 5.0,
+ heartbeat: Callable[[], None] | None = None,
) -> HouseIMVBackfillResult:
"""Обработать конкретный набор house_id (для sweep-интеграции).
@@ -479,6 +507,10 @@ async def process_houses_imv_batch(
поэтому лучше не гонять лишних API-запросов, если данные свежие.
Для форс-обновления — оставь это решение caller'у (sweep по умолчанию
обходит дома с ok, обновляя только pending/not_found/error/transient_error).
+
+ heartbeat: optional callback дёргается каждые _HEARTBEAT_EVERY_N_HOUSES домов,
+ чтобы scrape_runs.heartbeat_at обновлялся в течение долгой IMV-фазы и
+ reap_zombies не пометил живой run 'zombie' (#1363). Best-effort.
"""
result = HouseIMVBackfillResult()
t0 = time.time()
@@ -547,6 +579,11 @@ async def process_houses_imv_batch(
status_str,
)
+ # Периодический heartbeat внутри длинного цикла — иначе reap_zombies
+ # может пометить живой run 'zombie' за время IMV-фазы (#1363).
+ if (i + 1) % _HEARTBEAT_EVERY_N_HOUSES == 0:
+ _beat(heartbeat)
+
if i < len(rows) - 1:
if status_str == "auth_error":
logger.warning("IMV auth error — extra delay 60s before next house")
diff --git a/tradein-mvp/backend/app/services/scrape_pipeline.py b/tradein-mvp/backend/app/services/scrape_pipeline.py
index 5da1b716..f9c373f9 100644
--- a/tradein-mvp/backend/app/services/scrape_pipeline.py
+++ b/tradein-mvp/backend/app/services/scrape_pipeline.py
@@ -599,10 +599,18 @@ async def run_avito_city_sweep(
len(all_touched_house_ids),
)
try:
+ # Периодический heartbeat внутри длинной IMV-фазы (#1363):
+ # без него десятки/сотни домов × request_delay_sec проходят
+ # без update_heartbeat, и reap_zombies помечает живой run
+ # 'zombie' → последующий mark_done становится no-op (дубль-sweep).
+ def _imv_heartbeat() -> None:
+ scrape_runs.update_heartbeat(db, run_id, counters.to_dict())
+
imv_result = await process_houses_imv_batch(
db,
all_touched_house_ids,
request_delay_sec=request_delay_sec,
+ heartbeat=_imv_heartbeat,
)
counters.imv_attempted += imv_result.checked
counters.imv_enriched += imv_result.saved