feat(osm): foundation — admin boundaries, POI catalog, sync orchestrator

This is the Phase 0 + Phase 1 + Phase 4 foundation of the full OSM
integration plan. It backfills three things the rest of the platform
has been faking with hardcoded tables, and gives admins one dashboard
for every OSM-sourced layer.

Phase 0 — Vietnam administrative boundaries
* New columns on vn_provinces / vn_districts / vn_wards: PostGIS
  geometry (MultiPolygon), centroid (Point), areaKm2, osmId, population,
  lastSyncedAt + GIST indexes on geometry/centroid.
* `scripts/sync-osm-admin-boundaries.ts` pulls
  `boundary=administrative + admin_level=4|6|8` from Overpass per chunk,
  filters to mainland VN via the existing country polygon, resolves the
  GSO code (or generates `OSM_<id>`), and upserts via raw SQL because
  Prisma can't manage PostGIS columns.
* `GeoLookupService` (shared module) replaces the old
  `nearestProvince()` heuristic — `lookup(lng,lat)` returns
  province/district/ward via `ST_Contains` on the GIST-indexed polygons.
* The KCN sync now resolves province/district from the polygon table
  and falls back to the centroid heuristic only when polygons aren't
  loaded yet.
* `scripts/backfill-admin-codes.ts` rewrites province/district/ward on
  IndustrialPark, ProjectDevelopment and Property using the new lookup.

Phase 1 — POI catalog (15 categories, schema only here)
* New `Poi` table with `PoiCategory` enum, OSM provenance columns,
  GIST index on `location`. New `TransportLine` for metro/highway
  multilinestrings.
* `scripts/sync-osm-poi.ts` queries Overpass per category × chunk,
  resolves province/district codes from the boundary polygons, upserts
  with `osmLocked` / `lockedFields` honour same as KCN.
* New NestJS `PoiModule` exposes:
    GET /poi/by-bbox    — GeoJSON for map overlays
    GET /poi/nearby     — sidebar "tiện ích xung quanh" (HMAC distance ranks)
    GET /poi/coverage   — admin per-category counts
* New web component `<NearbyPoiSidebar />` ready to drop into listing /
  project / KCN detail pages.

Phase 4 — Sync orchestrator + admin dashboard
* New `OsmSyncRun` audit table tracks every sync invocation
  (RUNNING / SUCCESS / PARTIAL / FAILED + row stats + error message).
* `OsmSyncService` spawns the right tsx script for any (layer, category,
  chunk) tuple, parses stats out of stdout, updates the run row.
* `OsmSyncCronService` schedules:
    Daily 02:00  → POI category rotation (1/day, 20-day cycle)
    Mon  02:30  → admin-boundaries provinces
    Wed  02:30  → admin-boundaries districts
    Sat  02:30  → admin-boundaries wards
    1st of month 03:00 → industrial-parks (per chunk)
  All gated by `OSM_SYNC_ENABLED=true`.
* New admin endpoints under `/admin/osm/*` (layers / coverage / runs /
  trigger), guarded by JWT + ADMIN role.
* New `/admin/osm` Next.js page: stat cards, coverage table with
  per-row "Sync now", recent runs list with auto-refresh every 15s.

Run on dev so far: 33 provinces + 1100+ districts (still finishing) +
305 hospitals POI imported.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-05-01 12:01:19 +07:00
parent 73ff469126
commit fba536406d
38 changed files with 3411 additions and 11 deletions

View File

@@ -0,0 +1,151 @@
'use client';
import { Loader2, MapPin } from 'lucide-react';
import * as React from 'react';
import {
POI_ICONS,
POI_LABELS,
poiApi,
type NearbyPoiResult,
type PoiCategory,
} from '@/lib/poi-api';
interface Props {
/** Centre coordinates of the asset (listing / project / KCN). */
lat: number;
lng: number;
/** Search radius in metres. Default 1500m (~15 phút đi bộ). */
radius?: number;
/** Restrict to these categories. Default: 6 most relevant for residential. */
categories?: PoiCategory[];
/** N nearest POI shown per category. */
limitPerCategory?: number;
className?: string;
}
const DEFAULT_CATEGORIES: PoiCategory[] = [
'SCHOOL_PRIMARY',
'SCHOOL_SECONDARY',
'HOSPITAL',
'MARKET',
'BANK',
'METRO_STATION',
];
function formatDistance(m: number): string {
if (m < 1000) return `${m} m`;
return `${(m / 1000).toFixed(1)} km`;
}
/**
* Sidebar widget that lists the nearest POI of each category around a
* geo-tagged asset. Renders inside listing detail, project detail and KCN
* detail pages.
*/
export function NearbyPoiSidebar({
lat,
lng,
radius = 1500,
categories = DEFAULT_CATEGORIES,
limitPerCategory = 3,
className,
}: Props) {
const [data, setData] = React.useState<NearbyPoiResult | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
poiApi
.nearby({ lat, lng, radius, categories, limitPerCategory })
.then((res) => {
if (cancelled) return;
setData(res);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message ?? 'Không tải được tiện ích');
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [lat, lng, radius, categories, limitPerCategory]);
if (loading) {
return (
<div
className={`flex items-center gap-2 rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground ${className ?? ''}`}
>
<Loader2 className="h-4 w-4 animate-spin" />
Đang tải tiện ích xung quanh
</div>
);
}
if (error) {
return (
<div
className={`rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive ${className ?? ''}`}
>
{error}
</div>
);
}
if (!data || data.all.length === 0) {
return (
<div
className={`rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground ${className ?? ''}`}
>
Chưa dữ liệu tiện ích trong bán kính {formatDistance(radius)}.
</div>
);
}
return (
<div className={`rounded-lg border border-border bg-card ${className ?? ''}`}>
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
<h3 className="text-sm font-semibold text-foreground">Tiện ích xung quanh</h3>
<span className="text-xs text-muted-foreground">
{data.meta.totalCount} điểm · bán kính {formatDistance(data.meta.radiusMeters)}
</span>
</div>
<div className="flex flex-col divide-y divide-border">
{categories.map((cat) => {
const items = data.byCategory[cat] ?? [];
if (items.length === 0) return null;
return (
<div key={cat} className="px-4 py-2.5">
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium uppercase text-muted-foreground">
<span aria-hidden>{POI_ICONS[cat]}</span>
{POI_LABELS[cat]}
</div>
<ul className="flex flex-col gap-1.5">
{items.map((p) => (
<li key={p.id} className="flex items-start gap-2 text-sm">
<MapPin className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">{p.name}</div>
{p.address && (
<div className="truncate text-xs text-muted-foreground">{p.address}</div>
)}
</div>
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
{formatDistance(p.distanceM)}
</span>
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
);
}