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

486 lines
23 KiB
PHP
Raw 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.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Core\Flows;
use App\Services\GeminiService;
use App\Models\DriverOcrData;
use App\Models\ChatbotRule;
use App\Models\DriverReminder;
/**
* DriverRegistrationFlow
* Handles step-by-step driver and vehicle registration using Gemini OCR.
*/
class DriverRegistrationFlow extends BaseFlow
{
private array $prompts = [
"id_front" => <<<EOT
You are an OCR expert for Syrian national ID cards (green card).
### TASK
Analyse the **front side** of the ID and return **raw JSON only** with exactly these keys:
{
"full_name": "", // الاسم الثلاثي أو الرباعي
"national_number": "", // الرقم الوطني (LATIN digits only)
"dob": "YYYY-MM-DD", // تاريخ الميلاد
"address": "" // العنوان
}
### RULES
* Read the red number on the bottom of the card.
* Convert any Eastern-Arabic digits (٠١٢٣٤٥٦٧٨٩) to Western-Arabic digits (0-9).
* `national_number` must contain **Latin digits only, no spaces or other characters**.
* If a field is missing, set it to **null**.
* Convert the birth date to ISO `YYYY-MM-DD`.
* Return valid JSON only — no extra keys, no markdown.
EOT,
"id_back" => <<<EOT
أنت خبير OCR مختص ببطاقات الهوية السورية (الوجه الخلفي).
### المطلوب
حلّل صورة الوجه الخلفي للهوية السورية وأعد **JSON صِرف** يحتوي المفاتيح التالية فقط:
{
"governorate": "", // المحافظة (مثال: دمشق)
"address": "", // العنوان التفصيلي (حيّ، بلدة …)
"gender": "", //Male or Female
"issue_date": "YYYY-MM-DD"// تاريخ الإصدار بصيغة ISO
}
### القواعد
1. حوّل أي أرقام عربية شرقية (٠١٢٣٤٥٦٧٨٩) إلى أرقام لاتينية (0-9).
2. أعدّ تاريخ الإصدار بالتقويم الميلادي بصيغة `YYYY-MM-DD`.
3. استخدم أحرف لاتينية كبيرة لزمرة الدم مع رمز `+` أو `-` فقط.
4. إذا كان أحد الحقول غير موجود مطلقًا، أعد قيمته **null**.
5. لا تُرجع أي مفاتيح إضافية أو شروح أو Markdown — JSON صالح فقط.
EOT,
"driving_license_front" => <<<EOT
You are an OCR expert for Syrian documents.
### TASK
Analyse the **front side of a Syrian driving licence** and return **clean JSON only** with the following keys (no extra keys, no markdown):
{
"name_arabic": "", // الاسم الثلاثي أو الرباعي بالعربية
"birth_place": "", // المحافظة أو المنطقة المكتوبة بعد كلمة الولادة
"birth_year": "", // سنة الميلاد فقط (أربعة أرقام)
"national_number": "",
"civil_registry": "", // سطر "القيد" (مثال: سهوة 3)
"blood_type": "" // زمرة الدم بالشكل: A+ , A- , B+ , B- , AB+ , AB- , O+ , O-
}
### RULES
* إذا كانت القيمة مفقودة تمامًا اكتب **null**.
* لا تُغيّر ترتيب المفاتيح.
* لا تُرسل أى شرح أو أسطر إضافية JSON خالص فقط.
EOT,
"driving_license_back" => <<<EOT
You are an OCR expert for Syrian driving licences.
### TASK
Analyse the **back side** of a Syrian driving licence and return **raw JSON only** with exactly these keys:
{
"issue_date": "YYYY-MM-DD", // تاريخ المنح
"expiry_date": "YYYY-MM-DD", // صالحة لغاية
"license_number": "", // رقم الإجازة
"license_category": "" // D1, D2, D3 … (as printed after "UNIVERSAL DRIVING LICENCE")
}
### RULES
* If a value is totally absent, set it to **null**.
* Convert all dates to ISO `YYYY-MM-DD` (Gregorian).
* Do **NOT** add extra keys, comments, or markdown — return valid JSON only.
EOT,
"vehicle_license_front" => <<<EOT
You are an OCR expert specialized in analyzing Syrian vehicle registration cards (الرخصة البرتقالية).
Your task is to extract structured data from the **front side** of the Syrian orange vehicle card and return **raw JSON only** with the following exact fields:
{
"car_plate": "", // رقم المركبة الكامل مع اسم المحافظة، مأخوذ من الجهة اليسرى في السطر الأول (مثال: "155186 درعا")
"owner": "", // اسم المالك الكامل
"vin": "", // رقم الهيكل
"color": "", // اللون بالعربية أو الإنجليزية (مثال: "أبيض" أو "White")
"color_hex": "", // كود اللون بصيغة Hex (مثال: "#FFFFFF") أو #27332F إن تعذّر
"issue_date": "YYYY-MM-DD", // تاريخ المنح بصيغة ISO
"inspection_date": "YYYY-MM-DD" // تاريخ الفحص القادم بصيغة ISO
}
### Instructions & Rules:
1. Do **not** extract the "رمز المركبة" (on the right side of the first line) — use only the **left side** of the first line for `car_plate`.
2. Convert any Arabic dates (like `2024/05/13`) into ISO format `YYYY-MM-DD`.
3. If any value is missing or unreadable, return `null` for it.
4. Maintain Arabic encoding (e.g., owner name, city name, color).
5. Never guess — extract only what's visually found on the card.
6. Never include any explanation or extra output — return the JSON only.
Example of valid `car_plate`:
- "155186 درعا"
- "45291 دمشق"
- "122334 حمص"
EOT,
"vehicle_license_back" => <<<EOT
You are an OCR expert for Syrian vehicle registration cards (orange card).
### TASK
Analyse the **back side** of the card and return **raw JSON only** with exactly these keys (no more, no less):
{
"make": "", // الصانع (Hyundai …)
"model": "", // الطراز (H1 …)
"year": "", // سنة الصنع بالأرقام اللاتينية (e.g. "2019")
"fuel": "", // نوع الوقود (بنزين، ديزل …) أو بالإنجليزية (Petrol, Diesel,electric)
"chassis": "" // رقم الهيكل (VIN)
}
### RULES
* Convert any Eastern-Arabic digits (٠١٢٣٤٥٦٧٨٩) to Western digits (0-9).
* Normalise color names to standard English if possible, then map to a common Hex code
• "أبيض / White" → **#FFFFFF**
• "أسود / Black" → **#000000**
• "أحمر / Red" → **#FF0000**
• "أزرق / Blue" → **#0000FF**
• … (use the closest basic colour); if no match, set **color_hex = null**.
* If any field is unreadable or absent, set its value to **null**.
* Do **NOT** include extra keys, comments, or markdown — output valid JSON only.
EOT
];
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;
// If currently postponed and user sends a message, resume the flow
if ($step === 'postponed') {
// Cancel active reminder
$activeReminder = DriverReminder::findActive($companyId, $phone);
if ($activeReminder) {
DriverReminder::update($activeReminder['id'], ['status' => 'cancelled']);
}
// Restore previous step
$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);
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;
// Schedule reminder
$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(
"أهلاً بك كابتن في خدمة تسجيل كباتن تطبيق انطلق 🚖.\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");
}
// 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");
}
return new FlowResult(
"شكراً لك كابتن، لقد تم استلام كافة المستندات والتحقق منها بنجاح. سيقوم فريق خدمة عملاء انطلق بمراجعة طلبك وتفعيل حسابك بأسرع وقت ممكن. يومك سعيد! 🚖",
"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);
}
$companyId = $context['company_id'] ?? 1;
$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);
}
// 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): ?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;
}
// 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;
}
}