Deploy: 2026-06-18 16:46:51

This commit is contained in:
Hamza-Ayed
2026-06-18 16:46:51 +03:00
parent 9a4d610bdd
commit e0c7f39ff6
4 changed files with 464 additions and 61 deletions

View File

@@ -0,0 +1,240 @@
<?php
namespace App\Core\Flows;
use App\Services\SiroService;
/**
* ComplaintFlow — Submit a trip complaint via WhatsApp
*
* Flow: start → await_description → await_ride → await_confirmation → finished
*
* Steps:
* start → resolve user, ask for problem description
* await_description → collect description text (or voice transcription)
* await_ride → fetch recent rides, let user pick one
* await_confirmation → show full details, ask to confirm
* finished → show AI analysis result
*/
class ComplaintFlow extends BaseFlow
{
public function handleStep(string $step, array $messageData, array &$context): FlowResult
{
$phone = $messageData['phone'] ?? '';
$text = $messageData['body'] ?? $messageData['text'] ?? '';
$country = $context['country'] ?? SiroService::detectCountry($phone);
switch ($step) {
// ─────────────────────────────────────────────────
// START: resolve user, ask for description
// ─────────────────────────────────────────────────
case 'start':
$context['country'] = $country;
// Resolve user type via Siro
try {
$driverData = SiroService::checkDriverStatus($phone, $country);
if ($driverData && !empty($driverData['data']['driver_id'])) {
$context['user_type'] = 'driver';
$context['user_id'] = $driverData['data']['driver_id'];
$context['user_name'] = $driverData['data']['name'] ?? '';
} else {
$context['user_type'] = 'passenger';
}
} catch (\Exception $e) {
$context['user_type'] = 'driver';
}
return new FlowResult(
"أهلاً بك في نظام الشكاوى.\n\n"
. "📝 يرجى وصف المشكلة التي حدثت بالتفصيل.\n"
. "مثال: \"السائق تأخر 20 دقيقة واتصلت فيه وما رد\"\n"
. "يمكنك إرسال النص أو تسجيل مقطع صوتي.\n\n"
. "🟡 للخروج اكتب: إلغاء",
"await_description"
);
// ─────────────────────────────────────────────────
// AWAIT_DESCRIPTION: collect complaint text
// ─────────────────────────────────────────────────
case 'await_description':
if (empty(trim($text))) {
return new FlowResult(
"الرجاء كتابة وصف المشكلة أو تسجيل مقطع صوتي:",
"await_description"
);
}
$context['complaint_text'] = trim($text);
// Fetch recent rides from Siro
try {
$rides = SiroService::getUserRides($country, $phone, 5);
$context['rides'] = $rides ?? [];
} catch (\Exception $e) {
$context['rides'] = [];
}
if (empty($context['rides'])) {
return new FlowResult(
"تم حفظ وصف المشكلة. ✅\n\n"
. "لم نتمكن من العثور على رحلات حديثة لحسابك.\n"
. "الرجاء إرسال رقم الرحلة (مثال: 831):",
"await_ride"
);
}
$rideList = "تم حفظ وصف المشكلة. ✅\n\n"
. "🚖 آخر رحلاتك:\n\n";
foreach ($context['rides'] as $i => $r) {
$num = $i + 1;
$date = $r['date'] ?? '';
$time = $r['time'] ?? '';
$from = $r['start_location'] ?? '---';
$to = $r['end_location'] ?? '---';
$price = $r['price'] ?? '0';
$status = $r['status'] ?? '';
$rideList .= "{$num}. رحلة #{$r['id']} | {$date} {$time}\n"
. " من: {$from} → إلى: {$to}\n"
. " السعر: {$price} | الحالة: {$status}\n\n";
}
$rideList .= "الرجاء إرسال رقم الرحلة من القائمة (1-{$num})\n"
. "أو اكتب رقم الرحلة كاملاً (مثال: 831):";
return new FlowResult($rideList, "await_ride");
// ─────────────────────────────────────────────────
// AWAIT_RIDE: let user pick a ride
// ─────────────────────────────────────────────────
case 'await_ride':
$selectedRide = null;
$rides = $context['rides'] ?? [];
// Check if user entered a list number (1, 2, 3...)
$cleanNum = preg_replace('/[^0-9]/', '', $text);
if (!empty($cleanNum) && is_numeric($cleanNum)) {
$index = (int)$cleanNum - 1;
if (isset($rides[$index])) {
$selectedRide = $rides[$index];
}
// If not found in list, try as ride_id directly
if (!$selectedRide) {
foreach ($rides as $r) {
if ((string)$r['id'] === $cleanNum) {
$selectedRide = $r;
break;
}
}
}
// If still not found, try the number as ride_id
if (!$selectedRide) {
$selectedRide = ['id' => $cleanNum];
}
}
if (!$selectedRide) {
return new FlowResult(
"لم نتعرف على الرقم. الرجاء إرسال رقم الرحلة من القائمة:",
"await_ride"
);
}
$context['ride_id'] = $selectedRide['id'];
$context['ride_details'] = $selectedRide;
$locFrom = $selectedRide['start_location'] ?? '---';
$locTo = $selectedRide['end_location'] ?? '---';
$date = $selectedRide['date'] ?? '---';
$time = $selectedRide['time'] ?? '---';
$price = $selectedRide['price'] ?? '---';
$status = $selectedRide['status'] ?? '---';
return new FlowResult(
"🚖 تفاصيل الرحلة المحددة:\n"
. "• رقم الرحلة: {$selectedRide['id']}\n"
. "• التاريخ: {$date} {$time}\n"
. "• من: {$locFrom}\n"
. "• إلى: {$locTo}\n"
. "• السعر: {$price}\n"
. "• الحالة: {$status}\n\n"
. "📋 وصف المشكلة:\n"
. "{$context['complaint_text']}\n\n"
. "هل تريد تأكيد إرسال الشكوى؟\n"
. "✅ أرسل: تأكيد\n"
. "🔄 أرسل: تعديل (لإعادة كتابة الوصف)\n"
. "🟡 أرسل: إلغاء",
"await_confirmation"
);
// ─────────────────────────────────────────────────
// AWAIT_CONFIRMATION: confirm and submit
// ─────────────────────────────────────────────────
case 'await_confirmation':
$clean = trim(mb_strtolower($text));
if (in_array($clean, ['تعديل', 'edit', 'تعديل الوصف'])) {
return new FlowResult(
"الرجاء إرسال وصف المشكلة الجديد:",
"await_description"
);
}
if (!in_array($clean, ['تأكيد', 'نعم', 'اكيد', 'ok', 'yes', 'confirm', 'تاكيد', 'okay'])) {
return new FlowResult(
"❌ لم يتم التأكيد.\n"
. "✅ للتأكيد أرسل: تأكيد\n"
. "🔄 للتعديل أرسل: تعديل\n"
. "🟡 للإلغاء أرسل: إلغاء",
"await_confirmation"
);
}
// Submit complaint via Siro
try {
$result = SiroService::submitComplaint(
$country,
$phone,
$context['ride_id'],
$context['complaint_text']
);
if ($result && ($result['status'] ?? '') === 'success') {
$aiResult = $result['ai_result'] ?? [];
$report = $result['report'] ?? [];
$reportTitle = $report['title'] ?? '';
$reportBody = $report['body'] ?? '';
$reply = "✅ تم إرسال شكواك بنجاح!\n\n"
. "📋 نتيجة التحليل:\n"
. "• تصنيف الشكوى: " . ($aiResult['complaint_type'] ?? '---') . "\n"
. "• الطرف المخطئ: " . ($aiResult['fault_determination'] ?? '---') . "\n"
. "• طبيعة الشكوى: " . ($aiResult['complaint_nature'] ?? '---') . "\n\n";
if ($reportBody) {
$reply .= "📄 {$reportTitle}\n{$reportBody}\n\n";
}
$reply .= "سيتم التواصل معك من قبل فريق الدعم إذا لزم الأمر.";
return new FlowResult($reply, "finished", true);
}
return new FlowResult(
"⚠️ حدث خطأ في إرسال الشكوى. يرجى المحاولة مرة أخرى أو التواصل مع الدعم الفني.",
"finished",
true
);
} catch (\Exception $e) {
error_log("[ComplaintFlow] Submit error: " . $e->getMessage());
return new FlowResult(
"⚠️ تعذر إرسال الشكوى حالياً. يرجى المحاولة لاحقاً.",
"finished",
true
);
}
default:
return new FlowResult("حدث خطأ في المسار.", "finished", true);
}
}
}

