From 232c81eae9ee6d268a8af5a0000f86534be6cd39 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Mon, 11 May 2026 20:51:59 +0300 Subject: [PATCH] feat(site-finder): v3.3 - score label + market_trend + multi-thematic bulk --- backend/app/api/v1/admin_scrape.py | 231 ++++++++++++----- backend/app/api/v1/parcels.py | 89 +++++++ .../src/components/admin/BulkGeoPanel.tsx | 239 ++++++++++++++---- .../site-finder/MarketTrendBlock.tsx | 137 ++++++++++ .../src/components/site-finder/ScoreCard.tsx | 73 +++++- frontend/src/types/site-finder.ts | 14 + 6 files changed, 668 insertions(+), 115 deletions(-) create mode 100644 frontend/src/components/site-finder/MarketTrendBlock.tsx diff --git a/backend/app/api/v1/admin_scrape.py b/backend/app/api/v1/admin_scrape.py index 7a06d461..c5eb1965 100644 --- a/backend/app/api/v1/admin_scrape.py +++ b/backend/app/api/v1/admin_scrape.py @@ -731,34 +731,51 @@ class BulkGeoEnqueueRequest(BaseModel): """Параметры для параллельного backfill geo по Свердловской обл.""" parallelism: int = Field(default=5, ge=1, le=10) - thematic_id: int = Field(default=2, ge=1, le=15, description="1=parcel, 2=quarter, 5=building") + thematic_ids: list[int] = Field( + default=[2], + description="Список thematic_id: 1=parcel, 2=quarter, 5=building. Можно несколько.", + ) + region_codes: list[int] = Field(default=[66], description="Список region кодов") only_ddu: bool = Field( default=False, description="True = только ДДУ (002001003000); False = все cad-кварталы", ) - - -@router.post("/geo/bulk") -def bulk_enqueue_geo( - payload: BulkGeoEnqueueRequest, - db: Annotated[Session, Depends(get_db)], - x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None, -) -> dict[str, Any]: - """Разбить pending cad-номера (region 66) на N чанков и запустить N geo-jobs параллельно. - - Логика выборки pending: distinct quarter_cad_number из rosreestr_deals - (region 66, валидный cad-формат) которых нет в cad_quarters_geom. - Если only_ddu=True — дополнительный фильтр по ДДУ (тип 002001003000). - """ - _check_token(x_admin_token) - from app.services.job_settings import get_setting_value - from app.workers.tasks.nspd_geo import enqueue_geo_job as enqueue_helper - from app.workers.tasks.nspd_geo import process_nspd_geo_job - - # 1) Собрать все pending cad-номера для region 66 - ddu_filter = ( - "AND doc_type = 'ДДУ' AND realestate_type_code = '002001003000'" if payload.only_ddu else "" + source: str = Field( + default="rosreestr_pending", + pattern="^(rosreestr_pending|all_in_region)$", + description=( + "rosreestr_pending — cad-номера из rosreestr_deals которых нет в geo-таблице; " + "all_in_region — UNION всех cad из rosreestr_deals + cad_buildings + complexes" + ), ) + + +# Маппинг thematic_id → таблица проверки существования + колонка + job_kind-метка +_THEMATIC_META: dict[int, dict[str, str]] = { + 1: {"exists_table": "cad_parcels_geom", "exists_col": "cad_num", "label": "parcels"}, + 2: {"exists_table": "cad_quarters_geom", "exists_col": "cad_number", "label": "quarters"}, + 5: {"exists_table": "cad_buildings", "exists_col": "cad_num", "label": "buildings"}, +} + + +def _collect_pending_cad( + db: Session, + thematic_id: int, + region_codes: list[int], + only_ddu: bool, +) -> list[str]: + """Собрать cad-номера из rosreestr_deals которых нет в соответствующей geo-таблице.""" + meta = _THEMATIC_META.get(thematic_id) + if meta is None: + raise HTTPException( + status_code=400, + detail=f"thematic_id={thematic_id} не поддерживается (допустимы: 1, 2, 5)", + ) + ddu_filter = ( + "AND doc_type = 'ДДУ' AND realestate_type_code = '002001003000'" if only_ddu else "" + ) + exists_table = meta["exists_table"] + exists_col = meta["exists_col"] rows = db.execute( text( f""" @@ -769,52 +786,148 @@ def bulk_enqueue_geo( AND quarter_cad_number IS NOT NULL AND quarter_cad_number NOT LIKE :bp AND quarter_cad_number NOT LIKE :bs - AND NOT EXISTS (SELECT 1 FROM cad_quarters_geom g - WHERE g.cad_number = rosreestr_deals.quarter_cad_number) + AND NOT EXISTS ( + SELECT 1 FROM {exists_table} g + WHERE g.{exists_col} = rosreestr_deals.quarter_cad_number + ) LIMIT 50000 """ ), - {"rc": [66], "bp": "00:00:%", "bs": "%:0000000"}, + {"rc": region_codes, "bp": "00:00:%", "bs": "%:0000000"}, ).all() + return [r[0] for r in rows] - all_cad: list[str] = [r[0] for r in rows] - pending_total = len(all_cad) - if pending_total == 0: - raise HTTPException(status_code=400, detail="Нет cad-номеров для backfill") +def _collect_all_in_region( + db: Session, + region_codes: list[int], +) -> list[str]: + """UNION всех cad-номеров из rosreestr_deals + cad_buildings + complexes.cad_quarter + с фильтром по region prefix (66xx для кодов региона 66).""" + rows = db.execute( + text(""" + SELECT DISTINCT cad FROM ( + SELECT quarter_cad_number AS cad + FROM rosreestr_deals + WHERE quarter_cad_number IS NOT NULL + AND region_code = ANY(:rc) + UNION + SELECT cad_num AS cad + FROM cad_buildings + WHERE cad_num IS NOT NULL + AND cad_num ~ :region_re + UNION + SELECT cad_quarter AS cad + FROM complexes + WHERE cad_quarter IS NOT NULL + AND cad_quarter ~ :region_re + ) sub + WHERE cad NOT LIKE :bp + AND cad NOT LIKE :bs + LIMIT 100000 + """), + { + "rc": region_codes, + "region_re": "^(" + "|".join(str(rc) for rc in region_codes) + "):", + "bp": "00:00:%", + "bs": "%:0000000", + }, + ).all() + return [r[0] for r in rows] - # 2) Разбить на чанки (numpy-style array_split — равные куски, хвост меньше) - n_jobs = min(payload.parallelism, pending_total) - chunk_size, remainder = divmod(pending_total, n_jobs) - chunks: list[list[str]] = [] - start = 0 - for i in range(n_jobs): - end = start + chunk_size + (1 if i < remainder else 0) - chunks.append(all_cad[start:end]) - start = end - # 3) Создать job + enqueue для каждого чанка +@router.post("/geo/bulk") +def bulk_enqueue_geo( + payload: BulkGeoEnqueueRequest, + db: Annotated[Session, Depends(get_db)], + x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None, +) -> dict[str, Any]: + """Разбить pending cad-номера на N чанков и запустить N×len(thematic_ids) geo-jobs. + + source=rosreestr_pending: для каждого thematic_id выбирает cad из rosreestr_deals + которых нет в соответствующей geo-таблице. + source=all_in_region: UNION из rosreestr_deals + cad_buildings + complexes — один + набор для всех thematic_ids (каждый thematic_id обрабатывает весь список). + """ + _check_token(x_admin_token) + from app.services.job_settings import get_setting_value + from app.workers.tasks.nspd_geo import enqueue_geo_job as enqueue_helper + from app.workers.tasks.nspd_geo import process_nspd_geo_job + geo_queue = get_setting_value("nspd_geo", "queue_name", "geo") - job_ids: list[int] = [] - for idx, chunk in enumerate(chunks): - cad_with_thematic = [(c, payload.thematic_id) for c in chunk] - job_id = enqueue_helper( - name=f"bulk_svrd_{idx + 1}/{n_jobs}", - job_kind="quarters", - source_kind="rosreestr_pending_chunk", - source_params={"region_codes": [66], "thematic_id": payload.thematic_id}, - cad_nums_with_thematic=cad_with_thematic, - triggered_by="bulk_admin", - ) - process_nspd_geo_job.apply_async(args=[job_id], queue=geo_queue) - job_ids.append(job_id) - return { - "job_ids": job_ids, - "targets_total": pending_total, - "parallelism": n_jobs, - "targets_per_job": chunk_size + (1 if remainder else 0), - } + jobs_summary: list[dict[str, Any]] = [] + + for thematic_id in payload.thematic_ids: + if thematic_id not in _THEMATIC_META: + raise HTTPException( + status_code=400, + detail=f"thematic_id={thematic_id} не поддерживается (допустимы: 1, 2, 5)", + ) + meta = _THEMATIC_META[thematic_id] + + # 1) Собрать cad-номера + if payload.source == "rosreestr_pending": + all_cad = _collect_pending_cad(db, thematic_id, payload.region_codes, payload.only_ddu) + else: # all_in_region + all_cad = _collect_all_in_region(db, payload.region_codes) + + if not all_cad: + jobs_summary.append( + { + "thematic_id": thematic_id, + "job_ids": [], + "targets_total": 0, + "parallelism": 0, + "note": "нет cad-номеров для backfill", + } + ) + continue + + # 2) Разбить на чанки + pending_total = len(all_cad) + n_jobs = min(payload.parallelism, pending_total) + chunk_size, remainder = divmod(pending_total, n_jobs) + chunks: list[list[str]] = [] + start = 0 + for i in range(n_jobs): + end = start + chunk_size + (1 if i < remainder else 0) + chunks.append(all_cad[start:end]) + start = end + + # 3) Создать jobs + job_ids: list[int] = [] + label = meta["label"] + for idx, chunk in enumerate(chunks): + cad_with_thematic = [(c, thematic_id) for c in chunk] + job_id = enqueue_helper( + name=f"bulk_{label}_t{thematic_id}_{idx + 1}/{n_jobs}", + job_kind=label, + source_kind=f"{payload.source}_chunk", + source_params={ + "region_codes": payload.region_codes, + "thematic_id": thematic_id, + "source": payload.source, + }, + cad_nums_with_thematic=cad_with_thematic, + triggered_by="bulk_admin", + ) + process_nspd_geo_job.apply_async(args=[job_id], queue=geo_queue) + job_ids.append(job_id) + + jobs_summary.append( + { + "thematic_id": thematic_id, + "job_ids": job_ids, + "targets_total": pending_total, + "parallelism": n_jobs, + } + ) + + if not any(j["targets_total"] > 0 for j in jobs_summary): + raise HTTPException(status_code=400, detail="Нет cad-номеров для backfill (все thematic)") + + return {"jobs": jobs_summary} @router.get("/geo/jobs") diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 268cf5ec..a4183f7e 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -108,6 +108,18 @@ def _fetch_wind_sync(lat: float, lon: float) -> dict | None: return None +# Эмпирические пороги score для ЕКБ: средний диапазон 15-30, max редко >40. +SCORE_THRESHOLDS: dict[str, float] = {"плохо": 5.0, "средне": 15.0, "хорошо": 25.0, "отлично": 40.0} +SCORE_MAX_REFERENCE: float = 40.0 + + +def _score_label(s: float) -> str: + """Текстовая интерпретация POI-score по эмпирическим порогам ЕКБ.""" + if s < SCORE_THRESHOLDS["средне"]: + return "плохо" if s < SCORE_THRESHOLDS["плохо"] else "средне" + return "хорошо" if s < SCORE_THRESHOLDS["отлично"] else "отлично" + + # Веса POI-категорий для scoring (Максим: трамвай = минус) _POI_WEIGHTS: dict[str, float] = { "school": 1.5, @@ -394,12 +406,88 @@ def analyze_parcel( # 9) Wind — Open-Meteo (best-effort, null при недоступности) wind_data = _fetch_wind_sync(centroid_lat, centroid_lon) + # 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев + market_trend: dict[str, Any] | None = None + try: + trend_row = ( + db.execute( + text(""" + WITH district_deals AS ( + SELECT d.deal_date, d.price_per_m2 + FROM rosreestr_deals d + WHERE d.region_code = 66 + AND d.doc_type = 'ДДУ' + AND d.realestate_type_code = '002001003000' + AND d.price_per_m2 BETWEEN 30000 AND 500000 + AND d.deal_date > NOW() - INTERVAL '12 months' + AND ST_DWithin( + (SELECT ST_Centroid(geom) + FROM cad_quarters_geom + WHERE cad_number = d.quarter_cad_number)::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, + 3000 + ) + ) + SELECT + AVG(price_per_m2) + FILTER (WHERE deal_date > NOW() - INTERVAL '6 months') + AS recent_avg, + AVG(price_per_m2) + FILTER (WHERE deal_date BETWEEN NOW() - INTERVAL '12 months' + AND NOW() - INTERVAL '6 months') + AS prior_avg, + COUNT(*) + FILTER (WHERE deal_date > NOW() - INTERVAL '6 months') + AS recent_n, + COUNT(*) + FILTER (WHERE deal_date BETWEEN NOW() - INTERVAL '12 months' + AND NOW() - INTERVAL '6 months') + AS prior_n + FROM district_deals + """), + {"wkt": geom_wkt}, + ) + .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"]) + # 6-месячное изменение; ×2 даёт годовой эквивалент + delta_6m_pct = round((recent_p - prior_p) / prior_p * 100, 1) + if delta_6m_pct > 8: + perspective_label = "Сильный рост — рынок растёт быстрее инфляции" + elif delta_6m_pct > 0: + perspective_label = "Умеренный рост — стабильный спрос" + elif delta_6m_pct > -5: + perspective_label = "Стагнация — рынок остыл" + else: + perspective_label = "Падение — риск переоценки" + market_trend = { + "recent_avg_price_per_m2": round(recent_p), + "prior_avg_price_per_m2": round(prior_p), + "delta_6m_pct": delta_6m_pct, + "recent_deals_count": int(trend_row["recent_n"]), + "prior_deals_count": int(trend_row["prior_n"]), + "label": perspective_label, + "radius_km": 3, + } + except Exception as e: + logger.warning("market_trend query failed for %s: %s", cad_num, e) + market_trend = None + return { "cad_num": cad_num, "source": source, "geom_geojson": json.loads(geom_geojson) if geom_geojson else None, "district": dict(district_row) if district_row else None, "score": round(score, 2), + "score_label": _score_label(score), + "score_max_reference": SCORE_MAX_REFERENCE, + "score_explanation": ( + "Сумма close-distance POI (школы/сады/парки +, трамваи -). " + ">40 = редко, типичный город. центр 15-30." + ), "score_breakdown": by_category, "poi_count": len(poi_rows), "competitors": [dict(c) for c in competitor_rows], @@ -411,4 +499,5 @@ def analyze_parcel( }, "air_quality": air_q, "wind": wind_data, + "market_trend": market_trend, } diff --git a/frontend/src/components/admin/BulkGeoPanel.tsx b/frontend/src/components/admin/BulkGeoPanel.tsx index 527367f6..3304bb52 100644 --- a/frontend/src/components/admin/BulkGeoPanel.tsx +++ b/frontend/src/components/admin/BulkGeoPanel.tsx @@ -5,29 +5,66 @@ import { useMutation } from "@tanstack/react-query"; import { apiFetch } from "@/lib/api"; -interface BulkGeoResponse { +// ── Types ──────────────────────────────────────────────────────────────────── + +interface BulkGeoJobSummary { + thematic_id: number; job_ids: number[]; targets_total: number; parallelism: number; - targets_per_job: number; + note?: string; } -interface BulkGeoError { - detail: string; +interface BulkGeoResponse { + jobs: BulkGeoJobSummary[]; } +interface BulkGeoRequest { + parallelism: number; + thematic_ids: number[]; + source: "rosreestr_pending" | "all_in_region"; + only_ddu: boolean; +} + +// ── Constants ──────────────────────────────────────────────────────────────── + +const THEMATIC_OPTIONS = [ + { id: 1, label: "Parcels (1)" }, + { id: 2, label: "Quarters (2)" }, + { id: 5, label: "Buildings (5)" }, +] as const; + +// ── Component ──────────────────────────────────────────────────────────────── + export function BulkGeoPanel({ token }: { token: string }) { const [parallelism, setParallelism] = useState(5); + const [thematicIds, setThematicIds] = useState([2]); + const [source, setSource] = useState<"rosreestr_pending" | "all_in_region">( + "rosreestr_pending", + ); const [result, setResult] = useState(null); const [errorMsg, setErrorMsg] = useState(null); + const toggleThematic = (id: number) => { + setThematicIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + ); + }; + const bulkMutation = useMutation({ - mutationFn: () => - apiFetch("/api/v1/admin/scrape/geo/bulk", { + mutationFn: () => { + const body: BulkGeoRequest = { + parallelism, + thematic_ids: thematicIds.length > 0 ? thematicIds : [2], + source, + only_ddu: false, + }; + return apiFetch("/api/v1/admin/scrape/geo/bulk", { method: "POST", headers: { "X-Admin-Token": token }, - body: JSON.stringify({ parallelism, thematic_id: 2 }), - }), + body: JSON.stringify(body), + }); + }, onSuccess: (data) => { setResult(data); setErrorMsg(null); @@ -44,47 +81,115 @@ export function BulkGeoPanel({ token }: { token: string }) { bulkMutation.mutate(); }; + const activeIds = thematicIds.length > 0 ? thematicIds : [2]; + return (

