Deploy: 2026-05-22 21:52:51
This commit is contained in:
245
ai_document.php
Normal file
245
ai_document.php
Normal file
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$driverId = filterRequest("driver_id");
|
||||
$type = filterRequest("type");
|
||||
|
||||
// 🔒 Validate image
|
||||
if (!isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
|
||||
error_log("Upload error: Image not provided or upload failed.");
|
||||
jsonError("Image upload failed");
|
||||
exit;
|
||||
}
|
||||
|
||||
$file = $_FILES['image'];
|
||||
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
$allowed = ['jpg', 'jpeg', 'png'];
|
||||
|
||||
if (!in_array($extension, $allowed)) {
|
||||
error_log("Unsupported file type: $extension");
|
||||
jsonError("Unsupported file type");
|
||||
exit;
|
||||
}
|
||||
|
||||
$uniqueName = "driver_" . $type . "_" . $driverId . ".$extension";
|
||||
$uploadDir = "../uploads/documents/";
|
||||
$uploadPath = $uploadDir . $uniqueName;
|
||||
|
||||
if (!is_dir($uploadDir)) {
|
||||
mkdir($uploadDir, 0755, true);
|
||||
}
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $uploadPath)) {
|
||||
error_log("Failed to move uploaded file.");
|
||||
jsonError("Failed to move uploaded image");
|
||||
exit;
|
||||
}
|
||||
|
||||
$imageUrl = "https://intaleq.xyz/intaleq/auth/uploads/documents/" . $uniqueName ;
|
||||
$imageData = file_get_contents($uploadPath);
|
||||
$imageBase64 = base64_encode($imageData);
|
||||
|
||||
$mimeType = match ($extension) {
|
||||
'jpg', 'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
default => 'application/octet-stream',
|
||||
};
|
||||
|
||||
$prompts = [
|
||||
"id_front_sy" => <<<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_sy" => <<<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_sy_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_sy_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_sy_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_sy_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
|
||||
];
|
||||
|
||||
$prompt = $prompts[$type] ?? $prompts["id_front_sy"];
|
||||
|
||||
$apiKey = getenv("GEMINI_API_KEY");
|
||||
$apiURL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-lite:generateContent?key=$apiKey";
|
||||
|
||||
$headers = ["Content-Type: application/json"];
|
||||
$payload = [
|
||||
"contents" => [
|
||||
["role" => "user", "parts" => [["text" => $prompt]]],
|
||||
["role" => "user", "parts" => [["inlineData" => ["mimeType" => $mimeType, "data" => $imageBase64]]]]
|
||||
]
|
||||
];
|
||||
|
||||
$ch = curl_init($apiURL);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if (curl_errno($ch)) {
|
||||
$error_msg = curl_error($ch);
|
||||
error_log("CURL error: $error_msg");
|
||||
jsonError("AI Error: $error_msg");
|
||||
curl_close($ch);
|
||||
exit;
|
||||
}
|
||||
|
||||
curl_close($ch);
|
||||
error_log("AI raw response: $response");
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
error_log("JSON decode error: " . json_last_error_msg());
|
||||
jsonError("Failed to parse AI response");
|
||||
exit;
|
||||
}
|
||||
|
||||
$textRaw = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
$textRaw = trim(preg_replace('/```json|```/', '', $textRaw));
|
||||
$json = json_decode($textRaw, true);
|
||||
|
||||
$requiredKey = match ($type) {
|
||||
'id_front_sy' => 'national_number',
|
||||
'id_back_sy' => 'gender',
|
||||
'driving_license_sy' => 'license_type',
|
||||
'vehicle_license_sy' => 'chassis',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (!$json || ($requiredKey && !isset($json[$requiredKey]))) {
|
||||
error_log("AI response missing required key '$requiredKey': $textRaw");
|
||||
jsonError("AI failed to extract required information");
|
||||
exit;
|
||||
}
|
||||
|
||||
printSuccess([
|
||||
"image_url" => $imageUrl,
|
||||
"data" => $json
|
||||
]);
|
||||
@@ -16,6 +16,7 @@ class ConversationFlowEngine
|
||||
*/
|
||||
private static array $flows = [
|
||||
'test_flow' => TestFlow::class,
|
||||
'driver_registration_flow' => DriverRegistrationFlow::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,9 @@ class ConversationFlowEngine
|
||||
private static array $startTriggers = [
|
||||
'test' => 'test_flow',
|
||||
'اختبار' => 'test_flow',
|
||||
'سجل' => 'driver_registration_flow',
|
||||
'تسجيل' => 'driver_registration_flow',
|
||||
'register' => 'driver_registration_flow',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -39,6 +43,20 @@ class ConversationFlowEngine
|
||||
$companyId = $session['company_id'];
|
||||
$text = isset($msgData['body']) ? trim($msgData['body']) : '';
|
||||
|
||||
// If incoming message is audio, transcribe it via Gemini
|
||||
$isAudio = !empty($msgData['audio']) && !empty($msgData['mimeType']);
|
||||
if ($isAudio) {
|
||||
$rule = \App\Models\ChatbotRule::findActiveForRule($companyId);
|
||||
$apiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : getenv('GEMINI_API_KEY');
|
||||
if (!empty($apiKey)) {
|
||||
$transcription = \App\Services\GeminiService::transcribeAudio($apiKey, $msgData['audio'], $msgData['mimeType']);
|
||||
if ($transcription) {
|
||||
$text = $transcription;
|
||||
$msgData['body'] = $transcription;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Lookup existing active flow
|
||||
$state = ConversationState::findActive($companyId, $phone);
|
||||
|
||||
@@ -84,6 +102,7 @@ class ConversationFlowEngine
|
||||
$flowInstance = new $flowClass();
|
||||
|
||||
try {
|
||||
$context['company_id'] = $companyId;
|
||||
$result = $flowInstance->handleStep($currentStep, $msgData, $context);
|
||||
|
||||
if ($result->isFinished()) {
|
||||
@@ -104,8 +123,8 @@ class ConversationFlowEngine
|
||||
}
|
||||
|
||||
// 5. Send reply if one is provided
|
||||
if ($result->getReplyText() !== '') {
|
||||
self::sendReply($session, $phone, $result->getReplyText());
|
||||
if ($result->getReplyText() !== '' || $result->getMediaUrl() !== null) {
|
||||
self::sendReply($session, $phone, $result->getReplyText(), $result->getMediaUrl());
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -118,8 +137,14 @@ class ConversationFlowEngine
|
||||
/**
|
||||
* Send outbound message reply via the Baileys Gateway
|
||||
*/
|
||||
private static function sendReply(array $session, string $phone, string $message): void
|
||||
{
|
||||
public static function sendReply(
|
||||
array $session,
|
||||
string $phone,
|
||||
string $message,
|
||||
?string $mediaUrl = null,
|
||||
?string $audioBase64 = null,
|
||||
?string $mimetype = null
|
||||
): void {
|
||||
$gatewayUrl = rtrim(getenv('WHATSAPP_GATEWAY_URL') ?: 'http://localhost:3722', '/');
|
||||
if (substr($gatewayUrl, -4) === '/api') {
|
||||
$sendUrl = $gatewayUrl . '/messages/send';
|
||||
@@ -127,11 +152,25 @@ class ConversationFlowEngine
|
||||
$sendUrl = $gatewayUrl . '/api/messages/send';
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
$payloadData = [
|
||||
'session_key' => $session['session_key'],
|
||||
'phone' => $phone,
|
||||
'message' => $message
|
||||
]);
|
||||
'phone' => $phone
|
||||
];
|
||||
|
||||
if ($message !== '') {
|
||||
$payloadData['message'] = $message;
|
||||
}
|
||||
if ($mediaUrl !== null) {
|
||||
$payloadData['media_url'] = $mediaUrl;
|
||||
}
|
||||
if ($audioBase64 !== null) {
|
||||
$payloadData['audio'] = $audioBase64;
|
||||
}
|
||||
if ($mimetype !== null) {
|
||||
$payloadData['mimetype'] = $mimetype;
|
||||
}
|
||||
|
||||
$payload = json_encode($payloadData);
|
||||
|
||||
$ch = curl_init($sendUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
@@ -161,14 +200,17 @@ class ConversationFlowEngine
|
||||
error_log("[Flow Engine Gateway Error] Failed to send: " . $errorMsg);
|
||||
}
|
||||
|
||||
$msgType = ($audioBase64 !== null || $mediaUrl !== null) ? 'audio' : 'text';
|
||||
|
||||
// Log the outbound auto-reply message
|
||||
MessageLog::logMessage([
|
||||
'company_id' => $session['company_id'],
|
||||
'session_id' => $session['id'],
|
||||
'contact_phone' => $phone,
|
||||
'direction' => 'outbound',
|
||||
'message_type' => 'text',
|
||||
'message_type' => $msgType,
|
||||
'message_body' => $message,
|
||||
'media_url' => $mediaUrl,
|
||||
'whatsapp_message_id' => $waMsgId,
|
||||
'status' => $status,
|
||||
'error_message' => $errorMsg
|
||||
|
||||
483
backend/app/Core/Flows/DriverRegistrationFlow.php
Normal file
483
backend/app/Core/Flows/DriverRegistrationFlow.php
Normal file
@@ -0,0 +1,483 @@
|
||||
<?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);
|
||||
$apiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : getenv('GEMINI_API_KEY');
|
||||
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);
|
||||
$apiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : getenv('GEMINI_API_KEY');
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
144
backend/app/Models/DriverOcrData.php
Normal file
144
backend/app/Models/DriverOcrData.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\Database;
|
||||
use App\Core\Security;
|
||||
|
||||
/**
|
||||
* DriverOcrData Model
|
||||
* Manages collected driver documents and OCR results with binary field encryption.
|
||||
*/
|
||||
class DriverOcrData extends BaseModel
|
||||
{
|
||||
protected static string $table = 'driver_ocr_data';
|
||||
|
||||
/**
|
||||
* Find decrypted record for a company and contact phone
|
||||
*/
|
||||
public static function findByPhone(int $companyId, string $phone): ?array
|
||||
{
|
||||
self::ensureTableExists();
|
||||
$hash = Security::blindIndex($phone);
|
||||
$record = Database::selectOne(
|
||||
"SELECT * FROM " . static::$table . " WHERE company_id = ? AND (phone_hash = ? OR phone = ?) LIMIT 1",
|
||||
[$companyId, $hash, $phone]
|
||||
);
|
||||
return self::decryptRecord($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save or update driver registration OCR state securely
|
||||
*/
|
||||
public static function saveSecure(array $data): string
|
||||
{
|
||||
self::ensureTableExists();
|
||||
|
||||
$data = self::encryptRecord($data);
|
||||
|
||||
$existing = Database::selectOne(
|
||||
"SELECT id FROM " . static::$table . " WHERE company_id = ? AND phone_hash = ? LIMIT 1",
|
||||
[$data['company_id'], $data['phone_hash']]
|
||||
);
|
||||
|
||||
if ($existing) {
|
||||
self::update($existing['id'], $data);
|
||||
return $existing['id'];
|
||||
} else {
|
||||
return self::create($data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to encrypt sensitive fields in a record (simplified to plain text)
|
||||
*/
|
||||
public static function encryptRecord(array $data): array
|
||||
{
|
||||
if (!empty($data['phone'])) {
|
||||
$data['phone_hash'] = Security::blindIndex($data['phone']);
|
||||
}
|
||||
|
||||
$ocrFields = [
|
||||
'id_front_ocr', 'id_back_ocr',
|
||||
'driving_license_front_ocr', 'driving_license_back_ocr',
|
||||
'vehicle_license_front_ocr', 'vehicle_license_back_ocr'
|
||||
];
|
||||
|
||||
foreach ($ocrFields as $field) {
|
||||
if (isset($data[$field])) {
|
||||
if (is_array($data[$field])) {
|
||||
$jsonStr = json_encode($data[$field], JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
$jsonStr = $data[$field];
|
||||
}
|
||||
$data[$field] = $jsonStr ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to decrypt sensitive fields in a record (simplified to plain text)
|
||||
*/
|
||||
public static function decryptRecord(?array $record): ?array
|
||||
{
|
||||
if (!$record) return null;
|
||||
|
||||
$ocrFields = [
|
||||
'id_front_ocr', 'id_back_ocr',
|
||||
'driving_license_front_ocr', 'driving_license_back_ocr',
|
||||
'vehicle_license_front_ocr', 'vehicle_license_back_ocr'
|
||||
];
|
||||
|
||||
foreach ($ocrFields as $field) {
|
||||
if (!empty($record[$field])) {
|
||||
$record[$field] = json_decode($record[$field], true);
|
||||
}
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the driver_ocr_data table exists dynamically
|
||||
*/
|
||||
public static function ensureTableExists(): void
|
||||
{
|
||||
static $checked = false;
|
||||
if ($checked) return;
|
||||
|
||||
try {
|
||||
Database::execute("
|
||||
CREATE TABLE IF NOT EXISTS `driver_ocr_data` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`company_id` INT NOT NULL,
|
||||
`phone` VARCHAR(512) NOT NULL,
|
||||
`phone_hash` VARCHAR(64) NOT NULL,
|
||||
`name` VARCHAR(512) NOT NULL,
|
||||
`id_front_url` VARCHAR(512) DEFAULT NULL,
|
||||
`id_front_ocr` TEXT DEFAULT NULL,
|
||||
`id_back_url` VARCHAR(512) DEFAULT NULL,
|
||||
`id_back_ocr` TEXT DEFAULT NULL,
|
||||
`driving_license_front_url` VARCHAR(512) DEFAULT NULL,
|
||||
`driving_license_front_ocr` TEXT DEFAULT NULL,
|
||||
`driving_license_back_url` VARCHAR(512) DEFAULT NULL,
|
||||
`driving_license_back_ocr` TEXT DEFAULT NULL,
|
||||
`vehicle_license_front_url` VARCHAR(512) DEFAULT NULL,
|
||||
`vehicle_license_front_ocr` TEXT DEFAULT NULL,
|
||||
`vehicle_license_back_url` VARCHAR(512) DEFAULT NULL,
|
||||
`vehicle_license_back_ocr` TEXT DEFAULT NULL,
|
||||
`criminal_record_url` VARCHAR(512) DEFAULT NULL,
|
||||
`status` VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `unique_company_driver_phone` (`company_id`, `phone_hash`),
|
||||
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
");
|
||||
$checked = true;
|
||||
} catch (\Exception $e) {
|
||||
error_log("Failed to ensure driver_ocr_data table: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
74
backend/app/Models/DriverReminder.php
Normal file
74
backend/app/Models/DriverReminder.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\Database;
|
||||
|
||||
/**
|
||||
* DriverReminder Model
|
||||
* Manages driver registration reminders for follow-up notifications.
|
||||
*/
|
||||
class DriverReminder extends BaseModel
|
||||
{
|
||||
protected static string $table = 'driver_registration_reminders';
|
||||
|
||||
/**
|
||||
* Find active reminder by company and contact phone
|
||||
*/
|
||||
public static function findActive(int $companyId, string $phone): ?array
|
||||
{
|
||||
self::ensureTableExists();
|
||||
return Database::selectOne(
|
||||
"SELECT * FROM " . static::$table . " WHERE company_id = ? AND phone = ? AND status = 'pending' LIMIT 1",
|
||||
[$companyId, $phone]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save or update driver reminder state
|
||||
*/
|
||||
public static function saveReminder(array $data): string
|
||||
{
|
||||
self::ensureTableExists();
|
||||
|
||||
$existing = Database::selectOne(
|
||||
"SELECT id FROM " . static::$table . " WHERE company_id = ? AND phone = ? AND status = 'pending' LIMIT 1",
|
||||
[$data['company_id'], $data['phone']]
|
||||
);
|
||||
|
||||
if ($existing) {
|
||||
self::update($existing['id'], $data);
|
||||
return $existing['id'];
|
||||
} else {
|
||||
return self::create($data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the driver_registration_reminders table exists dynamically
|
||||
*/
|
||||
public static function ensureTableExists(): void
|
||||
{
|
||||
static $checked = false;
|
||||
if ($checked) return;
|
||||
|
||||
try {
|
||||
Database::execute("
|
||||
CREATE TABLE IF NOT EXISTS `driver_registration_reminders` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`company_id` INT NOT NULL,
|
||||
`phone` VARCHAR(50) NOT NULL,
|
||||
`scheduled_at` DATETIME NOT NULL,
|
||||
`postpone_count` INT DEFAULT 0,
|
||||
`status` VARCHAR(20) DEFAULT 'pending',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
");
|
||||
$checked = true;
|
||||
} catch (\Exception $e) {
|
||||
error_log("Failed to ensure driver_registration_reminders table: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,59 @@ class GeminiService
|
||||
return $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcribe incoming audio voice note to text using gemini-2.0-flash-lite
|
||||
*/
|
||||
public static function transcribeAudio(string $apiKey, string $audioBase64, string $mimeType): ?string
|
||||
{
|
||||
$url = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-lite:generateContent?key=' . $apiKey;
|
||||
|
||||
// Clean mimeType if it contains codec info
|
||||
if (strpos($mimeType, ';') !== false) {
|
||||
$mimeType = trim(explode(';', $mimeType)[0]);
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'contents' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
'parts' => [
|
||||
[
|
||||
'inlineData' => [
|
||||
'mimeType' => $mimeType,
|
||||
'data' => $audioBase64
|
||||
]
|
||||
],
|
||||
[
|
||||
'text' => "Transcribe the following audio message to Arabic text. Output only the transcription, no translation, no commentary, no markdown, and no code blocks."
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json'
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
error_log("[Gemini Audio Transcription Error] HTTP " . $httpCode . " | Response: " . $response);
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
return trim($data['candidates'][0]['content']['parts'][0]['text'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Gemini API with image inline data and system instruction to generate a response text
|
||||
*/
|
||||
@@ -213,6 +266,64 @@ class GeminiService
|
||||
return $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Gemini API with image inline data and custom prompt to extract structured OCR data
|
||||
*/
|
||||
public static function generateOcrFromImage(string $apiKey, string $prompt, string $imageBase64, string $mimeType): ?string
|
||||
{
|
||||
$url = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-lite:generateContent?key=' . $apiKey;
|
||||
|
||||
// Clean mimeType if it contains codec info
|
||||
if (strpos($mimeType, ';') !== false) {
|
||||
$mimeType = trim(explode(';', $mimeType)[0]);
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'contents' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
'parts' => [
|
||||
[
|
||||
'text' => $prompt
|
||||
],
|
||||
[
|
||||
'inlineData' => [
|
||||
'mimeType' => $mimeType,
|
||||
'data' => $imageBase64
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json'
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 35); // 35 seconds timeout for image analysis
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
error_log("[Gemini OCR Image Response Error] HTTP " . $httpCode . " | Response: " . $response);
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
$textRaw = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||
if ($textRaw) {
|
||||
// Clean markdown block if present
|
||||
$textRaw = trim(preg_replace('/```json|```/', '', $textRaw));
|
||||
}
|
||||
return $textRaw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call ElevenLabs API to generate a native audio response from text
|
||||
*/
|
||||
|
||||
@@ -1073,6 +1073,7 @@
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; flex-wrap: wrap; gap: 0.5rem;">
|
||||
<label class="form-label" x-text="chatbotSettings.trigger_type === 'gemini_ai' ? 'System Instruction Prompt' : 'Predefined Auto-Reply Message'" style="margin-bottom: 0;"></label>
|
||||
<div x-show="chatbotSettings.trigger_type === 'gemini_ai'" style="display: flex; gap: 0.35rem; flex-wrap: wrap; align-items: center;">
|
||||
<button type="button" class="btn btn-secondary" style="font-size: 0.75rem; padding: 0.25rem 0.5rem; width: auto;" @click="loadPromptTemplate('intaleq')">قالب خدمة عملاء انطلق (سوري)</button>
|
||||
<button type="button" class="btn btn-secondary" style="font-size: 0.75rem; padding: 0.25rem 0.5rem; width: auto;" @click="loadPromptTemplate('nabeh')">قالب تطبيق نبيه (سوري)</button>
|
||||
<button type="button" class="btn btn-secondary" style="font-size: 0.75rem; padding: 0.25rem 0.5rem; width: auto;" @click="loadPromptTemplate('store')">قالب متجر إلكتروني</button>
|
||||
<button type="button" class="btn btn-secondary" style="font-size: 0.75rem; padding: 0.25rem 0.5rem; width: auto;" @click="loadPromptTemplate('general')">قالب عام (إنجليزي)</button>
|
||||
@@ -2006,7 +2007,22 @@
|
||||
},
|
||||
|
||||
loadPromptTemplate(type) {
|
||||
if (type === 'nabeh') {
|
||||
if (type === 'intaleq') {
|
||||
this.chatbotSettings.ai_prompt = `أنت روبوت خدمة العملاء الخاص بشركة "انطلق" (Intaleq). مهمتك هي مساعدة المستخدمين والإجابة على استفساراتهم حول خدماتنا بلهجة سورية ودودة ومهنية للغاية.
|
||||
|
||||
التزم بالقواعد والتفاصيل التالية بدقة عند الرد:
|
||||
1. اسم الشركة: انطلق.
|
||||
2. ساعات العمل: يومياً من الساعة 11 صباحاً حتى 5 مساءً.
|
||||
3. طرق الدفع المتاحة: شام كاش، سيريتل، إم تي إن، وبطاقات البنك.
|
||||
4. طريقة التسجيل: يمكن للمستخدمين تحميل التطبيق من متجر جوجل بلاي، آبل ستور، أو عبر رابط مباشر. عملية التسجيل سهلة وسريعة.
|
||||
5. مميزات التطبيق:
|
||||
- عمولة التطبيق هي 10% فقط (وهي الأقل في السوق).
|
||||
- يوجد ذكاء اصطناعي متطور لتحليل المشاكل وحلها.
|
||||
- ضمان حقوق السائق والركاب بشكل كامل.
|
||||
- خدمة الطلب من أي مكان وبكل سهولة.
|
||||
6. تحدث دائماً باللهجة السورية اللطيفة والترحيبية والودية عند التحدث باللغة العربية (مثال: "ياهلا بك"، "كيف بقدر أساعدك اليوم؟").
|
||||
7. كوني مختصرة ومباشرة ومفيدة في إجاباتكِ وتجنبي الإطالة غير الضرورية.`;
|
||||
} else if (type === 'nabeh') {
|
||||
this.chatbotSettings.ai_prompt = `أنتِ "سارة"، موظفة خدمة العملاء الافتراضية الذكية والودودة لتطبيق "نبيه" (Nabeh).
|
||||
مهمتكِ هي مساعدة المستخدمين والإجابة على استفساراتهم بلطف وأدب بالمسائل التقنية والتجارية المتعلقة بالتطبيق.
|
||||
|
||||
|
||||
@@ -148,5 +148,237 @@ $router->post('/api/external/verify-payment', function ($request, $response) {
|
||||
});
|
||||
|
||||
|
||||
// REST API for Entaleq Driver OCR Details (GET)
|
||||
$router->get('/api/external/driver-ocr', function ($request, $response) {
|
||||
$apiKey = getenv('ENTALEQ_API_KEY');
|
||||
$incomingKey = $request->getHeader('x-api-key') ?? '';
|
||||
|
||||
if (empty($apiKey) || $incomingKey !== $apiKey) {
|
||||
$response->status(401)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Unauthorized'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$phone = $request->get('phone') ?? '';
|
||||
$companyId = $request->get('company_id') ?? '';
|
||||
|
||||
if (empty($phone)) {
|
||||
$response->status(400)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Missing phone parameter'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
\App\Models\DriverOcrData::ensureTableExists();
|
||||
$hash = \App\Core\Security::blindIndex($phone);
|
||||
|
||||
if ($companyId) {
|
||||
$records = \App\Core\Database::select(
|
||||
"SELECT * FROM driver_ocr_data WHERE company_id = ? AND phone_hash = ?",
|
||||
[$companyId, $hash]
|
||||
);
|
||||
} else {
|
||||
$records = \App\Core\Database::select(
|
||||
"SELECT * FROM driver_ocr_data WHERE phone_hash = ?",
|
||||
[$hash]
|
||||
);
|
||||
}
|
||||
|
||||
if (empty($records)) {
|
||||
$response->status(404)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'No driver records found for this phone number'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$decryptedRecords = [];
|
||||
$host = ($request->getHeader('x-forwarded-proto') ?: 'http') . '://' . ($request->getHeader('host') ?: 'localhost');
|
||||
|
||||
foreach ($records as $record) {
|
||||
$decrypted = \App\Models\DriverOcrData::decryptRecord($record);
|
||||
if ($decrypted) {
|
||||
// Include absolute paths for all document URLs
|
||||
$urlFields = [
|
||||
'id_front_url', 'id_back_url',
|
||||
'driving_license_front_url', 'driving_license_back_url',
|
||||
'vehicle_license_front_url', 'vehicle_license_back_url',
|
||||
'criminal_record_url'
|
||||
];
|
||||
foreach ($urlFields as $field) {
|
||||
if (!empty($decrypted[$field])) {
|
||||
$decrypted[$field . '_absolute'] = $host . $decrypted[$field];
|
||||
} else {
|
||||
$decrypted[$field . '_absolute'] = null;
|
||||
}
|
||||
}
|
||||
$decryptedRecords[] = $decrypted;
|
||||
}
|
||||
}
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'data' => $decryptedRecords
|
||||
]);
|
||||
});
|
||||
|
||||
// REST API to Mark Driver Registered (POST)
|
||||
$router->post('/api/external/register-driver', function ($request, $response) {
|
||||
$apiKey = getenv('ENTALEQ_API_KEY');
|
||||
$incomingKey = $request->getHeader('x-api-key') ?? '';
|
||||
|
||||
if (empty($apiKey) || $incomingKey !== $apiKey) {
|
||||
$response->status(401)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Unauthorized'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$phone = $request->get('phone') ?? '';
|
||||
$companyId = $request->get('company_id') ?? '';
|
||||
|
||||
if (empty($phone)) {
|
||||
$response->status(400)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Missing phone parameter'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
\App\Models\DriverOcrData::ensureTableExists();
|
||||
$hash = \App\Core\Security::blindIndex($phone);
|
||||
|
||||
if ($companyId) {
|
||||
$existing = \App\Core\Database::selectOne(
|
||||
"SELECT id FROM driver_ocr_data WHERE company_id = ? AND phone_hash = ? LIMIT 1",
|
||||
[$companyId, $hash]
|
||||
);
|
||||
} else {
|
||||
$existing = \App\Core\Database::selectOne(
|
||||
"SELECT id FROM driver_ocr_data WHERE phone_hash = ? LIMIT 1",
|
||||
[$hash]
|
||||
);
|
||||
}
|
||||
|
||||
if (!$existing) {
|
||||
$response->status(404)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Driver record not found'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
\App\Core\Database::execute(
|
||||
"UPDATE driver_ocr_data SET status = 'registered' WHERE id = ?",
|
||||
[$existing['id']]
|
||||
);
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'message' => 'Driver status successfully updated to registered'
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
// Cron job endpoint to send pending driver registration reminders
|
||||
$router->get('/api/external/cron/send-reminders', function ($request, $response) {
|
||||
$apiKey = getenv('ENTALEQ_API_KEY');
|
||||
$incomingKey = $request->getHeader('x-api-key') ?? '';
|
||||
|
||||
if (empty($apiKey) || $incomingKey !== $apiKey) {
|
||||
$response->status(401)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Unauthorized'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
\App\Models\DriverReminder::ensureTableExists();
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$reminders = \App\Core\Database::select(
|
||||
"SELECT * FROM driver_registration_reminders WHERE status = 'pending' AND scheduled_at <= ?",
|
||||
[$now]
|
||||
);
|
||||
|
||||
$processed = 0;
|
||||
foreach ($reminders as $reminder) {
|
||||
$companyId = $reminder['company_id'];
|
||||
$phone = $reminder['phone'];
|
||||
|
||||
// Get company chatbot rule details
|
||||
$rule = \App\Models\ChatbotRule::findActiveForRule($companyId);
|
||||
$geminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : getenv('GEMINI_API_KEY');
|
||||
$elApiKey = ($rule && !empty($rule['elevenlabs_api_key'])) ? $rule['elevenlabs_api_key'] : getenv('ELEVENLABS_API_KEY');
|
||||
$elVoiceId = ($rule && !empty($rule['elevenlabs_voice_id'])) ? $rule['elevenlabs_voice_id'] : getenv('ELEVENLABS_VOICE_ID');
|
||||
|
||||
// Fetch company WhatsApp session
|
||||
$session = \App\Models\WhatsAppSession::findByCompany($companyId);
|
||||
if (!$session || $session['status'] !== 'connected') {
|
||||
error_log("Cron Reminder: WhatsApp session not connected for company {$companyId}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to get driver name from active conversation flow state
|
||||
$driverName = '';
|
||||
$state = \App\Models\ConversationState::findActive($companyId, $phone);
|
||||
if ($state) {
|
||||
$ctx = json_decode($state['context_data'] ?: '{}', true);
|
||||
$driverName = $ctx['name'] ?? '';
|
||||
}
|
||||
|
||||
$nameStr = $driverName ? " كابتن " . $driverName : " كابتن";
|
||||
$reminderMsg = "أهلاً بك{$nameStr}، حابين نذكرك تكمل خطوات تسجيلك لتنضم لعائلة انطلق 🚖. بقية الأوراق كتير مهمة لنفعل حسابك ونبدأ سوا. بانتظار إرسالها!";
|
||||
|
||||
// Generate Audio voice note if key is present
|
||||
$audioData = null;
|
||||
if (!empty($geminiKey)) {
|
||||
$audioData = \App\Services\GeminiService::generateAudioResponse(
|
||||
$geminiKey,
|
||||
"أنت روبوت خدمة العملاء لشركة انطلق، تتحدث باللهجة السورية الودودة.",
|
||||
$reminderMsg,
|
||||
'Puck',
|
||||
$elApiKey ?: null,
|
||||
$elVoiceId ?: null
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Send the text reminder
|
||||
\App\Core\Flows\ConversationFlowEngine::sendReply($session, $phone, $reminderMsg);
|
||||
|
||||
// 2. Send the voice note reminder if generated successfully
|
||||
if ($audioData && !empty($audioData['audio'])) {
|
||||
\App\Core\Flows\ConversationFlowEngine::sendReply(
|
||||
$session,
|
||||
$phone,
|
||||
'',
|
||||
null,
|
||||
$audioData['audio'],
|
||||
$audioData['mimeType'] ?? 'audio/mp4'
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Mark the reminder as sent
|
||||
\App\Models\DriverReminder::update($reminder['id'], [
|
||||
'status' => 'sent'
|
||||
]);
|
||||
$processed++;
|
||||
} catch (\Exception $ex) {
|
||||
error_log("Failed to process reminder ID {$reminder['id']}: " . $ex->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'processed_count' => $processed,
|
||||
'total_pending_due' => count($reminders)
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
// 4. Dispatch the request
|
||||
$router->dispatch($request, $response);
|
||||
|
||||
139
backend/public/test_simulation.php
Normal file
139
backend/public/test_simulation.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
/**
|
||||
* Driver Registration & Postponement Flow Simulation Script
|
||||
* Run this on the server: php backend/public/test_simulation.php
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
use App\Core\Database;
|
||||
use App\Models\ConversationState;
|
||||
use App\Models\DriverReminder;
|
||||
use App\Core\Flows\ConversationFlowEngine;
|
||||
use App\Models\WhatsAppSession;
|
||||
|
||||
echo "=== Starting Driver Registration & Reminder Flow Simulation ===\n\n";
|
||||
|
||||
$companyId = 1;
|
||||
$phone = "963999999999"; // Test Phone Number
|
||||
|
||||
// Setup mock session
|
||||
$session = WhatsAppSession::findOrCreate($companyId);
|
||||
// Ensure session is marked connected for simulation to go through sendReply
|
||||
WhatsAppSession::updateState($session['id'], [
|
||||
'phone' => $phone,
|
||||
'status' => 'connected'
|
||||
]);
|
||||
// Refresh session data
|
||||
$session = WhatsAppSession::findByCompany($companyId);
|
||||
|
||||
echo "1. Cleaning up existing test states/reminders...\n";
|
||||
Database::execute("DELETE FROM conversation_states WHERE contact_phone = ?", [$phone]);
|
||||
Database::execute("DELETE FROM driver_registration_reminders WHERE phone = ?", [$phone]);
|
||||
Database::execute("DELETE FROM driver_ocr_data WHERE phone = ?", [$phone]);
|
||||
|
||||
echo "2. Initializing conversation to 'id_front' state...\n";
|
||||
ConversationState::saveState([
|
||||
'company_id' => $companyId,
|
||||
'contact_phone' => $phone,
|
||||
'flow_name' => 'driver_registration_flow',
|
||||
'current_step' => 'id_front',
|
||||
'context_data' => json_encode(['name' => 'جميل الشام'], JSON_UNESCAPED_UNICODE),
|
||||
'expires_at' => date('Y-m-d H:i:s', strtotime('+1 hour'))
|
||||
]);
|
||||
|
||||
echo "3. Simulating user postponement request: 'بكرة المسا ببعتلك الهوية الشخصية'...\n";
|
||||
$msgData = [
|
||||
'phone' => $phone,
|
||||
'body' => 'بكرة المسا ببعتلك الهوية الشخصية'
|
||||
];
|
||||
|
||||
$handled = ConversationFlowEngine::processMessage($session, $msgData);
|
||||
echo " Message processed by flow engine: " . ($handled ? "YES" : "NO") . "\n";
|
||||
|
||||
// Fetch the new state and scheduled reminders
|
||||
$state = ConversationState::findActive($companyId, $phone);
|
||||
echo " New Flow Step: " . ($state ? $state['current_step'] : 'None (finished)') . "\n";
|
||||
echo " Context: " . ($state ? $state['context_data'] : 'None') . "\n";
|
||||
|
||||
$reminders = Database::select("SELECT * FROM driver_registration_reminders WHERE phone = ?", [$phone]);
|
||||
echo " Scheduled Reminders Count: " . count($reminders) . "\n";
|
||||
if (!empty($reminders)) {
|
||||
foreach ($reminders as $r) {
|
||||
echo " - ID: {$r['id']}, Scheduled At: {$r['scheduled_at']}, Postpone Count: {$r['postpone_count']}, Status: {$r['status']}\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n4. Testing Cron Scheduler (Simulate reminder execution by setting scheduled_at to the past)...\n";
|
||||
if (!empty($reminders)) {
|
||||
Database::execute("UPDATE driver_registration_reminders SET scheduled_at = ? WHERE id = ?", [
|
||||
date('Y-m-d H:i:s', strtotime('-1 minute')),
|
||||
$reminders[0]['id']
|
||||
]);
|
||||
|
||||
echo " Triggering cron process via internal endpoint request simulation...\n";
|
||||
// We call the main cron logic directly by querying and executing the reminder
|
||||
$dueReminders = Database::select(
|
||||
"SELECT * FROM driver_registration_reminders WHERE status = 'pending' AND scheduled_at <= ?",
|
||||
[date('Y-m-d H:i:s')]
|
||||
);
|
||||
|
||||
echo " Due Reminders found: " . count($dueReminders) . "\n";
|
||||
foreach ($dueReminders as $reminder) {
|
||||
$rule = \App\Models\ChatbotRule::findActiveForRule($companyId);
|
||||
$geminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : getenv('GEMINI_API_KEY');
|
||||
$elApiKey = ($rule && !empty($rule['elevenlabs_api_key'])) ? $rule['elevenlabs_api_key'] : getenv('ELEVENLABS_API_KEY');
|
||||
$elVoiceId = ($rule && !empty($rule['elevenlabs_voice_id'])) ? $rule['elevenlabs_voice_id'] : getenv('ELEVENLABS_VOICE_ID');
|
||||
|
||||
$driverName = 'جميل الشام';
|
||||
$nameStr = $driverName ? " كابتن " . $driverName : " كابتن";
|
||||
$reminderMsg = "أهلاً بك{$nameStr}، حابين نذكرك تكمل خطوات تسجيلك لتنضم لعائلة انطلق 🚖. بقية الأوراق كتير مهمة لنفعل حسابك ونبدأ سوا. بانتظار إرسالها!";
|
||||
|
||||
echo " Generating reminder Audio via TTS...\n";
|
||||
$audioData = null;
|
||||
if (!empty($geminiKey)) {
|
||||
$audioData = \App\Services\GeminiService::generateAudioResponse(
|
||||
$geminiKey,
|
||||
"أنت روبوت خدمة العملاء لشركة انطلق، تتحدث باللهجة السورية الودودة.",
|
||||
$reminderMsg,
|
||||
'Puck',
|
||||
$elApiKey ?: null,
|
||||
$elVoiceId ?: null
|
||||
);
|
||||
}
|
||||
|
||||
echo " Sending outbound simulated message...\n";
|
||||
ConversationFlowEngine::sendReply($session, $phone, $reminderMsg);
|
||||
|
||||
if ($audioData && !empty($audioData['audio'])) {
|
||||
echo " Audio voice note generated successfully (Length: " . strlen($audioData['audio']) . " bytes). Sending...\n";
|
||||
ConversationFlowEngine::sendReply(
|
||||
$session,
|
||||
$phone,
|
||||
'',
|
||||
null,
|
||||
$audioData['audio'],
|
||||
$audioData['mimeType'] ?? 'audio/mp4'
|
||||
);
|
||||
} else {
|
||||
echo " Audio voice note generation skipped or failed (Gemini Key may be missing or empty).\n";
|
||||
}
|
||||
|
||||
DriverReminder::update($reminder['id'], ['status' => 'sent']);
|
||||
echo " Reminder ID {$reminder['id']} status set to 'sent'.\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n5. Simulating user resuming registration (sending message 'هلا يا كابتن')...\n";
|
||||
$msgData = [
|
||||
'phone' => $phone,
|
||||
'body' => 'هلا يا كابتن'
|
||||
];
|
||||
|
||||
$handled = ConversationFlowEngine::processMessage($session, $msgData);
|
||||
echo " Resumed Message handled: " . ($handled ? "YES" : "NO") . "\n";
|
||||
|
||||
$state = ConversationState::findActive($companyId, $phone);
|
||||
echo " Resumed Flow Step: " . ($state ? $state['current_step'] : 'None') . "\n";
|
||||
|
||||
echo "\n=== Simulation Finished ===\n";
|
||||
@@ -1,298 +1,78 @@
|
||||
# خطة تنفيذ المعمارية الخلفية ونظام النشر المؤتمت — تطبيق نبيه (Nabeh)
|
||||
# Implementation Plan - Intaleq Customer Service & Driver Registration Flow
|
||||
|
||||
<div dir="rtl" align="right">
|
||||
This plan details the implementation of the Intaleq customer service prompt template, the multi-stage WhatsApp driver registration flow using Gemini OCR, and the authenticated REST API endpoints for Nabeh's backend.
|
||||
|
||||
توضح هذه الخطة التفاصيل الهيكلية والخطوات العملية لبناء النواة البرمجية للخلفية (Backend Base Architecture) باستخدام **Pure PHP OOP** وبناء نظام نشر تلقائي آمن عبر **Git/SSH** إلى السيرفر.
|
||||
|
||||
---
|
||||
|
||||
## أولاً: تقسيم المشروع إلى مراحل (Milestones)
|
||||
|
||||
من أجل ضمان الانتقال السلس وبناء أساسات متينة وقابلة للتطوير، سنقسم العمل على الخلفية والنشر إلى المراحل التالية:
|
||||
|
||||
### المرحلة 1: بناء نواة الـ MVC وتهيئة الهيكل البرمجي
|
||||
- بناء هيكل المجلدات المنظم وفصل الملفات العامة (Public) عن الكود البرمجي الأساسي.
|
||||
- إعداد نظام التحميل التلقائي للملفات (Autoloader) المتوافق مع معيار PSR-4.
|
||||
- برمجة موجه الطلبات (Router) المتوافق مع خادم Nginx.
|
||||
- برمجة قارئ ملفات البيئة المتطور والآمن (`.env` Reader) مع تخزينه خارج المسار العام لضمان أعلى مستويات الحماية.
|
||||
|
||||
### المرحلة 2: تصميم قاعدة البيانات ونواة الاتصال (Database & Core)
|
||||
- تصميم ملف تهيئة الاتصال بقاعدة البيانات باستخدام PDO.
|
||||
- بناء الموديل الرئيسي (Base Model) والمتحكم الرئيسي (Base Controller) لإدارة المدخلات والمخرجات (JSON Responses).
|
||||
- بناء مخطط الجداول الأساسية لقاعدة البيانات (الهيكل متعدد المستأجرين - Multi-Tenant Prep).
|
||||
|
||||
### المرحلة 3: نظام النشر التلقائي للسيرفر (`deploy.sh`)
|
||||
- كتابة سكربت النشر الآمن لرفع التحديثات لـ Git وعمل Pull تلقائي وإعادة بناء التهيئات على السيرفر.
|
||||
- إعداد البنية التحتية للمجلدات على السيرفر وربط الـ Subdomain.
|
||||
|
||||
---
|
||||
|
||||
## ثانياً: معمارية مجلدات الخلفية المقترحة (Directory Structure)
|
||||
|
||||
سنقوم بتنظيم كود الخلفية داخل مجلد `backend/` لحماية كود التطبيق وملف البيانات الحساسة (`.env`) من الوصول المباشر من المتصفح، حيث سيكون مجلد `public/` هو المجلد الوحيد المتاح للعامة والمربوط بالـ Document Root في Nginx.
|
||||
|
||||
الهيكل المستهدف للمجلدات هو كالتالي:
|
||||
|
||||
</div>
|
||||
|
||||
```
|
||||
nabeh/
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── Controllers/ # متحكمات معالجة الطلبات
|
||||
│ │ │ ├── BaseController.php
|
||||
│ │ │ └── AuthController.php
|
||||
│ │ ├── Models/ # موديلات قواعد البيانات
|
||||
│ │ │ ├── BaseModel.php
|
||||
│ │ │ └── Tenant.php
|
||||
│ │ ├── Core/ # نواة التطبيق البرمجية
|
||||
│ │ │ ├── Router.php
|
||||
│ │ │ ├── Database.php
|
||||
│ │ │ ├── Request.php
|
||||
│ │ │ ├── Response.php
|
||||
│ │ │ └── Env.php
|
||||
│ │ └── Middlewares/ # فلاتر التحقق والوساطة
|
||||
│ │ └── AuthMiddleware.php
|
||||
│ ├── config/ # ملفات الإعدادات العامة
|
||||
│ │ └── app.php
|
||||
│ ├── public/ # المجلد الوحيد المتاح للمتصفح
|
||||
│ │ └── index.php # نقطة الدخول الموحدة (Front Controller)
|
||||
│ ├── .env # ملف البيئة الحساس (مخفي تماماً)
|
||||
│ ├── .env.example
|
||||
│ └── composer.json # لإدارة التحميل التلقائي PSR-4 وحزم التطبيق
|
||||
```
|
||||
|
||||
<div dir="rtl" align="right">
|
||||
|
||||
---
|
||||
|
||||
## ثالثاً: تصميم النواة البرمجية (Core Architecture)
|
||||
|
||||
### 1. نقطة الدخول الموحدة `backend/public/index.php`
|
||||
يقوم هذا الملف بتهيئة بيئة العمل، تحميل الملفات تلقائياً، قراءة متغيرات البيئة، ثم تمرير الطلب إلى الـ Router.
|
||||
|
||||
### 2. قارئ البيئة الآمن `backend/app/Core/Env.php`
|
||||
يقرأ ملف `.env` المتواجد خارج المجلد العام ويقوم بتحميل المتغيرات داخل `$_ENV` و `getenv()` بأمان تام.
|
||||
|
||||
### 3. نظام توجيه الطلبات `backend/app/Core/Router.php`
|
||||
نظام توجيه خفيف وقوي يدعم تعبيرات Regex، ويتيح تعريف مسارات من نوع GET و POST و PUT و DELETE وتمرير البارامترات للمتحكمات (Controllers) وتطبيق فلاتر الوساطة (Middlewares).
|
||||
|
||||
---
|
||||
|
||||
## رابعاً: نظام النشر المؤتمت (`deploy.sh`)
|
||||
|
||||
سنقوم بإنشاء سكربت النشر الذكي في الجذر الرئيسي للمشروع لتبسيط عملية الدفع والتحديث. سيقوم السكربت بالخطوات التالية بشكل تفاعلي وآمن:
|
||||
|
||||
1. التأكد من كتابة تعليق الالتزام (Commit Message).
|
||||
2. إضافة كافة التغييرات محلياً والدفع إلى الفرع الرئيسي على GitHub/GitLab.
|
||||
3. الاتصال بالسيرفر عبر بروتوكول SSH بشكل آمن.
|
||||
4. التوجه إلى مجلد المشروع على السيرفر وسحب الكود البرمجي المحدث (`git pull origin main`).
|
||||
5. تطبيق أي أوامر تهيئة مثل تثبيت حزم الملحقات (`composer install`) أو تنظيف الكاش.
|
||||
|
||||
محتوى سكربت `deploy.sh` المقترح:
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================
|
||||
# سكريبت النشر الآلي وتحديث السيرفر - تطبيق نبيه
|
||||
# ==========================================
|
||||
|
||||
# 1. إعدادات السيرفر والمتغيرات الأساسية
|
||||
SERVER_USER="hamza" # اسم مستخدم السيرفر
|
||||
SERVER_IP="your-server-ip" # عنوان السيرفر
|
||||
SERVER_PATH="/var/www/nabeh" # مسار المشروع على السيرفر
|
||||
GIT_BRANCH="main" # الفرع الرئيسي للنشر
|
||||
|
||||
echo "=========================================="
|
||||
echo "🚀 بدء عملية النشر والتحديث لتطبيق (نبيه)..."
|
||||
echo "=========================================="
|
||||
|
||||
# 2. التأكد من حالة التغييرات المحلية
|
||||
if [ -z "$(git status --porcelain)" ]; then
|
||||
echo "ℹ️ لا توجد تغييرات برمجية غير محفوظة للنشر."
|
||||
else
|
||||
# طلب وصف للتحديثات (Commit Message)
|
||||
echo "✍️ الرجاء إدخال رسالة الالتزام (Commit Message):"
|
||||
read commit_msg
|
||||
|
||||
if [ -z "$commit_msg" ]; then
|
||||
commit_msg="Update: Automatic deployment via deploy.sh"
|
||||
fi
|
||||
|
||||
# الإضافة والالتزام البرمجي
|
||||
git add .
|
||||
git commit -m "$commit_msg"
|
||||
fi
|
||||
|
||||
# 3. الدفع إلى Git Remote
|
||||
echo "📤 دفع التحديثات إلى المستودع البعيد (Git Push)..."
|
||||
git push origin $GIT_BRANCH
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ فشلت عملية الدفع البرمجي (Git Push). تم إلغاء النشر."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ تم رفع الكود بنجاح إلى المستودع البرمجي."
|
||||
|
||||
# 4. الاتصال بالسيرفر وتحديث الكود (Git Pull)
|
||||
echo "🌐 الاتصال بالسيرفر وسحب الكود المحدث (Git Pull)..."
|
||||
ssh ${SERVER_USER}@${SERVER_IP} << EOF
|
||||
cd ${SERVER_PATH}
|
||||
echo "📁 الانتقال إلى مجلد المشروع: ${SERVER_PATH}"
|
||||
|
||||
echo "📥 سحب التحديثات من الفرع ${GIT_BRANCH}..."
|
||||
git pull origin ${GIT_BRANCH}
|
||||
|
||||
if [ -f "backend/composer.json" ]; then
|
||||
echo "📦 تحديث الحزم البرمجية والتحميل التلقائي (Composer)..."
|
||||
cd backend
|
||||
composer install --no-dev --optimize-autoloader
|
||||
cd ..
|
||||
fi
|
||||
|
||||
echo "🎉 تم تحديث السيرفر وتثبيت التحديثات بنجاح!"
|
||||
EOF
|
||||
|
||||
echo "=========================================="
|
||||
echo "✨ تمت عملية النشر والتحديث بالكامل بنجاح!"
|
||||
echo "=========================================="
|
||||
```
|
||||
|
||||
<div dir="rtl" align="right">
|
||||
|
||||
---
|
||||
|
||||
## خامساً: خطة إعداد الخادم والـ Subdomain
|
||||
|
||||
لتشغيل نظام الـ Front Controller والمسارات الديناميكية بشكل متوافق مع **Nginx** وبدون الحاجة لملفات `.htaccess` الخاصة بـ Apache، يجب إعداد ملف تهيئة الـ Subdomain (مثال: `api.nabeh.sa` أو `app.nabeh.sa`) ليوجه الطلبات إلى المجلد العام `backend/public/`:
|
||||
|
||||
إعداد Nginx المقترح للمخدم:
|
||||
|
||||
</div>
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
|
||||
server_name api.nabeh.sa; # النطاق الفرعي للتطبيق
|
||||
|
||||
# تحديد مسار المجلد العام فقط
|
||||
root /var/www/nabeh/backend/public;
|
||||
index index.php index.html;
|
||||
|
||||
charset utf-8;
|
||||
|
||||
# توجيه كافة الطلبات إلى index.php لدعم الـ Router
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location = /favicon.ico { access_log off; log_not_found off; }
|
||||
location = /robots.txt { access_log off; log_not_found off; }
|
||||
|
||||
error_page 404 /index.php;
|
||||
|
||||
# تشغيل ملفات PHP وتكاملها مع PHP-FPM
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock; # حسب إصدار PHP المثبت
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
# منع الوصول تماماً لأي ملفات حساسة أو مخفية
|
||||
location ~ /\.(?!well-known).* {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<div dir="rtl" align="right">
|
||||
|
||||
---
|
||||
|
||||
## سادساً: خطة التحقق والتدقيق (Verification Plan)
|
||||
|
||||
### التحقق الذاتي والمحلي:
|
||||
1. **التحقق من المسارات (Router Testing)**: إنشاء طلبات محلية للتحقق من تشغيل المسارات المختلفة (GET/POST) وإرجاع بيانات JSON صحيحة.
|
||||
2. **التحقق من حماية متغيرات البيئة (`.env` Security)**: محاولة استعراض ملف `.env` عبر المسار المباشر للتأكد من حظر الوصول العام إليه، وقراءته بنجاح من داخل تطبيق PHP.
|
||||
3. **فحص التحميل التلقائي**: التحقق من استدعاء كلاسات الـ Controllers والموديلات دون الحاجة لكتابة `require_once` يدوياً لكل ملف.
|
||||
|
||||
---
|
||||
|
||||
## المرحلة الرابعة: نظام التوثيق والمصادقة (Authentication Phase)
|
||||
|
||||
بناءً على النواة الأمنية التي تم تجهيزها (JWT, AES-256-GCM, Bcrypt, HMAC Blind Index)، سنقوم ببناء نظام المصادقة ليكون جاهزاً لإدارة جلسات المستخدمين.
|
||||
|
||||
### Proposed Changes
|
||||
|
||||
#### [NEW] `backend/app/Models/User.php`
|
||||
- كلاس يمتد من `BaseModel` لإدارة جدول `users`.
|
||||
- يحتوي على دالة `findByEmail` التي تقوم بتوليد (Blind Index HMAC) للبريد الإلكتروني للبحث السريع في قاعدة البيانات دون فك التشفير.
|
||||
- دالة `createSecure` لإنشاء مستخدم جديد مع تشفير بريده الإلكتروني وكلمة مروره.
|
||||
|
||||
#### [NEW] `backend/app/Controllers/AuthController.php`
|
||||
- دالة `login`: تستقبل البريد الإلكتروني وكلمة المرور، تتحقق منها عبر الموديل، وتولد JWT Token.
|
||||
- دالة `register` (اختيارية كأداة تطوير مبدئية أو للأدمن): إنشاء حساب موظف جديد.
|
||||
- دالة `me`: جلب بيانات المستخدم الحالي باستخدام الـ `AuthMiddleware` لاختبار سلامة الجلسة.
|
||||
|
||||
#### [MODIFY] `backend/public/index.php`
|
||||
- إضافة المسارات الخاصة بنظام التوثيق:
|
||||
- `POST /api/auth/login`
|
||||
- `GET /api/auth/me` (مرفق بـ `AuthMiddleware`)
|
||||
|
||||
### User Review Required
|
||||
## User Review Required
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - هل ترغب بإنشاء مسار `POST /api/auth/register` مفتوح للجميع لإنشاء حسابات شركات جديدة؟ أم نكتفي حالياً بإنشاء مستخدم مدير (Admin) افتراضي يدوياً أو عبر سكربت ليكون النظام مغلقاً للشركات المعتمدة فقط؟
|
||||
> - في عملية تسجيل الدخول، هل نحتاج لإرجاع بيانات الشركة المرتبطة بالمستخدم ضمن نفس الـ Response، أم نكتفي بإرجاع التوكن (Token) وبيانات المستخدم الأساسية فقط؟
|
||||
> - **API Key Configuration**: We will authenticate the REST endpoints using a shared `ENTALEQ_API_KEY` defined in the `.env` file. Please ensure this environment variable is populated on your environment before calling these endpoints.
|
||||
> - **Direct Upload Path**: Decoded WhatsApp images will be saved under the public directory `backend/public/uploads/documents/` to ensure they are accessible via public URL paths.
|
||||
> - **Prompt Dialect**: The custom service prompt is written in a professional, warm Syrian dialect (اللهجة السورية) adhering to all business rules.
|
||||
|
||||
---
|
||||
|
||||
## المرحلة الخامسة: معالجة وإصلاح الثغرات الأمنية (Security Audit Remediation)
|
||||
## Proposed Changes
|
||||
|
||||
بناءً على التقرير الأمني الصادر، سنقوم بتطبيق التعديلات البرمجية لرفع مستوى أمان الخادم وحماية البيانات.
|
||||
### Database Schema Component
|
||||
|
||||
### Proposed Changes
|
||||
#### [NEW] [DriverOcrData.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Models/DriverOcrData.php)
|
||||
- Class extending `BaseModel` that manages the `driver_ocr_data` table.
|
||||
- Dynamically creates the table via `ensureTableExists()`.
|
||||
- Implements `saveSecure` to encrypt sensitive fields (`phone`, `name`, `password`, and all JSON OCR strings) using AES-256-GCM (`Security::encrypt()`) and generate search hashes (`Security::blindIndex()`).
|
||||
- Implements `findByPhone` to retrieve and decrypt records.
|
||||
|
||||
#### [MODIFY] [Security.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Core/Security.php)
|
||||
- إرجاع خطأ (Exception) فوراً في حال عدم وجود متغيرات البيئة (`ENCRYPTION_KEY`, `HMAC_SALT`, `JWT_SECRET`) لمنع تشفير البيانات بمفاتيح فارغة.
|
||||
---
|
||||
|
||||
#### [MODIFY] [BaseModel.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Models/BaseModel.php)
|
||||
- تنظيف وفلترة أسماء الأعمدة ديناميكياً باستخدام مصفوفة بيضاء (Whitelist Regex) لمنع ثغرات الـ SQL Injection عبر أسماء الأعمدة في دالتي `create()` و `update()`.
|
||||
### Gemini Service Component
|
||||
|
||||
#### [MODIFY] [SecurityMiddleware.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Middlewares/SecurityMiddleware.php)
|
||||
- إلغاء تطبيق `htmlspecialchars` و `strip_tags` على المدخلات الخام القادمة للخلفية لمنع تلف كلمات المرور والرموز الخاصة، وتأجيل التنظيف ليكون حصراً عند الطباعة/العرض (Output Encoding).
|
||||
#### [MODIFY] [GeminiService.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Services/GeminiService.php)
|
||||
- Add the `generateOcrFromImage` static method to support custom structured document analysis. It forwards custom prompts (e.g. green card OCR instructions) and base64 inline images to the `gemini-2.0-flash-lite` model.
|
||||
|
||||
#### [MODIFY] [AuthController.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Controllers/AuthController.php)
|
||||
- إزالة التنظيف المزدوج (Double Encoding) لأسماء المستخدمين والشركات.
|
||||
---
|
||||
|
||||
#### [MODIFY] [Router.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Core/Router.php)
|
||||
- إخفاء مسار الملفات الحقيقي من رسائل الخطأ 404 و 500 وإظهار رسائل عامة لمنع تسريب بنية المجلدات (Information Disclosure).
|
||||
### Dashboard Component
|
||||
|
||||
#### [MODIFY] [Response.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Core/Response.php)
|
||||
- جعل رابط الـ CORS ديناميكياً يعتمد على متغير بيئة `ALLOWED_ORIGIN` بدلاً من فتح النطاق للجميع عبر `*`.
|
||||
#### [MODIFY] [index.html](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/public/index.html)
|
||||
- Add the new prompt template button: `<button type="button" class="btn btn-secondary" @click="loadPromptTemplate('intaleq')">قالب خدمة عملاء انطلق (سوري)</button>`.
|
||||
- Add `intaleq` template case inside `loadPromptTemplate(type)` to load the full customer service system instruction prompt.
|
||||
|
||||
#### [MODIFY] [bootstrap.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/bootstrap.php)
|
||||
- تسجيل معالج أخطاء عام (`set_exception_handler` / `set_error_handler`) لتحويل الأخطاء الفادحة غير المعالجة إلى استجابات JSON نظيفة ومخفية في الإنتاج، مع تسجيل التفاصيل التقنية في الـ `error_log` فقط.
|
||||
---
|
||||
|
||||
#### [MODIFY] [Request.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Core/Request.php)
|
||||
- تعريف الخصائص المعرفية (`$user_id`, `$company_id`, `$role`) بشكل صريح لتفادي مشاكل الخصائص الديناميكية (Dynamic Properties Deprecation) في إصدارات PHP 8.2+.
|
||||
### Conversation Flow Component
|
||||
|
||||
#### [NEW] [RateLimitMiddleware.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Middlewares/RateLimitMiddleware.php)
|
||||
- بناء فلتر مخصص للحد من معدل الطلبات (Rate Limiting) على مسارات تسجيل الدخول والتسجيل لحماية التطبيق من محاولات التخمين العنيف (Brute Force).
|
||||
#### [NEW] [DriverRegistrationFlow.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Core/Flows/DriverRegistrationFlow.php)
|
||||
- Implement a step-by-step driver registration state machine.
|
||||
- Steps:
|
||||
1. `start`: Welcome message, request full name.
|
||||
2. `ask_name`: Save name, request password.
|
||||
3. `ask_password`: Validate password, request ID Front image.
|
||||
4. `id_front`: Run `id_front_sy` Gemini OCR prompt. Request ID Back.
|
||||
5. `id_back`: Run `id_back_sy` Gemini OCR prompt. Request Driving License Front.
|
||||
6. `driving_license_front`: Run `driving_license_sy_front` Gemini OCR prompt. Request Driving License Back.
|
||||
7. `driving_license_back`: Run `driving_license_sy_back` Gemini OCR prompt. Request Vehicle License Front.
|
||||
8. `vehicle_license_front`: Run `vehicle_license_sy_front` Gemini OCR prompt. Request Vehicle License Back.
|
||||
9. `vehicle_license_back`: Run `vehicle_license_sy_back` Gemini OCR prompt. Request Criminal Record (لا حكم عليه).
|
||||
10. `criminal_record`: Save document, update status to `ocr_completed`, save to `driver_ocr_data` table, and conclude.
|
||||
|
||||
#### [MODIFY] [ConversationFlowEngine.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Core/Flows/ConversationFlowEngine.php)
|
||||
- Add trigger mappings: `'تسجيل' => 'driver_registration_flow'`, `'سجل' => 'driver_registration_flow'`, and `'register' => 'driver_registration_flow'`.
|
||||
- Register the flow class: `'driver_registration_flow' => DriverRegistrationFlow::class`.
|
||||
|
||||
---
|
||||
|
||||
### REST API Component
|
||||
|
||||
#### [MODIFY] [index.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/public/index.php)
|
||||
- تفعيل فلتر الحد من معدل الطلبات على مسارات الحماية.
|
||||
- Define 2 REST API endpoints:
|
||||
- `GET /api/external/driver-ocr`: Accepts a query parameter `phone`. Authenticates against `ENTALEQ_API_KEY`. Retrieves and decrypts driver documents, OCR logs, and password details.
|
||||
- `POST /api/external/register-driver`: Accepts a `phone` body parameter. Authenticates against `ENTALEQ_API_KEY`. Marks the local record status as `registered` (confirming transfer to primary DB).
|
||||
|
||||
### User Review Required
|
||||
---
|
||||
|
||||
> [!IMPORTANT]
|
||||
> هل نعتمد نظام تخزين المحاولات للحد من معدل الطلبات (Rate Limiting) في ملفات مؤقتة داخل مجلد `storage/` محلي (سهل الإعداد ومستقل)، أم نستخدم جدولاً مخصصاً في قاعدة البيانات؟
|
||||
|
||||
</div>
|
||||
## Verification Plan
|
||||
|
||||
### Manual Verification
|
||||
1. Open the dashboard, click **قالب خدمة عملاء انطلق (سوري)**, and verify the prompt loads.
|
||||
2. Send a WhatsApp message to the bot saying `"تسجيل"` and follow the registration wizard. Send test images for each stage, verifying database storage and Gemini OCR parsing logs.
|
||||
3. Query the `GET /api/external/driver-ocr` endpoint using `curl` with the header `X-API-Key: your_key` and confirm decrypted data is returned.
|
||||
4. Execute `POST /api/external/register-driver` with the API key header and confirm the record status updates to `registered`.
|
||||
|
||||
237
registerDriverAndCarService.php
Normal file
237
registerDriverAndCarService.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
// --- 1. Dependencias y Conexión ---
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
// دالة مساعدة لتسجيل الخطوات في ملف الـ LOG
|
||||
function logStep($step, $message) {
|
||||
error_log("[DriverReg] Step $step: $message");
|
||||
}
|
||||
|
||||
try {
|
||||
// --- بدء المعاملة ---
|
||||
$con->beginTransaction();
|
||||
logStep(1, "Transaction started via beginTransaction()");
|
||||
|
||||
// --- 2. Recolección de Datos (Conductor + Coche) ---
|
||||
$phone = filterRequest("phone");
|
||||
$password = filterRequest("password");
|
||||
$firstName = filterRequest("first_name");
|
||||
$lastName = filterRequest("last_name");
|
||||
|
||||
// تسجيل البيانات المبدئية (بدون كلمات المرور) للتأكد من وصولها
|
||||
logStep(2, "Inputs received -> Phone: $phone, Name: $firstName $lastName");
|
||||
|
||||
// التحقق من الحقول الإجبارية
|
||||
if (empty($phone) || empty($password) || empty($firstName) || empty($lastName)) {
|
||||
throw new Exception("Required fields missing (phone, password, first_name, last_name).");
|
||||
}
|
||||
|
||||
// --- 3. Generar ID de Conductor ---
|
||||
$driverId = substr(md5($phone), 0, 20);
|
||||
logStep(3, "Driver ID generated: $driverId");
|
||||
|
||||
// --- 4. Procesamiento de Datos del Conductor ---
|
||||
$password_hashed = password_hash($password, PASSWORD_DEFAULT);
|
||||
$email = filterRequest("email");
|
||||
|
||||
if (empty($email) || $email === 'Not specified') {
|
||||
$email = $phone . '@intaleqapp.com';
|
||||
}
|
||||
|
||||
$nameArabic = $firstName . ' ' . $lastName;
|
||||
$site = filterRequest("site");
|
||||
$address = $site;
|
||||
|
||||
// بيانات إضافية
|
||||
$gender = filterRequest("gender");
|
||||
$license_type = filterRequest("license_type");
|
||||
$nationalNumber = filterRequest("national_number");
|
||||
$issue_date = filterRequest("issue_date");
|
||||
$expiry_date = filterRequest("expiry_date");
|
||||
$licenseCategories = filterRequest("license_categories");
|
||||
$licenseIssueDate = filterRequest("license_issue_date");
|
||||
$birthdate = filterRequest("birthdate");
|
||||
$maritalStatus = filterRequest("maritalStatus");
|
||||
|
||||
// --- 5. Recolección de Datos del Coche ---
|
||||
$owner = filterRequest("owner");
|
||||
$color = filterRequest("color");
|
||||
$colorHex = filterRequest("color_hex");
|
||||
$model = filterRequest("model");
|
||||
$carPlate = filterRequest("car_plate");
|
||||
$make = filterRequest("make");
|
||||
$fuel = filterRequest("fuel");
|
||||
$year = filterRequest("year");
|
||||
$vin = filterRequest("vin");
|
||||
|
||||
if (empty($vin)) {
|
||||
$vin = 'unknown';
|
||||
}
|
||||
|
||||
$carExpirationDate = filterRequest("expiration_date");
|
||||
|
||||
logStep(4, "Data processing completed. Car Plate: $carPlate, VIN: $vin");
|
||||
|
||||
// --- 6. Cifrado de Datos ---
|
||||
try {
|
||||
$encryptedPhone = $encryptionHelper->encryptData($phone);
|
||||
$encryptedEmail = $encryptionHelper->encryptData($email);
|
||||
$encryptedFirstName = $encryptionHelper->encryptData($firstName);
|
||||
$encryptedLastName = $encryptionHelper->encryptData($lastName);
|
||||
$encryptedNameArabic = $encryptionHelper->encryptData($nameArabic);
|
||||
$encryptedGender = $encryptionHelper->encryptData($gender);
|
||||
$encryptedNationalNumber = $encryptionHelper->encryptData($nationalNumber);
|
||||
$encryptedAddress = $encryptionHelper->encryptData($address);
|
||||
$encryptedSite = $encryptionHelper->encryptData($site);
|
||||
$encryptedBirthdate = $encryptionHelper->encryptData($birthdate);
|
||||
$encryptedOwner = $encryptionHelper->encryptData($owner);
|
||||
$encryptedCarPlate = $encryptionHelper->encryptData($carPlate);
|
||||
|
||||
logStep(5, "Encryption successful for sensitive fields.");
|
||||
} catch (Exception $encEx) {
|
||||
throw new Exception("Encryption Error: " . $encEx->getMessage());
|
||||
}
|
||||
|
||||
// --- 7. Comprobación de Duplicados ---
|
||||
// ملاحظة: إذا كان التشفير عشوائياً، فلن يجد التكرار هنا.
|
||||
$dup = $con->prepare("SELECT id FROM driver WHERE phone = :phone OR email = :email OR national_number = :national_number");
|
||||
$dup->execute([':phone' => $encryptedPhone, ':email' => $encryptedEmail, ':national_number' =>$encryptedNationalNumber]);
|
||||
|
||||
if ($dup->rowCount() > 0) {
|
||||
logStep(6, "Duplicate found! Phone or Email or encryptedNationalNumber already exists.");
|
||||
throw new Exception("Phone or email already registered.");
|
||||
}
|
||||
logStep(6, "No duplicates found. Proceeding.");
|
||||
|
||||
// --- 8. INSERCIÓN 1: Tabla 'driver' ---
|
||||
$sqlDriver = "
|
||||
INSERT INTO driver (
|
||||
id, phone, email, password, gender, license_type, national_number,
|
||||
name_arabic, issue_date, expiry_date, license_categories,
|
||||
address, licenseIssueDate, status, birthdate, site,
|
||||
first_name, last_name, accountBank, bankCode,
|
||||
employmentType, maritalStatus, fullNameMaritial, expirationDate,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
:id, :phone, :email, :pwd, :gender, :license_type, :national_number,
|
||||
:name_arabic, :issue_date, :expiry_date, :license_categories,
|
||||
:address, :licenseIssueDate, :status, :birthdate, :site,
|
||||
:first_name, :last_name, :accountBank, :bankCode,
|
||||
:employmentType, :maritalStatus, :fullNameMaritial, :expirationDate,
|
||||
NOW(), NOW()
|
||||
)
|
||||
";
|
||||
|
||||
$stmtDriver = $con->prepare($sqlDriver);
|
||||
|
||||
// تم توحيد المفاتيح لتشمل النقطتين (:)
|
||||
$driverData = [
|
||||
':id' => $driverId,
|
||||
':phone' => $encryptedPhone,
|
||||
':email' => $encryptedEmail,
|
||||
':pwd' => $password_hashed,
|
||||
':gender' => $encryptedGender,
|
||||
':license_type' => $license_type,
|
||||
':national_number' => $encryptedNationalNumber,
|
||||
':name_arabic' => $encryptedNameArabic,
|
||||
':issue_date' => $issue_date,
|
||||
':expiry_date' => $expiry_date,
|
||||
':license_categories' => $licenseCategories ?? 'B',
|
||||
':address' => $encryptedAddress,
|
||||
':licenseIssueDate' => $licenseIssueDate,
|
||||
':status' => 'actives',
|
||||
':birthdate' => $encryptedBirthdate,
|
||||
':site' => $encryptedSite,
|
||||
':first_name' => $encryptedFirstName,
|
||||
':last_name' => $encryptedLastName,
|
||||
':accountBank' => 'yet',
|
||||
':bankCode' => 'yet',
|
||||
':employmentType' => $maritalStatus ?? 'yet',
|
||||
':maritalStatus' => $maritalStatus ?? 'yet',
|
||||
':fullNameMaritial' => 'yet',
|
||||
':expirationDate' => 'yet',
|
||||
];
|
||||
|
||||
if (!$stmtDriver->execute($driverData)) {
|
||||
// تسجيل خطأ SQL بالتفصيل
|
||||
$errInfo = $stmtDriver->errorInfo();
|
||||
throw new Exception("Driver Insert Failed: " . $errInfo[2]);
|
||||
}
|
||||
logStep(7, "Driver table insert successful.");
|
||||
|
||||
// --- 9. INSERCIÓN 2: Tabla 'CarRegistration' ---
|
||||
$sqlCar = "
|
||||
INSERT INTO CarRegistration (
|
||||
driverID, vin, owner, color, color_hex, model, car_plate,
|
||||
make, fuel, `year`, expiration_date, created_at
|
||||
) VALUES (
|
||||
:driverId, :vin, :owner, :color, :color_hex, :model, :car_plate,
|
||||
:make, :fuel, :year, :expiration_date, NOW()
|
||||
)
|
||||
";
|
||||
|
||||
$stmtCar = $con->prepare($sqlCar);
|
||||
$carData = [
|
||||
':driverId' => $driverId,
|
||||
':vin' => $vin,
|
||||
':owner' => $encryptedOwner,
|
||||
':color' => $color,
|
||||
':color_hex' => $colorHex,
|
||||
':model' => $model,
|
||||
':car_plate' => $encryptedCarPlate,
|
||||
':make' => $make,
|
||||
':fuel' => $fuel,
|
||||
':year' => $year,
|
||||
':expiration_date' => $carExpirationDate
|
||||
];
|
||||
|
||||
if (!$stmtCar->execute($carData)) {
|
||||
$errInfo = $stmtCar->errorInfo();
|
||||
throw new Exception("Car Insert Failed: " . $errInfo[2]);
|
||||
}
|
||||
logStep(8, "CarRegistration insert successful.");
|
||||
|
||||
// --- 10. Confirmar Transacción ---
|
||||
$con->commit();
|
||||
logStep(9, "COMMIT successful. Sending Success Response.");
|
||||
|
||||
jsonSuccess(["driverId" => $driverId, "message" => "Driver and car registered successfully."]);
|
||||
|
||||
// --- 11. Enviar Notificación (خارج المعاملة يفضل، ولكن هنا كما في الكود الأصلي) ---
|
||||
try {
|
||||
$supportPhones = ['0952475740', '0952475742'];
|
||||
$randomIndex = array_rand($supportPhones);
|
||||
$phoneToUse = $supportPhones[$randomIndex];
|
||||
$randomNumber = rand(1000, 999999);
|
||||
|
||||
$messageBody = "أهلاً وسهلاً كابتن $firstName 👋\n"
|
||||
. "تم تفعيل حسابك على تطبيق *انطلق*.\n"
|
||||
. "يمكنك الآن تسجيل الدخول والبدء بالعمل مباشرة.\n"
|
||||
. "للمساعدة تواصل معنا على الرقم: $phoneToUse\n"
|
||||
. "نتمنى لك عمل موفق 🚖\n\n"
|
||||
. "معرف الرسالة: $randomNumber";
|
||||
|
||||
sendWhatsAppFromServer($phone, $messageBody);
|
||||
logStep(10, "WhatsApp notification sent.");
|
||||
} catch (Exception $waError) {
|
||||
// لا نوقف العملية إذا فشل الواتساب، فقط نسجل الخطأ
|
||||
logStep(10, "WhatsApp Warning: " . $waError->getMessage());
|
||||
}
|
||||
|
||||
} catch (PDOException $e) {
|
||||
$con->rollBack();
|
||||
$errorMsg = "Database Error (PDO): " . $e->getMessage();
|
||||
logStep("ERROR-PDO", $errorMsg);
|
||||
// إظهار رسالة عامة للمستخدم، وتسجيل التفاصيل في السيرفر
|
||||
jsonError("System error during registration. Please contact support.");
|
||||
} catch (Exception $e) {
|
||||
// إذا كانت المعاملة مفتوحة، قم بإلغائها
|
||||
if ($con->inTransaction()) {
|
||||
$con->rollBack();
|
||||
}
|
||||
$errorMsg = "General Error: " . $e->getMessage();
|
||||
logStep("ERROR-GEN", $errorMsg);
|
||||
jsonError($e->getMessage());
|
||||
}
|
||||
?>
|
||||
150
updateDriverToActive.php
Normal file
150
updateDriverToActive.php
Normal file
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
// --- تضمين الملفات الأساسية ---
|
||||
require_once __DIR__ . '/../connect.php'; // يفترض أن يحتوي على الاتصال ومساعد التشفير
|
||||
|
||||
// --- استقبال البيانات من التطبيق ---
|
||||
$driverId = filterRequest("driverId");
|
||||
$phone = filterRequest("phone"); // استقبال رقم الهاتف مباشرة
|
||||
|
||||
// --- بيانات جدول السائق (driver) ---
|
||||
$firstName = filterRequest("first_name");
|
||||
$lastName = filterRequest("last_name");
|
||||
$site = filterRequest("site"); // مكان القيد/الولادة
|
||||
$nationalNumber = filterRequest("national_number");
|
||||
$licenseCategories = filterRequest("license_categories"); // فئة الرخصة
|
||||
$expiryDate = filterRequest("expiry_date"); // تاريخ انتهاء رخصة السائق
|
||||
$licenseIssueDate = filterRequest("license_issue_date");
|
||||
$gender = filterRequest("gender"); // الحقل الجديد
|
||||
$birthdate = filterRequest("birthdate"); // الحقل الجديد
|
||||
$maritalStatus=filterRequest("maritalStatus");
|
||||
|
||||
// --- بيانات جدول السيارة (CarRegistration) ---
|
||||
$owner = filterRequest("owner");
|
||||
$color = filterRequest("color");
|
||||
$colorHex = filterRequest("color_hex");
|
||||
$model = filterRequest("model"); // الموديل
|
||||
$carPlate = filterRequest("car_plate");
|
||||
$make = filterRequest("make"); // الصانع
|
||||
$fuel = filterRequest("fuel");
|
||||
$year = filterRequest("year");
|
||||
$carExpirationDate = filterRequest("expiration_date"); // تاريخ انتهاء رخصة السيارة
|
||||
|
||||
// --- بدء المعاملة لضمان سلامة البيانات ---
|
||||
$con->beginTransaction();
|
||||
|
||||
try {
|
||||
// --- 1. معالجة وتشفير البيانات ---
|
||||
$nameArabic = $firstName . ' ' . $lastName;
|
||||
$address = $site;
|
||||
|
||||
// تشفير الحقول الحساسة
|
||||
$encryptedFirstName = $encryptionHelper->encryptData($firstName);
|
||||
$encryptedLastName = $encryptionHelper->encryptData($lastName);
|
||||
$encryptedSite = $encryptionHelper->encryptData($site);
|
||||
$encryptedAddress = $encryptionHelper->encryptData($address);
|
||||
$encryptedNameArabic = $encryptionHelper->encryptData($nameArabic);
|
||||
$encryptedNationalNumber = $encryptionHelper->encryptData($nationalNumber);
|
||||
$encryptedOwner = $encryptionHelper->encryptData($owner);
|
||||
$encryptedCarPlate = $encryptionHelper->encryptData($carPlate);
|
||||
$encryptedBirthdate = $encryptionHelper->encryptData($birthdate);
|
||||
$encryptedGender = $encryptionHelper->encryptData($gender);
|
||||
|
||||
// --- 2. تحديث جدول السائق ---
|
||||
$sqlDriver = "UPDATE `driver` SET
|
||||
`first_name` = :first_name,
|
||||
`last_name` = :last_name,
|
||||
`site` = :site,
|
||||
`address` = :address,
|
||||
`national_number` = :national_number,
|
||||
`license_categories` = :license_categories,
|
||||
`expiry_date` = :expiry_date,
|
||||
`issue_date` = :issue_date,
|
||||
`gender` = :gender,
|
||||
`birthdate` = :birthdate,
|
||||
`name_arabic` = :name_arabic,
|
||||
`maritalStatus` = :maritalStatus,
|
||||
`status` = 'actives'
|
||||
WHERE `id` = :driverId";
|
||||
|
||||
$stmtDriver = $con->prepare($sqlDriver);
|
||||
$stmtDriver->execute([
|
||||
':first_name' => $encryptedFirstName,
|
||||
':last_name' => $encryptedLastName,
|
||||
':site' => $encryptedSite,
|
||||
':address' => $encryptedAddress,
|
||||
':national_number' => $encryptedNationalNumber,
|
||||
':license_categories' => $licenseCategories,
|
||||
':expiry_date' => $expiryDate,
|
||||
':issue_date' => $licenseIssueDate,
|
||||
':gender' => $encryptedGender,
|
||||
':birthdate' => $encryptedBirthdate,
|
||||
':name_arabic' => $encryptedNameArabic,
|
||||
':driverId' => $driverId,
|
||||
':maritalStatus' =>$maritalStatus
|
||||
]);
|
||||
|
||||
// --- 3. تحديث جدول السيارة ---
|
||||
$sqlCar = "UPDATE `CarRegistration` SET
|
||||
`owner` = :owner,
|
||||
`color` = :color,
|
||||
`color_hex` = :color_hex,
|
||||
`model` = :model,
|
||||
`car_plate` = :car_plate,
|
||||
`make` = :make,
|
||||
`fuel` = :fuel,
|
||||
`year` = :year,
|
||||
`expiration_date` = :expiration_date
|
||||
WHERE `driverID` = :driverId";
|
||||
|
||||
$stmtCar = $con->prepare($sqlCar);
|
||||
$stmtCar->execute([
|
||||
':owner' => $encryptedOwner,
|
||||
':color' => $color,
|
||||
':color_hex' => $colorHex,
|
||||
':model' => $model,
|
||||
':car_plate' => $encryptedCarPlate,
|
||||
':make' => $make,
|
||||
':fuel' => $fuel,
|
||||
':year' => $year,
|
||||
':expiration_date' => $carExpirationDate,
|
||||
':driverId' => $driverId
|
||||
]);
|
||||
|
||||
// --- 4. تأكيد المعاملة ---
|
||||
$con->commit();
|
||||
jsonSuccess(["message" => "Driver and car data updated successfully."]);
|
||||
|
||||
// --- 5. إرسال رسالة واتساب مبسطة وآمنة (باختيار رقم عشوائي) ---
|
||||
|
||||
// 5.1. تعريف الأرقام
|
||||
$supportPhones = ['0952475740', '0952475742']; // يمكنك إضافة المزيد من الأرقام هنا
|
||||
|
||||
// 5.2. اختيار رقم عشوائي من القائمة
|
||||
$randomIndex = array_rand($supportPhones); // يختار "مفتاح" عشوائي (index)
|
||||
$phoneToUse = $supportPhones[$randomIndex]; // يحصل على الرقم من المفتاح
|
||||
|
||||
|
||||
// --- !!! التعديل: إضافة رقم عشوائي ---
|
||||
// هذا يضيف رقم عشوائي (4-6 خانات) لجعل الرسالة فريدة
|
||||
$randomNumber = rand(1000, 999999);
|
||||
|
||||
// 5.5. إعداد نص الرسالة بالرقم المتغير
|
||||
$messageBody = "أهلاً وسهلاً كابتن $firstName 👋\n"
|
||||
. "تم تفعيل حسابك على تطبيق *انطلق*.\n"
|
||||
. "يمكنك الآن تسجيل الدخول والبدء بالعمل مباشرة.\n"
|
||||
. "للمساعدة تواصل معنا على الرقم: $phoneToUse\n" // <-- تم استخدام المتغير العشوائي هنا
|
||||
. "نتمنى لك عمل موفق 🚖\n\n"
|
||||
. "معرف الرسالة: $randomNumber"; // <-- إضافة الرقم العشوائي
|
||||
|
||||
// 5.6. إرسال الرسالة
|
||||
sendWhatsAppFromServer($phone, $messageBody);
|
||||
|
||||
} catch (Exception $e) {
|
||||
// --- 6. التراجع في حال الخطأ ---
|
||||
$con->rollBack();
|
||||
jsonError("An error occurred: " . $e->getMessage());
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
Reference in New Issue
Block a user