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 по Свердловской обл."""
|
||||
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (1–10)</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 (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>
|
||||
|
||||
{!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>
|
||||
|
|
|
|||
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,
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue