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:
@@ -0,0 +1,80 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { LoggerService } from '@modules/shared';
|
||||
import { OsmSyncService } from '../osm-sync.service';
|
||||
|
||||
/**
|
||||
* Scheduled sync runner. Spreads layer refreshes across the week so we
|
||||
* never hit Overpass with two heavy queries simultaneously and stay
|
||||
* under the per-IP rate limit.
|
||||
*
|
||||
* Schedule (Asia/Ho_Chi_Minh):
|
||||
* - Daily 02:00 → POI category rotation (one per day, 20-day cycle)
|
||||
* - Mon 02:30 → admin-boundaries level=4 (provinces, light)
|
||||
* - Wed 02:30 → admin-boundaries level=6 (districts, medium)
|
||||
* - Sat 02:30 → admin-boundaries level=8 (wards, heavy)
|
||||
* - 1st of month 03:00 → industrial-parks (existing flow, kept here so
|
||||
* everything routes through one orchestrator)
|
||||
*
|
||||
* All routes respect `OSM_SYNC_ENABLED=true` to allow disabling in dev.
|
||||
*/
|
||||
@Injectable()
|
||||
export class OsmSyncCronService {
|
||||
private readonly POI_CATEGORIES = [
|
||||
'SCHOOL_PRIMARY', 'SCHOOL_SECONDARY', 'UNIVERSITY',
|
||||
'HOSPITAL', 'CLINIC', 'PHARMACY',
|
||||
'MARKET', 'SUPERMARKET', 'MALL', 'CONVENIENCE',
|
||||
'BANK', 'ATM',
|
||||
'PARK',
|
||||
'GAS_STATION', 'POLICE', 'POST_OFFICE',
|
||||
'METRO_STATION', 'RAILWAY_STATION', 'BUS_STATION', 'AIRPORT',
|
||||
];
|
||||
|
||||
constructor(
|
||||
private readonly osmSync: OsmSyncService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
private isEnabled(): boolean {
|
||||
return process.env['OSM_SYNC_ENABLED'] === 'true';
|
||||
}
|
||||
|
||||
@Cron('0 2 * * *', { timeZone: 'Asia/Ho_Chi_Minh' })
|
||||
async dailyPoiRotation(): Promise<void> {
|
||||
if (!this.isEnabled()) return;
|
||||
// Pick one category based on day-of-year so we cycle evenly.
|
||||
const dayOfYear = Math.floor(
|
||||
(Date.now() - new Date(new Date().getUTCFullYear(), 0, 0).getTime()) / 86_400_000,
|
||||
);
|
||||
const category = this.POI_CATEGORIES[dayOfYear % this.POI_CATEGORIES.length]!;
|
||||
this.logger.log(`Daily POI rotation: ${category}`, 'OsmSyncCronService');
|
||||
await this.osmSync.run({ layer: 'poi', category, wait: false });
|
||||
}
|
||||
|
||||
@Cron('30 2 * * 1', { timeZone: 'Asia/Ho_Chi_Minh' }) // Monday
|
||||
async weeklyProvinces(): Promise<void> {
|
||||
if (!this.isEnabled()) return;
|
||||
await this.osmSync.run({ layer: 'admin-boundaries', category: 'province', wait: false });
|
||||
}
|
||||
|
||||
@Cron('30 2 * * 3', { timeZone: 'Asia/Ho_Chi_Minh' }) // Wednesday
|
||||
async weeklyDistricts(): Promise<void> {
|
||||
if (!this.isEnabled()) return;
|
||||
await this.osmSync.run({ layer: 'admin-boundaries', category: 'district', wait: false });
|
||||
}
|
||||
|
||||
@Cron('30 2 * * 6', { timeZone: 'Asia/Ho_Chi_Minh' }) // Saturday
|
||||
async weeklyWards(): Promise<void> {
|
||||
if (!this.isEnabled()) return;
|
||||
await this.osmSync.run({ layer: 'admin-boundaries', category: 'ward', wait: false });
|
||||
}
|
||||
|
||||
@Cron('0 3 1 * *', { timeZone: 'Asia/Ho_Chi_Minh' }) // 1st of month
|
||||
async monthlyIndustrialParks(): Promise<void> {
|
||||
if (!this.isEnabled()) return;
|
||||
// KCN sync runs per chunk to spread load.
|
||||
for (const chunk of ['north', 'northCentral', 'southCentral', 'south']) {
|
||||
await this.osmSync.run({ layer: 'industrial-parks', chunk, wait: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user