Files
Siro/backend/sms/ride_request.php

305 lines
14 KiB
PHP
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}