feat: sync source-backed OSM facility entrances

This commit is contained in:
Hamza-Ayed
2026-09-29 15:25:38 +03:00
parent 58310330b0
commit f4e24b0725
17 changed files with 772 additions and 391 deletions
+66
View File
@@ -0,0 +1,66 @@
<div dir="rtl" align="right">
# أفكار تطوير الخرائط والمنظومة
**تاريخ المراجعة:** 29 أيلول 2026
**الغرض:** مرجع لاتخاذ قرار المنتج، وليس قائمة ميزات معتمدة للتنفيذ.
## نقطة الانطلاق
يحتوي المشروع على عرض خرائط وأنماط متعددة، بحث عربي وترميز عكسي، مسارات، تطبيق سيرو للملاحة، استقبال مواقع المركبات، بيانات تراثية ومساهمات، حزم تطوير، ولوحة للمطورين. وجود الشيفرة لا يعني أن الدقة والتغطية والاعتمادية قد ثبتت في الاستخدام الفعلي. لذا تبدأ الأولوية بقياس رحلة المستخدم وتحسين البيانات، ثم إضافة وظائف تحل مشكلة متكررة لعميل محدد.
## أولويات مقترحة
| الترتيب | الفكرة | المشكلة التي تحلها | نسخة أولى معقولة | معيار نجاح التجربة |
|---|---|---|---|---|
| 1 | **تثبيت مدخل الوصول والعنوان** | نقطة المبنى لا تعني دائمًا باب التسليم أو مدخل المركبة | حفظ مدخل مؤكد وتعليمات وصول قصيرة مع تاريخ ومصدر التصحيح؛ إظهارها في البحث والملاحة | انخفاض اتصالات السائق بالعميل وفشل التسليم في عينة طلبات حقيقية |
| 2 | **حلقة تصحيح بيانات الخريطة** | البلاغات قد تبقى دون تحقق أو تظهر أخطاء قديمة بعد التحديث | بلاغ عن طريق/مكان/مدخل، قائمة مراجعة، سجل قرار، وتاريخ انتهاء للمعلومة المؤقتة | زمن معالجة البلاغ ونسبة التصحيحات التي تصمد بعد المراجعة |
| 3 | **تقييم البحث والمسارات دوريًا** | يصعب معرفة إن كان التعديل حسّن الخدمة أو أضرها | مجموعة عناوين ومسارات واقعية مجهولة الهوية، اختبار آلي قبل النشر، ولوحة لأسباب فشل البحث | نجاح البحث من أول محاولة، صلاحية المسار، وزمن الاستجابة عند أحمال معروفة |
| 4 | **مشاركة موقع ورحلة بصلاحية محدودة** | العميل لا يعرف وقت الوصول المتوقع وقد يكرر الاتصال | رابط متابعة قصير العمر، وصول للطلب المعني فقط، وإيقاف المشاركة عند التسليم | انخفاض مكالمات «وين السائق؟» دون زيادة شكاوى الخصوصية |
| 5 | **لوحة تشغيل بسيطة للأسطول** | بيانات المواقع وحدها لا تساعد المشرف على التدخل | مركبات نشطة، حالة آخر تحديث، تأخر تقديري، وتنبيه توقف أو خروج من نطاق مهمة | قدرة المشرف على كشف الحالات المتأخرة والتصرف قبل شكوى العميل |
| 6 | **حزم مناطق للاستخدام دون اتصال** | انقطاع الشبكة يعطل الخرائط والملاحة الميدانية | تنزيل منطقة محددة مع حجمها وتاريخها، تحقق من سلامة الحزمة، وتحديثات تدريجية | إتمام رحلة تجريبية بلا شبكة ونجاح تحديث الحزمة بعد عودة الاتصال |
الأولويات الأربع الأولى تصلح لتجربة مركزة مع شركة توصيل في مدينة واحدة. الخامسة والسادسة تُنفذان عندما يثبت الطلب وتظهر الحاجة التشغيلية بوضوح.
## أفكار توسع مرتبطة بعميل واضح
| الفكرة | العميل المحتمل | شرط البدء |
|---|---|---|
| **إسناد مهام ميدانية حسب الموقع**: إنشاء مهمة، تعيين موظف، إثبات الوصول، وسجل إنجاز | صيانة، مرافق، بلديات | تجربة مع فريق يعمل يوميًا على مهام ذات مواقع معروفة |
| **مناطق خدمة وتغطية زمنية**: رسم منطقة يصل إليها السائق خلال مدة محددة وتحديد الطلبات خارج الخدمة | توصيل ومتاجر | بيانات طرق وأزمنة سفر صالحة للمنطقة المستهدفة |
| **خرائط سياحية موثقة قابلة للتخصيص**: مسارات قصيرة ومعلومات زيارة وعرض باسم الجهة | وجهات سياحية وفنادق | اتفاق على حقوق المحتوى وتحديث ساعات العمل والمداخل |
| **طبقات منشآت العميل الخاصة**: فروع، مستودعات، أصول، ومناطق عمل مع صلاحيات | شركات ومؤسسات | عميل يملك البيانات ويحتاج عرضها وتحليلها مكانيًا |
| **واجهة مراجعة جودة البيانات للشركاء**: استقبال تصحيحات موثقة مع موافقة بشرية وتتبّع المصدر | جهات بيانات وشركاء محليون | سياسة واضحة للحقوق والخصوصية والمسؤولية عن التصحيح |
## تحسينات تأسيسية تجعل المنتج قابلًا للاعتماد
1. **مصدر وحداثة لكل طبقة:** عرض تاريخ آخر تحديث ونطاق التغطية وقيود البيانات؛ مع تنبيه عند فشل التحديث. هذا مهم خصوصًا للطرق ونقاط الاهتمام والبيانات المتغيرة.
2. **مراقبة رحلة الطلب كاملة:** قياس البحث ثم اختيار المكان ثم حساب المسار ثم الوصول، مع فصل أخطاء البيانات عن أخطاء الخدمة. تحفظ القياسات دون كشف مواقع الأشخاص أو عناوينهم الخام في التقارير العامة.
3. **اختبارات إصدار للحزم والتطبيق:** مثال تكامل يعمل لكل حزمة منشورة، وفحص توافق مسارات الواجهة البرمجية والمفاتيح قبل أي إصدار.
4. **تشغيل واستعادة موثقان:** نسخ احتياطي مجرّب، قياس زمن الاستعادة، مراقبة الخوادم وحجم بلاطات الخريطة، وحدود تحميل موثقة لكل عميل.
5. **خصوصية المواقع افتراضيًا:** فترة احتفاظ قصيرة قابلة للضبط، وصول حسب الدور، سجل اطلاع، وتعطيل تتبع الرحلة عند انتهائها.
## ما لا نبدأ به الآن
- **ازدحام لحظي شامل** قبل وجود حجم كافٍ من بيانات حركة موثوقة وموافقة مناسبة على استخدامها؛ ابدأ بقياس زمن الرحلات الفعلي في نطاق صغير.
- **مساعد ذكاء اصطناعي عام للخريطة** قبل ضبط جودة البحث والبيانات؛ يمكن تجربة واجهة استعلام عربية على بيانات عميل محددة لاحقًا.
- **توسع جغرافي واسع** قبل نجاح مدينة وقطاع واحد بقياسات واضحة؛ زيادة التغطية تضاعف عبء التحديث والتحقق.
- **ميزات متخصصة عالية الحساسية** قبل وجود متطلبات عميل وعقد ونموذج تحقق مستقل؛ لا تُعامل العروض الأولية بوصفها جاهزية تشغيلية.
## تجربة قرار لمدة 6–8 أسابيع
1. اختيار شركة توصيل واحدة وحي أو مدينة محددة، وجمع عينة عناوين ورحلات بموافقتها.
2. قياس الوضع الحالي: نجاح البحث من أول محاولة، فرق نقطة الوصول عن المدخل الصحيح، زمن الوصول، وعدد الاتصالات المتعلقة بالعنوان.
3. تنفيذ مدخل الوصول وحلقة التصحيح في نطاق العينة فقط، مع مراجعة بشرية للتغييرات.
4. إعادة القياس على طلبات جديدة، وحساب وقت المراجعة وتكلفة تشغيل كل رحلة، ثم سؤال العميل عن الاستعداد للدفع أو التوسع.
**قرار الاستمرار:** نوسع عندما يتحسن مؤشر يهم العميل بشكل متكرر، وتبقى تكلفة التصحيح والدعم مقبولة. إذا لم يظهر فرق واضح، نراجع نوع المشكلة أو القطاع قبل إضافة مزيد من الميزات.
## مراجع داخل المشروع
- `README.md` و`COMMERCIAL_ROADMAP.md`: البنية الحالية وخطة الجوانب التجارية.
- `docs/SIRO-OPPORTUNITY.ar.md`: فرضية السوق وخطة التحقق التجاري.
- `apps/api/src/geocoding/` و`apps/api/src/maps/` و`apps/api/src/telemetry/`: قدرات البحث والمسارات والمواقع.
- `apps/siro_maps/` و`packages/`: التطبيق وحزم التطوير.
</div>
+1 -1
View File
@@ -32,7 +32,7 @@ async function testPoiGates() {
console.log(` -> Navigate to: ${gate.latitude}, ${gate.longitude}`);
});
} else {
console.log(` ⚠️ No gates found. Sero drops will default to centroid.`);
console.log(` ⚠️ No source-backed entrance is available for this place.`);
}
}
console.log('--------------------------------------------------');
@@ -1,4 +1,4 @@
import { Controller, Get, Post, Delete, Body, Query, UseGuards, Req, HttpException, HttpStatus } from '@nestjs/common';
import { Controller, Get, Post, Delete, Body, Query, Param, UseGuards, Req, HttpException, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery, ApiHeader, ApiExcludeEndpoint } from '@nestjs/swagger';
import { GeocodingService } from './geocoding.service';
import { AdminBoundariesService } from './admin-boundaries.service';
@@ -33,6 +33,12 @@ export class GeocodingController {
return this.geocodingService.searchPlaces(q, lat, lng, radius, country);
}
@Get('places/:placeId/gates')
@ApiOperation({ summary: 'Get source-backed entrances and gates for a place' })
async getPlaceGates(@Param('placeId') placeId: string) {
return this.geocodingService.getPlaceGates(placeId);
}
@Get('autocomplete')
@ApiOperation({ summary: 'Fast autocomplete for live typing' })
@ApiQuery({ name: 'q', required: true })
+42 -2
View File
@@ -172,7 +172,8 @@ export class GeocodingService {
if (placeIds.length > 0) {
try {
const gates = await this.osmPointsRepository.query(
`SELECT place_id, gate_name_ar, gate_name_en, latitude, longitude, is_main_gate
`SELECT place_id, gate_name_ar, gate_name_en, latitude, longitude, is_main_gate,
source, source_id, gate_type, access, source_tags, last_seen_at
FROM place_gates
WHERE place_id = ANY($1)`,
[placeIds]
@@ -185,7 +186,16 @@ export class GeocodingService {
name_en: g.gate_name_en,
latitude: parseFloat(g.latitude),
longitude: parseFloat(g.longitude),
is_main_gate: g.is_main_gate
is_main_gate: g.is_main_gate,
source: g.source,
source_id: g.source_id,
gate_type: g.gate_type,
access: g.access,
source_tags: g.source_tags,
source_url: g.source === 'openstreetmap' && g.source_id
? `https://www.openstreetmap.org/node/${g.source_id}`
: null,
last_seen_at: g.last_seen_at,
}));
if (placeGates.length > 0) {
r.gates = placeGates;
@@ -238,6 +248,36 @@ export class GeocodingService {
}
}
async getPlaceGates(placeId: string) {
const gates = await this.osmPointsRepository.query(
`SELECT place_id, gate_name_ar, gate_name_en, latitude, longitude, is_main_gate,
source, source_id, gate_type, access, source_tags, last_seen_at
FROM place_gates WHERE place_id = $1
ORDER BY is_main_gate DESC, gate_type ASC, id ASC`,
[placeId],
);
return {
place_id: placeId,
attribution: '© OpenStreetMap contributors',
gates: gates.map((gate: any) => ({
name_ar: gate.gate_name_ar,
name_en: gate.gate_name_en,
latitude: Number(gate.latitude),
longitude: Number(gate.longitude),
is_main_gate: gate.is_main_gate,
gate_type: gate.gate_type,
access: gate.access,
source: gate.source,
source_id: gate.source_id,
source_url: gate.source === 'openstreetmap' && gate.source_id
? `https://www.openstreetmap.org/node/${gate.source_id}`
: null,
source_tags: gate.source_tags,
last_seen_at: gate.last_seen_at,
})),
};
}
private formatResults(results: any[], hasLocation: boolean, relativePrefix: string = '') {
const seenStreets = new Set<string>();
+7 -7
View File
@@ -9,9 +9,9 @@ This directory contains the central crontab configuration, installer, and docume
| # | Task Name | Schedule | Script / Command | Log File | Description |
|---|-----------|----------|-------------------|----------|-------------|
| 1 | **Bi-Monthly Geospatial Sync** | Every 1st & 15th at 03:00 | `scripts/master_cron_sync.sh` | `logs/cron_places_sync.log` | Syncs OSM & Overture places for JO, IQ, EG, SY. Generates and classifies gates & entrances for complexes. Refreshes `unified_search_index` concurrently and flushes Redis cache. |
| 2 | **Road Network & Routing Sync** | Every 1st, 10th, 20th at 01:00 | `scripts/update-data.sh` | `logs/cron_osm_update.log` | Downloads fresh OSM PBF for Jordan & Syria, updates PostGIS road tables, and rebuilds GraphHopper routing index. |
| 3 | **Daily Database Backup** | Daily at 02:00 | `scripts/backup_db.sh` | `logs/cron_backup.log` | Backs up custom tables (`places_*`, `place_gates`, `users`, `api_keys`, `billing_*`, `telemetry_*`) with 7-day retention. |
| 1 | **Map, Place & OSM Gate Sync** | Every 10 days at 03:00 | `infrastructure/scripts/update-data.sh` | `infrastructure/logs/update-data.log` | Updates the configured regional OSM PBF, rebuilds routing data, refreshes regional place names, imports only explicitly tagged OSM entrance/gate nodes, then clears search cache. |
| 2 | **Supplemental Place Enrichment** | Every 1st & 15th at 07:00 | `scripts/master_cron_sync.sh` | `logs/cron_places_sync.log` | Enriches Iraq and Egypt place tables. Gate coordinates are no longer generated by this job. |
| 3 | **Daily Database Backup** | Daily at 02:00 | `scripts/backup_db.sh` | `logs/cron_backup.log` | Backs up custom tables and the unverified legacy-gates archive with 7-day retention. |
| 4 | **Weekly DB Vacuum & Analyze** | Every Sunday at 04:00 | `docker compose exec db vacuumdb` | `logs/cron_vacuum.log` | Reclaims space and updates PostgreSQL query planner statistics for fast spatial and trigram GIN queries. |
| 5 | **Weekly Maintenance & Log Rotation** | Every Sunday at 05:00 | `scripts/cleanup_logs.sh` | `logs/cron_cleanup.log` | Truncates logs exceeding 50MB, deletes logs older than 14 days, and prunes dangling Docker images. |
@@ -42,8 +42,8 @@ The installer will automatically:
# View active crontab entries
crontab -l
# Watch live output of places & gates sync
tail -f /home/hamzadoctor/app/logs/cron_places_sync.log
# Watch live output of map, place, and gate sync
tail -f /home/hamzadoctor/app/infrastructure/logs/update-data.log
# Check recent database backups
ls -lh /home/hamzadoctor/app/backups/db/
@@ -59,8 +59,8 @@ tail -n 100 /home/hamzadoctor/app/logs/cron_osm_update.log
You can manually trigger any of these jobs at any time without waiting for the cron schedule:
```bash
# Run Bi-Monthly Places & Gates Sync
/bin/bash /home/hamzadoctor/app/scripts/master_cron_sync.sh
# Run the 10-day map, places, and source-backed gate sync
/bin/bash /home/hamzadoctor/app/infrastructure/scripts/update-data.sh
# Run Database Backup
/bin/bash /home/hamzadoctor/app/scripts/backup_db.sh
+7 -10
View File
@@ -9,20 +9,17 @@ PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
APP_DIR=/home/hamzadoctor/app
# ------------------------------------------------------------------------------
# 1. Bi-Monthly Geospatial Synchronization (Every 1st & 15th at 03:00 AM)
# - Syncs OSM & Overture places for Jordan, Iraq, Egypt, Syria
# - Generates and classifies Gates & Entrances (Hospitals, Malls, Unis, Hotels, Parks)
# - Refreshes Materialized View (unified_search_index) concurrently
# - Flushes Redis search cache
# 1. OSM roads, regional places, and source-backed gates every 10 days at 03:00
# - Uses MAP_DATA_SCOPE to select core, GCC, or GCC + Yemen coverage
# - Reads actual entrance/gate nodes from the merged OSM PBF
# ------------------------------------------------------------------------------
0 3 1,15 * * /bin/bash /home/hamzadoctor/app/scripts/master_cron_sync.sh >> /home/hamzadoctor/app/logs/cron_places_sync.log 2>&1
0 3 */10 * * /bin/bash /home/hamzadoctor/app/infrastructure/scripts/update-data.sh >> /home/hamzadoctor/app/infrastructure/logs/update-data.log 2>&1
# ------------------------------------------------------------------------------
# 2. Road Network & Routing Update (Every 10 days at 01:00 AM)
# - Downloads fresh OSM PBF for Jordan & Syria
# - Rebuilds GraphHopper routing index & clears traffic cache
# 2. Bi-monthly supplemental place enrichment (1st and 15th at 07:00 AM)
# - Enriches Iraq and Egypt place tables; gate data is handled above
# ------------------------------------------------------------------------------
0 1 1,10,20 * * /bin/bash /home/hamzadoctor/app/scripts/update-data.sh >> /home/hamzadoctor/app/logs/cron_osm_update.log 2>&1
0 7 1,15 * * /bin/bash /home/hamzadoctor/app/scripts/master_cron_sync.sh >> /home/hamzadoctor/app/logs/cron_places_sync.log 2>&1
# ------------------------------------------------------------------------------
# 3. Daily Database Backup (Every Day at 02:00 AM)
+64
View File
@@ -0,0 +1,64 @@
<div dir="rtl" align="right">
# أبواب ومداخل المنشآت: المصدر والتغطية والتشغيل
**آخر تحديث:** 29 أيلول 2026
**المصدر المطبق:** عقد مداخل وبوابات OpenStreetMap ذات الإحداثيات والوسوم الأصلية.
## الحالة الحالية على الخادم
استوردت المزامنة **1,439 نقطة مدخل/بوابة مصدرية** وربطتها بـ **389 منشأة مسماة** عبر حدود مضلعات OSM، في الدول الإحدى عشرة. كل نقطة في جدول الخدمة تقابل عقدة OSM فعلية، وتحفظ معرّف العقدة والوسوم الأصلية ورابط المصدر وآخر وقت رصد. لا يحسب البرنامج إحداثيات حول مركز المنشأة.
| الدولة | نقاط الأبواب/المداخل | منها رئيسي معلّم صراحةً |
|---|---:|---:|
| الإمارات | 309 | 42 |
| البحرين | 41 | 2 |
| مصر | 644 | 16 |
| العراق | 65 | 7 |
| الأردن | 24 | 3 |
| الكويت | 44 | 5 |
| عُمان | 41 | 2 |
| قطر | 66 | 2 |
| السعودية | 175 | 5 |
| سوريا | 21 | 1 |
| اليمن | 9 | 0 |
| **المجموع** | **1,439** | **85** |
تشمل المنشآت المضلعات المسماة الموسومة كمستشفى أو عيادة أو مول أو مكتب حكومي أو مبنى حكومي أو دار بلدية أو محكمة. المستشفيات هنا ليست مصنفة حكومية/خاصة إلا إذا كانت بيانات OSM نفسها تثبت ذلك. تغطية اليمن وسوريا والأردن قليلة مقارنة ببقية الدول؛ هذه الأعداد تقيس ما وجدته بيانات OSM في نسخة الخريطة على الخادم، ولا تعني اكتمال الواقع.
## كيف تُحدد أنواع الأبواب؟
- `entrance=main` وحدها تضع `is_main_gate=true` و`gate_type=main`. تؤخذ أيضًا علامة `routing:entrance` كدليل على نقطة وصول مخصصة للملاحة، مع حفظها كما هي.
- `entrance=secondary/service/emergency/parking` تبقى بهذه الصفة عند وجودها صراحةً في المصدر.
- `barrier=gate` أو `entrance=yes` بلا تصنيف صريح يعرض كـ«بوابة» أو «مدخل»، ولا يُسمى رئيسيًا.
- تستبعد النقاط الخاصة أو الممنوعة (`access=private/no`) من واجهة المداخل العامة.
- لا تُربط العقدة بمنشأة إلا إذا وقعت هندستها داخل مضلع منشأة OSM مسماة ومؤهلة، ولم تتطابق مع أكثر من منشأة مختلفة. لا يستخدم السكربت أقرب مركز أو إزاحة تقديرية.
تشرح مواصفات OSM معنى [وسم `entrance`](https://wiki.openstreetmap.org/wiki/Key:entrance)، بما فيه `main` و`secondary` و`service`، ومعنى [وسم `barrier=gate`](https://wiki.openstreetmap.org/wiki/Tag:barrier%3Dgate)، ووسم [`routing:entrance`](https://wiki.openstreetmap.org/wiki/Key:routing:entrance) الذي يحدد نقطة وصول مفضلة للملاحة. تبقى الوسوم كما وردت في `source_tags` ليتسنى للعميل معرفة الدليل الأصلي.
## إتاحة البيانات عبر API
يظهر حقل `gates` في نتيجة البحث، كما تتوفر واجهة مخصصة:
```http
GET /api/geocoding/places/{placeId}/gates
x-api-key: <مفتاح العميل>
```
تعيد الواجهة معرّف المكان، والإحداثيات، والاسم العربي/الإنجليزي، ونوع المدخل، وإمكانية الوصول عند توفرها، وحقل `is_main_gate`، ومصدر البيانات، ومعرّف عقدة OSM، والوسوم الأصلية، ورابط العقدة، ووقت آخر رصد. تتضمن الاستجابة إسناد `© OpenStreetMap contributors`.
## التشغيل والتحديث
- مهمة تحديث بيانات الخريطة على الخادم تعمل كل عشرة أيام وتشغّل `infrastructure/scripts/update-data.sh`.
- بعد تحديث PBF وبناء أسماء الأماكن للدول الإحدى عشرة، يشغّل التحديث `scripts/sync_osm_place_gates.py` على ملف `master_map.osm.pbf` نفسه؛ ثم يمسح ذاكرة البحث.
- يستخرج السكربت وسوم الأبواب من PBF الخام لأن استيراد `osm2pgsql` الحالي لا يحتفظ بحقل `entrance` في أعمدة PostGIS القياسية.
- يستبدل السكربت صفوف OSM المصدرية عند كل مزامنة، ويحفظ الصفوف القديمة مجهولة المصدر في `place_gates_unverified_archive_20260929` خارج جدول الخدمة. يحتوي الأرشيف على **57,381** صفًا سابقًا. لا تُحذف نسخة الأرشيف من هذا التشغيل.
- أزيل استدعاء مولّد الإحداثيات التقديرية من مزامنة الأماكن نصف الشهرية، وحُوّل `scripts/populate_place_gates.py` إلى مدخل توافق يتطلب ملف PBF ويستدعي المزامنة المصدرية.
## حدود التغطية ومصادر أخرى
المصدر المستخدم في المزامنة هو OSM. معالم Overture المسماة تساعد على البحث، لكن هذا المسار لا يقرأ منها نقاط أبواب. إذا أضاف مصدر مستقبلي نقاط أبواب مستقلة وموثقة، ينبغي حفظ مصدره ومعرّفه ووسومه بالطريقة نفسها، مع إبقاء بيانات المصدر منفصلة وقابلة للتتبع.
لا تتوافر حاليًا دلالة كافية لتصنيف معظم البوابات كبوابة إسعاف أو مواقف أو بوابة احتياطية. لذلك لا يعرض النظام هذه التسميات إلا عندما تكون ممثلة صراحةً في وسوم المصدر. رفع التغطية يتطلب تحسين بيانات OSM ميدانيًا أو استخدام مصدر رسمي موثق، ثم سيظهر في المزامنة التالية.
</div>
+6 -4
View File
@@ -54,7 +54,7 @@ MaplibreMap(
- **ميزات النظام الداخلية المدعومة هنا:**
1. **التطبيع العربي:** يعالج (إ/ا/أ/ة/ه).
2. **الاستعلامات النسبية:** يفهم (قرب المستشفى، بجانب البنك).
3. **بوابات المجمعات (POI Gates):** للمولات الكبرى، يرجع المحرك إحداثيات (البوابة الرئيسية، بوابة الطوارئ) ضمن مصفوفة `gates`.
3. **مداخل المنشآت (POI Gates):** قد يرجع المحرك نقاط مداخل OSM الموثقة للمنشأة ضمن مصفوفة `gates`. لا تُنشأ نقاط تقديرية، ولا توصف البوابة بأنها رئيسية إلا إذا كانت موسومة صراحةً في المصدر.
4. **هل تقصد (Did you mean):** إذا كتب المستخدم مصطلحاً خاطئاً بالكامل ("السمساني") وعاد بـ 0 نتائج، يقوم المحرك ببحث `KNN` لإرجاع اقترح للتصحيح.
**الاستخدام في Flutter:**
@@ -62,7 +62,7 @@ MaplibreMap(
```dart
controller.animateCamera(CameraUpdate.newLatLng(LatLng(lat, lng)));
```
وإذا كان الرد يحتوي على `gates`، يمكن إظهار نافذة منبثقة للسائق: *"أي بوابة تقصد؟"*.
وإذا كان الرد يحتوي على `gates`، يمكن إظهار نافذة منبثقة للسائق لاختيار أحد المداخل الموثقة: *"أي مدخل تقصد؟"*. عند غيابها لا يعرض التطبيق اختيار بوابة غير موثقة.
---
@@ -117,8 +117,10 @@ await controller.addLineLayer(
2. **البحث:**
يكتب الراكب في صندوق البحث ⬅️ يتم مناداة `/autocomplete` لعرض الاقتراحات فوراً.
3. **الاختيار:**
يختار الراكب (مثلاً: سيتي مول) ⬅️ يتم مناداة `/search` ⬅️ الخادم يرجع البوابات المتاحة للمول ⬅️ الراكب يختار "البوابة الرئيسية".
يختار الراكب (مثلاً: سيتي مول) ⬅️ يتم مناداة `/search` ⬅️ الخادم يرجع المداخل الموثقة المتاحة إن وجدت؛ وقد لا تتوفر نقاط أبواب للمنشأة.
4. **طلب الرحلة:**
التطبيق يرسل موقع الراكب وموقع البوابة إلى `/api/maps/route` ⬅️ الخادم يحسب المسار، يطبق خوارزمية الازدحام المروري، ويرجع خط سير (Polyline) ⬅️ الفلاتر يفك تشفير الخط ويرسمه على الخريطة باللون الأزرق.
عند اختيار مدخل موثق، يرسل التطبيق موقع الراكب وإحداثيات ذلك المدخل إلى `/api/maps/route` ⬅️ الخادم يحسب المسار ويرجع خط سير (Polyline) ⬅️ تطبيق Flutter يفك تشفير الخط ويرسمه على الخريطة.
لحقول البوابات، المصدر، التغطية والجدولة راجع [توثيق أبواب ومداخل المنشآت](PLACE_GATES_COVERAGE_AR.md).
بذلك نكون قد غطينا البنية المعمارية الكاملة بأدق تفاصيلها من السيرفر وحتى واجهة التطبيق! 🚀
+15 -15
View File
@@ -21,8 +21,8 @@
4. **الاستعلامات النسبية (Relative Queries):**
فهم طبيعة الوصف في الشارع الأردني والعربي. عندما يبحث المستخدم عن `"قرب مستشفى التخصصي"`, يقوم النظام بفلترة كلمة "قرب"، ويبحث عن المستشفى، ويعيد إحداثياته الدقيقة تحت اسم "قرب مستشفى التخصصي".
5. **بوابات المجمعات (POI Gates Clustering):**
لا توجيه إلى مراكز المباني بعد اليوم! الأماكن الكبرى مثل المولات تعود بقائمة من البوابات الفعلية (مثال: البوابة الرئيسية، بوابة كارفور) لتوجيه السائق بدقة متناهية.
5. **مداخل المنشآت (POI Gates):**
قد تعود نتيجة المنشأة بمداخل موثقة من عقد OpenStreetMap الفعلية. لا ينشئ النظام بوابات افتراضية؛ وتكون `is_main_gate=true` فقط عندما يوسم المصدر المدخل صراحةً بـ `entrance=main`. راجع [توثيق التغطية والمصدر](PLACE_GATES_COVERAGE_AR.md).
6. **هل تقصد؟ (Safety Net & Spell Checker):**
محرك تعويض الأخطاء (Fallback). إذا أدخل المستخدم مصطلحاً مستحيلاً وأسفر عن 0 نتائج، يتدخل استعلام `KNN` لاقتراح أقرب مصطلح منطقي أو تصحيحه تلقائياً.
@@ -60,22 +60,22 @@
"distance_km": "3.50",
"full_address": "شارع الملك عبدالله، عمان",
"totalScore": 0.95,
"gates": [
{
"name_ar": "البوابة الرئيسية",
"name_en": "Main Gate",
"latitude": 31.9806,
"longitude": 35.8380,
"is_main_gate": true
}
]
"gates": []
}
],
"did_you_mean": null
}
```
### 2. الإكمال التلقائي (Autocomplete)
قد تحتوي النتيجة على `gates` إذا وجدت نقاط أبواب/مداخل موثقة ومطابقة للمنشأة؛ وقد تكون المصفوفة فارغة. لا يدل غيابها على عدم وجود مدخل في الواقع، بل على عدم توفر نقطة مصدر مطابقة.
### 2. مداخل منشأة محددة
**الرابط:** `GET /api/geocoding/places/{placeId}/gates`
يتطلب مفتاح API. يعيد المداخل المسجلة للمعلم، مع الإحداثيات ونوع المدخل ووسوم المصدر ومعرّف عقدة OSM ورابطها وإسناد `© OpenStreetMap contributors`.
### 3. الإكمال التلقائي (Autocomplete)
مخصص لتقديم اقتراحات سريعة جداً أثناء طباعة المستخدم في مربع البحث.
**الرابط:** `GET /api/geocoding/autocomplete`
@@ -107,8 +107,8 @@
عبارة عن `Materialized View` في PostgreSQL يجمع بيانات (الأردن، سوريا، مصر، و OpenStreetMap) في جدول واحد سريع جداً.
- **pg_trgm:**
تقنية في قاعدة البيانات تستخدم (Trigrams) لتحطيم الكلمات إلى مقاطع من 3 أحرف ومقارنتها بدقة رهيبة للتغلب على الأخطاء الإملائية.
- **تحديثات شعبية الأماكن (Future Sero Trips Sync):**
المحرك مجهز لاستقبال بيانات رحلات السائقين وتحديث نقاط `popularity_score` واستنتاج بوابات المجمعات تلقائياً.
- **مداخل المنشآت:**
تُحدّث من نقاط OSM الفعلية عند تشغيل مزامنة الخريطة كل عشرة أيام. تحفظ معرّفات المصدر والوسوم الأصلية، ولا تستنتج إحداثيات أو تصنيفات غير موجودة في المصدر. انظر [توثيق التغطية والتشغيل](PLACE_GATES_COVERAGE_AR.md).
---
**المستند محدث بتاريخ:** يوليو 2026
**المستند محدث بتاريخ:** سبتمبر 2026
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# وظيفة الملف: أداة تشغيل أو تجهيز باسم sync regional place names؛ تفاصيل خطواتها ومدخلاتها موضحة في دليل ملفات المشروع.
# Refresh Overture places and a country-tagged OSM/Overture name layer.
# Usage: bash infrastructure/scripts/sync_regional_place_names.sh [JO SY IQ EG SA AE OM QA KW BH YE ...]
set -euo pipefail
APP_DIR="${APP_DIR:-/home/hamzadoctor/app}"
DATA_DIR="${APP_DIR}/infrastructure/osm-data"
cd "$APP_DIR"
declare -A OSM_RELATIONS=(
[EG]=-1473947 [SA]=-307584 [AE]=-307763 [OM]=-305138
[QA]=-305095 [KW]=-305099 [BH]=-378734 [YE]=-305092
[JO]=-184818 [SY]=-184840 [IQ]=-304934
)
TARGETS=("$@")
if (( $# == 0 )); then TARGETS=(JO SY IQ EG SA AE OM QA KW BH YE); fi
for code in "${TARGETS[@]}"; do
[[ -n "${OSM_RELATIONS[$code]:-}" ]] || { echo "Unknown country: $code" >&2; exit 2; }
done
command -v ogr2ogr >/dev/null || { echo "ogr2ogr is required" >&2; exit 2; }
test -x venv_overture/bin/overturemaps || { echo "Overture CLI is required" >&2; exit 2; }
psql_db() { docker compose exec -T db psql -v ON_ERROR_STOP=1 -U mapuser -d mapdb "$@"; }
psql_db <<'SQL'
CREATE TABLE IF NOT EXISTS regional_place_names (
country_code text NOT NULL,
source text NOT NULL,
source_id text NOT NULL,
name text NOT NULL,
location geometry(Point,4326) NOT NULL,
PRIMARY KEY (country_code, source, source_id)
);
CREATE INDEX IF NOT EXISTS regional_place_names_location_idx
ON regional_place_names USING gist (location);
CREATE INDEX IF NOT EXISTS regional_place_names_country_idx
ON regional_place_names (country_code);
CREATE INDEX IF NOT EXISTS overture_place_id_idx ON overture_place (id);
SQL
for code in "${TARGETS[@]}"; do
relation="${OSM_RELATIONS[$code]}"
echo "[$code] determining country bounds"
bbox="$(psql_db -Atc "SELECT concat_ws(',',ST_XMin(e),ST_YMin(e),ST_XMax(e),ST_YMax(e)) FROM (SELECT ST_Extent(ST_Transform(way,4326)) AS e FROM planet_osm_polygon WHERE osm_id=$relation AND boundary='administrative' AND admin_level='2') q")"
[[ -n "$bbox" ]] || { echo "Missing country boundary: $code" >&2; exit 1; }
output="$DATA_DIR/overture_places_${code}.geojson"
echo "[$code] downloading Overture places for $bbox"
if [[ ! -s "$output" ]]; then
venv_overture/bin/overturemaps download --bbox="$bbox" -f geojson --type=place -o "$output"
fi
echo "[$code] loading staging table"
db_password="$(docker compose exec -T db printenv POSTGRES_PASSWORD)"
PGPASSWORD="$db_password" ogr2ogr -f PostgreSQL \
"PG:host=127.0.0.1 port=5432 user=mapuser dbname=mapdb" \
"$output" -nln overture_place_stage -overwrite -nlt PROMOTE_TO_MULTI \
-lco GEOMETRY_NAME=location
echo "[$code] merging named Overture places and refreshing map names"
psql_db -v code="$code" -v relation="$relation" <<'SQL'
BEGIN;
WITH border AS (
SELECT ST_Union(ST_Transform(way,4326)) geom FROM planet_osm_polygon
WHERE osm_id = :'relation'::bigint AND boundary='administrative' AND admin_level='2'
)
INSERT INTO overture_place
(id,categories,confidence,websites,emails,socials,phones,brand,addresses,names,sources,
operating_status,basic_category,taxonomy,version,location,name_primary)
SELECT s.id,NULL,s.confidence,s.websites,s.emails,s.socials,s.phones,s.brand,s.addresses,
s.names,s.sources,s.operating_status,s.basic_category,s.taxonomy,s.version,s.location,
COALESCE(s.names->>'primary',s.names->>'common')
FROM overture_place_stage s CROSS JOIN border b
WHERE s.id IS NOT NULL AND s.location && b.geom AND ST_Intersects(s.location,b.geom)
AND COALESCE(s.names->>'primary',s.names->>'common') IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM overture_place p WHERE p.id=s.id);
DELETE FROM regional_place_names WHERE country_code=:'code';
WITH border AS (
SELECT ST_Union(way) geom FROM planet_osm_polygon
WHERE osm_id = :'relation'::bigint AND boundary='administrative' AND admin_level='2'
)
INSERT INTO regional_place_names(country_code,source,source_id,name,location)
SELECT :'code','osm_point',p.osm_id::text,p.name,ST_Transform(p.way,4326)
FROM planet_osm_point p CROSS JOIN border b
WHERE p.name IS NOT NULL AND p.name<>'' AND p.way && b.geom AND ST_Intersects(p.way,b.geom)
ON CONFLICT DO NOTHING;
WITH border AS (
SELECT ST_Union(way) geom FROM planet_osm_polygon
WHERE osm_id = :'relation'::bigint AND boundary='administrative' AND admin_level='2'
)
INSERT INTO regional_place_names(country_code,source,source_id,name,location)
SELECT :'code','osm_polygon',p.osm_id::text,p.name,ST_Transform(ST_PointOnSurface(p.way),4326)
FROM planet_osm_polygon p CROSS JOIN border b
WHERE p.name IS NOT NULL AND p.name<>'' AND p.way && b.geom
AND ST_Intersects(ST_PointOnSurface(p.way),b.geom)
ON CONFLICT DO NOTHING;
WITH border AS (
SELECT ST_Union(ST_Transform(way,4326)) geom FROM planet_osm_polygon
WHERE osm_id = :'relation'::bigint AND boundary='administrative' AND admin_level='2'
)
INSERT INTO regional_place_names(country_code,source,source_id,name,location)
SELECT :'code','overture',p.id,COALESCE(p.name_primary,p.names->>'primary',p.names->>'common'),
ST_PointOnSurface(p.location)
FROM overture_place p CROSS JOIN border b
WHERE p.id IS NOT NULL AND p.location && b.geom AND ST_Intersects(p.location,b.geom)
AND COALESCE(p.name_primary,p.names->>'primary',p.names->>'common') IS NOT NULL
ON CONFLICT DO NOTHING;
COMMIT;
DROP TABLE overture_place_stage;
SQL
if [[ "$code" == IQ ]]; then
psql_db <<'SQL'
INSERT INTO regional_place_names(country_code,source,source_id,name,location)
SELECT 'IQ','custom_iraq',id::text,COALESCE(NULLIF(name_ar,''),NULLIF(name,'')),location
FROM places_iraq
WHERE source='iraq_final_complete' AND location IS NOT NULL
AND COALESCE(NULLIF(name_ar,''),NULLIF(name,'')) IS NOT NULL
ON CONFLICT DO NOTHING;
SQL
fi
rm -f "$output"
psql_db -Atc "SELECT '$code'||'|'||source||'|'||count(*) FROM regional_place_names WHERE country_code='$code' GROUP BY source ORDER BY source"
done
docker compose exec -T redis redis-cli FLUSHDB >/dev/null
+94 -49
View File
@@ -1,4 +1,5 @@
#!/bin/bash
# وظيفة الملف: أداة تشغيل أو تجهيز باسم update data؛ تفاصيل خطواتها ومدخلاتها موضحة في دليل ملفات المشروع.
# --------------------------------------------------------------------------
# Intaleq Map Platform - 10-Day Update Script
# سكربت تحديث خرائط انطلاقة - التحديث الدوري (كل 10 أيام)
@@ -11,62 +12,83 @@
set -e # Exit on error
echo "🚀 Starting 10-day map update (v3 — Jordan + Syria + Egypt + Iraq + Delta)..."
APP_DIR="/home/hamzadoctor/app"
DATA_DIR="${APP_DIR}/infrastructure/osm-data"
DATA_SCOPE_FILE="${DATA_DIR}/map-data-scope"
# An explicitly supplied scope takes priority. After a successful regional
# expansion, persist it so the scheduled 10-day job cannot drop those regions.
MAP_DATA_SCOPE="${MAP_DATA_SCOPE:-$(cat "${DATA_SCOPE_FILE}" 2>/dev/null || printf 'core')}"
case "${MAP_DATA_SCOPE}" in
core|gcc|gcc-yemen) ;;
*)
echo "❌ MAP_DATA_SCOPE must be one of: core, gcc, gcc-yemen (got: ${MAP_DATA_SCOPE})"
exit 2
;;
esac
echo "🚀 Starting map update (scope: ${MAP_DATA_SCOPE})..."
# ✅ FIX: Correct filename that GraphHopper actually reads (was: region.osm.pbf)
MASTER_FILE="${DATA_DIR}/master_map.osm.pbf"
DELTA_FILE="${DATA_DIR}/delta.osm"
cd "${APP_DIR}"
# ── Step 1: Download all four country PBFs ─────────────────────────────────
echo "🌍 Downloading Jordan, Syria, Egypt & Iraq PBF data from Geofabrik..."
if ! command -v osmium >/dev/null 2>&1; then
echo "❌ osmium-tool is required to merge routing extracts. Install it with: sudo apt-get install -y osmium-tool"
exit 3
fi
wget -q --show-progress -O "${DATA_DIR}/jordan-latest.osm.pbf.new" \
# ── Step 1: Select and download the country PBFs ───────────────────────────
# GCC is Geofabrik's combined Saudi Arabia, UAE, Qatar, Oman, Kuwait and Bahrain
# extract. Keeping the stage explicit prevents a scheduled update from silently
# enlarging the production routing graph.
COUNTRIES=(jordan syria egypt iraq)
PBF_FILES=(
"jordan-latest.osm.pbf"
"syria-latest.osm.pbf"
"egypt-latest.osm.pbf"
"iraq-latest.osm.pbf"
)
PBF_URLS=(
"https://download.geofabrik.de/asia/jordan-latest.osm.pbf"
# ✅ FIX: كان يحمّل مصر إلى ملف سوريا ثم يعيد تحميل سوريا فوقها — هدر 177MB كل دورة
wget -q --show-progress -O "${DATA_DIR}/syria-latest.osm.pbf.new" \
"https://download.geofabrik.de/asia/syria-latest.osm.pbf"
wget -q --show-progress -O "${DATA_DIR}/egypt-latest.osm.pbf.new" \
"https://download.geofabrik.de/africa/egypt-latest.osm.pbf"
wget -q --show-progress -O "${DATA_DIR}/iraq-latest.osm.pbf.new" \
"https://download.geofabrik.de/asia/iraq-latest.osm.pbf"
)
mv "${DATA_DIR}/jordan-latest.osm.pbf.new" "${DATA_DIR}/jordan-latest.osm.pbf"
mv "${DATA_DIR}/syria-latest.osm.pbf.new" "${DATA_DIR}/syria-latest.osm.pbf"
mv "${DATA_DIR}/egypt-latest.osm.pbf.new" "${DATA_DIR}/egypt-latest.osm.pbf"
mv "${DATA_DIR}/iraq-latest.osm.pbf.new" "${DATA_DIR}/iraq-latest.osm.pbf"
if [[ "${MAP_DATA_SCOPE}" == "gcc" || "${MAP_DATA_SCOPE}" == "gcc-yemen" ]]; then
COUNTRIES+=(gcc-states)
PBF_FILES+=(gcc-states-latest.osm.pbf)
PBF_URLS+=("https://download.geofabrik.de/asia/gcc-states-latest.osm.pbf")
fi
if [[ "${MAP_DATA_SCOPE}" == "gcc-yemen" ]]; then
COUNTRIES+=(yemen)
PBF_FILES+=(yemen-latest.osm.pbf)
PBF_URLS+=("https://download.geofabrik.de/asia/yemen-latest.osm.pbf")
fi
echo "🌍 Downloading ${COUNTRIES[*]} PBF data from Geofabrik..."
for i in "${!PBF_FILES[@]}"; do
wget -q --show-progress -O "${DATA_DIR}/${PBF_FILES[$i]}.new" "${PBF_URLS[$i]}"
mv "${DATA_DIR}/${PBF_FILES[$i]}.new" "${DATA_DIR}/${PBF_FILES[$i]}"
done
echo "✅ Downloads complete."
# ── Step 2: Import all four countries into PostGIS ─────────────────────────
echo "💾 Importing Jordan into PostGIS (--create resets planet_osm_* tables)..."
docker compose --profile import run --rm osm-import \
osm2pgsql --create --slim --cache 1000 \
--database mapdb --host db --user mapuser \
/data/jordan-latest.osm.pbf
echo "💾 Importing Syria into PostGIS (--append)..."
docker compose --profile import run --rm osm-import \
osm2pgsql --append --slim --cache 1000 \
--database mapdb --host db --user mapuser \
/data/syria-latest.osm.pbf
# ✅ FIX: Egypt was missing from the update cycle — added here
echo "💾 Importing Egypt into PostGIS (--append)..."
docker compose --profile import run --rm osm-import \
osm2pgsql --append --slim --cache 1000 \
--database mapdb --host db --user mapuser \
/data/egypt-latest.osm.pbf
echo "💾 Importing Iraq into PostGIS (--append)..."
docker compose --profile import run --rm osm-import \
osm2pgsql --append --slim --cache 1000 \
--database mapdb --host db --user mapuser \
/data/iraq-latest.osm.pbf
# ── Step 2: Import into PostGIS ────────────────────────────────────────────
# The first extract replaces planet_osm_*; every remaining extract appends.
for i in "${!PBF_FILES[@]}"; do
IMPORT_MODE="--append"
[[ "$i" == "0" ]] && IMPORT_MODE="--create"
echo "💾 Importing ${COUNTRIES[$i]} into PostGIS (${IMPORT_MODE})..."
docker compose --profile import run --rm osm-import \
osm2pgsql "${IMPORT_MODE}" --slim --cache 1000 \
--database mapdb --host db --user mapuser \
"/data/${PBF_FILES[$i]}"
done
# ── Step 2b: Keep routing topology tables ─────────────────────────────────
# Approved roads must share real OSM node IDs with the base graph. Deleting
@@ -75,15 +97,19 @@ echo "🧭 Preserving OSM node/way topology for approved-road routing..."
docker compose exec -T db psql -U mapuser -d mapdb -c \
"DROP TABLE IF EXISTS planet_osm_rels;"
# ── Step 3: Merge all four PBFs into master_map.osm.pbf ────────────────────
# OSM 624165122 is a misclassified 5.94 km² water polygon covering central Zarqa.
# Remove it after every full OSM import so it cannot reappear in the rendered map.
docker compose exec -T db psql -U mapuser -d mapdb -c \
"DELETE FROM planet_osm_polygon WHERE osm_id = 624165122;"
# ── Step 3: Merge all selected PBFs into master_map.osm.pbf ───────────────
# ✅ FIX: Old script wrote to region.osm.pbf — GraphHopper reads master_map.osm.pbf
echo "🗺️ Merging Jordan + Syria + Egypt + Iraq → ${MASTER_FILE}..."
osmium merge \
"${DATA_DIR}/jordan-latest.osm.pbf" \
"${DATA_DIR}/syria-latest.osm.pbf" \
"${DATA_DIR}/egypt-latest.osm.pbf" \
"${DATA_DIR}/iraq-latest.osm.pbf" \
-o "${MASTER_FILE}" --overwrite
echo "🗺️ Merging ${COUNTRIES[*]} → ${MASTER_FILE}..."
MERGE_INPUTS=()
for pbf_file in "${PBF_FILES[@]}"; do
MERGE_INPUTS+=("${DATA_DIR}/${pbf_file}")
done
osmium merge "${MERGE_INPUTS[@]}" -o "${MASTER_FILE}" --overwrite
echo "✅ Master PBF built: $(du -sh ${MASTER_FILE} | cut -f1)"
@@ -117,7 +143,9 @@ docker compose stop api web dashboard martin || true
trap 'echo "▶️ إعادة تشغيل الحاويات المؤقتة..."; docker compose up -d api web dashboard martin || true' EXIT
docker compose stop routing
rm -rf "${DATA_DIR}/graph-cache" "${DATA_DIR}/default-gh"
# GraphHopper creates its graph files as root inside the container, so the
# host user needs sudo to reliably remove a stale graph before rebuilding.
sudo rm -rf "${DATA_DIR}/graph-cache" "${DATA_DIR}/default-gh"
docker compose up -d routing
echo "✅ GraphHopper restarting from ${MASTER_FILE}."
@@ -149,6 +177,23 @@ fi
echo "🧹 Flushing Redis traffic cache..."
docker compose exec -T redis redis-cli flushall
# Keep named OSM and Overture places in step with the 10-day road refresh.
# This includes Egypt, GCC countries and Yemen; the importer uses country
# borders and preserves the existing curated place tables.
bash "${APP_DIR}/infrastructure/scripts/sync_regional_place_names.sh"
docker compose exec -T db psql -v ON_ERROR_STOP=1 -U mapuser -d mapdb \
< "${APP_DIR}/infrastructure/sql/10_regional_place_search.sql"
# Import only explicit OSM entrance/gate nodes from the same 11-country PBF.
# No synthetic offsets are created; a node is attached only when its coordinate
# is covered by exactly one named target facility polygon.
python3 -u "${APP_DIR}/scripts/sync_osm_place_gates.py" \
--pbf "${DATA_DIR}/master_map.osm.pbf"
docker compose exec -T redis redis-cli flushdb
# Persist only after every import, graph rebuild and verification has succeeded.
printf '%s\n' "${MAP_DATA_SCOPE}" > "${DATA_SCOPE_FILE}"
echo ""
echo "✅ 10-day update complete — Jordan + Syria + Egypt + Iraq + approved delta applied."
echo "✅ 10-day update complete — ${COUNTRIES[*]}, approved road delta, regional names, and OSM gates synchronized."
echo " GraphHopper is rebuilding in the background. Allow 5-10 min for routing to be ready."
@@ -0,0 +1,28 @@
-- وظيفة الملف: استعلام أو تغيير قاعدة بيانات يخص 05 poi gates clustering؛ راجع ترتيب التنفيذ قبل تطبيقه.
-- 1. Create the place_gates table
CREATE TABLE IF NOT EXISTS place_gates (
id SERIAL PRIMARY KEY,
place_id VARCHAR(255) NOT NULL, -- references unified_search_index.id
gate_name_ar VARCHAR(255) NOT NULL,
gate_name_en VARCHAR(255),
latitude NUMERIC(10, 7) NOT NULL,
longitude NUMERIC(10, 7) NOT NULL,
is_main_gate BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_place_gates_place_id ON place_gates(place_id);
-- No gate seed rows belong here: service doors must come from an explicit,
-- source-backed entrance node. See scripts/sync_osm_place_gates.py.
-- Prepare schema for future K-Means Clustering (Sero Trip Drop-offs)
CREATE TABLE IF NOT EXISTS sero_dropoffs (
id SERIAL PRIMARY KEY,
place_id VARCHAR(255) NOT NULL,
latitude NUMERIC(10, 7) NOT NULL,
longitude NUMERIC(10, 7) NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_sero_dropoffs_place_id ON sero_dropoffs(place_id);
@@ -0,0 +1,8 @@
-- وظيفة الملف: استعلام أو تغيير قاعدة بيانات يخص 10 regional place search؛ راجع ترتيب التنفيذ قبل تطبيقه.
-- Run outside a transaction so these indexes do not block map reads/writes.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX CONCURRENTLY IF NOT EXISTS regional_place_names_normalized_trgm_idx
ON regional_place_names USING gin (normalize_arabic(name) gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS regional_place_names_country_prefix_idx
ON regional_place_names (country_code, (normalize_arabic(name)) text_pattern_ops);
ANALYZE regional_place_names;
+3 -1
View File
@@ -1,4 +1,5 @@
#!/bin/bash
# وظيفة الملف: أداة تشغيل أو تجهيز باسم backup db؛ تفاصيل خطواتها ومدخلاتها موضحة في دليل ملفات المشروع.
# ==============================================================================
# SIRO Maps — Daily Database Backup Script
# ==============================================================================
@@ -14,10 +15,11 @@ mkdir -p "$BACKUP_DIR"
echo "💾 [$(date '+%Y-%m-%d %H:%M:%S')] Starting SIRO Maps custom tables backup..."
# Dump custom tables: places_*, place_gates, billing, users, telemetry
# Dump custom tables, including archived legacy gate rows for rollback.
docker compose -f "${APP_DIR}/docker-compose.yml" exec -T db pg_dump -U mapuser -d mapdb \
-t 'places_*' \
-t 'place_gates' \
-t 'place_gates_unverified_archive_20260929' \
-t 'users' \
-t 'api_keys' \
-t 'billing_*' \
+3 -6
View File
@@ -1,11 +1,12 @@
#!/bin/bash
# وظيفة الملف: أداة تشغيل أو تجهيز باسم master cron sync؛ تفاصيل خطواتها ومدخلاتها موضحة في دليل ملفات المشروع.
# ==============================================================================
# SIRO Maps — Master Bi-Monthly Geospatial Synchronization (Every 15 Days)
# ==============================================================================
# This script runs automatically via crontab on the 1st and 15th of every month.
# It checks and syncs:
# 1. OpenStreetMap & Overture Maps for Jordan, Iraq, Syria, and Egypt.
# 2. Gate & Entrance mapping (Hospitals, Malls, Universities, Hotels, Parks).
# 2. Gate & Entrance mapping is handled by the 10-day OSM source sync.
# 3. Administrative boundary resolution (Governorates & Districts).
# 4. Materialized view concurrent refresh (unified_search_index).
# 5. Redis search cache flushing.
@@ -33,11 +34,7 @@ python3 -u scripts/enrich_iraq_comprehensive.py >> "$LOG_FILE" 2>&1 || echo "⚠
echo "🇪🇬 Syncing Egypt places..." >> "$LOG_FILE"
python3 -u scripts/enrich_egypt_places.py >> "$LOG_FILE" 2>&1 || echo "⚠️ Egypt sync had warnings" >> "$LOG_FILE"
# 3. Populate Gates & Entrances (Hospitals, Malls, Universities, Hotels, Parks)
echo "🚪 Updating Place Gates & Entrances..." >> "$LOG_FILE"
python3 -u scripts/populate_place_gates.py --country all >> "$LOG_FILE" 2>&1 || echo "⚠️ Gates update had warnings" >> "$LOG_FILE"
# 4. Flush Redis Search Cache via Python socket
# 3. Flush Redis Search Cache via Python socket
echo "⚡ Flushing Redis search cache..." >> "$LOG_FILE"
python3 -c "
import socket
+13 -295
View File
@@ -1,305 +1,23 @@
#!/usr/bin/env python3
"""
Populate and enrich `place_gates` table for major complexes:
- Hospitals (Main Gate, Emergency & Ambulance Gate, Outpatient/Service Gate)
- Malls & Shopping Centers (Main Entrance, Parking Entrance, Delivery/Service Gate)
- Universities & Colleges (Main Gate, North Gate, South/Student Gate)
- Hotels & Resorts (Main Entrance, Valet/Parking Gate, Service Gate)
- Parks & Public Gardens (Main Entrance, Family Entrance, Secondary Gate)
"""Compatibility entry point for source-backed OSM gate synchronization.
Harvests from:
1. Real OSM gate/entrance nodes (`planet_osm_point` where barrier in ('gate', 'entrance') or entrance is not null)
2. Complex boundary polygons and perimeter road-facing access points
The previous implementation generated guessed gate coordinates around POI
centroids. That behavior has been removed. Use --pbf to import real OSM nodes.
"""
import os
import sys
import math
import time
import argparse
import subprocess
import sys
from pathlib import Path
def haversine(lat1, lon1, lat2, lon2):
R = 6371000 # meters
phi1 = math.radians(lat1)
phi2 = math.radians(lat2)
delta_phi = math.radians(lat2 - lat1)
delta_lambda = math.radians(lon2 - lon1)
a = math.sin(delta_phi / 2)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2)**2
return 2 * R * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def clean_sql_str(val):
if val is None:
return 'NULL'
s = str(val).strip().replace("'", "''")
return f"'{s}'"
def main():
parser = argparse.ArgumentParser(description="Populate place_gates for major complexes.")
parser.add_argument("--db-host", default="127.0.0.1")
parser.add_argument("--db-port", type=int, default=5432)
parser.add_argument("--db-user", default="mapuser")
parser.add_argument("--db-pass", default="mappass")
parser.add_argument("--db-name", default="mapdb")
parser.add_argument("--country", default="all", choices=["jordan", "iraq", "syria", "egypt", "all"])
parser.add_argument("--dry-run", action="store_true")
def main() -> int:
parser = argparse.ArgumentParser(description="Import real OSM gate/entrance nodes; no coordinates are generated.")
parser.add_argument("--pbf", required=True, help="Merged OSM PBF path for the current map-data scope")
args = parser.parse_args()
target = Path(__file__).with_name("sync_osm_place_gates.py")
return subprocess.call([sys.executable, str(target), "--pbf", args.pbf])
import pg8000.native
print("=" * 70)
print("🚪 SIRO Maps — Intelligent Place Gates & Entrances Ingestion")
print("=" * 70)
con = pg8000.native.Connection(
user=args.db_user,
password=args.db_pass,
host=args.db_host,
port=args.db_port,
database=args.db_name
)
countries = ["jordan", "iraq", "syria", "egypt"] if args.country == "all" else [args.country]
# 1. Ensure place_gates schema and indexes
con.run("""
CREATE TABLE IF NOT EXISTS place_gates (
id SERIAL PRIMARY KEY,
place_id VARCHAR(64) NOT NULL,
gate_name_ar VARCHAR(255) NOT NULL,
gate_name_en VARCHAR(255),
latitude NUMERIC(10,7) NOT NULL,
longitude NUMERIC(10,7) NOT NULL,
is_main_gate BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_place_gates_place_id ON place_gates(place_id);
""")
# Get already existing place_ids in place_gates to avoid duplicate inserts
existing_place_ids = set(r[0] for r in con.run("SELECT DISTINCT place_id FROM place_gates;"))
print(f"📌 Already configured places in place_gates: {len(existing_place_ids):,}")
total_gates_to_insert = []
for country in countries:
table_name = f"places_{country}"
prefix = f"places_{country}_"
print(f"\n🔍 Processing {country.upper()} ({table_name})...")
# Select major complexes that benefit from gates
rows = con.run(f"""
SELECT id, name, name_ar, category, latitude, longitude
FROM {table_name}
WHERE latitude IS NOT NULL AND longitude IS NOT NULL
AND (
category ILIKE '%مستشف%' OR category ILIKE '%hospital%'
OR category ILIKE '%مول%' OR category ILIKE '%mall%' OR category ILIKE '%مركز تسوق%'
OR category ILIKE '%جامع%' OR category ILIKE '%university%' OR category ILIKE '%college%'
OR category ILIKE '%فندق%' OR category ILIKE '%hotel%'
OR category ILIKE '%حديق%' OR category ILIKE '%منتزه%' OR category ILIKE '%park%'
OR category ILIKE '%مطار%' OR category ILIKE '%airport%'
OR category ILIKE '%ملعب%' OR category ILIKE '%استاد%' OR category ILIKE '%stadium%'
);
""")
print(f" -> Found {len(rows):,} major complexes.")
count_added = 0
for r in rows:
p_id, p_name, p_name_ar, p_cat, p_lat, p_lon = r
full_place_id = f"{prefix}{p_id}"
if full_place_id in existing_place_ids:
continue
lat = float(p_lat)
lon = float(p_lon)
cat = str(p_cat or '').lower()
gates_for_place = []
# 1. Is it a Hospital? (Emergency Gate + Main Gate + Service Gate)
if any(k in cat for k in ('مستشف', 'hospital', 'طبي')):
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'البوابة الرئيسية',
'gate_name_en': 'Main Entrance',
'latitude': lat + 0.00035,
'longitude': lon,
'is_main_gate': True
})
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'بوابة الطوارئ والإسعاف',
'gate_name_en': 'Emergency & Ambulance Gate',
'latitude': lat - 0.00025,
'longitude': lon + 0.00040,
'is_main_gate': False
})
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'بوابة العيادات الخارجية والخدمات',
'gate_name_en': 'Outpatient Clinics & Service Gate',
'latitude': lat,
'longitude': lon - 0.00040,
'is_main_gate': False
})
# 2. Is it a Mall / Shopping Center?
elif any(k in cat for k in ('مول', 'mall', 'مركز تسوق')):
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'البوابة الرئيسية',
'gate_name_en': 'Main Entrance',
'latitude': lat + 0.00030,
'longitude': lon,
'is_main_gate': True
})
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'بوابة مواقف السيارات',
'gate_name_en': 'Parking Entrance',
'latitude': lat - 0.00030,
'longitude': lon + 0.00030,
'is_main_gate': False
})
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'بوابة الخدمات والشحن',
'gate_name_en': 'Service & Delivery Gate',
'latitude': lat,
'longitude': lon - 0.00035,
'is_main_gate': False
})
# 3. Is it a University / College / Education Campus?
elif any(k in cat for k in ('جامع', 'university', 'college', 'معهد')):
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'البوابة الرئيسية',
'gate_name_en': 'Main Campus Gate',
'latitude': lat + 0.00040,
'longitude': lon,
'is_main_gate': True
})
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'البوابة الشمالية (بوابة الطلاب)',
'gate_name_en': 'North Student Gate',
'latitude': lat + 0.00060,
'longitude': lon + 0.00030,
'is_main_gate': False
})
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'البوابة الجنوبية / الكليات الطبية',
'gate_name_en': 'South / Medical Gate',
'latitude': lat - 0.00050,
'longitude': lon - 0.00030,
'is_main_gate': False
})
# 4. Is it a Hotel / Resort?
elif any(k in cat for k in ('فندق', 'hotel', 'منتجع')):
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'مدخل الفندق الرئيسي (الاستقبال)',
'gate_name_en': 'Main Hotel Entrance / Lobby',
'latitude': lat + 0.00020,
'longitude': lon,
'is_main_gate': True
})
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'مدخل مواقف النزلاء (Valet)',
'gate_name_en': 'Valet & Guest Parking Gate',
'latitude': lat - 0.00025,
'longitude': lon + 0.00025,
'is_main_gate': False
})
# 5. Is it a Park / Stadium / Garden?
elif any(k in cat for k in ('حديق', 'منتزه', 'park', 'ملعب', 'استاد', 'stadium')):
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'البوابة الرئيسية',
'gate_name_en': 'Main Entrance',
'latitude': lat + 0.00040,
'longitude': lon,
'is_main_gate': True
})
gates_for_place.append({
'place_id': full_place_id,
'gate_name_ar': 'بوابة العائلات / البوابة الشرقية',
'gate_name_en': 'Family / East Entrance',
'latitude': lat - 0.00040,
'longitude': lon + 0.00030,
'is_main_gate': False
})
if gates_for_place:
total_gates_to_insert.extend(gates_for_place)
existing_place_ids.add(full_place_id)
count_added += 1
print(f" ✓ Generated gates for {count_added:,} complexes in {country.upper()}.")
print(f"\n🌟 Total Gates to Insert: {len(total_gates_to_insert):,}")
if args.dry_run:
print("💡 Dry run complete. No database changes made.")
con.close()
return
# Insert into place_gates
print(f"\n🚀 Inserting {len(total_gates_to_insert):,} gates into place_gates...")
t_ins = time.time()
batch_size = 2000
total_ins = 0
con.run("BEGIN;")
for i in range(0, len(total_gates_to_insert), batch_size):
batch = total_gates_to_insert[i:i + batch_size]
values = []
for g in batch:
p_id = clean_sql_str(g['place_id'])
g_ar = clean_sql_str(g['gate_name_ar'])
g_en = clean_sql_str(g['gate_name_en'])
g_lat = g['latitude']
g_lon = g['longitude']
g_main = 'TRUE' if g['is_main_gate'] else 'FALSE'
line = f"({p_id}, {g_ar}, {g_en}, {g_lat:.7f}, {g_lon:.7f}, {g_main})"
values.append(line)
sql = (
"INSERT INTO place_gates ("
" place_id, gate_name_ar, gate_name_en, latitude, longitude, is_main_gate"
") VALUES " + ",\n".join(values) + ";"
)
con.run(sql)
total_ins += len(batch)
print(f" -> Progress: {total_ins:,} / {len(total_gates_to_insert):,} gates inserted...")
con.run("COMMIT;")
print(f"✅ Gates insertion committed in {time.time() - t_ins:.2f}s!")
con.run("ANALYZE place_gates;")
total_count = con.run("SELECT count(*) FROM place_gates;")[0][0]
print(f"\n🎯 Total Gates in place_gates: {total_count:,}")
# Sample verification
sample = con.run("""
SELECT place_id, gate_name_ar, gate_name_en, is_main_gate, latitude, longitude
FROM place_gates
ORDER BY id DESC
LIMIT 6;
""")
print("\n🔍 Sample New Gates:")
for s in sample:
print(f" - [{s[0]}] {s[1]} ({s[2]}) - Main: {s[3]} @ {s[4]}, {s[5]}")
con.close()
print("\n🎉 Place Gates enrichment completed successfully!")
if __name__ == '__main__':
main()
if __name__ == "__main__":
raise SystemExit(main())
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""Sync only explicitly mapped OpenStreetMap entrances into place_gates.
Entrances are extracted from the same OSM PBF used for the routing/map update.
The importer associates a node only when it is covered by exactly one named
target facility polygon (hospital/clinic, mall, or government facility).
No coordinates or gate types are inferred from facility centroids.
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import re
import subprocess
import sys
import tempfile
from collections import Counter
from pathlib import Path
APP_DIR = Path(__file__).resolve().parents[1]
COUNTRY_NAMES = {
"AE": "الإمارات", "BH": "البحرين", "EG": "مصر", "IQ": "العراق",
"JO": "الأردن", "KW": "الكويت", "OM": "عُمان", "QA": "قطر",
"SA": "السعودية", "SY": "سوريا", "YE": "اليمن",
}
USEFUL_ENTRANCES = {
"yes", "main", "secondary", "service", "emergency", "parking", "entrance"
}
def run(command: list[str], *, input_text: str | None = None) -> str:
proc = subprocess.run(
command, cwd=APP_DIR, input=input_text, text=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
if proc.returncode:
sys.stderr.write(proc.stderr)
raise RuntimeError(f"Command failed ({proc.returncode}): {' '.join(command)}")
return proc.stdout
def psql(sql: str) -> str:
return run([
"docker", "compose", "exec", "-T", "db", "psql", "-X", "-v",
"ON_ERROR_STOP=1", "-U", os.getenv("DB_USER", "mapuser"), "-d",
os.getenv("DB_NAME", "mapdb"), "-At", "-c", sql,
])
def label_for(tags: dict[str, str]) -> tuple[str, str, str, bool] | None:
entrance = tags.get("entrance", "").lower()
routing_entrance = tags.get("routing:entrance", "").lower()
barrier = tags.get("barrier", "").lower()
if (entrance not in USEFUL_ENTRANCES
and routing_entrance not in {"yes", "main"}
and barrier not in {"gate", "entrance"}):
return None
if tags.get("access", "").lower() in {"no", "private"}:
return None
labels = {
"main": ("المدخل الرئيسي", "Main entrance", "main", True),
"secondary": ("مدخل ثانوي", "Secondary entrance", "secondary", False),
"service": ("مدخل الخدمات", "Service entrance", "service", False),
"emergency": ("مدخل الطوارئ", "Emergency entrance", "emergency", False),
"parking": ("مدخل المواقف", "Parking entrance", "parking", False),
}
if entrance in labels:
fallback = labels[entrance]
elif routing_entrance in {"yes", "main"}:
fallback = ("مدخل", "Entrance", "routing", False)
else:
fallback = ("بوابة" if barrier == "gate" else "مدخل", "Gate" if barrier == "gate" else "Entrance", "gate" if barrier == "gate" else "entrance", False)
name_ar = tags.get("name:ar") or fallback[0]
name_en = tags.get("name:en") or tags.get("name") or fallback[1]
return name_ar, name_en, fallback[2], fallback[3]
def main() -> int:
parser = argparse.ArgumentParser(description="Import source-mapped OSM entrance nodes.")
parser.add_argument("--pbf", required=True, help="The current merged OSM PBF used by the map updater")
parser.add_argument("--dry-run", action="store_true", help="Extract and report exact matches without changing the live table")
args = parser.parse_args()
pbf_path = Path(args.pbf)
if not pbf_path.is_absolute():
pbf_path = APP_DIR / pbf_path
if not pbf_path.is_file():
parser.error(f"OSM source file does not exist: {pbf_path}")
run(["osmium", "--version"])
print(f"Extracting tagged OSM entrances from {pbf_path} …", flush=True)
with tempfile.TemporaryDirectory(prefix="siro-osm-gates-") as temp:
extracted = Path(temp) / "entrances.osm.pbf"
geojson_path = Path(temp) / "entrances.geojson"
run([
"osmium", "tags-filter", str(pbf_path),
"n/entrance", "n/barrier=gate", "n/barrier=entrance",
"n/routing:entrance", "-o", str(extracted),
])
run(["osmium", "export", "-u", "type_id", str(extracted), "-o", str(geojson_path)])
features = json.loads(geojson_path.read_text(encoding="utf-8")).get("features", [])
rows: list[tuple] = []
tag_counts: Counter[str] = Counter()
for feature in features:
osm_node_id = feature.get("id", "")
match = re.fullmatch(r"n(-?\d+)", str(osm_node_id))
geometry = feature.get("geometry") or {}
coordinates = geometry.get("coordinates") or []
tags = feature.get("properties") or {}
if not match or len(coordinates) < 2:
continue
classified = label_for(tags)
if classified is None:
continue
lon, lat = coordinates[:2]
rows.append((
int(match.group(1)), float(lon), float(lat),
tags.get("entrance"), tags.get("routing:entrance"), tags.get("barrier"),
tags.get("access"), tags.get("name"), tags.get("name:ar"),
tags.get("name:en"), *classified,
json.dumps(tags, ensure_ascii=False, separators=(",", ":")),
))
tag_counts[tags.get("entrance") or tags.get("barrier") or "other"] += 1
print(f"Source nodes selected: {len(rows):,}", flush=True)
if not rows:
raise RuntimeError("No usable entrance nodes found; keeping the live gate table unchanged.")
# Stage exact WGS84 source points. This table is not read by the API.
psql("""
CREATE TABLE IF NOT EXISTS place_gates_osm_stage (
osm_id bigint PRIMARY KEY, longitude double precision NOT NULL,
latitude double precision NOT NULL, entrance text, routing_entrance text,
barrier text, access text, name text, name_ar text, name_en text,
gate_name_ar text NOT NULL, gate_name_en text NOT NULL,
gate_type text NOT NULL, is_main_gate boolean NOT NULL,
source_tags jsonb NOT NULL
);
TRUNCATE place_gates_osm_stage;
""")
with tempfile.TemporaryFile(mode="w+t", encoding="utf-8", newline="") as tsv:
writer = csv.writer(tsv, delimiter="\t", lineterminator="\n", quoting=csv.QUOTE_MINIMAL)
writer.writerows(rows)
tsv.seek(0)
run([
"docker", "compose", "exec", "-T", "db", "psql", "-X", "-v",
"ON_ERROR_STOP=1", "-U", os.getenv("DB_USER", "mapuser"), "-d",
os.getenv("DB_NAME", "mapdb"), "-c",
"COPY place_gates_osm_stage (osm_id,longitude,latitude,entrance,routing_entrance,barrier,access,name,name_ar,name_en,gate_name_ar,gate_name_en,gate_type,is_main_gate,source_tags) FROM STDIN WITH (FORMAT csv, DELIMITER E'\\t', NULL '')",
], input_text=tsv.read())
psql("""
ALTER TABLE place_gates_osm_stage ADD COLUMN IF NOT EXISTS geom geometry(Point,3857);
UPDATE place_gates_osm_stage
SET geom=ST_Transform(ST_SetSRID(ST_MakePoint(longitude,latitude),4326),3857);
CREATE INDEX IF NOT EXISTS place_gates_osm_stage_geom_idx
ON place_gates_osm_stage USING gist (geom);
ANALYZE place_gates_osm_stage;
""")
print("Matching source nodes to OSM facility polygons by exact polygon coverage …", flush=True)
report_sql = """
WITH facilities AS (
SELECT p.osm_id, p.way, r.country_code,
'regional_' || r.country_code || '_osm_polygon_' || r.source_id AS place_id
FROM planet_osm_polygon p
JOIN regional_place_names r
ON r.source = 'osm_polygon' AND r.source_id = p.osm_id::text
WHERE p.name IS NOT NULL
AND (p.amenity IN ('hospital','clinic','townhall','courthouse')
OR p.shop = 'mall' OR p.office = 'government' OR p.building = 'government')
), matched AS (
SELECT g.osm_id AS gate_osm_id, f.country_code, f.place_id
FROM place_gates_osm_stage g
JOIN facilities f
ON ST_Covers(f.way, g.geom)
), unique_matches AS (
SELECT gate_osm_id, min(country_code) AS country_code, min(place_id) AS place_id
FROM matched GROUP BY gate_osm_id HAVING count(DISTINCT place_id) = 1
)
SELECT country_code || '|' || count(*) || '|' || count(DISTINCT place_id)
FROM unique_matches GROUP BY country_code ORDER BY country_code;
"""
report = psql(report_sql).strip().splitlines()
for line in report:
code, gates, places = line.split("|")
print(f"{COUNTRY_NAMES.get(code, code)} ({code}): {gates} مصدرًا موثقًا ↔ {places} منشأة", flush=True)
if not report:
raise RuntimeError("No source nodes matched named target facilities; the live gate table is unchanged.")
samples = psql("""
WITH matches AS (
SELECT g.osm_id,g.gate_name_ar,g.gate_type,r.country_code,r.name,
'regional_'||r.country_code||'_osm_polygon_'||r.source_id AS place_id
FROM place_gates_osm_stage g
JOIN planet_osm_polygon p
ON ST_Covers(p.way,g.geom)
JOIN regional_place_names r ON r.source='osm_polygon' AND r.source_id=p.osm_id::text
WHERE p.name IS NOT NULL
AND (p.amenity IN ('hospital','clinic','townhall','courthouse') OR p.shop='mall'
OR p.office='government' OR p.building='government')
), unique_ids AS (
SELECT osm_id,min(place_id) AS place_id FROM matches
GROUP BY osm_id HAVING count(DISTINCT place_id)=1
)
SELECT m.country_code||'|'||m.name||'|'||m.gate_name_ar||'|'||m.gate_type||'|'||m.osm_id
FROM matches m JOIN unique_ids u ON u.osm_id=m.osm_id AND u.place_id=m.place_id
ORDER BY m.country_code,m.name LIMIT 12;
""").strip()
if samples:
print("Source-matched examples (country|facility|gate label|OSM tag type|node id):", flush=True)
print(samples, flush=True)
if args.dry_run:
print("Dry run complete; live place_gates was not changed.", flush=True)
return 0
# Archive unverifiable legacy rows once, then expose only source-backed OSM rows.
sync_sql = """
BEGIN;
ALTER TABLE place_gates ADD COLUMN IF NOT EXISTS source text;
ALTER TABLE place_gates ADD COLUMN IF NOT EXISTS source_id text;
ALTER TABLE place_gates ADD COLUMN IF NOT EXISTS gate_type text;
ALTER TABLE place_gates ADD COLUMN IF NOT EXISTS access text;
ALTER TABLE place_gates ADD COLUMN IF NOT EXISTS source_tags jsonb;
ALTER TABLE place_gates ADD COLUMN IF NOT EXISTS last_seen_at timestamptz;
CREATE TABLE IF NOT EXISTS place_gates_unverified_archive_20260929 AS
SELECT * FROM place_gates WHERE source IS NULL AND false;
INSERT INTO place_gates_unverified_archive_20260929
SELECT * FROM place_gates WHERE source IS NULL;
DELETE FROM place_gates WHERE source IS NULL OR source = 'openstreetmap';
WITH facilities AS (
SELECT p.osm_id, p.way, r.country_code,
'regional_' || r.country_code || '_osm_polygon_' || r.source_id AS place_id
FROM planet_osm_polygon p
JOIN regional_place_names r
ON r.source = 'osm_polygon' AND r.source_id = p.osm_id::text
WHERE p.name IS NOT NULL
AND (p.amenity IN ('hospital','clinic','townhall','courthouse')
OR p.shop = 'mall' OR p.office = 'government' OR p.building = 'government')
), matches AS (
SELECT g.*, f.place_id
FROM place_gates_osm_stage g
JOIN facilities f
ON ST_Covers(f.way, g.geom)
), unique_matches AS (
SELECT osm_id, min(place_id) AS place_id FROM matches
GROUP BY osm_id HAVING count(DISTINCT place_id) = 1
), exact_matches AS (
SELECT DISTINCT ON (m.osm_id) m.*
FROM matches m JOIN unique_matches u ON u.osm_id=m.osm_id AND u.place_id=m.place_id
)
INSERT INTO place_gates
(place_id,gate_name_ar,gate_name_en,latitude,longitude,is_main_gate,source,source_id,gate_type,access,source_tags,last_seen_at)
SELECT place_id,gate_name_ar,gate_name_en,latitude,longitude,is_main_gate,
'openstreetmap',osm_id::text,gate_type,access,source_tags,now()
FROM exact_matches;
CREATE INDEX IF NOT EXISTS idx_place_gates_source_id ON place_gates(source,source_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_place_gates_osm_identity
ON place_gates(source,source_id,place_id) WHERE source='openstreetmap';
ANALYZE place_gates;
COMMIT;
"""
psql(sync_sql)
totals = psql("SELECT source || '|' || count(*) FROM place_gates GROUP BY source ORDER BY source;")
print("Live place_gates rows (source|count):", flush=True)
print(totals.strip(), flush=True)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc: # preserve the existing live table on any failed preflight/import
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(1)