feat(site-finder): cad_parcels_geom migration + POI markers on map + bulk-job UX

This commit is contained in:
lekss361 2026-05-11 19:54:56 +03:00
parent f419900968
commit 81cd7499f6
4 changed files with 106 additions and 12 deletions

View file

@ -0,0 +1,26 @@
-- 83_cad_parcels_geom.sql
-- Context : хранение геометрии участков (4-part cad: XX:YY:ZZZZZZZ:NN) из NSPD (thematic_id=1).
-- По аналогии с cad_quarters_geom (3-part cad).
-- Dependency: PostGIS extension уже установлен (используется cad_quarters_geom, cad_buildings).
-- Deploy order: standalone, нет FK-зависимостей от других таблиц.
-- Идемпотентность: безопасно повторно запускать (IF NOT EXISTS).
BEGIN;
CREATE TABLE IF NOT EXISTS cad_parcels_geom (
cad_num TEXT PRIMARY KEY, -- 4-part cad: 66:41:0204016:10
geom GEOMETRY(Geometry, 4326) NOT NULL, -- Polygon | MultiPolygon
raw_props JSONB DEFAULT '{}'::jsonb,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE cad_parcels_geom IS
'Геометрия участков (4-part cad, thematic_id=1) из NSPD. '
'Источник: nspd_geo task. '
'Используется site-finder analyze endpoint как fallback для 4-part cad '
'если в cad_quarters_geom нет.';
CREATE INDEX IF NOT EXISTS cad_parcels_geom_gist
ON cad_parcels_geom USING GIST (geom);
COMMIT;

View file

@ -73,11 +73,14 @@ export default function GeoScrapeAdminPage() {
// Авто-синк thematic_id при смене job_kind. Пользователь может override через
// селект ниже (например quarters + thematic_id=15 для комплексов).
// Также переключаем source на manual_list когда job_kind != quarters, т.к.
// rosreestr_pending содержит только кварталы (иначе backend вернёт 400).
const handleJobKindChange = (kind: "quarters" | "parcels" | "buildings") => {
setJobKind(kind);
if (kind === "quarters") setThematicId(2);
else if (kind === "parcels") setThematicId(1);
else if (kind === "buildings") setThematicId(5);
if (kind !== "quarters") setSourceKind("manual_list");
};
const [cadNumsText, setCadNumsText] = useState("");
const [regionCodes, setRegionCodes] = useState("66,74,72,59");
@ -306,6 +309,23 @@ export default function GeoScrapeAdminPage() {
</label>
</div>
{sourceKind === "rosreestr_pending" && jobKind !== "quarters" && (
<div
style={{
background: "#fef3c7",
border: "1px solid #f59e0b",
borderRadius: 6,
padding: "10px 14px",
fontSize: 13,
color: "#78350f",
}}
>
rosreestr_pending пока работает только для кварталов. Для
участков/зданий используй manual_list с cad-номерами через
запятую.
</div>
)}
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<button
type="button"

View file

@ -1,7 +1,13 @@
"use client";
import { useEffect } from "react";
import { MapContainer, TileLayer, GeoJSON } from "react-leaflet";
import {
MapContainer,
TileLayer,
GeoJSON,
CircleMarker,
Popup,
} from "react-leaflet";
import type { Geometry, Position } from "geojson";
import "leaflet/dist/leaflet.css";
@ -14,20 +20,21 @@ import type { ParcelAnalysis } from "@/types/site-finder";
interface CategoryStyle {
color: string;
label: string;
radius: number;
}
export const CATEGORY_STYLES: Record<string, CategoryStyle> = {
school: { color: "#2563eb", label: "Школа" },
kindergarten: { color: "#0891b2", label: "Детский сад" },
pharmacy: { color: "#16a34a", label: "Аптека" },
hospital: { color: "#059669", label: "Больница" },
shop_mall: { color: "#f59e0b", label: "ТЦ" },
shop_supermarket: { color: "#f97316", label: "Супермаркет" },
shop_small: { color: "#fb923c", label: "Магазин" },
park: { color: "#166534", label: "Парк" },
tram_stop: { color: "#dc2626", label: "Трамвай" },
bus_stop: { color: "#9ca3af", label: "Автобус" },
metro_stop: { color: "#7c3aed", label: "Метро" },
school: { color: "#1d4ed8", label: "Школа", radius: 8 },
kindergarten: { color: "#1d4ed8", label: "Детский сад", radius: 8 },
pharmacy: { color: "#10b981", label: "Аптека", radius: 6 },
hospital: { color: "#f59e0b", label: "Больница", radius: 8 },
shop_mall: { color: "#a855f7", label: "ТЦ", radius: 8 },
shop_supermarket: { color: "#a855f7", label: "Супермаркет", radius: 6 },
shop_small: { color: "#c084fc", label: "Магазин", radius: 6 },
park: { color: "#16a34a", label: "Парк", radius: 6 },
tram_stop: { color: "#dc2626", label: "Трамвай", radius: 6 },
bus_stop: { color: "#6b7280", label: "Автобус", radius: 6 },
metro_stop: { color: "#1e40af", label: "Метро", radius: 8 },
};
// ---------------------------------------------------------------------------
@ -135,6 +142,45 @@ export function SiteMap({ data }: Props) {
}}
/>
)}
{/* POI markers */}
{Object.entries(data.score_breakdown).flatMap(([cat, pois]) => {
const style = CATEGORY_STYLES[cat];
if (!style) return [];
return pois
.filter((poi) => poi.lat !== 0 && poi.lon !== 0)
.map((poi, idx) => (
<CircleMarker
key={`${cat}-${idx}`}
center={[poi.lat, poi.lon]}
radius={style.radius}
pathOptions={{
color: style.color,
fillColor: style.color,
fillOpacity: 0.85,
weight: 1.5,
}}
>
<Popup>
<div style={{ fontSize: 13, lineHeight: 1.5 }}>
<strong>{style.label}</strong>
<br />
{poi.name ?? <em>без названия</em>}
<br />
{poi.distance_m.toFixed(0)} м
{poi.last_edit && (
<>
<br />
<span style={{ color: "#6b7280", fontSize: 11 }}>
OSM: {poi.last_edit}
</span>
</>
)}
</div>
</Popup>
</CircleMarker>
));
})}
</MapContainer>
</div>

View file

@ -20,6 +20,8 @@ export interface ParcelAnalysisPoi {
name: string | null;
distance_m: number;
last_edit: string | null;
lat: number;
lon: number;
}
export interface ParcelAnalysis {