Bulk Свердловская geo backfill

- Запускает пакетное geo-обогащение участков без координат (thematic_id=2, - регион 66). Разбивает на N параллельных job-ов через{" "} + Запускает пакетное geo-обогащение участков без координат (регион 66). + Разбивает на N параллельных job-ов через{" "} POST /api/v1/admin/scrape/geo/bulk.

- + {/* Thematic checkboxes */} +
+
Thematic IDs
+
+ {THEMATIC_OPTIONS.map(({ id, label }) => ( + + ))} +
+
- + + + +
{!token && ( @@ -102,22 +207,52 @@ export function BulkGeoPanel({ token }: { token: string }) { {result && (
- Создано job-ов: {result.job_ids.length} -
-
- Targets: {result.targets_total} участков ÷{" "} - {result.parallelism} воркеров ={" "} - ~{result.targets_per_job} на job -
-
- job_ids: [{result.job_ids.join(", ")}] + Создано job-ов:{" "} + {result.jobs.reduce((s, j) => s + j.job_ids.length, 0)}
+ {result.jobs.map((j) => ( +
+
+ thematic_id={j.thematic_id} + {j.note ? ( + + {" "} + — {j.note} + + ) : null} +
+ {j.targets_total > 0 && ( + <> +
+ Targets: {j.targets_total} участков ÷{" "} + {j.parallelism} воркеров ={" "} + + ~{Math.ceil(j.targets_total / Math.max(j.parallelism, 1))} + {" "} + на job +
+
+ job_ids: [{j.job_ids.join(", ")}] +
+ + )} +
+ ))}
)}
diff --git a/frontend/src/components/site-finder/MarketTrendBlock.tsx b/frontend/src/components/site-finder/MarketTrendBlock.tsx new file mode 100644 index 00000000..9eaca405 --- /dev/null +++ b/frontend/src/components/site-finder/MarketTrendBlock.tsx @@ -0,0 +1,137 @@ +"use client"; + +import type { MarketTrend } from "@/types/site-finder"; + +interface Props { + trend?: MarketTrend | null; +} + +const LABEL_COLORS: Record = { + "Сильный рост": { bg: "#dcfce7", color: "#15803d" }, + "Умеренный рост": { bg: "#dbeafe", color: "#1d4ed8" }, + Стагнация: { bg: "#fef3c7", color: "#b45309" }, + Падение: { bg: "#fecaca", color: "#b91c1c" }, +}; + +function trendArrow(delta: number): string { + if (delta > 1) return "↑"; + if (delta < -1) return "↓"; + return "→"; +} + +function deltaColor(delta: number): string { + if (delta > 1) return "#15803d"; + if (delta < -1) return "#b91c1c"; + return "#6b7280"; +} + +function fmtPrice(v: number): string { + return v.toLocaleString("ru-RU", { maximumFractionDigits: 0 }); +} + +export function MarketTrendBlock({ trend }: Props) { + if (!trend) { + return ( +
+
+ Тренд рынка +
+
+ Недостаточно сделок ДДУ в радиусе для тренда +
+
+ ); + } + + const arrow = trendArrow(trend.delta_6m_pct); + const arrowColor = deltaColor(trend.delta_6m_pct); + const labelStyle = LABEL_COLORS[trend.label] ?? { + bg: "#f3f4f6", + color: "#374151", + }; + + return ( +
+
+ Тренд рынка в радиусе {trend.radius_km} км +
+ + {/* Main price */} +
+ + {fmtPrice(trend.recent_avg_price_per_m2)} ₽/м² + +
+
+ за последние 6 мес · {trend.recent_deals_count} сделок +
+ + {/* Delta */} +
+ {arrow} + + {trend.delta_6m_pct > 0 ? "+" : ""} + {trend.delta_6m_pct.toFixed(1)}% + +
+ + {/* Prior comparison */} +
+ vs {fmtPrice(trend.prior_avg_price_per_m2)} ₽/м² за предыдущие 6 мес ( + {trend.prior_deals_count}→{trend.recent_deals_count} сделок) +
+ + {/* Label badge */} +
+ {trend.label} +
+
+ ); +} diff --git a/frontend/src/components/site-finder/ScoreCard.tsx b/frontend/src/components/site-finder/ScoreCard.tsx index eede065b..ff7c3f4c 100644 --- a/frontend/src/components/site-finder/ScoreCard.tsx +++ b/frontend/src/components/site-finder/ScoreCard.tsx @@ -7,6 +7,7 @@ import type { ParcelAnalysisAirQuality, ParcelAnalysisWind, } from "@/types/site-finder"; +import { MarketTrendBlock } from "./MarketTrendBlock"; interface Props { data: ParcelAnalysis; @@ -39,6 +40,22 @@ function scoreColor(score: number): string { return "#dc2626"; } +type ScoreLabel = "плохо" | "средне" | "хорошо" | "отлично"; + +const SCORE_LABEL_BG: Record = { + отлично: "#dcfce7", + хорошо: "#dbeafe", + средне: "#fef3c7", + плохо: "#fecaca", +}; + +const SCORE_LABEL_COLOR: Record = { + отлично: "#15803d", + хорошо: "#1d4ed8", + средне: "#b45309", + плохо: "#b91c1c", +}; + function avgDist(items: Array<{ distance_m: number }>): number { if (!items.length) return 0; return Math.round(items.reduce((s, i) => s + i.distance_m, 0) / items.length); @@ -355,28 +372,64 @@ export function ScoreCard({ data }: Props) { background: "#f9fafb", borderBottom: "1px solid #e5e7eb", display: "flex", - alignItems: "center", + alignItems: "flex-start", gap: 16, }} >
- {data.score.toFixed(1)} + {data.score.toFixed(2)}
-
-
+
+ {/* score_max_reference + label badge */} +
+ {data.score_max_reference !== undefined && ( + + / {data.score_max_reference.toFixed(0)} + + )} + {data.score_label && ( + + {data.score_label} + + )} +
+
Социальный балл участка
{data.poi_count} POI ·{" "} {data.source === "cad_quarter" ? "квартал" : "участок"}
+ {data.score_explanation && ( +
+ {data.score_explanation} +
+ )}
@@ -517,6 +570,18 @@ export function ScoreCard({ data }: Props) {
)} + + {/* Market trend */} + {"market_trend" in data && ( +
+ +
+ )} ); } diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 916b7bc9..4fe1ac69 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -55,12 +55,26 @@ export interface ParcelAnalysisPoi { lon: number; } +export interface MarketTrend { + recent_avg_price_per_m2: number; + prior_avg_price_per_m2: number; + delta_6m_pct: number; + recent_deals_count: number; + prior_deals_count: number; + label: string; + radius_km: number; +} + export interface ParcelAnalysis { cad_num: string; source: "cad_quarter" | "cad_building"; geom_geojson: Geometry | null; district: ParcelAnalysisDistrict | null; score: number; + score_label?: "плохо" | "средне" | "хорошо" | "отлично"; + score_max_reference?: number; + score_explanation?: string; + market_trend?: MarketTrend | null; score_breakdown: Record; poi_count: number; competitors: ParcelAnalysisCompetitor[];