feat(site-finder): v3.3 - score label + market_trend + multi-thematic bulk
This commit is contained in:
parent
1e9d32ee3c
commit
232c81eae9
6 changed files with 668 additions and 115 deletions
|
|
@ -731,34 +731,51 @@ class BulkGeoEnqueueRequest(BaseModel):
|
||||||
"""Параметры для параллельного backfill geo по Свердловской обл."""
|
"""Параметры для параллельного backfill geo по Свердловской обл."""
|
||||||
|
|
||||||
parallelism: int = Field(default=5, ge=1, le=10)
|
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(
|
only_ddu: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description="True = только ДДУ (002001003000); False = все cad-кварталы",
|
description="True = только ДДУ (002001003000); False = все cad-кварталы",
|
||||||
)
|
)
|
||||||
|
source: str = Field(
|
||||||
|
default="rosreestr_pending",
|
||||||
@router.post("/geo/bulk")
|
pattern="^(rosreestr_pending|all_in_region)$",
|
||||||
def bulk_enqueue_geo(
|
description=(
|
||||||
payload: BulkGeoEnqueueRequest,
|
"rosreestr_pending — cad-номера из rosreestr_deals которых нет в geo-таблице; "
|
||||||
db: Annotated[Session, Depends(get_db)],
|
"all_in_region — UNION всех cad из rosreestr_deals + cad_buildings + complexes"
|
||||||
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 ""
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Маппинг 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(
|
rows = db.execute(
|
||||||
text(
|
text(
|
||||||
f"""
|
f"""
|
||||||
|
|
@ -769,52 +786,148 @@ def bulk_enqueue_geo(
|
||||||
AND quarter_cad_number IS NOT NULL
|
AND quarter_cad_number IS NOT NULL
|
||||||
AND quarter_cad_number NOT LIKE :bp
|
AND quarter_cad_number NOT LIKE :bp
|
||||||
AND quarter_cad_number NOT LIKE :bs
|
AND quarter_cad_number NOT LIKE :bs
|
||||||
AND NOT EXISTS (SELECT 1 FROM cad_quarters_geom g
|
AND NOT EXISTS (
|
||||||
WHERE g.cad_number = rosreestr_deals.quarter_cad_number)
|
SELECT 1 FROM {exists_table} g
|
||||||
|
WHERE g.{exists_col} = rosreestr_deals.quarter_cad_number
|
||||||
|
)
|
||||||
LIMIT 50000
|
LIMIT 50000
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"rc": [66], "bp": "00:00:%", "bs": "%:0000000"},
|
{"rc": region_codes, "bp": "00:00:%", "bs": "%:0000000"},
|
||||||
).all()
|
).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:
|
def _collect_all_in_region(
|
||||||
raise HTTPException(status_code=400, detail="Нет cad-номеров для backfill")
|
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")
|
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 {
|
jobs_summary: list[dict[str, Any]] = []
|
||||||
"job_ids": job_ids,
|
|
||||||
"targets_total": pending_total,
|
for thematic_id in payload.thematic_ids:
|
||||||
"parallelism": n_jobs,
|
if thematic_id not in _THEMATIC_META:
|
||||||
"targets_per_job": chunk_size + (1 if remainder else 0),
|
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")
|
@router.get("/geo/jobs")
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,18 @@ def _fetch_wind_sync(lat: float, lon: float) -> dict | None:
|
||||||
return 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-категорий для scoring (Максим: трамвай = минус)
|
||||||
_POI_WEIGHTS: dict[str, float] = {
|
_POI_WEIGHTS: dict[str, float] = {
|
||||||
"school": 1.5,
|
"school": 1.5,
|
||||||
|
|
@ -394,12 +406,88 @@ def analyze_parcel(
|
||||||
# 9) Wind — Open-Meteo (best-effort, null при недоступности)
|
# 9) Wind — Open-Meteo (best-effort, null при недоступности)
|
||||||
wind_data = _fetch_wind_sync(centroid_lat, centroid_lon)
|
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 {
|
return {
|
||||||
"cad_num": cad_num,
|
"cad_num": cad_num,
|
||||||
"source": source,
|
"source": source,
|
||||||
"geom_geojson": json.loads(geom_geojson) if geom_geojson else None,
|
"geom_geojson": json.loads(geom_geojson) if geom_geojson else None,
|
||||||
"district": dict(district_row) if district_row else None,
|
"district": dict(district_row) if district_row else None,
|
||||||
"score": round(score, 2),
|
"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,
|
"score_breakdown": by_category,
|
||||||
"poi_count": len(poi_rows),
|
"poi_count": len(poi_rows),
|
||||||
"competitors": [dict(c) for c in competitor_rows],
|
"competitors": [dict(c) for c in competitor_rows],
|
||||||
|
|
@ -411,4 +499,5 @@ def analyze_parcel(
|
||||||
},
|
},
|
||||||
"air_quality": air_q,
|
"air_quality": air_q,
|
||||||
"wind": wind_data,
|
"wind": wind_data,
|
||||||
|
"market_trend": market_trend,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,29 +5,66 @@ import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
interface BulkGeoResponse {
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface BulkGeoJobSummary {
|
||||||
|
thematic_id: number;
|
||||||
job_ids: number[];
|
job_ids: number[];
|
||||||
targets_total: number;
|
targets_total: number;
|
||||||
parallelism: number;
|
parallelism: number;
|
||||||
targets_per_job: number;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BulkGeoError {
|
interface BulkGeoResponse {
|
||||||
detail: string;
|
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 }) {
|
export function BulkGeoPanel({ token }: { token: string }) {
|
||||||
const [parallelism, setParallelism] = useState(5);
|
const [parallelism, setParallelism] = useState(5);
|
||||||
|
const [thematicIds, setThematicIds] = useState<number[]>([2]);
|
||||||
|
const [source, setSource] = useState<"rosreestr_pending" | "all_in_region">(
|
||||||
|
"rosreestr_pending",
|
||||||
|
);
|
||||||
const [result, setResult] = useState<BulkGeoResponse | null>(null);
|
const [result, setResult] = useState<BulkGeoResponse | null>(null);
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const toggleThematic = (id: number) => {
|
||||||
|
setThematicIds((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const bulkMutation = useMutation({
|
const bulkMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () => {
|
||||||
apiFetch<BulkGeoResponse>("/api/v1/admin/scrape/geo/bulk", {
|
const body: BulkGeoRequest = {
|
||||||
|
parallelism,
|
||||||
|
thematic_ids: thematicIds.length > 0 ? thematicIds : [2],
|
||||||
|
source,
|
||||||
|
only_ddu: false,
|
||||||
|
};
|
||||||
|
return apiFetch<BulkGeoResponse>("/api/v1/admin/scrape/geo/bulk", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "X-Admin-Token": token },
|
headers: { "X-Admin-Token": token },
|
||||||
body: JSON.stringify({ parallelism, thematic_id: 2 }),
|
body: JSON.stringify(body),
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setResult(data);
|
setResult(data);
|
||||||
setErrorMsg(null);
|
setErrorMsg(null);
|
||||||
|
|
@ -44,47 +81,115 @@ export function BulkGeoPanel({ token }: { token: string }) {
|
||||||
bulkMutation.mutate();
|
bulkMutation.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const activeIds = thematicIds.length > 0 ? thematicIds : [2];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section style={cardStyle}>
|
<section style={cardStyle}>
|
||||||
<h3 style={sectionTitle}>Bulk Свердловская geo backfill</h3>
|
<h3 style={sectionTitle}>Bulk Свердловская geo backfill</h3>
|
||||||
<p style={{ color: "#5b6066", fontSize: 13, marginTop: 0 }}>
|
<p style={{ color: "#5b6066", fontSize: 13, marginTop: 0 }}>
|
||||||
Запускает пакетное geo-обогащение участков без координат (thematic_id=2,
|
Запускает пакетное geo-обогащение участков без координат (регион 66).
|
||||||
регион 66). Разбивает на N параллельных job-ов через{" "}
|
Разбивает на N параллельных job-ов через{" "}
|
||||||
<code>POST /api/v1/admin/scrape/geo/bulk</code>.
|
<code>POST /api/v1/admin/scrape/geo/bulk</code>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
style={{
|
style={{ display: "flex", flexDirection: "column", gap: 14 }}
|
||||||
display: "flex",
|
|
||||||
gap: 12,
|
|
||||||
alignItems: "flex-end",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
{/* Thematic checkboxes */}
|
||||||
<span style={labelStyle}>Parallelism (1–10)</span>
|
<div>
|
||||||
<input
|
<div style={labelStyle}>Thematic IDs</div>
|
||||||
type="number"
|
<div style={{ display: "flex", gap: 12, marginTop: 6 }}>
|
||||||
value={parallelism}
|
{THEMATIC_OPTIONS.map(({ id, label }) => (
|
||||||
min={1}
|
<label
|
||||||
max={10}
|
key={id}
|
||||||
onChange={(e) => setParallelism(Number(e.target.value))}
|
style={{
|
||||||
style={{ ...numInput }}
|
display: "flex",
|
||||||
/>
|
alignItems: "center",
|
||||||
</label>
|
gap: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={thematicIds.includes(id)}
|
||||||
|
onChange={() => toggleThematic(id)}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
{/* Source radio */}
|
||||||
type="submit"
|
<div>
|
||||||
disabled={!token || bulkMutation.isPending}
|
<div style={labelStyle}>Source</div>
|
||||||
style={
|
<div style={{ display: "flex", gap: 16, marginTop: 6 }}>
|
||||||
!token || bulkMutation.isPending
|
{(
|
||||||
? { ...triggerBtn, opacity: 0.6 }
|
[
|
||||||
: triggerBtn
|
["rosreestr_pending", "rosreestr_pending — только pending"],
|
||||||
}
|
["all_in_region", "all_in_region — весь регион"],
|
||||||
|
] as const
|
||||||
|
).map(([val, lbl]) => (
|
||||||
|
<label
|
||||||
|
key={val}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="source"
|
||||||
|
value={val}
|
||||||
|
checked={source === val}
|
||||||
|
onChange={() => setSource(val)}
|
||||||
|
/>
|
||||||
|
{lbl}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Parallelism + submit row */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 12,
|
||||||
|
alignItems: "flex-end",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{bulkMutation.isPending ? "Запуск…" : "Запустить bulk-job"}
|
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
</button>
|
<span style={labelStyle}>Parallelism (1–10)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={parallelism}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
onChange={(e) => setParallelism(Number(e.target.value))}
|
||||||
|
style={{ ...numInput }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!token || bulkMutation.isPending}
|
||||||
|
style={
|
||||||
|
!token || bulkMutation.isPending
|
||||||
|
? { ...triggerBtn, opacity: 0.6 }
|
||||||
|
: triggerBtn
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{bulkMutation.isPending
|
||||||
|
? "Запуск…"
|
||||||
|
: `Запустить bulk-job (${activeIds.join(", ")})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{!token && (
|
{!token && (
|
||||||
|
|
@ -102,22 +207,52 @@ export function BulkGeoPanel({ token }: { token: string }) {
|
||||||
{result && (
|
{result && (
|
||||||
<div style={successBox}>
|
<div style={successBox}>
|
||||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
||||||
Создано job-ов: {result.job_ids.length}
|
Создано job-ов:{" "}
|
||||||
</div>
|
{result.jobs.reduce((s, j) => s + j.job_ids.length, 0)}
|
||||||
<div style={{ fontSize: 13, color: "#374151", marginBottom: 6 }}>
|
|
||||||
Targets: <strong>{result.targets_total}</strong> участков ÷{" "}
|
|
||||||
{result.parallelism} воркеров ={" "}
|
|
||||||
<strong>~{result.targets_per_job}</strong> на job
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: "ui-monospace,SFMono-Regular,monospace",
|
|
||||||
fontSize: 12,
|
|
||||||
color: "#5b6066",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
job_ids: [{result.job_ids.join(", ")}]
|
|
||||||
</div>
|
</div>
|
||||||
|
{result.jobs.map((j) => (
|
||||||
|
<div
|
||||||
|
key={j.thematic_id}
|
||||||
|
style={{
|
||||||
|
marginBottom: 10,
|
||||||
|
paddingBottom: 10,
|
||||||
|
borderBottom: "1px solid #bbf7d0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
|
thematic_id={j.thematic_id}
|
||||||
|
{j.note ? (
|
||||||
|
<span style={{ color: "#9ca3af", fontWeight: 400 }}>
|
||||||
|
{" "}
|
||||||
|
— {j.note}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{j.targets_total > 0 && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{ fontSize: 13, color: "#374151", marginBottom: 4 }}
|
||||||
|
>
|
||||||
|
Targets: <strong>{j.targets_total}</strong> участков ÷{" "}
|
||||||
|
{j.parallelism} воркеров ={" "}
|
||||||
|
<strong>
|
||||||
|
~{Math.ceil(j.targets_total / Math.max(j.parallelism, 1))}
|
||||||
|
</strong>{" "}
|
||||||
|
на job
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "ui-monospace,SFMono-Regular,monospace",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#5b6066",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
job_ids: [{j.job_ids.join(", ")}]
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
137
frontend/src/components/site-finder/MarketTrendBlock.tsx
Normal file
137
frontend/src/components/site-finder/MarketTrendBlock.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { MarketTrend } from "@/types/site-finder";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
trend?: MarketTrend | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABEL_COLORS: Record<string, { bg: string; color: string }> = {
|
||||||
|
"Сильный рост": { 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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "#f3f4f6",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||||||
|
Тренд рынка
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#9ca3af" }}>
|
||||||
|
Недостаточно сделок ДДУ в радиусе для тренда
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "#fafafa",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||||||
|
Тренд рынка в радиусе {trend.radius_km} км
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main price */}
|
||||||
|
<div style={{ display: "flex", alignItems: "baseline", gap: 6 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "#111827",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmtPrice(trend.recent_avg_price_per_m2)} ₽/м²
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#6b7280", marginTop: -4 }}>
|
||||||
|
за последние 6 мес · {trend.recent_deals_count} сделок
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delta */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: arrowColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{arrow}</span>
|
||||||
|
<span>
|
||||||
|
{trend.delta_6m_pct > 0 ? "+" : ""}
|
||||||
|
{trend.delta_6m_pct.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prior comparison */}
|
||||||
|
<div style={{ fontSize: 11, color: "#9ca3af" }}>
|
||||||
|
vs {fmtPrice(trend.prior_avg_price_per_m2)} ₽/м² за предыдущие 6 мес (
|
||||||
|
{trend.prior_deals_count}→{trend.recent_deals_count} сделок)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label badge */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "3px 10px",
|
||||||
|
background: labelStyle.bg,
|
||||||
|
color: labelStyle.color,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{trend.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import type {
|
||||||
ParcelAnalysisAirQuality,
|
ParcelAnalysisAirQuality,
|
||||||
ParcelAnalysisWind,
|
ParcelAnalysisWind,
|
||||||
} from "@/types/site-finder";
|
} from "@/types/site-finder";
|
||||||
|
import { MarketTrendBlock } from "./MarketTrendBlock";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: ParcelAnalysis;
|
data: ParcelAnalysis;
|
||||||
|
|
@ -39,6 +40,22 @@ function scoreColor(score: number): string {
|
||||||
return "#dc2626";
|
return "#dc2626";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ScoreLabel = "плохо" | "средне" | "хорошо" | "отлично";
|
||||||
|
|
||||||
|
const SCORE_LABEL_BG: Record<ScoreLabel, string> = {
|
||||||
|
отлично: "#dcfce7",
|
||||||
|
хорошо: "#dbeafe",
|
||||||
|
средне: "#fef3c7",
|
||||||
|
плохо: "#fecaca",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCORE_LABEL_COLOR: Record<ScoreLabel, string> = {
|
||||||
|
отлично: "#15803d",
|
||||||
|
хорошо: "#1d4ed8",
|
||||||
|
средне: "#b45309",
|
||||||
|
плохо: "#b91c1c",
|
||||||
|
};
|
||||||
|
|
||||||
function avgDist(items: Array<{ distance_m: number }>): number {
|
function avgDist(items: Array<{ distance_m: number }>): number {
|
||||||
if (!items.length) return 0;
|
if (!items.length) return 0;
|
||||||
return Math.round(items.reduce((s, i) => s + i.distance_m, 0) / items.length);
|
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",
|
background: "#f9fafb",
|
||||||
borderBottom: "1px solid #e5e7eb",
|
borderBottom: "1px solid #e5e7eb",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "flex-start",
|
||||||
gap: 16,
|
gap: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
title={data.score_explanation ?? undefined}
|
||||||
style={{
|
style={{
|
||||||
fontSize: 48,
|
fontSize: 48,
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
color: scoreColor(data.score),
|
color: scoreColor(data.score),
|
||||||
|
cursor: data.score_explanation ? "help" : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.score.toFixed(1)}
|
{data.score.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
<div style={{ fontSize: 13, color: "#6b7280", marginBottom: 2 }}>
|
{/* score_max_reference + label badge */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
{data.score_max_reference !== undefined && (
|
||||||
|
<span style={{ fontSize: 14, color: "#6b7280" }}>
|
||||||
|
/ {data.score_max_reference.toFixed(0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{data.score_label && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "2px 10px",
|
||||||
|
background: SCORE_LABEL_BG[data.score_label],
|
||||||
|
color: SCORE_LABEL_COLOR[data.score_label],
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.score_label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#6b7280" }}>
|
||||||
Социальный балл участка
|
Социальный балл участка
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: "#9ca3af" }}>
|
<div style={{ fontSize: 12, color: "#9ca3af" }}>
|
||||||
{data.poi_count} POI ·{" "}
|
{data.poi_count} POI ·{" "}
|
||||||
{data.source === "cad_quarter" ? "квартал" : "участок"}
|
{data.source === "cad_quarter" ? "квартал" : "участок"}
|
||||||
</div>
|
</div>
|
||||||
|
{data.score_explanation && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#6b7280",
|
||||||
|
fontStyle: "italic",
|
||||||
|
maxWidth: 280,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.score_explanation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -517,6 +570,18 @@ export function ScoreCard({ data }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Market trend */}
|
||||||
|
{"market_trend" in data && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "12px 24px 16px",
|
||||||
|
borderTop: "1px solid #e5e7eb",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MarketTrendBlock trend={data.market_trend} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,12 +55,26 @@ export interface ParcelAnalysisPoi {
|
||||||
lon: number;
|
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 {
|
export interface ParcelAnalysis {
|
||||||
cad_num: string;
|
cad_num: string;
|
||||||
source: "cad_quarter" | "cad_building";
|
source: "cad_quarter" | "cad_building";
|
||||||
geom_geojson: Geometry | null;
|
geom_geojson: Geometry | null;
|
||||||
district: ParcelAnalysisDistrict | null;
|
district: ParcelAnalysisDistrict | null;
|
||||||
score: number;
|
score: number;
|
||||||
|
score_label?: "плохо" | "средне" | "хорошо" | "отлично";
|
||||||
|
score_max_reference?: number;
|
||||||
|
score_explanation?: string;
|
||||||
|
market_trend?: MarketTrend | null;
|
||||||
score_breakdown: Record<string, ParcelAnalysisPoi[]>;
|
score_breakdown: Record<string, ParcelAnalysisPoi[]>;
|
||||||
poi_count: number;
|
poi_count: number;
|
||||||
competitors: ParcelAnalysisCompetitor[];
|
competitors: ParcelAnalysisCompetitor[];
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue