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,321 @@
'use client';
import {
AlertTriangle,
CheckCircle,
Clock,
Layers,
MapPin,
PlayCircle,
RefreshCw,
Train,
XCircle,
} from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
osmSyncApi,
type OsmCoverageSummary,
type OsmSyncLayer,
type OsmSyncRun,
} from '@/lib/osm-sync-api';
const STATUS_STYLES: Record<OsmSyncRun['status'], string> = {
RUNNING: 'bg-blue-100 text-blue-800 ring-blue-200',
SUCCESS: 'bg-emerald-100 text-emerald-800 ring-emerald-200',
PARTIAL: 'bg-amber-100 text-amber-800 ring-amber-200',
FAILED: 'bg-red-100 text-red-800 ring-red-200',
};
const STATUS_ICONS: Record<OsmSyncRun['status'], React.ReactNode> = {
RUNNING: <RefreshCw className="h-3 w-3 animate-spin" />,
SUCCESS: <CheckCircle className="h-3 w-3" />,
PARTIAL: <AlertTriangle className="h-3 w-3" />,
FAILED: <XCircle className="h-3 w-3" />,
};
function formatRelative(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
const diff = Date.now() - d.getTime();
if (diff < 60_000) return 'vừa xong';
if (diff < 3_600_000) return `${Math.round(diff / 60_000)} phút trước`;
if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)} giờ trước`;
return `${Math.round(diff / 86_400_000)} ngày trước`;
}
function formatDuration(start: string, end: string | null): string {
const startMs = new Date(start).getTime();
const endMs = end ? new Date(end).getTime() : Date.now();
const sec = Math.round((endMs - startMs) / 1000);
if (sec < 60) return `${sec}s`;
if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`;
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
}
export default function AdminOsmDashboardPage() {
const [coverage, setCoverage] = useState<OsmCoverageSummary | null>(null);
const [runs, setRuns] = useState<OsmSyncRun[]>([]);
const [layers, setLayers] = useState<OsmSyncLayer[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [triggering, setTriggering] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [cov, rs, ls] = await Promise.all([
osmSyncApi.coverage(),
osmSyncApi.runs({ limit: 30 }),
osmSyncApi.layers(),
]);
setCoverage(cov);
setRuns(rs);
setLayers(ls);
} catch (e) {
setError(e instanceof Error ? e.message : 'Lỗi tải dashboard');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
const int = setInterval(refresh, 15_000); // poll while RUNNING runs visible
return () => clearInterval(int);
}, [refresh]);
const trigger = async (layer: string, category?: string) => {
const key = `${layer}/${category ?? '-'}`;
setTriggering(key);
try {
await osmSyncApi.trigger({ layer, category });
await refresh();
} catch (e) {
setError(e instanceof Error ? e.message : 'Trigger fail');
} finally {
setTriggering(null);
}
};
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">OSM Sync Dashboard</h1>
<p className="text-sm text-muted-foreground">
Đng bộ OpenStreetMap Goodgo: ranh giới hành chính, POI, KCN, giao thông.
</p>
</div>
<Button variant="outline" size="sm" onClick={refresh} disabled={loading}>
<RefreshCw className={`mr-1.5 h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
Làm mới
</Button>
</div>
{error && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-2 text-sm text-destructive">
{error}
</div>
)}
{/* Top stats */}
{coverage && (
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
<StatCard
icon={<Layers className="h-4 w-4" />}
label="Đơn vị hành chính"
value={coverage.totals.administrativeUnits.toLocaleString('vi-VN')}
/>
<StatCard
icon={<MapPin className="h-4 w-4" />}
label="POI tổng"
value={coverage.totals.poiTotal.toLocaleString('vi-VN')}
/>
<StatCard
icon={<MapPin className="h-4 w-4 text-green-600" />}
label="KCN"
value={coverage.totals.industrialParks.toLocaleString('vi-VN')}
/>
<StatCard
icon={<Train className="h-4 w-4" />}
label="Bến/Ga"
value={coverage.totals.transportStations.toLocaleString('vi-VN')}
/>
<StatCard
icon={<Train className="h-4 w-4" />}
label="Tuyến giao thông"
value={coverage.totals.transportLines.toLocaleString('vi-VN')}
/>
</div>
)}
{/* Coverage table */}
<Card>
<CardContent className="p-0">
<div className="border-b border-border px-4 py-2.5">
<h2 className="text-sm font-semibold">Coverage theo layer</h2>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Layer / Category</TableHead>
<TableHead className="text-right">Tổng</TableHead>
<TableHead className="text-right">Promoted</TableHead>
<TableHead className="text-right">Raw</TableHead>
<TableHead>Sync gần nhất</TableHead>
<TableHead className="text-right">Hành đng</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{coverage?.rows.map((r) => {
const key = `${r.layer}/${r.category ?? '-'}`;
const layerDef = layers.find(
(l) => l.layer === r.layer && (l.category ?? null) === (r.category ?? null),
);
return (
<TableRow key={key}>
<TableCell>
<div className="font-medium">{r.layer}</div>
{r.category && (
<div className="text-xs text-muted-foreground">{r.category}</div>
)}
</TableCell>
<TableCell className="text-right font-mono">
{r.total.toLocaleString('vi-VN')}
{r.withGeometry !== undefined && r.withGeometry !== r.total && (
<span className="ml-1 text-xs text-muted-foreground">
({r.withGeometry} geom)
</span>
)}
</TableCell>
<TableCell className="text-right font-mono">
{r.promoted?.toLocaleString('vi-VN') ?? '—'}
</TableCell>
<TableCell className="text-right font-mono">
{r.raw?.toLocaleString('vi-VN') ?? '—'}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatRelative(r.lastSyncedAt)}
</TableCell>
<TableCell className="text-right">
{layerDef && (
<Button
variant="outline"
size="sm"
disabled={triggering === key}
onClick={() => trigger(r.layer, r.category ?? undefined)}
>
{triggering === key ? (
<RefreshCw className="mr-1 h-3 w-3 animate-spin" />
) : (
<PlayCircle className="mr-1 h-3 w-3" />
)}
Sync
</Button>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Recent runs */}
<Card>
<CardContent className="p-0">
<div className="border-b border-border px-4 py-2.5">
<h2 className="text-sm font-semibold">Sync runs gần đây</h2>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Layer</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Added</TableHead>
<TableHead className="text-right">Updated</TableHead>
<TableHead className="text-right">Skipped</TableHead>
<TableHead>Bắt đu</TableHead>
<TableHead>Thời gian</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-8 text-center text-sm text-muted-foreground">
Chưa sync run nào.
</TableCell>
</TableRow>
) : (
runs.map((r) => (
<TableRow key={r.id}>
<TableCell>
<div className="font-medium">{r.layer}</div>
{r.category && (
<div className="text-xs text-muted-foreground">{r.category}</div>
)}
</TableCell>
<TableCell>
<span
className={`inline-flex items-center gap-1 rounded-pill px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${STATUS_STYLES[r.status]}`}
>
{STATUS_ICONS[r.status]}
{r.status}
</span>
</TableCell>
<TableCell className="text-right font-mono">{r.rowsAdded}</TableCell>
<TableCell className="text-right font-mono">{r.rowsUpdated}</TableCell>
<TableCell className="text-right font-mono">{r.rowsSkipped}</TableCell>
<TableCell className="text-sm text-muted-foreground">
<Clock className="mr-1 inline h-3 w-3" />
{formatRelative(r.startedAt)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDuration(r.startedAt, r.finishedAt)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
function StatCard({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
{icon}
</div>
<div className="min-w-0">
<div className="truncate text-xs uppercase text-muted-foreground">{label}</div>
<div className="truncate text-lg font-semibold">{value}</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -7,6 +7,7 @@ import {
ShieldCheck,
Building2,
Factory,
Globe,
LogOut,
Menu,
Sparkles,
@@ -38,6 +39,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{ href: '/admin/accounts/developers' as const, label: 'Tài khoản CĐT', icon: Building2 },
{ href: '/admin/accounts/park-operators' as const, label: 'Tài khoản KCN', icon: Factory },
{ href: '/admin/industrial/osm-review' as const, label: 'Review OSM (KCN)', icon: Factory },
{ href: '/admin/osm' as const, label: 'OSM Sync Dashboard', icon: Globe },
{ href: '/admin/settings/ai' as const, label: t('adminNav.aiSettings'), icon: Sparkles },
];