Update codebase

This commit is contained in:
Hamza-Ayed
2026-08-09 16:56:13 +03:00
parent 95e2e4f35d
commit b64debaa88
1058 changed files with 164327 additions and 113928 deletions
+304
View File
@@ -0,0 +1,304 @@
<?php
/**
* sms/ride_request.php — من رسالة نصية إلى رحلة فعلية
* ─────────────────────────────────────────────────────────────
* ‏الخطوات: تحليل ← إيجاد/إنشاء حساب الراكب ← تسجيل الطلب ← إنشاء
* ‏الرحلة عبر add_ride.php ← الرد على الراكب.
*
* ‏لا نكرّر منطق add_ride هنا: هو من يسعّر ويبني الحمولة ويشغّل الإسناد
* ‏ويكتب للقاعدتين. نسخة ثانية منه كانت ستتباعد عن الأصل مع أول تعديل —
* ‏نفس الدرس المستفاد من مسار الحجز المسبق.
*/
require_once __DIR__ . '/helpers.php';
require_once __DIR__ . '/parser.php';
require_once __DIR__ . '/gemini_parser.php';
/** ‏نوع السيارة الافتراضي لطلبات الرسائل — الراكب لا يستطيع الاختيار. */
const SMS_DEFAULT_CAR_TYPE = 'Speed';
/** ‏سرعة متوسطة في المدينة (كم/س) لتقدير الزمن. */
const SMS_AVG_SPEED_KMH = 25.0;
/**
* ‏يجد الراكب برقمه، أو ينشئ له حساباً.
*
* ‏قرار المالك: الرقم غير المسجّل يُنشأ له حساب وتُكمَل رحلته. المنطق
* ‏سليم — من لجأ للرسائل لأنه لا يستطيع فتح التطبيق لن يستطيع التسجيل
* ‏فيه أيضاً، وردّه إلى التسجيل يعني إقصاء الشريحة المستهدفة كلها.
*
* ‏المعرّف مشتقّ من الرقم بنفس صيغة auth/otp/verify.php
* ‏(`substr(md5($phone), 0, 16)`) لا عشوائياً — فلو سجّل الراكب لاحقاً
* ‏عبر التطبيق وقع على الحساب ذاته، وبقي تاريخ رحلاته وتقييمه معه.
*
* @return string|null ‏معرّف الراكب، أو null عند الفشل.
*/
function smsResolvePassenger(PDO $con, string $phoneNorm): ?string
{
global $encryptionHelper, $blindIndex;
// ‏الصيغة المحلية (07…) هي ما يُسجَّل به عادةً في التطبيق، والصيغة
// ‏الدولية (9627…) هي ما يصل من الشبكة. نبحث بالاثنتين وإلا صار
// ‏للراكب الواحد حسابان.
$cc = SMS_COUNTRY_CODES[getenv('APP_COUNTRY') ?: 'Jordan'] ?? '962';
$variants = array_unique(array_filter([
$phoneNorm,
'+' . $phoneNorm,
str_starts_with($phoneNorm, $cc) ? '0' . substr($phoneNorm, strlen($cc)) : null,
]));
foreach ($variants as $v) {
try {
$bidx = $blindIndex ? $blindIndex->index('passengers.phone', $v) : null;
$stmt = $con->prepare(
"SELECT id FROM passengers WHERE phone = ? OR (? IS NOT NULL AND phone_bidx = ?) LIMIT 1"
);
$stmt->execute([
$encryptionHelper ? $encryptionHelper->encryptData($v) : $v,
$bidx, $bidx,
]);
$id = $stmt->fetchColumn();
if ($id) return (string) $id;
} catch (PDOException $e) {
error_log('[sms] فشل البحث عن الراكب: ' . $e->getMessage());
return null;
}
}
// ── لم يُوجد: ننشئ حساباً بالحد الأدنى ──────────────────
// ‏نستعمل الصيغة المحلية للحفظ لتطابق ما يكتبه من يسجّل بالتطبيق.
$localPhone = str_starts_with($phoneNorm, $cc)
? '0' . substr($phoneNorm, strlen($cc))
: $phoneNorm;
$id = substr(md5($localPhone), 0, 16);
try {
$enc = static fn(string $v): string =>
$encryptionHelper ? $encryptionHelper->encryptData($v) : $v;
// ‏بريد اصطناعي: العمود NOT NULL وعليه مفتاح فريد مركّب مع
// ‏الهاتف. لا يُستعمل للمراسلة، ونطاق invalid محجوز قياسياً
// ‏(RFC 2606) فلا يصل منه شيء إلى أحد بالخطأ.
$email = $id . '@sms.invalid';
$con->prepare("
INSERT INTO passengers
(id, first_name, last_name, email, phone, password, gender, birthdate,
site, sosPhone, education, employmentType, maritalStatus, status,
created_at, updated_at, phone_bidx, email_bidx, phone_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', NOW(), NOW(), ?, ?, ?)
")->execute([
$id,
$enc('راكب'),
$enc('SMS'),
$enc($email),
$enc($localPhone),
// ‏كلمة مرور عشوائية غير قابلة للاستعمال: الحساب لا يُدخَل
// ‏إليه بكلمة مرور، والدخول الوحيد الممكن عبر OTP لاحقاً.
password_hash(bin2hex(random_bytes(32)), PASSWORD_DEFAULT),
$enc('none'), $enc('none'), $enc('sms'), $enc('sos'),
$enc('none'), $enc('none'), $enc('none'),
$blindIndex ? $blindIndex->index('passengers.phone', $localPhone) : null,
$blindIndex ? $blindIndex->index('passengers.email', $email) : null,
function_exists('otpPhoneKey') ? otpPhoneKey($localPhone) : null,
]);
error_log("[sms] أُنشئ حساب راكب جديد #$id من رسالة نصية");
return $id;
} catch (PDOException $e) {
// ‏سباق: رسالتان متتاليتان من نفس الرقم قد تنشئان الحساب معاً.
// ‏المعرّف مشتقّ من الرقم فالثانية تصطدم بالمفتاح الأساسي —
// ‏وهذا يعني أن الحساب صار موجوداً، وهو ما نريد.
if ($e->getCode() === '23000') {
return $id;
}
error_log('[sms] تعذّر إنشاء حساب الراكب: ' . $e->getMessage());
return null;
}
}
/**
* ‏ينشئ الرحلة عبر add_ride.php بمسار S2S.
*
* @return string|null ‏رقم الرحلة أو null.
*/
function smsCreateRide(array $req): ?string
{
$url = (getenv('INTERNAL_API_BASE') ?: 'http://nginx') . '/ride/rides/add_ride.php';
$fields = [
'passenger_id' => $req['passenger_id'],
'start_location' => $req['start_location'],
'end_location' => $req['end_location'],
'start_name' => $req['start_name'] ?? '',
'end_name' => $req['end_name'] ?? '',
'carType' => SMS_DEFAULT_CAR_TYPE,
'distance' => $req['distance'],
'duration' => $req['duration'],
// ‏قيمة رمزية: add_ride يسعّر داخلياً، لكن حارس الحقول المطلوبة
// ‏يرفض السعر الفارغ.
'price' => '1',
'source' => 'sms',
'sms_request_id' => $req['id'],
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($fields),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded',
'X-S2S-Api-Key: ' . getenv('S2S_SHARED_KEY'),
],
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200 || !$body) {
error_log("[sms] add_ride رمز=$code رد=" . substr((string) $body, 0, 200));
return null;
}
$json = json_decode($body, true);
$rideId = $json['message'] ?? $json['data'] ?? null;
return ($rideId && is_scalar($rideId)) ? (string) $rideId : null;
}
/**
* ‏المعالجة الكاملة لصف واحد من sms_ride_requests.
*
* ‏يُستدعى من الويب‌هوك مباشرةً (للسرعة) ومن الكرون (لإعادة المحاولة).
* ‏القفل التفاؤلي يمنع المعالجة المزدوجة حين يتزامن الاثنان.
*/
function smsProcessRequest(PDO $con, array $req): bool
{
// ‏قفل: من ينجح في نقل الحالة إلى 'dispatching' هو من يعالج.
$lock = $con->prepare("
UPDATE sms_ride_requests
SET status = 'dispatching', attempts = attempts + 1
WHERE id = ? AND status = 'pending'
");
$lock->execute([$req['id']]);
if ($lock->rowCount() === 0) {
return false;
}
$rideId = smsCreateRide($req);
if ($rideId) {
$con->prepare("UPDATE sms_ride_requests SET status='dispatched', ride_id=? WHERE id=?")
->execute([$rideId, $req['id']]);
smsEnqueue(
$con,
$req['sender_norm'],
"سيرو: استلمنا طلبك من {$req['start_name']} إلى {$req['end_name']}."
. " نبحث لك عن سائق الآن، وسنرسل لك اسمه ورقم لوحته حال قبوله.",
'ride_created',
$rideId
);
error_log("[sms] طلب #{$req['id']} → رحلة #$rideId");
return true;
}
// ‏نعيده 'pending' لا 'failed' ما دامت المحاولات قليلة: فشل شبكي
// ‏عابر لا يجوز أن يحرم راكباً لا يملك وسيلة أخرى للطلب.
if ((int) $req['attempts'] >= 2) {
$con->prepare("UPDATE sms_ride_requests SET status='failed', fail_reason=? WHERE id=?")
->execute(['add_ride failed', $req['id']]);
smsEnqueue($con, $req['sender_norm'],
'سيرو: تعذّر إنشاء رحلتك حالياً. حاول بعد قليل أو اتصل بنا.',
'error', (string) $req['id']);
} else {
$con->prepare("UPDATE sms_ride_requests SET status='pending' WHERE id=?")
->execute([$req['id']]);
}
return false;
}
/**
* ‏من رسالة خام إلى طلب مسجَّل. يرد بالصف الجاهز للمعالجة أو null،
* ‏وقد أرسل للراكب رسالة الاستيضاح المناسبة إن تعذّر الفهم.
*/
function smsIntakeRideMessage(PDO $con, string $senderNorm, string $body, ?int $inboxId): ?array
{
$parsed = smsParseRideRequestSmart($con, $body);
if (!$parsed['ok']) {
// ‏رسالة الاستيضاح تختلف باختلاف سبب الفشل — «لم أفهم صيغتك»
// ‏و«لا أعرف هذا المكان» يحتاجان تصرّفين مختلفين من الراكب.
$reply = match ($parsed['reason'] ?? '') {
'place', 'incomplete' => 'سيرو: لم نتعرّف على «'
. ($parsed['missing'] ?? 'المكان')
. '». جرّب اسماً أشهر قريباً منه، مثل: من الجامعة الأردنية إلى المستشفى التخصصي',
'not_ride' => '', // ‏ليست طلب رحلة أصلاً — لا نزعج المرسل بردّ.
default => 'سيرو: اكتب طلبك هكذا: من [مكانك] إلى [وجهتك]'
. "\nمثال: من الجامعة الأردنية إلى المستشفى التخصصي",
};
if ($reply !== '') {
smsEnqueue($con, $senderNorm, $reply, 'parse_error');
}
if ($inboxId && ($parsed['reason'] ?? '') !== 'not_ride') {
$con->prepare("
INSERT INTO sms_ride_requests (inbox_id, sender_norm, status, fail_reason)
VALUES (?, ?, 'unparsed', ?)
")->execute([$inboxId, $senderNorm, $parsed['reason'] ?? 'unknown']);
}
return null;
}
$passengerId = smsResolvePassenger($con, $senderNorm);
if (!$passengerId) {
smsEnqueue($con, $senderNorm,
'سيرو: تعذّر تجهيز حسابك حالياً. حاول بعد قليل.', 'error');
return null;
}
$km = smsHaversineKm($parsed['start']['lat'], $parsed['start']['lng'],
$parsed['end']['lat'], $parsed['end']['lng']);
$secs = (int) round(($km / SMS_AVG_SPEED_KMH) * 3600);
$startLoc = $parsed['start']['lat'] . ',' . $parsed['start']['lng'];
$endLoc = $parsed['end']['lat'] . ',' . $parsed['end']['lng'];
try {
$con->prepare("
INSERT INTO sms_ride_requests
(inbox_id, sender_norm, passenger_id, raw_start, raw_end,
start_location, end_location, start_name, end_name,
distance, duration, status)
VALUES (?,?,?,?,?,?,?,?,?,?,?, 'pending')
")->execute([
$inboxId, $senderNorm, $passengerId,
$parsed['raw_start'] ?? null, $parsed['raw_end'] ?? null,
$startLoc, $endLoc,
$parsed['start']['name'], $parsed['end']['name'],
round($km, 3), $secs,
]);
return [
'id' => (int) $con->lastInsertId(),
'sender_norm' => $senderNorm,
'passenger_id' => $passengerId,
'start_location' => $startLoc,
'end_location' => $endLoc,
'start_name' => $parsed['start']['name'],
'end_name' => $parsed['end']['name'],
'distance' => round($km, 3),
'duration' => $secs,
'attempts' => 0,
];
} catch (PDOException $e) {
error_log('[sms] تعذّر تسجيل الطلب: ' . $e->getMessage());
return null;
}
}