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:
@@ -215,9 +215,11 @@ function parseFeature(
|
||||
if (!isPointInVietnam(cLng, cLat)) return null;
|
||||
|
||||
// Province resolution: prefer explicit OSM tags, then fall back to a
|
||||
// nearest-centroid lookup against our 63-province table. The fallback
|
||||
// catches the (very common) case where Vietnamese landuse polygons have
|
||||
// no addr:* tags at all.
|
||||
// nearest-centroid lookup against our 63-province table. The actual DB
|
||||
// upsert step (`upsertFeature`) replaces this with a precise PostGIS
|
||||
// ST_Contains lookup against `vn_provinces.geometry` once those polygons
|
||||
// are synced — this is just the bootstrap value used when the polygon
|
||||
// table is empty.
|
||||
const province =
|
||||
VN_PROVINCE_HINTS.map((k) => tags[k]).find(Boolean) ??
|
||||
tags['addr:city'] ??
|
||||
@@ -271,6 +273,33 @@ async function upsertFeature(
|
||||
return;
|
||||
}
|
||||
|
||||
// Override the heuristic province with a precise PostGIS lookup against
|
||||
// the OSM-sourced admin polygons (when synced). Falls back to the
|
||||
// nearest-centroid value already on `parsed.province` if the polygon
|
||||
// table doesn't yet cover that area.
|
||||
const adminMatch = await prisma.$queryRawUnsafe<
|
||||
{ provinceName: string | null; districtName: string | null }[]
|
||||
>(
|
||||
`WITH p AS (
|
||||
SELECT code, name FROM "vn_provinces"
|
||||
WHERE geometry IS NOT NULL
|
||||
AND ST_Contains(geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326))
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT
|
||||
(SELECT name FROM p) AS "provinceName",
|
||||
(SELECT d.name FROM "vn_districts" d JOIN p ON p.code = d."provinceCode"
|
||||
WHERE d.geometry IS NOT NULL
|
||||
AND ST_Contains(d.geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326))
|
||||
LIMIT 1) AS "districtName"`,
|
||||
parsed.centroid.lng,
|
||||
parsed.centroid.lat,
|
||||
);
|
||||
const resolvedProvince = adminMatch[0]?.provinceName ?? parsed.province;
|
||||
const resolvedDistrict = adminMatch[0]?.districtName ?? parsed.district;
|
||||
parsed.province = resolvedProvince;
|
||||
if (!parsed.district) parsed.district = resolvedDistrict ?? '';
|
||||
|
||||
const region = guessRegion(parsed.centroid.lat);
|
||||
const slug = slugify(parsed.name, parsed.osmId.toString());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user