feat(osm): Phase 6 — proximity materialized views + refresh cron
* `mv_park_nearest_poi` — for each IndustrialPark, the 3 nearest POI of six priority categories (HOSPITAL/BANK/GAS/BUS/METRO/POLICE) within 5km. Refreshed weekly. Pre-aggregated 6,513 rows from the live catalog so the KCN sidebar can render in <50ms instead of running ST_Distance for every page hit. * `mv_poi_density_by_province` — count of POI per (province, category) for analytics heatmaps. * `OsmSyncService.refreshMaterializedViews()` calls `REFRESH MATERIALIZED VIEW CONCURRENTLY` so reads aren't blocked. * New cron entry `weeklyRefreshViews` (Sun 04:00 ICT) and admin endpoint `POST /admin/osm/refresh-views` for on-demand refresh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -77,4 +77,19 @@ export class OsmSyncCronService {
|
|||||||
await this.osmSync.run({ layer: 'industrial-parks', chunk, wait: true });
|
await this.osmSync.run({ layer: 'industrial-parks', chunk, wait: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Refresh proximity / density mat views every Sunday after the weekly
|
||||||
|
* ward sync had time to settle. Always runs (cheap, no Overpass). */
|
||||||
|
@Cron('0 4 * * 0', { timeZone: 'Asia/Ho_Chi_Minh' })
|
||||||
|
async weeklyRefreshViews(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.osmSync.refreshMaterializedViews();
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Refresh views failed: ${err instanceof Error ? err.message : err}`,
|
||||||
|
err instanceof Error ? err.stack : undefined,
|
||||||
|
'OsmSyncCronService',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,22 @@ export class OsmSyncService {
|
|||||||
private readonly logger: LoggerService,
|
private readonly logger: LoggerService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the proximity / density materialized views. Called by the
|
||||||
|
* cron and from the admin "Refresh views" button. Runs concurrently
|
||||||
|
* (`CONCURRENTLY`) so reads aren't blocked.
|
||||||
|
*/
|
||||||
|
async refreshMaterializedViews(): Promise<void> {
|
||||||
|
this.logger.log('Refreshing materialized views', 'OsmSyncService');
|
||||||
|
await this.prisma.$executeRawUnsafe(
|
||||||
|
`REFRESH MATERIALIZED VIEW CONCURRENTLY "mv_park_nearest_poi"`,
|
||||||
|
);
|
||||||
|
await this.prisma.$executeRawUnsafe(
|
||||||
|
`REFRESH MATERIALIZED VIEW "mv_poi_density_by_province"`,
|
||||||
|
);
|
||||||
|
this.logger.log('Materialized views refreshed', 'OsmSyncService');
|
||||||
|
}
|
||||||
|
|
||||||
/** Look up a sync layer by its (layer, category) tuple. */
|
/** Look up a sync layer by its (layer, category) tuple. */
|
||||||
findLayer(layer: string, category?: string | null): OsmSyncLayerDef | undefined {
|
findLayer(layer: string, category?: string | null): OsmSyncLayerDef | undefined {
|
||||||
return SYNC_LAYERS.find(
|
return SYNC_LAYERS.find(
|
||||||
|
|||||||
@@ -61,4 +61,14 @@ export class OsmSyncController {
|
|||||||
new TriggerOsmSyncCommand(dto.layer, dto.category, dto.chunk),
|
new TriggerOsmSyncCommand(dto.layer, dto.category, dto.chunk),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Refresh proximity materialized views',
|
||||||
|
description: 'Recomputes mv_park_nearest_poi + mv_poi_density_by_province.',
|
||||||
|
})
|
||||||
|
@Post('refresh-views')
|
||||||
|
async refreshViews(): Promise<{ ok: true }> {
|
||||||
|
await this.osmSync.refreshMaterializedViews();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- Phase 6: cached proximity scores for fast filter / sort.
|
||||||
|
-- Refreshed by `OsmRefreshViewsCron` (Sun 03:00 ICT) and on admin-trigger.
|
||||||
|
|
||||||
|
-- For each industrial park, the 3 nearest POI of each priority category.
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS "mv_park_nearest_poi" AS
|
||||||
|
SELECT
|
||||||
|
p.id AS park_id,
|
||||||
|
poi.category::text AS category,
|
||||||
|
poi.id AS poi_id,
|
||||||
|
poi.name AS poi_name,
|
||||||
|
ROUND(ST_Distance(p.location::geography, poi.location::geography)::numeric, 0)::int AS distance_m,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY p.id, poi.category
|
||||||
|
ORDER BY ST_Distance(p.location::geography, poi.location::geography)
|
||||||
|
) AS rank
|
||||||
|
FROM "IndustrialPark" p
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT id, name, category, location
|
||||||
|
FROM "Poi"
|
||||||
|
WHERE "isPublic" = true
|
||||||
|
AND category::text IN ('HOSPITAL','BANK','GAS_STATION','BUS_STATION','METRO_STATION','POLICE')
|
||||||
|
AND ST_DWithin(location::geography, p.location::geography, 5000)
|
||||||
|
) poi;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "mv_park_nearest_poi_park_cat" ON "mv_park_nearest_poi"(park_id, category, rank);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "mv_park_nearest_poi_uq" ON "mv_park_nearest_poi"(park_id, category, poi_id);
|
||||||
|
|
||||||
|
-- Per-province POI density (count by category) for analytics heatmaps.
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS "mv_poi_density_by_province" AS
|
||||||
|
SELECT
|
||||||
|
"provinceCode",
|
||||||
|
category::text AS category,
|
||||||
|
COUNT(*)::int AS poi_count
|
||||||
|
FROM "Poi"
|
||||||
|
WHERE "isPublic" = true
|
||||||
|
AND "provinceCode" IS NOT NULL
|
||||||
|
GROUP BY "provinceCode", category;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "mv_poi_density_by_province_idx" ON "mv_poi_density_by_province"("provinceCode", category);
|
||||||
Reference in New Issue
Block a user