Files
nabeh/backend/app/Core/Flows/DriverRegistrationFlow.php

511 lines
24 KiB
PHP

<?php
namespace App\Core\Flows;
use App\Services\GeminiService;
use App\Services\SiroService;
use App\Models\DriverOcrData;
use App\Models\ChatbotRule;
use App\Models\DriverReminder;
use App\Core\Database;
/**
* DriverRegistrationFlow
* Handles step-by-step driver and vehicle registration using Gemini OCR.
* Integrates with Siro platform for driver registration across Syria, Jordan, and Egypt.
*/
class DriverRegistrationFlow extends BaseFlow
{
private array $prompts = [];
private string $country = 'syria';
private array $stepToDocType = [
'id_front' => 'id_front',
'id_back' => 'id_back',
'driving_license_front' => 'driver_license_front',
'driving_license_back' => 'driver_license_back',
'vehicle_license_front' => 'car_license_front',
'vehicle_license_back' => 'car_license_back',
'criminal_record' => 'criminal_record',
];
public function __construct()
{
$this->prompts = SiroService::getDocumentPrompts($this->country);
}
public function handleStep(string $step, array $messageData, array &$context): FlowResult
{
$text = isset($messageData['body']) ? trim($messageData['body']) : '';
$phone = $messageData['phone'];
$companyId = $context['company_id'] ?? 1;
// Detect country from phone number
$this->country = SiroService::detectCountry($phone);
$context['country'] = $this->country;
// Set country-specific prompts
$this->prompts = SiroService::getDocumentPrompts($this->country);
// Country name in Arabic for messages
$countryNames = [
'syria' => 'سوريا',
'jordan' => 'الأردن',
'egypt' => 'مصر',
];
$countryName = $countryNames[$this->country] ?? 'سوريا';
// App name based on country
$appNames = [
'syria' => 'سيرو',
'jordan' => 'سيرو',
'egypt' => 'سيرو',
];
$appName = $appNames[$this->country] ?? 'سيرو';
// If currently postponed and user sends a message, resume the flow
if ($step === 'postponed') {
$activeReminder = DriverReminder::findActive($companyId, $phone);
if ($activeReminder) {
DriverReminder::update($activeReminder['id'], ['status' => 'cancelled']);
}
$step = $context['previous_step'] ?? 'ask_name';
}
// Check if user requests postponement/delay (only if already started and not finished)
if ($step !== 'start' && $step !== 'finished' && !empty($text)) {
$rule = ChatbotRule::findActiveForRule($companyId);
$configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null;
$apiKey = GeminiService::getGeminiApiKey($configuredGeminiKey);
if (!empty($apiKey)) {
$postponeData = $this->detectPostponement($text, $apiKey, $companyId);
if ($postponeData !== null) {
$hours = $postponeData['hours'];
$postponeCount = ($context['postpone_count'] ?? 0) + 1;
if ($postponeCount > 3) {
return new FlowResult(
"عذراً كابتن، لقد تجاوزت الحد الأقصى لمرات التأجيل (3 مرات). تم إلغاء طلب التسجيل الحالي. يمكنك البدء من جديد عندما تكون جاهزاً بكتابة 'تسجيل'.",
"finished",
true
);
}
$context['postpone_count'] = $postponeCount;
$context['previous_step'] = $step;
$scheduledAt = date('Y-m-d H:i:s', strtotime("+{$hours} hours"));
DriverReminder::saveReminder([
'company_id' => $companyId,
'phone' => $phone,
'scheduled_at' => $scheduledAt,
'postpone_count' => $postponeCount,
'status' => 'pending'
]);
return new FlowResult(
"حاضر كابتن، قمت بتأجيل التسجيل. سأقوم بتذكيرك بعد {$hours} ساعة لإكمال خطوات التسجيل. بالتوفيق!",
"postponed"
);
}
}
}
switch ($step) {
case 'start':
return new FlowResult(
"أهلاً بك كابتن في خدمة تسجيل كباتن تطبيق {$appName} في {$countryName} 🚖.\nيرجى إرسال اسمك الثلاثي الكامل للبدء:",
"ask_name"
);
case 'ask_name':
if (empty($text)) {
return new FlowResult("يرجى إدخال اسمك الثلاثي الكامل للاستمرار:", "ask_name");
}
$context['name'] = $text;
return new FlowResult(
"شكراً كابتن {$text}.\nالآن يرجى إرسال صورة **الوجه الأمامي للهوية الشخصية** (تأكد من أن الصورة واضحة والإضاءة جيدة):",
"id_front"
);
case 'id_front':
return $this->processOcrStep(
$step,
$messageData,
$context,
"id_front_sy",
"national_number",
"عذراً كابتن، لم أتمكن من قراءة الرقم الوطني من الهوية بوضوح. يرجى إرسال صورة أخرى للوجه الأمامي للهوية الشخصية تكون أكثر وضوحاً:",
"تم استخراج الرقم الوطني بنجاح ✅.\nالآن، يرجى إرسال صورة **الوجه الخلفي للهوية الشخصية**:",
"id_back"
);
case 'id_back':
return $this->processOcrStep(
$step,
$messageData,
$context,
"id_back_sy",
"gender",
"عذراً كابتن، لم أتمكن من قراءة بيانات الوجه الخلفي للهوية بوضوح. يرجى إرسال صورة أخرى للوجه الخلفي للهوية الشخصية:",
"تم استخراج البيانات بنجاح ✅.\nيرجى إرسال صورة **الوجه الأمامي لرخصة القيادة**:",
"driving_license_front"
);
case 'driving_license_front':
return $this->processOcrStep(
$step,
$messageData,
$context,
"driving_license_sy_front",
"national_number",
"عذراً كابتن، لم أتمكن من قراءة رخصة القيادة بوضوح. يرجى إرسال صورة أخرى واضحة للوجه الأمامي لرخصة القيادة:",
"تم استخراج بيانات رخصة القيادة بنجاح ✅.\nيرجى إرسال صورة **الوجه الخلفي لرخصة القيادة**:",
"driving_license_back"
);
case 'driving_license_back':
return $this->processOcrStep(
$step,
$messageData,
$context,
"driving_license_sy_back",
"license_number",
"عذراً كابتن، لم أتمكن من قراءة الوجه الخلفي لرخصة القيادة بوضوح. يرجى إعادة إرسال الصورة بشكل أكثر وضوحاً:",
"تم استخراج البيانات بنجاح ✅.\nيرجى إرسال صورة **الوجه الأمامي لرخصة السيارة (الرخصة البرتقالية)**:",
"vehicle_license_front"
);
case 'vehicle_license_front':
return $this->processOcrStep(
$step,
$messageData,
$context,
"vehicle_license_sy_front",
"car_plate",
"عذراً كابتن، لم أتمكن من قراءة رقم لوحة السيارة بوضوح. يرجى إرسال صورة واضحة للوجه الأمامي لرخصة السيارة:",
"تم استخراج رقم اللوحة بنجاح ✅.\nيرجى إرسال صورة **الوجه الخلفي لرخصة السيارة (الرخصة البرتقالية)**:",
"vehicle_license_back"
);
case 'vehicle_license_back':
return $this->processOcrStep(
$step,
$messageData,
$context,
"vehicle_license_sy_back",
"chassis",
"عذراً كابتن، لم أتمكن من قراءة مواصفات السيارة بوضوح. يرجى إرسال صورة واضحة للوجه الخلفي لرخصة السيارة:",
"تم استخراج مواصفات السيارة بنجاح ✅.\nيرجى إرسال صورة **وثيقة غير محكوم (لا حكم عليه)**:",
"criminal_record"
);
case 'criminal_record':
if (empty($messageData['image']) || empty($messageData['imageMimeType'])) {
return new FlowResult("الرجاء إرسال صورة وثيقة غير محكوم (لا حكم عليه) للاستمرار:", "criminal_record");
}
// Save non-OCR criminal record image
$imageUrl = $this->saveIncomingImage($step, $phone, $messageData);
if (!$imageUrl) {
return new FlowResult("عذراً، فشل حفظ الصورة. الرجاء إعادة إرسال صورة الوثيقة:", "criminal_record");
}
// Upload criminal record to Siro
$fullPath = __DIR__ . '/../../../../public' . $imageUrl;
$criminalSiroUrl = SiroService::uploadDocument(
$this->country,
SiroService::formatPhone($phone, $this->country),
'criminal_record',
$fullPath,
$messageData['imageMimeType']
);
$context['criminal_record_siro_url'] = $criminalSiroUrl;
// Securely save registration data to local database
try {
DriverOcrData::saveSecure([
'company_id' => $companyId,
'phone' => $phone,
'name' => $context['name'],
'id_front_url' => $context['id_front_url'] ?? null,
'id_front_ocr' => $context['id_front_ocr'] ?? null,
'id_back_url' => $context['id_back_url'] ?? null,
'id_back_ocr' => $context['id_back_ocr'] ?? null,
'driving_license_front_url' => $context['driving_license_front_url'] ?? null,
'driving_license_front_ocr' => $context['driving_license_front_ocr'] ?? null,
'driving_license_back_url' => $context['driving_license_back_url'] ?? null,
'driving_license_back_ocr' => $context['driving_license_back_ocr'] ?? null,
'vehicle_license_front_url' => $context['vehicle_license_front_url'] ?? null,
'vehicle_license_front_ocr' => $context['vehicle_license_front_ocr'] ?? null,
'vehicle_license_back_url' => $context['vehicle_license_back_url'] ?? null,
'vehicle_license_back_ocr' => $context['vehicle_license_back_ocr'] ?? null,
'criminal_record_url' => $imageUrl,
'status' => 'ocr_completed'
]);
} catch (\Exception $dbEx) {
error_log("[Registration Flow Error] DB Write Failed: " . $dbEx->getMessage());
return new FlowResult("عذراً، حدث خطأ أثناء حفظ طلبك في قاعدة البيانات. يرجى المحاولة مرة أخرى لاحقاً.", "criminal_record");
}
// Register driver in Siro with Siro-hosted URLs
$docUrls = [
'id_front' => $context['id_front_siro_url'] ?? '',
'id_back' => $context['id_back_siro_url'] ?? '',
'driving_license_front' => $context['driving_license_front_siro_url'] ?? '',
'driving_license_back' => $context['driving_license_back_siro_url'] ?? '',
'vehicle_license_front' => $context['vehicle_license_front_siro_url'] ?? '',
'vehicle_license_back' => $context['vehicle_license_back_siro_url'] ?? '',
'criminal_record' => $criminalSiroUrl ?? '',
];
$idOcr = $context['id_front_ocr'] ?? [];
$vlOcr = $context['vehicle_license_front_ocr'] ?? [];
$vlbOcr = $context['vehicle_license_back_ocr'] ?? [];
$dlOcr = $context['driving_license_front_ocr'] ?? [];
$formattedPhone = SiroService::formatPhone($phone, $this->country);
$driverId = 'DRV' . date('YmdHis') . rand(100, 999);
$driverData = [
'phone' => $formattedPhone,
'password' => substr(md5($formattedPhone . time()), 0, 12),
'first_name' => explode(' ', $context['name'] ?? '')[0] ?? $context['name'],
'last_name' => implode(' ', array_slice(explode(' ', $context['name'] ?? ''), 1)) ?: $context['name'],
'name_arabic' => $context['name'] ?? '',
'national_number' => $idOcr['national_number'] ?? '',
'birthdate' => $idOcr['dob'] ?? '',
'address' => $idOcr['address'] ?? '',
'id' => $driverId,
];
$carData = [
'vin' => $vlbOcr['chassis'] ?? $vlOcr['vin'] ?? '',
'car_plate' => $vlOcr['car_plate'] ?? '',
'make' => $vlbOcr['make'] ?? '',
'model' => $vlbOcr['model'] ?? '',
'year' => $vlbOcr['year'] ?? '',
'color' => $vlOcr['color'] ?? '',
'color_hex' => $vlOcr['color_hex'] ?? '#000000',
'owner' => $vlOcr['owner'] ?? '',
'fuel' => $vlbOcr['fuel'] ?? '',
'expiration_date' => $vlOcr['issue_date'] ?? '',
'vehicle_category_id' => 1,
];
$syroResult = SiroService::registerDriver($driverData, $carData, $docUrls, $this->country);
$appNames = [
'syria' => 'سيرو',
'jordan' => 'سيرو',
'egypt' => 'سيرو',
];
$appName = $appNames[$this->country] ?? 'سيرو';
if ($syroResult && ($syroResult['status'] ?? '') === 'success') {
DriverOcrData::saveSecure([
'company_id' => $companyId,
'phone' => $phone,
'name' => $context['name'],
'status' => 'registered'
]);
return new FlowResult(
"شكراً لك كابتن، تم تسجيلك بنجاح في تطبيق {$appName} 🚖✅\nسيتم مراجعة طلبك من قبل فريق الخدمة وتفعيل حسابك قريباً. يومك سعيد!",
"finished",
true
);
} else {
$errMsg = $syroResult['message'] ?? 'خطأ غير معروف';
error_log("[Registration Flow] Siro registration failed: " . json_encode($syroResult));
return new FlowResult(
"تم حفظ مستنداتك بنجاح في نظامنا. لكن حدث تأخير في تسجيلك على تطبيق {$appName}. سيقوم فريقنا بمراجعة بياناتك وتفعيل حسابك في أقرب وقت. شكراً لصبرك! 🙏",
"finished",
true
);
}
default:
return new FlowResult("خطأ في تحديد خطوة المسار.", "finished", true);
}
}
/**
* Process document upload and OCR extraction step
*/
private function processOcrStep(
string $step,
array $messageData,
array &$context,
string $promptKey,
string $requiredJsonKey,
string $failMessage,
string $successMessage,
string $nextStep
): FlowResult {
if (empty($messageData['image']) || empty($messageData['imageMimeType'])) {
return new FlowResult("الرجاء إرسال الصورة المطلوبة للمتابعة:", $step);
}
$imageUrl = $this->saveIncomingImage($step, $messageData['phone'], $messageData);
if (!$imageUrl) {
return new FlowResult("عذراً، فشل حفظ الصورة. الرجاء إعادة المحاولة وإرسال الصورة:", $step);
}
// Upload to Siro and store the signed URL
$fullPath = __DIR__ . '/../../../../public' . $imageUrl;
$siroUrl = SiroService::uploadDocument(
$this->country,
SiroService::formatPhone($messageData['phone'], $this->country),
$this->stepToDocType[$step] ?? $step,
$fullPath,
$messageData['imageMimeType']
);
if ($siroUrl) {
$context[$step . '_siro_url'] = $siroUrl;
error_log("[DriverRegistrationFlow] Uploaded {$step} to Siro: {$siroUrl}");
} else {
$context[$step . '_siro_url'] = null;
error_log("[DriverRegistrationFlow] Warning: Failed to upload {$step} to Siro, using local URL");
}
$companyId = $context['company_id'] ?? 1;
// Check subscription limit for OCR
if ($companyId !== 1) {
if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'ocr')) {
error_log("[DriverRegistrationFlow] Company {$companyId} has exceeded its OCR limit.");
return new FlowResult("⚠️ عذراً، لقد استهلك هذا المتجر الحد المسموح له من تحليل الصور والوصولات لهذا الشهر. يرجى إرسال استفسارك نصياً.", $step);
}
}
$rule = ChatbotRule::findActiveForRule($companyId);
$configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null;
$apiKey = GeminiService::getGeminiApiKey($configuredGeminiKey);
if (empty($apiKey)) {
error_log("[DriverRegistrationFlow] Gemini API key not configured.");
return new FlowResult("عذراً، عطل فني في خادم معالجة الصور بالذكاء الاصطناعي. يرجى المحاولة لاحقاً.", $step);
}
$prompt = $this->prompts[$step] ?? '';
$rawOcr = GeminiService::generateOcrFromImage($apiKey, $prompt, $messageData['image'], $messageData['imageMimeType']);
if (!$rawOcr) {
error_log("[DriverRegistrationFlow] OCR response empty or model request failed.");
return new FlowResult($failMessage, $step);
}
$ocrData = json_decode($rawOcr, true);
if (!$ocrData || (empty($ocrData[$requiredJsonKey]) && !array_key_exists($requiredJsonKey, $ocrData))) {
error_log("[DriverRegistrationFlow] Missing required key '$requiredJsonKey' in OCR response: " . $rawOcr);
return new FlowResult($failMessage, $step);
}
// Increment stats on successful OCR processing
if ($companyId !== 1) {
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'ocr');
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'request');
}
// Save URL and OCR JSON string in the conversation context
$context[$step . '_url'] = $imageUrl;
$context[$step . '_ocr'] = $ocrData;
return new FlowResult($successMessage, $nextStep);
}
/**
* Decode base64 image data and save it to the public directory
*/
private function saveIncomingImage(string $step, string $phone, array $messageData): ?string
{
try {
$extension = 'jpg';
if (strpos($messageData['imageMimeType'], 'png') !== false) {
$extension = 'png';
}
$uniqueName = 'driver_' . $step . '_' . md5($phone . time()) . '.' . $extension;
$uploadDir = __DIR__ . '/../../../../public/uploads/documents/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$uploadPath = $uploadDir . $uniqueName;
$imgData = base64_decode($messageData['image']);
if (file_put_contents($uploadPath, $imgData) === false) {
error_log("[DriverRegistrationFlow] Failed to write image file: " . $uploadPath);
return null;
}
return '/uploads/documents/' . $uniqueName;
} catch (\Exception $e) {
error_log("[DriverRegistrationFlow Exception] Failed to save image: " . $e->getMessage());
return null;
}
}
/**
* Detect if user wants to postpone, and return hours_delay if so.
*/
private function detectPostponement(string $text, string $apiKey, int $companyId): ?array
{
if (empty($text)) {
return null;
}
// Quick heuristic check: if the message is too long, or clearly doesn't contain postponement keywords, skip to save API costs
$keywords = ['بعدين', 'بكرا', 'بكرة', 'بعد', 'شوي', 'ثانية', 'مشغول', 'المسا', 'الليل', 'تأجيل', 'وقت ثاني', 'تعبان', 'بعدين برسل', 'بعدين ببعت', 'ببعثهم بعدين', 'ببعتهم بعدين', 'بعدين بكمل'];
$hasKeyword = false;
foreach ($keywords as $kw) {
if (mb_strpos($text, $kw) !== false) {
$hasKeyword = true;
break;
}
}
if (!$hasKeyword && mb_strlen($text) > 100) {
return null;
}
$systemPrompt = "You are an assistant that detects if a user wants to postpone, delay, or complete a registration flow later. Analyze the Arabic message.";
$userMessage = <<<EOT
Analyze the following Arabic message (often in Syrian dialect) to determine if the user wants to postpone/delay sending documents or complete the registration later.
Message: "{$text}"
Respond with ONLY a valid JSON object matching this schema:
{
"wants_postpone": true/false,
"hours_delay": 12
}
Do not include any markdown, code blocks, or explanations.
EOT;
$response = GeminiService::generateResponse($apiKey, $systemPrompt, $userMessage);
if (empty($response)) {
return null;
}
// Increment request limit on successful postponement check API call
if ($companyId !== 1) {
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'request');
}
// Clean markdown block if present
$response = trim(preg_replace('/```json|```/', '', $response));
$data = json_decode($response, true);
if (isset($data['wants_postpone']) && $data['wants_postpone'] === true) {
return [
'hours' => isset($data['hours_delay']) ? (int)$data['hours_delay'] : 12
];
}
return null;
}
}