305 lines
14 KiB
PHP
305 lines
14 KiB
PHP
<?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;
|
||
}
|
||
}
|