Backup before syncing server scripts
This commit is contained in:
@@ -6,6 +6,7 @@ import type { Cache } from 'cache-manager';
|
||||
import { PlaceSyria } from './entities/place-syria.entity';
|
||||
import { PlaceJordan } from './entities/place-jordan.entity';
|
||||
import { PlaceEgypt } from './entities/place-egypt.entity';
|
||||
import { PlaceIraq } from './entities/place-iraq.entity';
|
||||
import { BasePlace } from './entities/base-place.entity';
|
||||
import { OsmArea } from './entities/osm-area.entity';
|
||||
import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
|
||||
@@ -23,6 +24,8 @@ export class GeocodingService {
|
||||
private placesJordanRepository: Repository<PlaceJordan>,
|
||||
@InjectRepository(PlaceEgypt)
|
||||
private placesEgyptRepository: Repository<PlaceEgypt>,
|
||||
@InjectRepository(PlaceIraq)
|
||||
private placesIraqRepository: Repository<PlaceIraq>,
|
||||
@InjectRepository(OsmArea)
|
||||
private osmAreasRepository: Repository<OsmArea>,
|
||||
@InjectRepository(OsmPointWithArea)
|
||||
@@ -35,6 +38,11 @@ export class GeocodingService {
|
||||
* تحديد المستودع المناسب بناءً على الإحداثيات الجغرافية
|
||||
*/
|
||||
private getRepositoryForCoords(lat: number, lng: number): Repository<BasePlace> {
|
||||
// Iraq checked first: its western desert (Anbar) extends to lng ~38.7, which
|
||||
// overlaps the Jordan/Syria box below (lng <= 42.5). Order matters here.
|
||||
if (lat >= 29 && lat <= 37.5 && lng >= 38.7 && lng <= 48.8) {
|
||||
return (this.placesIraqRepository as unknown) as Repository<BasePlace>;
|
||||
}
|
||||
if (lat >= 29 && lat <= 37.5 && lng >= 34.5 && lng <= 42.5) {
|
||||
if (lat > 32.5 && lng > 35.8) return (this.placesSyriaRepository as unknown) as Repository<BasePlace>;
|
||||
return (this.placesJordanRepository as unknown) as Repository<BasePlace>;
|
||||
@@ -47,6 +55,9 @@ export class GeocodingService {
|
||||
|
||||
private identifyRegion(lat?: number, lng?: number): string | undefined {
|
||||
if (lat === undefined || lng === undefined) return undefined;
|
||||
if (lat >= 29 && lat <= 37.5 && lng >= 38.7 && lng <= 48.8) {
|
||||
return 'iraq';
|
||||
}
|
||||
if (lat >= 29 && lat <= 37.5 && lng >= 34.5 && lng <= 42.5) {
|
||||
if (lat > 32.5 && lng > 35.8) return 'syria';
|
||||
return 'jordan';
|
||||
@@ -60,6 +71,7 @@ export class GeocodingService {
|
||||
private getTableNameForRepo(repo: Repository<BasePlace>): string {
|
||||
if (repo === (this.placesJordanRepository as unknown)) return 'places_jordan';
|
||||
if (repo === (this.placesEgyptRepository as unknown)) return 'places_egypt';
|
||||
if (repo === (this.placesIraqRepository as unknown)) return 'places_iraq';
|
||||
return 'places_syria';
|
||||
}
|
||||
|
||||
@@ -98,7 +110,7 @@ export class GeocodingService {
|
||||
}
|
||||
|
||||
let regionCondition = '';
|
||||
if (targetRegion && ['syria', 'jordan', 'egypt'].includes(targetRegion)) {
|
||||
if (targetRegion && ['syria', 'jordan', 'egypt', 'iraq'].includes(targetRegion)) {
|
||||
regionCondition = `AND (region = '${targetRegion}' OR region = 'global')`;
|
||||
}
|
||||
|
||||
@@ -251,6 +263,7 @@ export class GeocodingService {
|
||||
|
||||
private getRepoByTableName(tableName: string): Repository<any> {
|
||||
if (tableName === 'places_egypt') return this.placesEgyptRepository;
|
||||
if (tableName === 'places_iraq') return this.placesIraqRepository;
|
||||
if (tableName === 'places_jordan') return this.placesJordanRepository;
|
||||
return this.placesSyriaRepository;
|
||||
}
|
||||
@@ -296,7 +309,7 @@ export class GeocodingService {
|
||||
|
||||
let queryParams: any[] = [`${normalizedQuery}%`];
|
||||
let regionCondition = '';
|
||||
if (targetRegion && ['syria', 'jordan', 'egypt'].includes(targetRegion)) {
|
||||
if (targetRegion && ['syria', 'jordan', 'egypt', 'iraq'].includes(targetRegion)) {
|
||||
regionCondition = `AND (region = '${targetRegion}' OR region = 'global')`;
|
||||
}
|
||||
|
||||
@@ -336,10 +349,62 @@ export class GeocodingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* العنوان العراقي بنية رقمية: محافظة ← منطقة ← محلة ← زقاق ← دار.
|
||||
* المحلة والزقاق هما ما يستعمله الناس فعلياً، لا اسم الشارع.
|
||||
*
|
||||
* في OSM يُخزَّن الزقاق كاسم الطريق نفسه بصيغة "647-19" (محلة-زقاق) داخل
|
||||
* planet_osm_line — وهو الجدول الوحيد الذي يحملها، ولم يكن reverseGeocode
|
||||
* يستعلمه إطلاقاً. لذا كان السائق يقف داخل زقاق 19 ولا نملك ما نسمّيه به.
|
||||
*
|
||||
* هندسة planet_osm_line بـ SRID 3857 (افتراضي osm2pgsql بلا -E)، لذا نحوّل
|
||||
* نقطة البحث إليها ليعمل فهرس GIST؛ ثم نقيس المسافة الحقيقية على geography
|
||||
* للفائز وحده — فمسافات 3857 منتفخة بنحو 19% عند خط عرض بغداد.
|
||||
*/
|
||||
private async findIraqiAddress(lat: number, lng: number) {
|
||||
const MAX_DISTANCE_M = 150; // أبعد من ذلك لم يعد الزقاق وصفاً للموقع
|
||||
try {
|
||||
const rows = await this.osmPointsRepository.query(
|
||||
`
|
||||
WITH p AS (
|
||||
SELECT ST_Transform(ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326), 3857) AS g3857,
|
||||
ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)::geography AS geog
|
||||
)
|
||||
SELECT l.name,
|
||||
ST_Distance(ST_Transform(l.way, 4326)::geography, p.geog) AS distance
|
||||
FROM planet_osm_line l, p
|
||||
WHERE l.name ~ '^[0-9]{2,4}-[0-9]{1,3}$'
|
||||
ORDER BY l.way <-> p.g3857
|
||||
LIMIT 1
|
||||
`,
|
||||
[lng, lat],
|
||||
);
|
||||
|
||||
const row = rows?.[0];
|
||||
if (!row || Number(row.distance) > MAX_DISTANCE_M) return null;
|
||||
|
||||
const [mahalla, zuqaq] = String(row.name).split('-');
|
||||
return {
|
||||
mahalla,
|
||||
zuqaq,
|
||||
distance: Number(row.distance),
|
||||
text: `محلة ${mahalla}، زقاق ${zuqaq}`,
|
||||
};
|
||||
} catch (e) {
|
||||
this.logger.warn(`Iraqi address lookup failed: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async reverseGeocode(lat: number, lng: number) {
|
||||
try {
|
||||
const repo = this.getRepositoryForCoords(lat, lng);
|
||||
const tableName = this.getTableNameForRepo(repo);
|
||||
// نطلقه بالتوازي مع بقية الاستعلامات؛ العراق وحده يدفع تكلفته
|
||||
const iraqiAddressPromise =
|
||||
this.identifyRegion(lat, lng) === 'iraq'
|
||||
? this.findIraqiAddress(lat, lng)
|
||||
: Promise.resolve(null);
|
||||
const queryPromises: Promise<any[]>[] = [];
|
||||
|
||||
queryPromises.push(repo.query(`
|
||||
@@ -402,7 +467,10 @@ export class GeocodingService {
|
||||
ORDER BY location <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
|
||||
`, [lng, lat]));
|
||||
|
||||
const results = await Promise.allSettled(queryPromises);
|
||||
const [results, iraqiAddress] = await Promise.all([
|
||||
Promise.allSettled(queryPromises),
|
||||
iraqiAddressPromise,
|
||||
]);
|
||||
let allResults: any[] = [];
|
||||
results.forEach(res => {
|
||||
if (res.status === 'fulfilled' && res.value) allResults.push(...res.value);
|
||||
@@ -413,7 +481,17 @@ export class GeocodingService {
|
||||
.slice(0, 5)
|
||||
.map(r => {
|
||||
const distance = Number(r.distance);
|
||||
const fullAddressParts = [r.name_ar || r.name, r.address, r.neighbourhood, r.district, r.governorate].filter(Boolean);
|
||||
const ZUQAQ_NAME_RE = /^[0-9]{2,4}-[0-9]{1,3}$/;
|
||||
const rawName = r.name_ar || r.name;
|
||||
const nameIsZuqaq = typeof rawName === 'string' && ZUQAQ_NAME_RE.test(rawName);
|
||||
const fullAddressParts = [
|
||||
nameIsZuqaq ? null : rawName, // "605-9" كنتيجة بحث لا يضيف شيئاً فوق "زقاق 9"
|
||||
r.address,
|
||||
iraqiAddress?.text,
|
||||
r.neighbourhood,
|
||||
r.district,
|
||||
r.governorate,
|
||||
].filter(Boolean);
|
||||
|
||||
let humanReadable = r.name_ar || r.name;
|
||||
if (distance <= 20) {
|
||||
@@ -430,8 +508,18 @@ export class GeocodingService {
|
||||
humanReadable = [streetPart, districtPart].filter(Boolean).join('، ');
|
||||
}
|
||||
|
||||
// في العراق المحلة والزقاق هما العنوان الفعلي، فيتصدّران الوصف
|
||||
// ويبقى المعلم القريب لاحقةً توضيحية — إلا إذا كان "المعلم" نفسه
|
||||
// مجرد اسم زقاق آخر (مثل "605-9")، فتكرار الرقم لا يفيد أحداً.
|
||||
if (iraqiAddress) {
|
||||
const landmark = distance <= 70 && !nameIsZuqaq ? rawName : null;
|
||||
humanReadable = [iraqiAddress.text, landmark].filter(Boolean).join(' — ');
|
||||
}
|
||||
|
||||
return {
|
||||
...r,
|
||||
mahalla: iraqiAddress?.mahalla,
|
||||
zuqaq: iraqiAddress?.zuqaq,
|
||||
latitude: parseFloat(r.latitude),
|
||||
longitude: parseFloat(r.longitude),
|
||||
human_readable_address: humanReadable,
|
||||
@@ -444,20 +532,50 @@ export class GeocodingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* يقبل lat/lng أو latitude/longitude ويرفض ما ليس رقماً.
|
||||
*
|
||||
* سبب وجوده: العملاء يرسلون {lat, lng} (كما في POST /geocoding/places)، بينما
|
||||
* addPlace كان يقرأ data.latitude فقط → Number(undefined) = NaN. عندها تفشل كل
|
||||
* مقارنات getRepositoryForCoords فيسقط الاستدعاء على مستودع سوريا الافتراضي،
|
||||
* فيظهر مكان أردني في دمشق بإحداثيات تالفة. الصمت هنا أسوأ من الخطأ.
|
||||
*/
|
||||
private extractCoords(data: any): { lat: number; lng: number } {
|
||||
const lat = Number(data?.lat ?? data?.latitude);
|
||||
const lng = Number(data?.lng ?? data?.longitude);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
|
||||
throw new HttpException(
|
||||
'Invalid coordinates: provide numeric lat/lng (or latitude/longitude)',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
||||
throw new HttpException(
|
||||
`Coordinates out of range: lat=${lat}, lng=${lng}`,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
return { lat, lng };
|
||||
}
|
||||
|
||||
async addPlace(data: Partial<BasePlace>) {
|
||||
try {
|
||||
const lat = Number(data.latitude), lng = Number(data.longitude);
|
||||
const { lat, lng } = this.extractCoords(data);
|
||||
const repo = this.getRepositoryForCoords(lat, lng), tableName = this.getTableNameForRepo(repo);
|
||||
const newPlace = repo.create({ ...data, latitude: lat, longitude: lng, created_at: new Date(), location: { type: 'Point', coordinates: [lng, lat] } });
|
||||
const savedPlace = await repo.save(newPlace);
|
||||
await repo.query(`UPDATE ${tableName} SET location = ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) WHERE id = $3`, [lng, lat, savedPlace.id]);
|
||||
return { ...savedPlace, latitude: lat, longitude: lng, location: `POINT(${lng} ${lat})` };
|
||||
} catch (error) { throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); }
|
||||
} catch (error) {
|
||||
// لا نبتلع أخطاء التحقق (400) ونحولها إلى 500
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async upsertPlace(data: Partial<BasePlace>) {
|
||||
try {
|
||||
const lat = Number(data.latitude), lng = Number(data.longitude);
|
||||
const { lat, lng } = this.extractCoords(data);
|
||||
const repo = this.getRepositoryForCoords(lat, lng), tableName = this.getTableNameForRepo(repo);
|
||||
const existing = await repo.query(`SELECT id, name FROM ${tableName} WHERE ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) < 15 LIMIT 1`, [lng, lat]);
|
||||
if (existing && existing.length > 0) {
|
||||
@@ -469,17 +587,48 @@ export class GeocodingService {
|
||||
} catch (error) { throw error; }
|
||||
}
|
||||
|
||||
/**
|
||||
* الاستيراد الجملي (يستخدمه السكرابر). كان يبتلع كل خطأ بصمت
|
||||
* (`catch (e) {}`) ويعيد عدد الناجحين فقط — فاستيراد 10 آلاف مكان يسقط منه
|
||||
* 3 آلاف دون أثر ولا سبب. الآن نُعيد أول 50 خطأ مع رقم السطر.
|
||||
*/
|
||||
async upsertBatch(places: Partial<BasePlace>[]) {
|
||||
const results: any[] = [];
|
||||
for (const place of places) { try { const r = await this.addPlace(place); results.push({ id: r.id, action: 'created' }); } catch (e) {} }
|
||||
return { total: places.length, processed: results.length, created: results.length, updated: 0 };
|
||||
const errors: { index: number; name?: string; reason: string }[] = [];
|
||||
|
||||
for (const [index, place] of places.entries()) {
|
||||
try {
|
||||
const r = await this.addPlace(place);
|
||||
results.push({ id: r.id, action: 'created' });
|
||||
} catch (e) {
|
||||
if (errors.length < 50) {
|
||||
errors.push({
|
||||
index,
|
||||
name: (place as any)?.name,
|
||||
reason: e instanceof HttpException ? e.message : (e?.message ?? 'unknown'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const failed = places.length - results.length;
|
||||
return {
|
||||
total: places.length,
|
||||
processed: results.length,
|
||||
created: results.length,
|
||||
updated: 0,
|
||||
failed,
|
||||
errors,
|
||||
errorsTruncated: failed > errors.length,
|
||||
};
|
||||
}
|
||||
|
||||
async getRecentPlaces(limit: number = 50) {
|
||||
const s = await this.placesSyriaRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
||||
const j = await this.placesJordanRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
||||
const e = await this.placesEgyptRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
||||
return [...s, ...j, ...e].sort((a, b) => b.created_at.getTime() - a.created_at.getTime()).slice(0, limit);
|
||||
const i = await this.placesIraqRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
||||
return [...s, ...j, ...e, ...i].sort((a, b) => b.created_at.getTime() - a.created_at.getTime()).slice(0, limit);
|
||||
}
|
||||
|
||||
async getAllPlacesGeoJSON() {
|
||||
@@ -488,6 +637,7 @@ export class GeocodingService {
|
||||
SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_syria
|
||||
UNION ALL SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_jordan
|
||||
UNION ALL SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_egypt
|
||||
UNION ALL SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_iraq
|
||||
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Building') as name, 'building' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_building WHERE names->>'primary' IS NOT NULL LIMIT 500
|
||||
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Street') as name, 'street' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_segment WHERE names->>'primary' IS NOT NULL LIMIT 500
|
||||
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Place') as name, 'place' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_place WHERE names->>'primary' IS NOT NULL LIMIT 500
|
||||
@@ -520,6 +670,7 @@ export class GeocodingService {
|
||||
case 'syria': return this.placesSyriaRepository;
|
||||
case 'jordan': return this.placesJordanRepository;
|
||||
case 'egypt': return this.placesEgyptRepository;
|
||||
case 'iraq': return this.placesIraqRepository;
|
||||
default: throw new HttpException('Invalid country: ' + country, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user