feat(site-finder): v3.3 - score label + market_trend + multi-thematic bulk

This commit is contained in:
lekss361 2026-05-11 20:51:59 +03:00
parent 1e9d32ee3c
commit 232c81eae9
6 changed files with 668 additions and 115 deletions

View file

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

View file

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

View file

@ -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<number[]>([2]);
const [source, setSource] = useState<"rosreestr_pending" | "all_in_region">(
"rosreestr_pending",
);
const [result, setResult] = useState<BulkGeoResponse | 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({
mutationFn: () =>
apiFetch<BulkGeoResponse>("/api/v1/admin/scrape/geo/bulk", {
mutationFn: () => {
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",
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 (
<section style={cardStyle}>
<h3 style={sectionTitle}>Bulk Свердловская geo backfill</h3>
<p style={{ color: "#5b6066", fontSize: 13, marginTop: 0 }}>
Запускает пакетное geo-обогащение участков без координат (thematic_id=2,
регион 66). Разбивает на N параллельных job-ов через{" "}
Запускает пакетное geo-обогащение участков без координат (регион 66).
Разбивает на N параллельных job-ов через{" "}
<code>POST /api/v1/admin/scrape/geo/bulk</code>.
</p>
<form
onSubmit={handleSubmit}
style={{
display: "flex",
gap: 12,
alignItems: "flex-end",
flexWrap: "wrap",
}}
style={{ display: "flex", flexDirection: "column", gap: 14 }}
>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={labelStyle}>Parallelism (110)</span>
<input
type="number"
value={parallelism}
min={1}
max={10}
onChange={(e) => setParallelism(Number(e.target.value))}
style={{ ...numInput }}
/>
</label>
{/* Thematic checkboxes */}
<div>
<div style={labelStyle}>Thematic IDs</div>
<div style={{ display: "flex", gap: 12, marginTop: 6 }}>
{THEMATIC_OPTIONS.map(({ id, label }) => (
<label
key={id}
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 13,
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={thematicIds.includes(id)}
onChange={() => toggleThematic(id)}
/>
{label}
</label>
))}
</div>
</div>
<button
type="submit"
disabled={!token || bulkMutation.isPending}
style={
!token || bulkMutation.isPending
? { ...triggerBtn, opacity: 0.6 }
: triggerBtn
}
{/* Source radio */}
<div>
<div style={labelStyle}>Source</div>
<div style={{ display: "flex", gap: 16, marginTop: 6 }}>
{(
[
["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"}
</button>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={labelStyle}>Parallelism (110)</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>
{!token && (
@ -102,22 +207,52 @@ export function BulkGeoPanel({ token }: { token: string }) {
{result && (
<div style={successBox}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>
Создано job-ов: {result.job_ids.length}
</div>
<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(", ")}]
Создано job-ов:{" "}
{result.jobs.reduce((s, j) => s + j.job_ids.length, 0)}
</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>
)}
</section>

View 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}&rarr;{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>
);
}

View file

@ -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<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 {
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,
}}
>
<div
title={data.score_explanation ?? undefined}
style={{
fontSize: 48,
fontWeight: 800,
lineHeight: 1,
color: scoreColor(data.score),
cursor: data.score_explanation ? "help" : undefined,
}}
>
{data.score.toFixed(1)}
{data.score.toFixed(2)}
</div>
<div>
<div style={{ fontSize: 13, color: "#6b7280", marginBottom: 2 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{/* 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 style={{ fontSize: 12, color: "#9ca3af" }}>
{data.poi_count} POI ·{" "}
{data.source === "cad_quarter" ? "квартал" : "участок"}
</div>
{data.score_explanation && (
<div
style={{
fontSize: 12,
color: "#6b7280",
fontStyle: "italic",
maxWidth: 280,
}}
>
{data.score_explanation}
</div>
)}
</div>
</div>
@ -517,6 +570,18 @@ export function ScoreCard({ data }: Props) {
</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>
);
}

View file

@ -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<string, ParcelAnalysisPoi[]>;
poi_count: number;
competitors: ParcelAnalysisCompetitor[];