View File

@@ -18,6 +18,7 @@ class ConversationFlowEngine
'test_flow' => TestFlow::class,
'driver_registration_flow' => DriverRegistrationFlow::class,
'payment_flow' => PaymentFlow::class,
'complaint_flow' => ComplaintFlow::class,
];
/**
@@ -33,6 +34,12 @@ class ConversationFlowEngine
'وصل' => 'payment_flow',
'تسديد' => 'payment_flow',
'رصيد' => 'payment_flow',
'شكوى' => 'complaint_flow',
'مشكلة' => 'complaint_flow',
'بلاغ' => 'complaint_flow',
'تظلم' => 'complaint_flow',
'شكوي' => 'complaint_flow',
'complaint' => 'complaint_flow',
];
/**

View File

@@ -9,62 +9,160 @@ use App\Services\SiroService;
*
* Flow: start → await_receipt → finished
*
* Automatically:
* • Detects country from phone prefix (963→Syria, 962→Jordan)
* • Sets payment method (Syria→shamcash, Jordan→cliq)
* • Forwards raw receipt image to payment server for AI verification
* • Payment server auto-finds the latest pending invoice by phone
* → no need for the user to type an invoice number
* Smart features:
* • Auto-detects country + payment method (shamcash/cliq)
* • Auto-finds pending invoice by phone (no invoice number needed)
* • AI receipt verification via payment server Gemini
* • Postponement detection (keyword-based)
* • Image validation + retry (max 3 attempts)
* • Currency-aware messages (SYP/JOD)
*/
class PaymentFlow extends BaseFlow
{
private const MAX_RETRIES = 3;
public function handleStep(string $step, array $messageData, array &$context): FlowResult
{
$phone = $messageData['phone'] ?? '';
$text = $messageData['body'] ?? $messageData['text'] ?? '';
$text = isset($messageData['body']) ? trim($messageData['body']) : '';
$image = $messageData['image'] ?? '';
$imageMimeType = $messageData['imageMimeType'] ?? 'image/jpeg';
// ── Postponement check (only if flow is active, not on start/finished) ──
if ($step !== 'start' && $step !== 'finished' && !empty($text)) {
$postpone = $this->detectPostponement($text);
if ($postpone !== null) {
$context['previous_step'] = $step;
$hours = $postpone;
return new FlowResult(
"حاضر كابتن، تم تأجيل طلب الدفع. سأذكرك بعد {$hours} ساعات.\n"
. "للمتابعة لاحقاً، أرسل 'دفع' مرة أخرى.",
"postponed"
);
}
}
// ── Country + method detection ──
$country = $context['country'] ?? SiroService::detectCountry($phone);
$context['country'] = $country;
$paymentMethod = $context['payment_method'] ?? match ($country) {
'jordan' => 'cliq',
default => 'shamcash',
};
$context['payment_method'] = $paymentMethod;
$methodName = $paymentMethod === 'cliq' ? 'كليك (Cliq)' : 'شام كاش (ShamCash)';
$currency = $paymentMethod === 'cliq' ? 'دينار أردني' : 'ل.س';
$countryName = match ($country) {
'jordan' => 'الأردن',
'egypt' => 'مصر',
default => 'سوريا',
};
switch ($step) {
// ─────────────────────────────────────────────────
// START: detect country, set method, ask for receipt
// START
// ─────────────────────────────────────────────────
case 'start':
$country = SiroService::detectCountry($phone);
$paymentMethod = match ($country) {
'jordan' => 'cliq',
default => 'shamcash',
};
$context['payment_method'] = $paymentMethod;
$context['country'] = $country;
$context['user_type'] = 'driver';
$methodName = match ($paymentMethod) {
'cliq' => 'كليك (Cliq)',
default => 'شام كاش (ShamCash)',
};
$context['retry_count'] = 0;
return new FlowResult(
"أهلاً بك. للتحقق من عملية الدفع:\n"
. "💰 طريقة الدفع المتوقعة: {$methodName}\n"
. "📍 الدولة: " . ($country === 'syria' ? 'سوريا' : ($country === 'jordan' ? 'الأردن' : $country))
. "\n\n📸 يرجى إرسال صورة الإيصال أو وصل التحويل للتحقق منه.",
"أهلاً بك في خدمة التحقق من الدفع.\n\n"
. "📍 الدولة: {$countryName}\n"
. "💰 طريقة الدفع: {$methodName}\n"
. "💵 العملة: {$currency}\n\n"
. "📸 يرجى إرسال صورة واضحة لإيصال الدفع أو صورة الشاشة.\n"
. "سيتم التحقق من الفاتورة المعلقة تلقائياً.\n\n"
. "🟡 للخروج اكتب: إلغاء\n"
. "⏰ للتأجيل اكتب: بعدين",
"await_receipt"
);
// ─────────────────────────────────────────────────
// AWAIT_RECEIPT: collect receipt image
// AWAIT_RECEIPT
// ─────────────────────────────────────────────────
case 'await_receipt':
// ── No image sent ──
if (empty($image)) {
return new FlowResult(
"الرجاء إرسال صورة واضحة لوصل الدفع أو صورة الشاشة:",
"📸 يرجى إرسال صورة الإيصال أو وصل التحويل.\n"
. "تأكد من أن الصورة واضحة وتظهر المبلغ وتفاصيل التحويل.",
"await_receipt"
);
}
return $this->sendToVerification($phone, $context, $image, $imageMimeType);
// ── Validate image size ──
$decoded = base64_decode($image, true);
if ($decoded === false || strlen($decoded) < 1024) {
$retry = ($context['retry_count'] ?? 0) + 1;
$context['retry_count'] = $retry;
if ($retry >= self::MAX_RETRIES) {
return new FlowResult(
"عذراً، لم نتمكن من قراءة الصورة بعد {$retry} محاولات.\n"
. "يرجى التواصل مع خدمة العملاء لإتمام عملية الدفع يدوياً.",
"finished",
true
);
}
return new FlowResult(
"⚠️ الصورة غير واضحة أو صغيرة جداً.\n"
. "يرجى إرسال صورة واضحة وحجم أكبر (محاولة {$retry} من " . self::MAX_RETRIES . "):",
"await_receipt"
);
}
// ── Normalize MIME type ──
if (strpos($imageMimeType, ';') !== false) {
$imageMimeType = trim(explode(';', $imageMimeType)[0]);
}
// ── Send to payment server ──
$companyId = $context['company_id'] ?? 1;
$result = \App\Controllers\WhatsAppController::verifyPaymentSlipStatic(
companyId: $companyId,
phone: $phone,
jsonStr: '',
userType: 'driver',
paymentMethod: $paymentMethod,
invoiceNumber: '',
receiptImage: $image,
imageMimeType: $imageMimeType,
);
if ($result) {
return new FlowResult($result, "finished", true);
}
$retry = ($context['retry_count'] ?? 0) + 1;
$context['retry_count'] = $retry;
if ($retry >= self::MAX_RETRIES) {
return new FlowResult(
"عذراً، تعذر التحقق من الدفع بعد {$retry} محاولات.\n"
. "سيتم مراجعة العملية من قبل الإدارة.",
"finished",
true
);
}
return new FlowResult(
"لم نتمكن من التحقق من الدفع حالياً (محاولة {$retry} من " . self::MAX_RETRIES . ").\n"
. "يرجى إرسال صورة أوضح والإضاءة جيدة:",
"await_receipt"
);
case 'postponed':
// Resume from postponed
$step = $context['previous_step'] ?? 'await_receipt';
return new FlowResult(
"مرحباً بك مرة أخرى! 👋\n"
. "📸 أرسل صورة إيصال الدفع للمتابعة:",
$step
);
default:
return new FlowResult("حدث خطأ في المسار.", "finished", true);
@@ -72,41 +170,25 @@ class PaymentFlow extends BaseFlow
}
/**
* Forward receipt image + phone to payment server for AI verification.
* Payment server internally resolves phone→driverID via Siro backend.
* Nabeh only makes ONE API call (to payment server).
* Detect if user wants to postpone (keyword-based, no AI call)
*/
private function sendToVerification(
string $phone,
array &$context,
string $image,
string $imageMimeType
): FlowResult {
$companyId = $context['company_id'] ?? 1;
private function detectPostponement(string $text): ?int
{
$keywords = [
'بعدين' => 2, 'بكرا' => 12, 'بكرة' => 12, 'بعد' => 3,
'شوي' => 1, 'مشغول' => 4, 'تأجيل' => 6, 'لاحقاً' => 6,
'لاحقا' => 6, 'الحق' => 6, 'وقت ثاني' => 8, 'تعبان' => 6,
'بعدين برسل' => 3, 'بعدين ببعت' => 3, 'بعدين بكمل' => 4,
'ببعثها' => 3, 'ببعت' => 3, 'برسل' => 2,
];
if (strpos($imageMimeType, ';') !== false) {
$imageMimeType = trim(explode(';', $imageMimeType)[0]);
$normalized = trim(mb_strtolower($text));
foreach ($keywords as $kw => $hours) {
if (mb_strpos($normalized, $kw) !== false) {
return $hours;
}
}
$result = \App\Controllers\WhatsAppController::verifyPaymentSlipStatic(
companyId: $companyId,
phone: $phone,
jsonStr: '',
userType: $context['user_type'] ?? 'driver',
paymentMethod: $context['payment_method'] ?? 'shamcash',
invoiceNumber: '',
receiptImage: $image,
imageMimeType: $imageMimeType,
);
if ($result) {
return new FlowResult($result, "finished", true);
}
return new FlowResult(
"لم نتمكن من التحقق من الدفع حالياً. يرجى المحاولة مرة أخرى أو التواصل مع الدعم الفني.",
"finished",
true
);
return null;
}
}

View File

@@ -517,4 +517,78 @@ EOT,
default => $syriaPrompts,
};
}
/**
* Fetch recent rides for a user from Siro
*/
public static function getUserRides(string $country, string $phone, int $limit = 5): ?array
{
$apiUrl = self::getApiUrl($country);
$apiKey = getenv('NABEH_API_KEY') ?: '';
$ridesUrl = $apiUrl . '/nabeh/get_user_rides.php';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $ridesUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'phone' => $phone,
'limit' => $limit,
]));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'X-API-Key: ' . $apiKey,
'Content-Type: application/json',
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$res = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
error_log("[SiroService] getUserRides failed: HTTP {$httpCode} - {$res}");
return null;
}
$data = json_decode($res, true);
return $data['rides'] ?? $data['data'] ?? null;
}
/**
* Submit a complaint with AI analysis via Siro
*/
public static function submitComplaint(string $country, string $phone, string $rideId, string $complaintText): ?array
{
$apiUrl = self::getApiUrl($country);
$apiKey = getenv('NABEH_API_KEY') ?: '';
$complaintUrl = $apiUrl . '/nabeh/submit_complaint.php';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $complaintUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'phone' => $phone,
'ride_id' => $rideId,
'complaint_text' => $complaintText,
]));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'X-API-Key: ' . $apiKey,
'Content-Type: application/json',
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$res = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
error_log("[SiroService] submitComplaint failed: HTTP {$httpCode} - {$res}");
return null;
}
return json_decode($res, true);
}
}