From b3143991cefa2400c4e51f443576a4ec708891d0 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 29 Apr 2026 19:22:32 +0700 Subject: [PATCH] feat(industrial): OSM bulk import + bbox map + admin review (PR 2-4/4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls every `landuse=industrial` feature from OpenStreetMap into the IndustrialPark catalog and surfaces it on the public KCN map. Admins can promote raw OSM rows into the public catalog or lock individual fields to protect them from the monthly reconciliation sync. PR 2 — Bulk import script (scripts/sync-osm-industrial-parks.ts): • Splits Vietnam into 4 chunks (north / northCentral / southCentral / south) to stay under Overpass 504 timeouts. • Posts to overpass-api.de with form-encoded body, converts via osmtogeojson, derives centroid + area via @turf/centroid + @turf/area. • Upsert keyed on osmId. Honours `osmLocked` (skip row entirely) and `lockedFields[]` (skip individual columns) so admin edits survive. • Inserts use $executeRawUnsafe with ST_SetSRID(ST_MakePoint, 4326) because Prisma can't manage the Unsupported geometry NOT NULL column. • CLI flags: --dry-run, --chunk=NAME. PR 3 — Bbox spatial API + Mapbox layer: • GET /industrial/parks/by-bbox returns a GeoJSON FeatureCollection filtered by ST_MakeEnvelope. Sends Point-only at zoom < 12, MultiPolygon outline at zoom >= 12 to keep payloads light. • Public consumers see MANUAL + OSM_PROMOTED only; admins can pass includeOsmRaw=true to also see raw OSM imports. • OsmParkBboxMap component drives Mapbox from viewport moveend with AbortController-debounced fetches, clusters at zoom < 12, expands via getClusterExpansionZoom (callback-style API). • /khu-cong-nghiep page now uses the bbox map in map + split views. PR 4 — Admin review queue + monthly cron: • Commands: PromoteOsmPark (OSM → OSM_PROMOTED + isPublic=true, optional lockFields), LockOsmPark (toggle row-level skip flag). • Query: ListOsmPending lists rows with dataSource='OSM' for review. • OsmSyncCronService runs `0 2 1 * *` Asia/Ho_Chi_Minh and spawns sync-osm-industrial-parks.ts per chunk. Skipped unless OSM_SYNC_ENABLED=true so dev never accidentally hits Overpass. • New admin page /admin/industrial/osm-review: searchable table, promote dialog with quick-pick lock fields (name, developer, description, etc.) plus a free-text fallback, lock/unlock toggle, deep-link to openstreetmap.org for verification. Repository changes: • PrismaIndustrialParkRepository now filters public queries to `isPublic = true AND dataSource IN (MANUAL, OSM_PROMOTED)` so raw OSM rows stay hidden from end users. • Added *.rdb to .gitignore (Redis dump local artefact). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 + .../lock-osm-park/lock-osm-park.command.ts | 11 + .../lock-osm-park/lock-osm-park.handler.ts | 24 + .../promote-osm-park.command.ts | 15 + .../promote-osm-park.handler.ts | 44 ++ .../get-industrial-parks-by-bbox.handler.ts | 140 +++++ .../get-industrial-parks-by-bbox.query.ts | 20 + .../list-osm-pending.handler.ts | 132 +++++ .../list-osm-pending.query.ts | 12 + .../modules/industrial/industrial.module.ts | 10 + .../cron/osm-sync-cron.service.ts | 96 ++++ .../prisma-industrial-park.repository.ts | 5 + .../industrial-parks.controller.ts | 87 +++ .../presentation/dto/parks-bbox.dto.ts | 63 +++ .../admin/industrial/osm-review/page.tsx | 500 ++++++++++++++++++ apps/web/app/[locale]/(admin)/layout.tsx | 1 + .../(public)/khu-cong-nghiep/page.tsx | 13 +- .../khu-cong-nghiep/osm-park-bbox-map.tsx | 288 ++++++++++ apps/web/lib/khu-cong-nghiep-api.ts | 67 +++ package.json | 4 + pnpm-lock.yaml | 212 ++++++++ scripts/sync-osm-industrial-parks.ts | 440 +++++++++++++++ 22 files changed, 2179 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/modules/industrial/application/commands/lock-osm-park/lock-osm-park.command.ts create mode 100644 apps/api/src/modules/industrial/application/commands/lock-osm-park/lock-osm-park.handler.ts create mode 100644 apps/api/src/modules/industrial/application/commands/promote-osm-park/promote-osm-park.command.ts create mode 100644 apps/api/src/modules/industrial/application/commands/promote-osm-park/promote-osm-park.handler.ts create mode 100644 apps/api/src/modules/industrial/application/queries/get-industrial-parks-by-bbox/get-industrial-parks-by-bbox.handler.ts create mode 100644 apps/api/src/modules/industrial/application/queries/get-industrial-parks-by-bbox/get-industrial-parks-by-bbox.query.ts create mode 100644 apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.handler.ts create mode 100644 apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.query.ts create mode 100644 apps/api/src/modules/industrial/infrastructure/cron/osm-sync-cron.service.ts create mode 100644 apps/api/src/modules/industrial/presentation/dto/parks-bbox.dto.ts create mode 100644 apps/web/app/[locale]/(admin)/admin/industrial/osm-review/page.tsx create mode 100644 apps/web/components/khu-cong-nghiep/osm-park-bbox-map.tsx create mode 100644 scripts/sync-osm-industrial-parks.ts diff --git a/.gitignore b/.gitignore index f10e5ac..cc8b6c5 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,9 @@ load-tests/results/*.json npm-debug.log* pnpm-debug.log* +# Redis dump (created when running redis locally without persistence config) +*.rdb + # personal notes / Obsidian .obsidian/ TEC/ diff --git a/apps/api/src/modules/industrial/application/commands/lock-osm-park/lock-osm-park.command.ts b/apps/api/src/modules/industrial/application/commands/lock-osm-park/lock-osm-park.command.ts new file mode 100644 index 0000000..8b83f28 --- /dev/null +++ b/apps/api/src/modules/industrial/application/commands/lock-osm-park/lock-osm-park.command.ts @@ -0,0 +1,11 @@ +/** + * Toggle the `osmLocked` flag on a park. When locked, the OSM sync cron + * skips this row entirely — useful when admin has curated values that + * conflict with what OSM contributors keep changing. + */ +export class LockOsmParkCommand { + constructor( + public readonly parkId: string, + public readonly locked: boolean, + ) {} +} diff --git a/apps/api/src/modules/industrial/application/commands/lock-osm-park/lock-osm-park.handler.ts b/apps/api/src/modules/industrial/application/commands/lock-osm-park/lock-osm-park.handler.ts new file mode 100644 index 0000000..5136b7d --- /dev/null +++ b/apps/api/src/modules/industrial/application/commands/lock-osm-park/lock-osm-park.handler.ts @@ -0,0 +1,24 @@ +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { LoggerService, PrismaService } from '@modules/shared'; +import { LockOsmParkCommand } from './lock-osm-park.command'; + +@CommandHandler(LockOsmParkCommand) +export class LockOsmParkHandler implements ICommandHandler { + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async execute(cmd: LockOsmParkCommand): Promise<{ id: string; locked: boolean }> { + const park = await this.prisma.industrialPark.update({ + where: { id: cmd.parkId }, + data: { osmLocked: cmd.locked }, + select: { id: true, osmLocked: true }, + }); + this.logger.log( + `Park ${park.id} osmLocked → ${park.osmLocked}`, + this.constructor.name, + ); + return { id: park.id, locked: park.osmLocked }; + } +} diff --git a/apps/api/src/modules/industrial/application/commands/promote-osm-park/promote-osm-park.command.ts b/apps/api/src/modules/industrial/application/commands/promote-osm-park/promote-osm-park.command.ts new file mode 100644 index 0000000..798e1f9 --- /dev/null +++ b/apps/api/src/modules/industrial/application/commands/promote-osm-park/promote-osm-park.command.ts @@ -0,0 +1,15 @@ +/** + * Promote a raw OSM-imported industrial park to the public catalogue. + * + * - Flips `dataSource` from `OSM` → `OSM_PROMOTED` + * - Sets `isPublic = true` + * - Optionally locks fields the admin has just curated so the next OSM + * sync doesn't overwrite them. + */ +export class PromoteOsmParkCommand { + constructor( + public readonly parkId: string, + /** Field names to add to `lockedFields`. Empty array = no lock. */ + public readonly lockFields: string[] = [], + ) {} +} diff --git a/apps/api/src/modules/industrial/application/commands/promote-osm-park/promote-osm-park.handler.ts b/apps/api/src/modules/industrial/application/commands/promote-osm-park/promote-osm-park.handler.ts new file mode 100644 index 0000000..7138494 --- /dev/null +++ b/apps/api/src/modules/industrial/application/commands/promote-osm-park/promote-osm-park.handler.ts @@ -0,0 +1,44 @@ +import { HttpStatus } from '@nestjs/common'; +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { DomainException, ErrorCode, LoggerService, PrismaService } from '@modules/shared'; +import { PromoteOsmParkCommand } from './promote-osm-park.command'; + +@CommandHandler(PromoteOsmParkCommand) +export class PromoteOsmParkHandler implements ICommandHandler { + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async execute(cmd: PromoteOsmParkCommand): Promise<{ id: string }> { + const park = await this.prisma.industrialPark.findUnique({ + where: { id: cmd.parkId }, + select: { id: true, dataSource: true, lockedFields: true }, + }); + if (!park) { + throw new DomainException( + ErrorCode.NOT_FOUND, + `Không tìm thấy KCN ${cmd.parkId}`, + HttpStatus.NOT_FOUND, + ); + } + if (park.dataSource === 'MANUAL') { + // Already in the public catalog as a manual seed; nothing to promote. + return { id: park.id }; + } + const newLocked = Array.from(new Set([...park.lockedFields, ...cmd.lockFields])); + await this.prisma.industrialPark.update({ + where: { id: cmd.parkId }, + data: { + dataSource: 'OSM_PROMOTED', + isPublic: true, + lockedFields: newLocked, + }, + }); + this.logger.log( + `Promoted park ${cmd.parkId} from OSM → OSM_PROMOTED (locked: ${newLocked.join(', ')})`, + this.constructor.name, + ); + return { id: park.id }; + } +} diff --git a/apps/api/src/modules/industrial/application/queries/get-industrial-parks-by-bbox/get-industrial-parks-by-bbox.handler.ts b/apps/api/src/modules/industrial/application/queries/get-industrial-parks-by-bbox/get-industrial-parks-by-bbox.handler.ts new file mode 100644 index 0000000..ffea1c5 --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/get-industrial-parks-by-bbox/get-industrial-parks-by-bbox.handler.ts @@ -0,0 +1,140 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import type { Feature, FeatureCollection } from 'geojson'; +import { LoggerService, PrismaService } from '@modules/shared'; +import { GetIndustrialParksByBboxQuery } from './get-industrial-parks-by-bbox.query'; + +interface BboxRow { + id: string; + slug: string; + name: string; + status: string; + province: string; + data_source: string; + occupancy_rate: number; + total_area_ha: number; + tenant_count: number; + point: string; // GeoJSON Point as text (ST_AsGeoJSON) + polygon: string | null; // GeoJSON MultiPolygon, only when zoom >= 12 +} + +export interface IndustrialParksGeoCollection extends FeatureCollection { + /** Quick metadata so the client can show "showing N of M parks" */ + meta: { + count: number; + truncated: boolean; + zoom: number; + }; +} + +/** Zoom threshold above which the boundary polygon is included. Below this + * we send only the centroid Point — enough to cluster + render dots. */ +const BOUNDARY_ZOOM = 12; + +@QueryHandler(GetIndustrialParksByBboxQuery) +export class GetIndustrialParksByBboxHandler + implements IQueryHandler +{ + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async execute( + q: GetIndustrialParksByBboxQuery, + ): Promise { + try { + const { south, west, north, east } = q.bbox; + const includeBoundary = q.zoom >= BOUNDARY_ZOOM; + const limit = Math.min(Math.max(q.limit, 1), 5000); + // Filter out raw OSM imports for public consumers; admins pass + // includeOsmRaw=true to see them. + const dataSourceFilter = q.includeOsmRaw + ? `'MANUAL', 'OSM', 'OSM_PROMOTED'` + : `'MANUAL', 'OSM_PROMOTED'`; + + // Single PostGIS query: bbox filter + optional polygon column. + // && is the bbox-intersect operator and uses the GiST index. + const rows = await this.prisma.$queryRawUnsafe( + ` + SELECT + id, + slug, + name, + status::text, + province, + "dataSource"::text AS data_source, + "occupancyRate" AS occupancy_rate, + "totalAreaHa" AS total_area_ha, + "tenantCount" AS tenant_count, + ST_AsGeoJSON(location) AS point, + ${includeBoundary ? `ST_AsGeoJSON(boundary)` : `NULL::text`} AS polygon + FROM "IndustrialPark" + WHERE "isPublic" = true + AND "dataSource"::text IN (${dataSourceFilter}) + AND location && ST_MakeEnvelope($1, $2, $3, $4, 4326) + ORDER BY "totalAreaHa" DESC NULLS LAST + LIMIT ${limit + 1} + `, + west, + south, + east, + north, + ); + + const truncated = rows.length > limit; + const trimmed = truncated ? rows.slice(0, limit) : rows; + + const features: Feature[] = trimmed.flatMap((r) => { + const properties = { + id: r.id, + slug: r.slug, + name: r.name, + status: r.status, + province: r.province, + dataSource: r.data_source, + occupancyRate: Number(r.occupancy_rate), + totalAreaHa: Number(r.total_area_ha), + tenantCount: Number(r.tenant_count), + }; + const out: Feature[] = []; + if (r.point) { + out.push({ + type: 'Feature', + id: `${r.id}:point`, + geometry: JSON.parse(r.point), + properties: { ...properties, _kind: 'point' }, + }); + } + if (r.polygon) { + out.push({ + type: 'Feature', + id: `${r.id}:polygon`, + geometry: JSON.parse(r.polygon), + properties: { ...properties, _kind: 'polygon' }, + }); + } + return out; + }); + + return { + type: 'FeatureCollection', + features, + meta: { + count: trimmed.length, + truncated, + zoom: q.zoom, + }, + }; + } catch (err) { + this.logger.error( + `Failed to query parks by bbox: ${err instanceof Error ? err.message : err}`, + err instanceof Error ? err.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException( + 'Không thể tải KCN theo khu vực. Vui lòng thử lại sau.', + ); + } + } +} diff --git a/apps/api/src/modules/industrial/application/queries/get-industrial-parks-by-bbox/get-industrial-parks-by-bbox.query.ts b/apps/api/src/modules/industrial/application/queries/get-industrial-parks-by-bbox/get-industrial-parks-by-bbox.query.ts new file mode 100644 index 0000000..d965987 --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/get-industrial-parks-by-bbox/get-industrial-parks-by-bbox.query.ts @@ -0,0 +1,20 @@ +/** + * Spatial bbox query for the public KCN map. Returns a GeoJSON + * FeatureCollection of industrial parks intersecting the given viewport. + * + * - At low zoom we return Point centroids only (cluster on the client). + * - At high zoom (>= 12) we also include the MultiPolygon `boundary` + * so Mapbox can render the park outline. + * + * Visibility is filtered to MANUAL + OSM_PROMOTED rows by default + * (`includeOsmRaw=false`); admin tooling can pass `true` to see the raw + * OSM-imported parks awaiting review. + */ +export class GetIndustrialParksByBboxQuery { + constructor( + public readonly bbox: { south: number; west: number; north: number; east: number }, + public readonly zoom: number, + public readonly includeOsmRaw: boolean = false, + public readonly limit: number = 1000, + ) {} +} diff --git a/apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.handler.ts b/apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.handler.ts new file mode 100644 index 0000000..d5445b3 --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.handler.ts @@ -0,0 +1,132 @@ +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { LoggerService, PrismaService } from '@modules/shared'; +import { ListOsmPendingQuery } from './list-osm-pending.query'; + +export interface OsmPendingItem { + id: string; + slug: string; + name: string; + nameEn: string | null; + province: string; + district: string; + region: string; + status: string; + osmId: string; + osmType: string | null; + osmTags: unknown; + totalAreaHa: number; + developer: string; + operator: string | null; + osmLocked: boolean; + lastSyncedAt: Date | null; + latitude: number | null; + longitude: number | null; +} + +export interface ListOsmPendingResult { + data: OsmPendingItem[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +@QueryHandler(ListOsmPendingQuery) +export class ListOsmPendingHandler implements IQueryHandler { + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async execute(q: ListOsmPendingQuery): Promise { + const limit = Math.min(Math.max(q.limit, 1), 200); + const offset = (Math.max(q.page, 1) - 1) * limit; + + const conditions: string[] = [`"dataSource"::text = 'OSM'`]; + const values: unknown[] = []; + let p = 1; + + if (q.province) { + conditions.push(`province = $${p++}`); + values.push(q.province); + } + if (q.query) { + conditions.push( + `(name ILIKE $${p} OR "nameEn" ILIKE $${p} OR developer ILIKE $${p})`, + ); + values.push(`%${q.query}%`); + p += 1; + } + const where = conditions.join(' AND '); + + const [{ count }] = await this.prisma.$queryRawUnsafe<[{ count: bigint }]>( + `SELECT COUNT(*)::bigint AS count FROM "IndustrialPark" WHERE ${where}`, + ...values, + ); + const total = Number(count); + + const rows = await this.prisma.$queryRawUnsafe< + Array<{ + id: string; + slug: string; + name: string; + nameEn: string | null; + province: string; + district: string; + region: string; + status: string; + osmId: bigint; + osmType: string | null; + osmTags: unknown; + totalAreaHa: number; + developer: string; + operator: string | null; + osmLocked: boolean; + lastSyncedAt: Date | null; + lat: number | null; + lng: number | null; + }> + >( + `SELECT + id, slug, name, "nameEn", province, district, + region::text AS region, status::text AS status, + "osmId", "osmType"::text AS "osmType", "osmTags", + "totalAreaHa", developer, operator, "osmLocked", "lastSyncedAt", + ST_Y(location::geometry) AS lat, ST_X(location::geometry) AS lng + FROM "IndustrialPark" + WHERE ${where} + ORDER BY "lastSyncedAt" DESC NULLS LAST, "totalAreaHa" DESC NULLS LAST + LIMIT $${p++} OFFSET $${p}`, + ...values, + limit, + offset, + ); + + return { + data: rows.map((r) => ({ + id: r.id, + slug: r.slug, + name: r.name, + nameEn: r.nameEn, + province: r.province, + district: r.district, + region: r.region, + status: r.status, + osmId: r.osmId.toString(), + osmType: r.osmType, + osmTags: r.osmTags, + totalAreaHa: Number(r.totalAreaHa), + developer: r.developer, + operator: r.operator, + osmLocked: r.osmLocked, + lastSyncedAt: r.lastSyncedAt, + latitude: r.lat, + longitude: r.lng, + })), + total, + page: q.page, + limit, + totalPages: Math.ceil(total / limit), + }; + } +} diff --git a/apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.query.ts b/apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.query.ts new file mode 100644 index 0000000..15226a8 --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.query.ts @@ -0,0 +1,12 @@ +/** + * Admin OSM review queue — list raw OSM-imported parks that haven't yet + * been promoted to the public catalogue. + */ +export class ListOsmPendingQuery { + constructor( + public readonly page: number = 1, + public readonly limit: number = 50, + public readonly query?: string, + public readonly province?: string, + ) {} +} diff --git a/apps/api/src/modules/industrial/industrial.module.ts b/apps/api/src/modules/industrial/industrial.module.ts index b1e739b..9357f73 100644 --- a/apps/api/src/modules/industrial/industrial.module.ts +++ b/apps/api/src/modules/industrial/industrial.module.ts @@ -5,6 +5,8 @@ import { CreateIndustrialListingHandler } from './application/commands/create-in import { CreateIndustrialParkHandler } from './application/commands/create-industrial-park/create-industrial-park.handler'; import { DeleteIndustrialListingHandler } from './application/commands/delete-industrial-listing/delete-industrial-listing.handler'; import { DeleteIndustrialParkHandler } from './application/commands/delete-industrial-park/delete-industrial-park.handler'; +import { LockOsmParkHandler } from './application/commands/lock-osm-park/lock-osm-park.handler'; +import { PromoteOsmParkHandler } from './application/commands/promote-osm-park/promote-osm-park.handler'; import { UpdateIndustrialListingHandler } from './application/commands/update-industrial-listing/update-industrial-listing.handler'; import { UpdateIndustrialParkHandler } from './application/commands/update-industrial-park/update-industrial-park.handler'; import { AnalyzeIndustrialLocationHandler } from './application/queries/analyze-industrial-location/analyze-industrial-location.handler'; @@ -12,12 +14,15 @@ import { CompareIndustrialParksHandler } from './application/queries/compare-ind import { EstimateIndustrialRentHandler } from './application/queries/estimate-industrial-rent/estimate-industrial-rent.handler'; import { GetIndustrialListingHandler } from './application/queries/get-industrial-listing/get-industrial-listing.handler'; import { GetIndustrialParkHandler } from './application/queries/get-industrial-park/get-industrial-park.handler'; +import { GetIndustrialParksByBboxHandler } from './application/queries/get-industrial-parks-by-bbox/get-industrial-parks-by-bbox.handler'; import { IndustrialMarketHandler } from './application/queries/industrial-market/industrial-market.handler'; import { IndustrialParkStatsHandler } from './application/queries/industrial-park-stats/industrial-park-stats.handler'; import { ListIndustrialListingsHandler } from './application/queries/list-industrial-listings/list-industrial-listings.handler'; import { ListIndustrialParksHandler } from './application/queries/list-industrial-parks/list-industrial-parks.handler'; +import { ListOsmPendingHandler } from './application/queries/list-osm-pending/list-osm-pending.handler'; import { INDUSTRIAL_LISTING_REPOSITORY } from './domain/repositories/industrial-listing.repository'; import { INDUSTRIAL_PARK_REPOSITORY } from './domain/repositories/industrial-park.repository'; +import { OsmSyncCronService } from './infrastructure/cron/osm-sync-cron.service'; import { PrismaIndustrialListingRepository } from './infrastructure/repositories/prisma-industrial-listing.repository'; import { PrismaIndustrialParkRepository } from './infrastructure/repositories/prisma-industrial-park.repository'; import { TypesenseIndustrialService } from './infrastructure/services/typesense-industrial.service'; @@ -31,18 +36,22 @@ const CommandHandlers = [ CreateIndustrialListingHandler, UpdateIndustrialListingHandler, DeleteIndustrialListingHandler, + PromoteOsmParkHandler, + LockOsmParkHandler, ]; const QueryHandlers = [ AnalyzeIndustrialLocationHandler, EstimateIndustrialRentHandler, GetIndustrialParkHandler, + GetIndustrialParksByBboxHandler, ListIndustrialParksHandler, CompareIndustrialParksHandler, IndustrialParkStatsHandler, IndustrialMarketHandler, GetIndustrialListingHandler, ListIndustrialListingsHandler, + ListOsmPendingHandler, ]; @Module({ @@ -52,6 +61,7 @@ const QueryHandlers = [ { provide: INDUSTRIAL_PARK_REPOSITORY, useClass: PrismaIndustrialParkRepository }, { provide: INDUSTRIAL_LISTING_REPOSITORY, useClass: PrismaIndustrialListingRepository }, TypesenseIndustrialService, + OsmSyncCronService, ...CommandHandlers, ...QueryHandlers, ], diff --git a/apps/api/src/modules/industrial/infrastructure/cron/osm-sync-cron.service.ts b/apps/api/src/modules/industrial/infrastructure/cron/osm-sync-cron.service.ts new file mode 100644 index 0000000..a3ca341 --- /dev/null +++ b/apps/api/src/modules/industrial/infrastructure/cron/osm-sync-cron.service.ts @@ -0,0 +1,96 @@ +import { spawn } from 'node:child_process'; +import * as path from 'node:path'; +import { Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { LoggerService } from '@modules/shared'; + +/** + * Monthly OSM industrial-park reconciliation. Schedules the same script + * we run manually for the bulk import (`scripts/sync-osm-industrial-parks.ts`) + * — this keeps the import logic in one place. The cron is a thin + * orchestrator that: + * + * • Spawns the sync script (one chunk at a time to avoid Overpass 504s) + * • Logs stdout/stderr line-by-line into the application logger + * • Skips entirely if `OSM_SYNC_ENABLED !== 'true'` so dev environments + * don't accidentally call Overpass + * + * Because the script uses upsert keyed on `osmId` and respects the + * `osmLocked` / `lockedFields` columns from PR 1, replays are safe — new + * OSM entities are added, removed entities stay in the DB (admin can + * delete them via the review UI), and admin-edited fields are preserved. + */ +@Injectable() +export class OsmSyncCronService { + constructor(private readonly logger: LoggerService) {} + + /** Run on the 1st of each month at 02:00 ICT. */ + @Cron('0 2 1 * *', { timeZone: 'Asia/Ho_Chi_Minh' }) + async monthlySync(): Promise { + if (process.env['OSM_SYNC_ENABLED'] !== 'true') { + this.logger.log( + 'OSM_SYNC_ENABLED != true — skipping monthly sync.', + 'OsmSyncCronService', + ); + return; + } + this.logger.log('Starting monthly OSM sync…', 'OsmSyncCronService'); + + const chunks = ['north', 'northCentral', 'southCentral', 'south']; + for (const chunk of chunks) { + try { + await this.runChunk(chunk); + } catch (err) { + this.logger.error( + `OSM sync chunk "${chunk}" failed: ${err instanceof Error ? err.message : err}`, + err instanceof Error ? err.stack : undefined, + 'OsmSyncCronService', + ); + // Continue with the next chunk — partial success is better than + // failing the whole pass. + } + } + this.logger.log('Monthly OSM sync complete.', 'OsmSyncCronService'); + } + + private runChunk(chunk: string): Promise { + return new Promise((resolve, reject) => { + const scriptPath = path.resolve( + __dirname, + '../../../../../../..', + 'scripts/sync-osm-industrial-parks.ts', + ); + const child = spawn( + 'pnpm', + ['tsx', scriptPath, `--chunk=${chunk}`], + { + cwd: path.resolve(__dirname, '../../../../../../..'), + env: { + ...process.env, + NODE_OPTIONS: '-r dotenv/config', + DOTENV_CONFIG_PATH: '.env', + }, + }, + ); + child.stdout?.on('data', (b) => { + for (const line of b.toString().split('\n')) { + if (line.trim()) { + this.logger.log(`[${chunk}] ${line.trim()}`, 'OsmSyncCronService'); + } + } + }); + child.stderr?.on('data', (b) => { + for (const line of b.toString().split('\n')) { + if (line.trim()) { + this.logger.warn(`[${chunk}] ${line.trim()}`, 'OsmSyncCronService'); + } + } + }); + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`sync-osm-industrial-parks exited ${code}`)); + }); + }); + } +} diff --git a/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts b/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts index 95ec387..86feee5 100644 --- a/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts +++ b/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts @@ -160,6 +160,11 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository if (params.ownerId) { conditions.push(`"ownerId" = $${paramIndex++}`); values.push(params.ownerId); + } else { + // Public list: hide raw OSM imports until an admin reviews + promotes + // them. MANUAL rows + OSM_PROMOTED rows stay visible. + conditions.push(`"isPublic" = true`); + conditions.push(`"dataSource"::text IN ('MANUAL', 'OSM_PROMOTED')`); } if (params.query) { conditions.push(`(name ILIKE $${paramIndex} OR "nameEn" ILIKE $${paramIndex} OR developer ILIKE $${paramIndex} OR province ILIKE $${paramIndex})`); diff --git a/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts b/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts index 4fb873f..0e4d167 100644 --- a/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts +++ b/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts @@ -6,18 +6,23 @@ import { CurrentUser, JwtAuthGuard, Roles, RolesGuard, type JwtPayload } from ' import { NotFoundException } from '@modules/shared'; import { CreateIndustrialParkCommand } from '../../application/commands/create-industrial-park/create-industrial-park.command'; import { DeleteIndustrialParkCommand } from '../../application/commands/delete-industrial-park/delete-industrial-park.command'; +import { LockOsmParkCommand } from '../../application/commands/lock-osm-park/lock-osm-park.command'; +import { PromoteOsmParkCommand } from '../../application/commands/promote-osm-park/promote-osm-park.command'; import { UpdateIndustrialParkCommand } from '../../application/commands/update-industrial-park/update-industrial-park.command'; import { AnalyzeIndustrialLocationQuery } from '../../application/queries/analyze-industrial-location/analyze-industrial-location.query'; import { CompareIndustrialParksQuery } from '../../application/queries/compare-industrial-parks/compare-industrial-parks.query'; import { EstimateIndustrialRentQuery } from '../../application/queries/estimate-industrial-rent/estimate-industrial-rent.query'; import { GetIndustrialParkQuery } from '../../application/queries/get-industrial-park/get-industrial-park.query'; +import { GetIndustrialParksByBboxQuery } from '../../application/queries/get-industrial-parks-by-bbox/get-industrial-parks-by-bbox.query'; import { IndustrialMarketQuery } from '../../application/queries/industrial-market/industrial-market.query'; import { IndustrialParkStatsQuery } from '../../application/queries/industrial-park-stats/industrial-park-stats.query'; import { ListIndustrialParksQuery } from '../../application/queries/list-industrial-parks/list-industrial-parks.query'; +import { ListOsmPendingQuery } from '../../application/queries/list-osm-pending/list-osm-pending.query'; import { AnalyzeIndustrialLocationDto } from '../dto/analyze-industrial-location.dto'; import { CompareIndustrialParksDto } from '../dto/compare-industrial-parks.dto'; import { CreateIndustrialParkDto } from '../dto/create-industrial-park.dto'; import { EstimateIndustrialRentDto } from '../dto/estimate-industrial-rent.dto'; +import { IndustrialParksBboxDto } from '../dto/parks-bbox.dto'; import { SearchIndustrialParksDto } from '../dto/search-industrial-parks.dto'; import { UpdateIndustrialParkDto } from '../dto/update-industrial-park.dto'; @@ -50,6 +55,24 @@ export class IndustrialParksController { ); } + @ApiOperation({ + summary: 'KCN trong viewport bản đồ', + description: + 'Trả về GeoJSON FeatureCollection của KCN nằm trong bbox. Zoom < 12 chỉ trả centroid Point, zoom >= 12 kèm MultiPolygon outline.', + }) + @ApiResponse({ status: 200, description: 'GeoJSON FeatureCollection + meta' }) + @Get('parks/by-bbox') + async parksByBbox(@Query() dto: IndustrialParksBboxDto) { + return this.queryBus.execute( + new GetIndustrialParksByBboxQuery( + { south: dto.south, west: dto.west, north: dto.north, east: dto.east }, + dto.zoom, + dto.includeOsmRaw ?? false, + dto.limit ?? 1000, + ), + ); + } + // ── Park Operator endpoints ─────────────────────────────────────── @ApiOperation({ @@ -261,4 +284,68 @@ export class IndustrialParksController { ); return { success: true }; } + + // ── OSM review & promote (admin only) ──────────────────────────────── + + @ApiOperation({ + summary: 'Hàng đợi review OSM (admin)', + description: + 'Liệt kê các KCN có dataSource=OSM (chưa được duyệt). Admin có thể promote → public hoặc lock để bảo vệ.', + }) + @ApiResponse({ status: 200, description: 'Danh sách KCN đang chờ review' }) + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Get('parks/osm/pending') + async listOsmPending( + @Query('page') page?: string, + @Query('limit') limit?: string, + @Query('q') q?: string, + @Query('province') province?: string, + ) { + return this.queryBus.execute( + new ListOsmPendingQuery( + page ? parseInt(page, 10) : 1, + limit ? parseInt(limit, 10) : 50, + q, + province, + ), + ); + } + + @ApiOperation({ + summary: 'Promote KCN từ OSM (admin)', + description: + 'Chuyển dataSource OSM → OSM_PROMOTED và set isPublic=true. Có thể lock các field admin vừa edit.', + }) + @ApiResponse({ status: 200, description: 'Đã promote' }) + @ApiResponse({ status: 404, description: 'Không tìm thấy KCN' }) + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Post('parks/:id/osm/promote') + async promoteOsm( + @Param('id') id: string, + @Body() body: { lockFields?: string[] }, + ) { + return this.commandBus.execute( + new PromoteOsmParkCommand(id, body.lockFields ?? []), + ); + } + + @ApiOperation({ + summary: 'Lock/unlock OSM sync cho KCN (admin)', + description: 'Khi locked=true, sync cron sẽ bỏ qua row này hoàn toàn.', + }) + @ApiResponse({ status: 200, description: 'Đã cập nhật trạng thái lock' }) + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Post('parks/:id/osm/lock') + async lockOsm( + @Param('id') id: string, + @Body() body: { locked: boolean }, + ) { + return this.commandBus.execute(new LockOsmParkCommand(id, body.locked)); + } } diff --git a/apps/api/src/modules/industrial/presentation/dto/parks-bbox.dto.ts b/apps/api/src/modules/industrial/presentation/dto/parks-bbox.dto.ts new file mode 100644 index 0000000..8ebdd39 --- /dev/null +++ b/apps/api/src/modules/industrial/presentation/dto/parks-bbox.dto.ts @@ -0,0 +1,63 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { IsBoolean, IsInt, IsNumber, Max, Min } from 'class-validator'; + +/** + * Query params for `GET /industrial/parks/by-bbox`. + * + * The bbox covers the user's current Mapbox viewport. `zoom` controls + * whether the response includes polygon boundaries (zoom >= 12) or just + * Point centroids. + */ +export class IndustrialParksBboxDto { + @ApiProperty({ example: 8.0, description: 'Southern (min) latitude of the viewport' }) + @Type(() => Number) + @IsNumber() + @Min(-90) + @Max(90) + south!: number; + + @ApiProperty({ example: 102.0, description: 'Western (min) longitude of the viewport' }) + @Type(() => Number) + @IsNumber() + @Min(-180) + @Max(180) + west!: number; + + @ApiProperty({ example: 23.5, description: 'Northern (max) latitude of the viewport' }) + @Type(() => Number) + @IsNumber() + @Min(-90) + @Max(90) + north!: number; + + @ApiProperty({ example: 110.0, description: 'Eastern (max) longitude of the viewport' }) + @Type(() => Number) + @IsNumber() + @Min(-180) + @Max(180) + east!: number; + + @ApiProperty({ example: 8, description: 'Mapbox zoom level (0-22)' }) + @Type(() => Number) + @IsInt() + @Min(0) + @Max(22) + zoom!: number; + + @ApiProperty({ + required: false, + default: false, + description: 'Include raw OSM imports (admin only). Default: false.', + }) + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + includeOsmRaw?: boolean = false; + + @ApiProperty({ required: false, default: 1000 }) + @Type(() => Number) + @IsInt() + @Min(1) + @Max(5000) + limit?: number = 1000; +} diff --git a/apps/web/app/[locale]/(admin)/admin/industrial/osm-review/page.tsx b/apps/web/app/[locale]/(admin)/admin/industrial/osm-review/page.tsx new file mode 100644 index 0000000..37b6cde --- /dev/null +++ b/apps/web/app/[locale]/(admin)/admin/industrial/osm-review/page.tsx @@ -0,0 +1,500 @@ +'use client'; + +import { + CheckCircle, + Lock, + LockOpen, + RefreshCw, + ChevronLeft, + ChevronRight, + ExternalLink, + X, + Search, + AlertTriangle, +} from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Link } from '@/i18n/navigation'; +import { + industrialApi, + type OsmPendingItem, + type OsmPendingResult, +} from '@/lib/khu-cong-nghiep-api'; + +/** + * Admin OSM review queue. Lists parks with `dataSource = 'OSM'` (raw imports + * from the monthly Overpass sync). Admins decide what to do with each row: + * + * - Promote → flips `dataSource` to `OSM_PROMOTED` and `isPublic = true`, + * so the row shows up in the public catalog. Optionally lock specific + * fields so the next sync run won't overwrite them. + * - Lock / Unlock → toggles `osmLocked`. When locked, the row is skipped + * entirely by the sync cron. + * + * Fields that admins commonly want to lock after edits: `name`, `developer`, + * `description`, `targetIndustries`. We surface these as quick-pick checkboxes + * in the promote dialog, plus a free-text fallback for anything else. + */ + +const PAGE_SIZE = 50; + +const QUICK_LOCK_FIELDS: { key: string; label: string }[] = [ + { key: 'name', label: 'Tên KCN' }, + { key: 'developer', label: 'Chủ đầu tư' }, + { key: 'description', label: 'Mô tả' }, + { key: 'targetIndustries', label: 'Ngành mục tiêu' }, + { key: 'totalAreaHa', label: 'Diện tích' }, + { key: 'status', label: 'Trạng thái' }, +]; + +function formatTags(tags: Record | null): string { + if (!tags) return '—'; + // Surface the most useful keys first, then anything else, capped to keep + // the cell readable. Tag values are user-generated on OSM so we trim hard. + const priorityKeys = ['name', 'name:vi', 'name:en', 'operator', 'website']; + const ordered = [ + ...priorityKeys.filter((k) => k in tags), + ...Object.keys(tags).filter((k) => !priorityKeys.includes(k)), + ]; + return ordered + .slice(0, 4) + .map((k) => `${k}=${String(tags[k]).slice(0, 30)}`) + .join(', '); +} + +export default function AdminOsmReviewPage() { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [actionError, setActionError] = useState(null); + const [page, setPage] = useState(1); + + // Filters + const [searchInput, setSearchInput] = useState(''); + const [search, setSearch] = useState(''); + const [provinceFilter, setProvinceFilter] = useState(''); + + // Promote dialog state + const [promoteTarget, setPromoteTarget] = useState(null); + const [lockFields, setLockFields] = useState>(new Set()); + const [extraField, setExtraField] = useState(''); + const [actionLoading, setActionLoading] = useState(false); + + const fetchQueue = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await industrialApi.listOsmPending({ + page, + limit: PAGE_SIZE, + q: search || undefined, + province: provinceFilter || undefined, + }); + setResult(data); + } catch (e) { + setError(e instanceof Error ? e.message : 'Không thể tải hàng đợi OSM'); + } finally { + setLoading(false); + } + }, [page, search, provinceFilter]); + + useEffect(() => { + fetchQueue(); + }, [fetchQueue]); + + const submitSearch = (e: React.FormEvent) => { + e.preventDefault(); + setPage(1); + setSearch(searchInput.trim()); + }; + + const handleToggleLock = async (item: OsmPendingItem) => { + setActionError(null); + try { + await industrialApi.lockOsm(item.id, !item.osmLocked); + fetchQueue(); + } catch (e) { + setActionError(e instanceof Error ? e.message : 'Không thể cập nhật trạng thái lock'); + } + }; + + const openPromoteDialog = (item: OsmPendingItem) => { + setPromoteTarget(item); + // Default: lock the name (so the next OSM sync doesn't rename it back to + // whatever Overpass has). Admins can uncheck if they want OSM to win. + setLockFields(new Set(['name'])); + setExtraField(''); + setActionError(null); + }; + + const closePromoteDialog = () => { + setPromoteTarget(null); + setLockFields(new Set()); + setExtraField(''); + }; + + const handlePromote = async () => { + if (!promoteTarget) return; + setActionLoading(true); + setActionError(null); + try { + const fields = Array.from(lockFields); + const extras = extraField + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + await industrialApi.promoteOsm(promoteTarget.id, [...fields, ...extras]); + closePromoteDialog(); + fetchQueue(); + } catch (e) { + setActionError(e instanceof Error ? e.message : 'Promote thất bại. Vui lòng thử lại.'); + } finally { + setActionLoading(false); + } + }; + + const toggleLockField = (key: string) => { + setLockFields((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + return ( +
+ {actionError && ( +
+ {actionError} + +
+ )} + + {/* Header */} +
+
+

Review KCN từ OpenStreetMap

+

+ Xét duyệt các KCN nhập từ OSM (chưa public). Promote → public catalog hoặc lock để giữ nguyên dữ liệu. +

+
+ +
+ + {/* Filters */} + + +
+
+ + setSearchInput(e.target.value)} + className="h-8 text-sm" + /> +
+
+ + { + setPage(1); + setProvinceFilter(e.target.value); + }} + className="h-8 text-sm" + /> +
+ + {(search || provinceFilter) && ( + + )} +
+
+
+ + {/* Table */} + + + {loading ? ( +
+ +
+ ) : error ? ( +
+

{error}

+ +
+ ) : !result || result.data.length === 0 ? ( +
+ +

Không có KCN nào trong hàng đợi

+
+ ) : ( +
+ + + + + Tên KCN + + + Tỉnh + + + Diện tích (ha) + + + OSM + + + Tags + + + Trạng thái + + + Hành động + + + + + {result.data.map((item) => ( + + +
+ {item.name} +
+ {item.nameEn && ( +
+ {item.nameEn} +
+ )} +
+ + {item.province} + + + {item.totalAreaHa + ? new Intl.NumberFormat('vi-VN', { + maximumFractionDigits: 1, + }).format(item.totalAreaHa) + : '—'} + + +
+ {item.osmType?.toLowerCase() ?? '—'}/{item.osmId} + {item.latitude != null && item.longitude != null && ( + + + + )} +
+
+ + {formatTags(item.osmTags)} + + + {item.osmLocked ? ( + + + Locked + + ) : ( + + Pending + + )} + + +
+ {item.latitude != null && item.longitude != null && ( + + + + )} + + +
+
+
+ ))} +
+
+ + {result.totalPages > 1 && ( +
+ + Trang {result.page}/{result.totalPages} · {result.total} KCN + +
+ + +
+
+ )} +
+ )} +
+
+ + {/* Promote dialog */} + { + if (!open) closePromoteDialog(); + }} + > + + + Promote KCN từ OSM + + + + + KCN {promoteTarget?.name} sẽ được chuyển sang trạng thái public + (OSM_PROMOTED). Chọn các trường muốn khóa để bảo vệ chúng khỏi OSM sync sau này. + + + + + +
+
+

Khóa các trường:

+
+ {QUICK_LOCK_FIELDS.map(({ key, label }) => ( + + ))} +
+
+ +
+

+ Trường tùy chỉnh (cách nhau bởi dấu phẩy) +

+ setExtraField(e.target.value)} + className="h-8 text-sm" + /> +
+
+ + + + + +
+
+
+ ); +} diff --git a/apps/web/app/[locale]/(admin)/layout.tsx b/apps/web/app/[locale]/(admin)/layout.tsx index ad62c7a..811ae71 100644 --- a/apps/web/app/[locale]/(admin)/layout.tsx +++ b/apps/web/app/[locale]/(admin)/layout.tsx @@ -37,6 +37,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) { href: '/admin/audit-log' as const, label: 'Nhật ký kiểm toán', icon: ScrollText }, { 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/settings/ai' as const, label: t('adminNav.aiSettings'), icon: Sparkles }, ]; diff --git a/apps/web/app/[locale]/(public)/khu-cong-nghiep/page.tsx b/apps/web/app/[locale]/(public)/khu-cong-nghiep/page.tsx index 6508d3b..bf3e519 100644 --- a/apps/web/app/[locale]/(public)/khu-cong-nghiep/page.tsx +++ b/apps/web/app/[locale]/(public)/khu-cong-nghiep/page.tsx @@ -2,9 +2,9 @@ import { Factory, Map as MapIcon, List, Columns } from 'lucide-react'; import * as React from 'react'; +import { OsmParkBboxMap } from '@/components/khu-cong-nghiep/osm-park-bbox-map'; import { ParkCard } from '@/components/khu-cong-nghiep/park-card'; import { ParkFilterBar } from '@/components/khu-cong-nghiep/park-filter-bar'; -import { ParkMap } from '@/components/khu-cong-nghiep/park-map'; import { Button } from '@/components/ui/button'; import { useIndustrialParksSearch } from '@/lib/hooks/use-khu-cong-nghiep'; import type { SearchIndustrialParksParams } from '@/lib/khu-cong-nghiep-api'; @@ -111,12 +111,12 @@ export default function KhuCongNghiepPage() { {data.total} khu công nghiệp được tìm thấy

- {/* Map-only view */} + {/* Map-only view — bbox-driven, loads ALL parks in viewport */} {viewMode === 'map' && ( - + )} - {/* Split view: list left, sticky map right (lg+ only) */} + {/* Split view: list left, sticky bbox map right (lg+ only) */} {viewMode === 'split' && (
@@ -127,10 +127,7 @@ export default function KhuCongNghiepPage() {
- +
)} diff --git a/apps/web/components/khu-cong-nghiep/osm-park-bbox-map.tsx b/apps/web/components/khu-cong-nghiep/osm-park-bbox-map.tsx new file mode 100644 index 0000000..6e7cbcb --- /dev/null +++ b/apps/web/components/khu-cong-nghiep/osm-park-bbox-map.tsx @@ -0,0 +1,288 @@ +'use client'; + +/* eslint-disable import-x/no-named-as-default-member */ +import mapboxgl from 'mapbox-gl'; +import * as React from 'react'; +import 'mapbox-gl/dist/mapbox-gl.css'; +import { useMapboxStyle } from '@/lib/mapbox-style'; + +const VN_CENTER: [number, number] = [106.0, 16.0]; +const DEFAULT_ZOOM = 5; + +const SOURCE_ID = 'osm-parks'; +const CLUSTER_LAYER_ID = 'osm-parks-clusters'; +const CLUSTER_COUNT_LAYER_ID = 'osm-parks-cluster-count'; +const POINT_LAYER_ID = 'osm-parks-points'; +const BOUNDARY_FILL_LAYER_ID = 'osm-parks-boundaries-fill'; +const BOUNDARY_LINE_LAYER_ID = 'osm-parks-boundaries-line'; + +interface OsmParkBboxMapProps { + className?: string; + /** Override the bbox API path. Default = `${NEXT_PUBLIC_API_URL}/industrial/parks/by-bbox`. */ + apiPath?: string; + /** Show raw OSM-imported parks (admin tools). Default false. */ + includeOsmRaw?: boolean; +} + +/** + * Viewport-driven KCN map. Pulls parks from the bbox endpoint as the user + * pans/zooms — clusters at low zoom (<12), shows polygon outlines at + * high zoom. Designed for the public catalog where we have ~2000 OSM + * imports + 50 curated rows; loading the entire dataset eagerly would + * be wasteful. + */ +export function OsmParkBboxMap({ + className, + apiPath, + includeOsmRaw = false, +}: OsmParkBboxMapProps) { + const containerRef = React.useRef(null); + const mapRef = React.useRef(null); + const fetchAbortRef = React.useRef(null); + const mapStyle = useMapboxStyle(); + + const apiBase = React.useMemo(() => { + if (apiPath) return apiPath; + const apiUrl = process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3201/api/v1'; + return `${apiUrl}/industrial/parks/by-bbox`; + }, [apiPath]); + + // Capture the current includeOsmRaw value via a ref so the moveend + // handler always sees the latest without re-binding the listener. + const includeOsmRawRef = React.useRef(includeOsmRaw); + React.useEffect(() => { + includeOsmRawRef.current = includeOsmRaw; + }, [includeOsmRaw]); + + React.useEffect(() => { + if (!containerRef.current) return; + const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; + if (!token) return; + mapboxgl.accessToken = token; + + const map = new mapboxgl.Map({ + container: containerRef.current, + style: mapStyle, + center: VN_CENTER, + zoom: DEFAULT_ZOOM, + attributionControl: false, + }); + map.addControl(new mapboxgl.NavigationControl(), 'top-right'); + map.addControl( + new mapboxgl.AttributionControl({ compact: true, customAttribution: 'Data © OSM' }), + 'bottom-right', + ); + mapRef.current = map; + + const fetchParks = async () => { + try { + // Cancel any in-flight request — only the latest viewport matters. + fetchAbortRef.current?.abort(); + const controller = new AbortController(); + fetchAbortRef.current = controller; + const bounds = map.getBounds(); + if (!bounds) return; + const sw = bounds.getSouthWest(); + const ne = bounds.getNorthEast(); + const zoom = Math.round(map.getZoom()); + const params = new URLSearchParams({ + south: sw.lat.toString(), + west: sw.lng.toString(), + north: ne.lat.toString(), + east: ne.lng.toString(), + zoom: zoom.toString(), + ...(includeOsmRawRef.current ? { includeOsmRaw: 'true' } : {}), + }); + const res = await fetch(`${apiBase}?${params}`, { + credentials: 'include', + signal: controller.signal, + }); + if (!res.ok) return; + const fc = (await res.json()) as GeoJSON.FeatureCollection; + const src = map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource | undefined; + if (src) src.setData(fc); + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') return; + console.warn('[osm-park-bbox-map] fetch failed:', err); + } + }; + + map.on('load', () => { + // Empty source — populated by the first fetchParks() call below. + map.addSource(SOURCE_ID, { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] }, + cluster: true, + clusterRadius: 50, + clusterMaxZoom: 11, + clusterProperties: { + // No extra metrics yet — total count is built-in. + }, + }); + + // Cluster bubbles. Mapbox color parser only accepts literal colors, + // so we use hex constants matching our design-system primary token. + map.addLayer({ + id: CLUSTER_LAYER_ID, + type: 'circle', + source: SOURCE_ID, + filter: ['has', 'point_count'], + paint: { + 'circle-color': [ + 'step', + ['get', 'point_count'], + '#22c55e', // primary + 10, + '#f59e0b', + 50, + '#ef4444', + ], + 'circle-radius': [ + 'step', + ['get', 'point_count'], + 16, + 10, + 22, + 50, + 30, + ], + 'circle-stroke-width': 2, + 'circle-stroke-color': '#ffffff', + 'circle-opacity': 0.9, + }, + }); + + map.addLayer({ + id: CLUSTER_COUNT_LAYER_ID, + type: 'symbol', + source: SOURCE_ID, + filter: ['has', 'point_count'], + layout: { + 'text-field': ['get', 'point_count_abbreviated'], + 'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'], + 'text-size': 12, + }, + paint: { 'text-color': '#ffffff' }, + }); + + // Individual park markers (centroid Points) when not clustered. + map.addLayer({ + id: POINT_LAYER_ID, + type: 'circle', + source: SOURCE_ID, + filter: [ + 'all', + ['!', ['has', 'point_count']], + ['==', ['get', '_kind'], 'point'], + ], + paint: { + 'circle-color': '#22c55e', + 'circle-radius': 6, + 'circle-stroke-color': '#ffffff', + 'circle-stroke-width': 1.5, + }, + }); + + // Polygon outlines — only present when zoom >= 12 (server omits them + // at lower zoom). Fill layer for hit-test, line layer for stroke. + map.addLayer({ + id: BOUNDARY_FILL_LAYER_ID, + type: 'fill', + source: SOURCE_ID, + filter: ['==', ['get', '_kind'], 'polygon'], + paint: { + 'fill-color': '#22c55e', + 'fill-opacity': 0.18, + }, + }); + map.addLayer({ + id: BOUNDARY_LINE_LAYER_ID, + type: 'line', + source: SOURCE_ID, + filter: ['==', ['get', '_kind'], 'polygon'], + paint: { + 'line-color': '#22c55e', + 'line-width': 2, + 'line-opacity': 0.6, + }, + }); + + // Click handler on point/polygon → navigate to detail. + const onClick = (e: mapboxgl.MapLayerMouseEvent) => { + const f = e.features?.[0]; + if (!f) return; + const slug = (f.properties as Record | null)?.['slug']; + if (typeof slug === 'string' && slug.length > 0) { + window.location.href = `/vi/khu-cong-nghiep/${slug}`; + } + }; + map.on('click', POINT_LAYER_ID, onClick); + map.on('click', BOUNDARY_FILL_LAYER_ID, onClick); + // Cursor feedback + for (const layerId of [POINT_LAYER_ID, BOUNDARY_FILL_LAYER_ID, CLUSTER_LAYER_ID]) { + map.on('mouseenter', layerId, () => { + map.getCanvas().style.cursor = 'pointer'; + }); + map.on('mouseleave', layerId, () => { + map.getCanvas().style.cursor = ''; + }); + } + // Cluster click — zoom in + map.on('click', CLUSTER_LAYER_ID, (e) => { + const features = map.queryRenderedFeatures(e.point, { layers: [CLUSTER_LAYER_ID] }); + const clusterFeature = features[0]; + if (!clusterFeature) return; + const clusterId = clusterFeature.properties?.['cluster_id']; + const src = map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource; + if (typeof clusterId === 'number') { + src.getClusterExpansionZoom( + clusterId, + (err: Error | null | undefined, zoom: number | null | undefined) => { + if (err || zoom == null) return; + const geom = clusterFeature.geometry; + if (geom.type === 'Point') { + map.easeTo({ center: geom.coordinates as [number, number], zoom }); + } + }, + ); + } + }); + + // Initial fetch + listen to viewport changes. + void fetchParks(); + }); + + map.on('moveend', () => { + void fetchParks(); + }); + + return () => { + fetchAbortRef.current?.abort(); + map.remove(); + mapRef.current = null; + }; + // We intentionally do NOT depend on includeOsmRaw — the ref-based + // approach avoids tearing down the map on every prop tick. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [apiBase]); + + // Sync mapStyle (theme switch) without rebuilding the map. + React.useEffect(() => { + const map = mapRef.current; + if (!map) return; + map.setStyle(mapStyle); + }, [mapStyle]); + + const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; + + return ( +
+
+ {!hasToken && ( +
+ Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để hiển thị bản đồ +
+ )} +
+ ); +} diff --git a/apps/web/lib/khu-cong-nghiep-api.ts b/apps/web/lib/khu-cong-nghiep-api.ts index 36ac607..edfbc91 100644 --- a/apps/web/lib/khu-cong-nghiep-api.ts +++ b/apps/web/lib/khu-cong-nghiep-api.ts @@ -233,6 +233,46 @@ export interface SearchIndustrialParksParams { limit?: number; } +// ─── OSM Admin Types ──────────────────────────────────── + +export interface OsmPendingItem { + id: string; + slug: string; + name: string; + nameEn: string | null; + province: string; + district: string; + region: string; + status: string; + /** OSM relation/way/node id, serialised as string (BigInt). */ + osmId: string; + osmType: 'NODE' | 'WAY' | 'RELATION' | null; + /** Raw OSM tags object — varies wildly per row. */ + osmTags: Record | null; + totalAreaHa: number; + developer: string; + operator: string | null; + osmLocked: boolean; + lastSyncedAt: string | null; + latitude: number | null; + longitude: number | null; +} + +export interface OsmPendingResult { + data: OsmPendingItem[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface ListOsmPendingParams { + page?: number; + limit?: number; + q?: string; + province?: string; +} + // ─── Labels ───────────────────────────────────────────── export const PARK_STATUS_LABELS: Record = { @@ -328,4 +368,31 @@ export const industrialApi = { deletePark: (id: string) => apiClient.delete<{ success: boolean }>(`/industrial/parks/${id}`), + + // ─── OSM admin endpoints (ADMIN role only) ─────────── + + listOsmPending: (params: ListOsmPendingParams = {}) => { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== '') query.append(key, String(value)); + }); + const qs = query.toString(); + return apiClient.get( + `/industrial/parks/osm/pending${qs ? `?${qs}` : ''}`, + ); + }, + + /** Promote OSM row → public OSM_PROMOTED. Optionally lock fields the admin + * just edited so the next sync run leaves them alone. */ + promoteOsm: (id: string, lockFields: string[] = []) => + apiClient.post<{ id: string }>(`/industrial/parks/${id}/osm/promote`, { + lockFields, + }), + + /** Toggle the row-level OSM lock. When `true`, sync skips this row entirely. */ + lockOsm: (id: string, locked: boolean) => + apiClient.post<{ id: string; locked: boolean }>( + `/industrial/parks/${id}/osm/lock`, + { locked }, + ), }; diff --git a/package.json b/package.json index b149cdd..85ae795 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,9 @@ "@eslint/js": "^9.39.4", "@next/eslint-plugin-next": "^16.2.4", "@playwright/test": "^1.59.1", + "@turf/area": "^7.3.5", + "@turf/centroid": "^7.3.5", + "@types/geojson": "^7946.0.16", "@types/pg": "^8.20.0", "dependency-cruiser": "^17.3.10", "dotenv": "^17.4.1", @@ -71,6 +74,7 @@ "globals": "^17.4.0", "husky": "^9.1.7", "lint-staged": "^16.4.0", + "osmtogeojson": "3.0.0-beta.5", "pg": "^8.20.0", "prettier": "^3.8.1", "prisma": "^7.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a58863..0b3ff01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,15 @@ importers: '@playwright/test': specifier: ^1.59.1 version: 1.59.1 + '@turf/area': + specifier: ^7.3.5 + version: 7.3.5 + '@turf/centroid': + specifier: ^7.3.5 + version: 7.3.5 + '@types/geojson': + specifier: ^7946.0.16 + version: 7946.0.16 '@types/pg': specifier: ^8.20.0 version: 8.20.0 @@ -63,6 +72,9 @@ importers: lint-staged: specifier: ^16.4.0 version: 16.4.0 + osmtogeojson: + specifier: 3.0.0-beta.5 + version: 3.0.0-beta.5 pg: specifier: ^8.20.0 version: 8.20.0 @@ -1509,6 +1521,10 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@mapbox/geojson-rewind@0.5.2': + resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==} + hasBin: true + '@mapbox/jsonlint-lines-primitives@2.0.2': resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==} engines: {node: '>= 0.6'} @@ -3143,6 +3159,18 @@ packages: cpu: [arm64] os: [win32] + '@turf/area@7.3.5': + resolution: {integrity: sha512-sSn80wPT7XfBIDN3vurCPxhk9W4U8ozS/XImSqeLN8qveTICOxzZkhsGDMp0CuncaN+plWut4a2TdNM7mzZB6Q==} + + '@turf/centroid@7.3.5': + resolution: {integrity: sha512-hkWaqwGFdOn6Tf0EWfn2yn1XZ1FWE1h2C5ZWstDMu/FxYO5DB+YjlmOFPl4K6SmSOEgdV07eK2vDCyPeTHqKGA==} + + '@turf/helpers@7.3.5': + resolution: {integrity: sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg==} + + '@turf/meta@7.3.5': + resolution: {integrity: sha512-r+ohqxoyqeigFB0oFrQx/YEHIkOKqcKpCjvZkvZs7Tkv+IFco5MezAd2zd4rzK+0DfFgDP3KpJc7HqrYjvEjhg==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -3616,12 +3644,20 @@ packages: '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 prom-client: ^15.0.0 + '@xmldom/xmldom@0.8.3': + resolution: {integrity: sha512-Lv2vySXypg4nfa51LY1nU8yDAGo/5YwF+EY/rUZgIbfvwVARcd67ttCM8SMsTeJy51YhHYavEq+FS6R0hW9PFQ==} + engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + JSONStream@0.8.0: + resolution: {integrity: sha512-PiV28BpoUorz9kKFwRbD7+wg0t/k0ITHKn0DgCU44YZ/GaGAZRPt9q5PzoifC85gE55SEPIdMu0Labfxevj8cw==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -4198,6 +4234,9 @@ packages: core-js@3.49.0: resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -4413,9 +4452,15 @@ packages: dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + domelementtype@1.3.1: + resolution: {integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==} + domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + domhandler@2.2.1: + resolution: {integrity: sha512-MFFBQFGkyTuNe3vL9WEw9JdlCwIoBYpOGESLeZAvc/jClYNsOl6P1KzevJbWg76GovdEycfR7/2/Ra7NnqtMKw==} + domhandler@5.0.3: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} @@ -4423,6 +4468,9 @@ packages: dompurify@3.4.0: resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==} + domutils@1.3.0: + resolution: {integrity: sha512-1UdPmldjSGewOuWE40YYFZB1Q4im4LZoCMXGYeTeLz3R9hvxrDYJPRcPHXR4yBbubQebgGNCY2hwpJxmAiUMzQ==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -4944,6 +4992,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + geojson-numeric@0.2.1: + resolution: {integrity: sha512-rvItMp3W7pe16o2EQTnRw54v6WHdiE4bYjUsdr3FZskFb6oPC7gjLe4zginP+Wd1B/HLl2acTukfn16Lmwn7lg==} + hasBin: true + geojson-vt@4.0.2: resolution: {integrity: sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==} @@ -4970,6 +5022,10 @@ packages: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} @@ -5110,6 +5166,9 @@ packages: htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + htmlparser2@3.5.1: + resolution: {integrity: sha512-9ouaQ6sjVJZS4NhPC65zNm2JCJotiH6BVm6iFvI90hRcsIEISMrgjqMUrPpU9G1VS4vTspH4dyaqSRf6JLQPbg==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -5290,6 +5349,9 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + isarray@0.0.1: + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -5376,6 +5438,10 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonparse@0.0.5: + resolution: {integrity: sha512-fw7Q/8gFR8iSekUi9I+HqWIap6mywuoe7hQIg3buTVjuZgALKj4HAmm0X6f+TaL4c9NJbvyFQdaI2ppr5p6dnQ==} + engines: {'0': node >= 0.2.0} + jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} @@ -5865,6 +5931,9 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + optimist@0.3.7: + resolution: {integrity: sha512-TCx0dXQzVtSCg2OgY/bO9hjM9cV4XYx09TVK+s3+FhkjT6LovsLe+pPMzpWf+6yXK/hUizs2gUoTw3jHM0VaTQ==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -5873,6 +5942,14 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} + osm-polygon-features@0.9.2: + resolution: {integrity: sha512-5zNEFCq+G6X2TDkqbKYLF1+GtWVCCLA8zX+FVhSogsiTRsGquyaGRy5cYNW4BE3ci0MKOLvNTkFNsjsCNtgz0A==} + + osmtogeojson@3.0.0-beta.5: + resolution: {integrity: sha512-izvaUWnunrYvMB4LB0ZN15O1+g90c628yHS4SeSR3daVSBF9vdTHL7iVHfg0wEr1uEYjQ+lMJHCiYFusL5yKVg==} + engines: {node: '>=0.5'} + hasBin: true + otplib@13.4.0: resolution: {integrity: sha512-RUcYcRMCgRWhUE/XabRppXpUwCwaWBNHe5iPXhdvP8wwDGpGpsIf/kxX/ec3zFsOaM1Oq8lEhUqDwk6W7DHkwg==} @@ -5986,6 +6063,10 @@ packages: pause@0.0.1: resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + pbf@3.3.0: + resolution: {integrity: sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==} + hasBin: true + pbf@4.0.1: resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==} hasBin: true @@ -6337,6 +6418,9 @@ packages: read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + readable-stream@1.1.14: + resolution: {integrity: sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -6720,6 +6804,9 @@ packages: resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} engines: {node: '>=20'} + string_decoder@0.10.31: + resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -6893,9 +6980,18 @@ packages: resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} engines: {node: '>=20'} + through@2.2.7: + resolution: {integrity: sha512-JIR0m0ybkmTcR8URann+HbwKmodP+OE8UCbsifQDYMLD5J3em1Cdn3MYPpbEd5elGDwmP98T+WbqP/tvzA5Mjg==} + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tiny-osmpbf@0.1.0: + resolution: {integrity: sha512-Sl0xuDdM0+bnrYPhTAWnQ5eui8+2cpYCnsBxq0EFR1/IgmfB7+FiC23I8aa7tdP4AjaWvBUMK34kfXdY6C1LCQ==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -7306,6 +7402,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wordwrap@0.0.3: + resolution: {integrity: sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw==} + engines: {node: '>=0.4.0'} + wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -8738,6 +8838,11 @@ snapshots: '@lukeed/csprng@1.1.0': {} + '@mapbox/geojson-rewind@0.5.2': + dependencies: + get-stream: 6.0.1 + minimist: 1.2.8 + '@mapbox/jsonlint-lines-primitives@2.0.2': {} '@mapbox/mapbox-gl-supported@3.0.0': {} @@ -10519,6 +10624,31 @@ snapshots: '@turbo/windows-arm64@2.9.4': optional: true + '@turf/area@7.3.5': + dependencies: + '@turf/helpers': 7.3.5 + '@turf/meta': 7.3.5 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + + '@turf/centroid@7.3.5': + dependencies: + '@turf/helpers': 7.3.5 + '@turf/meta': 7.3.5 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + + '@turf/helpers@7.3.5': + dependencies: + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + + '@turf/meta@7.3.5': + dependencies: + '@turf/helpers': 7.3.5 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -11103,10 +11233,17 @@ snapshots: '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) prom-client: 15.1.3 + '@xmldom/xmldom@0.8.3': {} + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} + JSONStream@0.8.0: + dependencies: + jsonparse: 0.0.5 + through: 2.2.7 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -11640,6 +11777,8 @@ snapshots: core-js@3.49.0: optional: true + core-util-is@1.0.3: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -11834,8 +11973,14 @@ snapshots: domhandler: 5.0.3 entities: 4.5.0 + domelementtype@1.3.1: {} + domelementtype@2.3.0: {} + domhandler@2.2.1: + dependencies: + domelementtype: 1.3.1 + domhandler@5.0.3: dependencies: domelementtype: 2.3.0 @@ -11845,6 +11990,10 @@ snapshots: '@types/trusted-types': 2.0.7 optional: true + domutils@1.3.0: + dependencies: + domelementtype: 1.3.1 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -12504,6 +12653,11 @@ snapshots: gensync@1.0.0-beta.2: {} + geojson-numeric@0.2.1: + dependencies: + concat-stream: 2.0.0 + optimist: 0.3.7 + geojson-vt@4.0.2: {} get-caller-file@2.0.5: {} @@ -12534,6 +12688,8 @@ snapshots: dependencies: pump: 3.0.4 + get-stream@6.0.1: {} + get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 @@ -12706,6 +12862,13 @@ snapshots: domutils: 3.2.2 entities: 7.0.1 + htmlparser2@3.5.1: + dependencies: + domelementtype: 1.3.1 + domhandler: 2.2.1 + domutils: 1.3.0 + readable-stream: 1.1.14 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -12881,6 +13044,8 @@ snapshots: is-unicode-supported@0.1.0: {} + isarray@0.0.1: {} + isexe@2.0.0: {} iterare@1.2.1: {} @@ -12966,6 +13131,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonparse@0.0.5: {} + jsonwebtoken@9.0.3: dependencies: jws: 4.0.1 @@ -13462,6 +13629,10 @@ snapshots: dependencies: mimic-function: 5.0.1 + optimist@0.3.7: + dependencies: + wordwrap: 0.0.3 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -13483,6 +13654,22 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + osm-polygon-features@0.9.2: {} + + osmtogeojson@3.0.0-beta.5: + dependencies: + '@mapbox/geojson-rewind': 0.5.2 + '@xmldom/xmldom': 0.8.3 + JSONStream: 0.8.0 + concat-stream: 2.0.0 + geojson-numeric: 0.2.1 + htmlparser2: 3.5.1 + optimist: 0.3.7 + osm-polygon-features: 0.9.2 + tiny-osmpbf: 0.1.0 + optionalDependencies: + '@types/geojson': 7946.0.16 + otplib@13.4.0: dependencies: '@otplib/core': 13.4.0 @@ -13603,6 +13790,11 @@ snapshots: pause@0.0.1: {} + pbf@3.3.0: + dependencies: + ieee754: 1.2.1 + resolve-protobuf-schema: 2.1.0 + pbf@4.0.1: dependencies: resolve-protobuf-schema: 2.1.0 @@ -13987,6 +14179,13 @@ snapshots: dependencies: pify: 2.3.0 + readable-stream@1.1.14: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 0.0.1 + string_decoder: 0.10.31 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -14475,6 +14674,8 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 + string_decoder@0.10.31: {} + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -14706,8 +14907,17 @@ snapshots: dependencies: real-require: 0.2.0 + through@2.2.7: {} + + tiny-inflate@1.0.3: {} + tiny-invariant@1.3.3: {} + tiny-osmpbf@0.1.0: + dependencies: + pbf: 3.3.0 + tiny-inflate: 1.0.3 + tinybench@2.9.0: {} tinyexec@1.1.1: {} @@ -15176,6 +15386,8 @@ snapshots: word-wrap@1.2.5: {} + wordwrap@0.0.3: {} + wordwrap@1.0.0: {} wrap-ansi@6.2.0: diff --git a/scripts/sync-osm-industrial-parks.ts b/scripts/sync-osm-industrial-parks.ts new file mode 100644 index 0000000..9dc15bc --- /dev/null +++ b/scripts/sync-osm-industrial-parks.ts @@ -0,0 +1,440 @@ +/** + * OSM → goodgo Industrial Park bulk sync (PR 2/4 of OSM-sync project). + * + * Usage: + * NODE_OPTIONS="-r dotenv/config" DOTENV_CONFIG_PATH=.env \ + * pnpm tsx scripts/sync-osm-industrial-parks.ts [--dry-run] [--chunk=north] + * + * Flags: + * --dry-run Fetch from Overpass + show counts, don't write to DB. + * --chunk=NAME Process only one chunk (north|northCentral|southCentral|south). + * Default: process all four. + * + * Strategy: + * • Vietnam is split into 4 horizontal bbox slices (Overpass times out + * on the whole country at once). + * • For each chunk we ask Overpass for `landuse=industrial` ways + + * relations + nodes within the bbox, with inline geometry. + * • osmtogeojson normalises the OSM JSON into a GeoJSON FeatureCollection. + * • For each Feature we upsert a row keyed on `osmId`. The schema + * guarantees uniqueness via the constraint added in PR 1. + * • PostGIS columns (`location` Point + `boundary` MultiPolygon) are + * written via raw SQL because Prisma cannot manage Geometry types. + * • Nodes get `location` only — no boundary. + * • Conflict resolution: respect `osmLocked` and `lockedFields` from PR 1. + */ +import 'dotenv/config'; +import { createId } from '@paralleldrive/cuid2'; +import area from '@turf/area'; +import centroid from '@turf/centroid'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { type Prisma, PrismaClient } from '@prisma/client'; +import type { Feature, MultiPolygon, Polygon, Point } from 'geojson'; +import osmtogeojson from 'osmtogeojson'; +import pg from 'pg'; + +const generateCuid = (): Promise => Promise.resolve(createId()); + +// ─── Setup ──────────────────────────────────────────────────────────────── +const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +const OVERPASS_URL = + process.env['OVERPASS_URL'] ?? 'https://overpass-api.de/api/interpreter'; + +interface BBox { + south: number; + west: number; + north: number; + east: number; +} + +/** Vietnam split horizontally — Overpass timeouts at country scale. */ +const CHUNKS: Record = { + north: { south: 19.0, west: 102.0, north: 23.5, east: 110.0 }, + northCentral: { south: 16.5, west: 102.0, north: 19.0, east: 110.0 }, + southCentral: { south: 13.0, west: 102.0, north: 16.5, east: 110.0 }, + south: { south: 8.0, west: 102.0, north: 13.0, east: 110.0 }, +}; + +// ─── CLI flags ──────────────────────────────────────────────────────────── +const argv = process.argv.slice(2); +const dryRun = argv.includes('--dry-run'); +const chunkArg = argv.find((a) => a.startsWith('--chunk='))?.slice('--chunk='.length); + +// ─── Province / region heuristic from centroid ──────────────────────────── + +/** Approximate centroid → region using latitude bands. Good enough as a + * fallback when the OSM tags lack `addr:state`; admin can correct later. */ +function guessRegion(lat: number): 'NORTH' | 'CENTRAL' | 'SOUTH' { + if (lat >= 19) return 'NORTH'; + if (lat >= 13) return 'CENTRAL'; + return 'SOUTH'; +} + +// ─── Helpers ────────────────────────────────────────────────────────────── + +function slugify(name: string, osmId: string): string { + const base = name + .toLowerCase() + .replace(/đ/g, 'd') + // strip Vietnamese diacritics + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); + return `${base || 'kcn'}-osm-${osmId}`; +} + +interface OverpassResult { + elements: unknown[]; +} + +async function fetchChunk(name: string, bbox: BBox): Promise { + const query = ` + [out:json][timeout:180]; + ( + way["landuse"="industrial"](${bbox.south},${bbox.west},${bbox.north},${bbox.east}); + relation["landuse"="industrial"](${bbox.south},${bbox.west},${bbox.north},${bbox.east}); + node["landuse"="industrial"](${bbox.south},${bbox.west},${bbox.north},${bbox.east}); + ); + out body geom; + `; + console.log(` → fetching chunk "${name}" from Overpass…`); + const start = Date.now(); + // Overpass expects the query in a `data=` body field with form-encoding. + const res = await fetch(OVERPASS_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'goodgo-osm-sync/1.0 (https://goodgo.vn)', + }, + body: 'data=' + encodeURIComponent(query), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Overpass returned ${res.status}: ${body.slice(0, 200)}`); + } + const json = (await res.json()) as OverpassResult; + console.log( + ` ← ${name}: ${json.elements?.length ?? 0} elements in ${( + (Date.now() - start) / + 1000 + ).toFixed(1)}s`, + ); + return json; +} + +// ─── Per-feature upsert ────────────────────────────────────────────────── + +interface ParsedFeature { + osmId: bigint; + osmType: 'NODE' | 'WAY' | 'RELATION'; + osmVersion: number | null; + name: string; + nameEn: string | null; + developer: string; + operator: string | null; + tags: Record; + centroid: { lng: number; lat: number }; + /** GeoJSON Polygon|MultiPolygon as an unparsed JSON string for ST_GeomFromGeoJSON. */ + boundaryGeoJson: string | null; + totalAreaHa: number; + province: string; + district: string; + address: string; +} + +/** OSM tag keys whose Vietnamese-province values we recognise. */ +const VN_PROVINCE_HINTS = ['addr:province', 'addr:state', 'addr:region', 'is_in:province']; + +function parseFeature( + feat: Feature, +): ParsedFeature | null { + const propsRaw = feat.properties as Record | null; + if (!propsRaw) return null; + const idStr = String(propsRaw['id'] ?? ''); + // osmtogeojson encodes id as "way/123", "relation/456", "node/789" + const slashIdx = idStr.indexOf('/'); + if (slashIdx < 0) return null; + const typeRaw = idStr.slice(0, slashIdx).toUpperCase(); + if (typeRaw !== 'NODE' && typeRaw !== 'WAY' && typeRaw !== 'RELATION') { + return null; + } + const osmType = typeRaw as 'NODE' | 'WAY' | 'RELATION'; + const osmId = BigInt(idStr.slice(slashIdx + 1)); + const tagsRaw = propsRaw['tags']; + const tags: Record = + tagsRaw && typeof tagsRaw === 'object' + ? (tagsRaw as Record) + : (propsRaw as Record); + + const name = tags['name:vi'] ?? tags['name'] ?? null; + // Skip purely unnamed industrial polygons — too noisy for our catalog. + if (!name) return null; + + const operator = tags['operator'] ?? null; + const developer = operator ?? tags['operator:wikidata'] ?? 'Chưa xác định'; + + // Derive centroid + (optional) boundary GeoJSON. + let cLng: number; + let cLat: number; + let boundaryGeoJson: string | null = null; + let totalAreaHa = 0; + + if (feat.geometry.type === 'Point') { + [cLng, cLat] = feat.geometry.coordinates; + } else { + // Polygon or MultiPolygon + const c = centroid(feat as Feature); + [cLng, cLat] = c.geometry.coordinates; + // Normalise to MultiPolygon for storage in the boundary column. + const geom: MultiPolygon = + feat.geometry.type === 'Polygon' + ? { type: 'MultiPolygon', coordinates: [feat.geometry.coordinates] } + : feat.geometry; + boundaryGeoJson = JSON.stringify(geom); + // turf/area returns m² — convert to ha (1 ha = 10 000 m²). + totalAreaHa = Math.round((area(feat as Feature) / 10000) * 100) / 100; + } + + const province = + VN_PROVINCE_HINTS.map((k) => tags[k]).find(Boolean) ?? + tags['addr:city'] ?? + 'Chưa xác định'; + const district = tags['addr:district'] ?? tags['addr:suburb'] ?? ''; + const address = + tags['addr:full'] ?? + [tags['addr:housenumber'], tags['addr:street']].filter(Boolean).join(' ') ?? + ''; + + const versionRaw = propsRaw['version']; + const osmVersion = typeof versionRaw === 'number' ? versionRaw : null; + + return { + osmId, + osmType, + osmVersion, + name, + nameEn: tags['name:en'] ?? null, + developer, + operator, + tags, + centroid: { lng: cLng, lat: cLat }, + boundaryGeoJson, + totalAreaHa, + province, + district, + address, + }; +} + +interface UpsertStats { + inserted: number; + updated: number; + skipped: number; + locked: number; +} + +async function upsertFeature( + parsed: ParsedFeature, + stats: UpsertStats, +): Promise { + // Cheap pre-flight: if a row already exists with osmLocked=true, skip. + const existing = await prisma.industrialPark.findUnique({ + where: { osmId: parsed.osmId }, + select: { id: true, osmLocked: true, lockedFields: true, dataSource: true }, + }); + + if (existing?.osmLocked) { + stats.locked += 1; + return; + } + + const region = guessRegion(parsed.centroid.lat); + const slug = slugify(parsed.name, parsed.osmId.toString()); + + if (!existing) { + // INSERT path via raw SQL — Prisma's create() can't satisfy the + // `location` PostGIS Geometry NOT NULL column (it's `Unsupported`). + const cuid = await generateCuid(); + const tagsJson = JSON.stringify(parsed.tags); + const boundarySql = parsed.boundaryGeoJson + ? `ST_Multi(ST_GeomFromGeoJSON('${parsed.boundaryGeoJson.replace(/'/g, "''")}'))` + : 'NULL'; + // Use a parameterised statement for safety + ST_* exprs inlined. + await prisma.$executeRawUnsafe( + ` + INSERT INTO "IndustrialPark" ( + id, name, "nameEn", slug, developer, operator, status, + location, address, district, province, region, + "totalAreaHa", "leasableAreaHa", "occupancyRate", "remainingAreaHa", + "tenantCount", "targetIndustries", + "osmId", "osmType", "osmVersion", "osmTags", + "dataSource", "isPublic", "lastSyncedAt", "createdAt", "updatedAt", + boundary + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7::"IndustrialParkStatus", + ST_SetSRID(ST_MakePoint($8, $9), 4326), $10, $11, $12, $13::"VietnamRegion", + $14, $15, $16, $17, + $18, $19::text[], + $20::bigint, $21::"IndustrialParkOsmType", $22, $23::jsonb, + $24::"IndustrialParkDataSource", $25, $26, NOW(), NOW(), + ${boundarySql} + ) + `, + cuid, + parsed.name, + parsed.nameEn, + slug, + parsed.developer, + parsed.operator, + 'OPERATIONAL', + parsed.centroid.lng, + parsed.centroid.lat, + parsed.address, + parsed.district, + parsed.province, + region, + parsed.totalAreaHa, + parsed.totalAreaHa, + 0, + parsed.totalAreaHa, + 0, + [], + parsed.osmId.toString(), + parsed.osmType, + parsed.osmVersion, + tagsJson, + 'OSM', + false, + new Date(), + ); + stats.inserted += 1; + } else { + // UPDATE path. Honour `lockedFields` — preserve listed columns. + const locked = new Set(existing.lockedFields); + const data: Prisma.IndustrialParkUpdateInput = { + osmTags: parsed.tags as Prisma.InputJsonValue, + osmVersion: parsed.osmVersion, + lastSyncedAt: new Date(), + }; + if (!locked.has('name')) data.name = parsed.name; + if (!locked.has('nameEn')) data.nameEn = parsed.nameEn; + if (!locked.has('operator')) data.operator = parsed.operator; + if (!locked.has('developer')) data.developer = parsed.developer; + if (!locked.has('address')) data.address = parsed.address; + if (!locked.has('district')) data.district = parsed.district; + if (!locked.has('province')) data.province = parsed.province; + if (!locked.has('region')) data.region = region; + if (!locked.has('totalAreaHa') && parsed.totalAreaHa > 0) { + data.totalAreaHa = parsed.totalAreaHa; + } + await prisma.industrialPark.update({ + where: { osmId: parsed.osmId }, + data, + }); + stats.updated += 1; + } + + // For UPDATE path, also refresh the geometry columns (INSERT already + // wrote them inline). Skip for fresh INSERTs we just wrote. + if (existing) { + if (parsed.boundaryGeoJson != null) { + await prisma.$executeRaw` + UPDATE "IndustrialPark" + SET + location = ST_SetSRID(ST_MakePoint(${parsed.centroid.lng}, ${parsed.centroid.lat}), 4326), + boundary = ST_Multi(ST_GeomFromGeoJSON(${parsed.boundaryGeoJson})) + WHERE "osmId" = ${parsed.osmId} + `; + } else { + await prisma.$executeRaw` + UPDATE "IndustrialPark" + SET location = ST_SetSRID(ST_MakePoint(${parsed.centroid.lng}, ${parsed.centroid.lat}), 4326) + WHERE "osmId" = ${parsed.osmId} + `; + } + } +} + +// ─── Main ───────────────────────────────────────────────────────────────── + +async function main() { + const chunkNames = chunkArg + ? [chunkArg] + : (Object.keys(CHUNKS) as Array); + const totalStats: UpsertStats = { inserted: 0, updated: 0, skipped: 0, locked: 0 }; + let parseSkipped = 0; + + console.log( + `🌍 OSM industrial-park sync — chunks: ${chunkNames.join(', ')} ` + + `(dry-run: ${dryRun})\n`, + ); + + for (const chunkName of chunkNames) { + const bbox = CHUNKS[chunkName as keyof typeof CHUNKS]; + if (!bbox) { + console.warn(` ⚠️ unknown chunk "${chunkName}" — skipping`); + continue; + } + const overpass = await fetchChunk(chunkName, bbox); + // osmtogeojson is loosely typed; it accepts the raw Overpass JSON. + const fc = osmtogeojson(overpass) as unknown as { + features: Feature[]; + }; + const parsed: ParsedFeature[] = []; + for (const f of fc.features) { + const p = parseFeature(f); + if (p) parsed.push(p); + else parseSkipped += 1; + } + console.log( + ` ▸ ${chunkName}: ${parsed.length} named industrial parks ` + + `(skipped ${fc.features.length - parsed.length} unnamed/malformed)`, + ); + + if (dryRun) { + console.log( + ` ▸ first 3 names: ${parsed + .slice(0, 3) + .map((p) => p.name) + .join(', ')}`, + ); + continue; + } + + for (const p of parsed) { + try { + await upsertFeature(p, totalStats); + } catch (err) { + totalStats.skipped += 1; + console.warn( + ` ⚠️ upsert failed for ${p.osmType.toLowerCase()}/${p.osmId}: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + } + } + } + + console.log('\n✓ Sync complete.'); + console.log( + ` inserted: ${totalStats.inserted} ` + + `· updated: ${totalStats.updated} ` + + `· locked-skipped: ${totalStats.locked} ` + + `· errors: ${totalStats.skipped} ` + + `· parse-skipped: ${parseSkipped}`, + ); +} + +main() + .catch((err) => { + console.error('❌ Sync failed:', err); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + await pool.end(); + });