Update codebase
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user