Update: 2026-08-07 05:03:39
This commit is contained in:
+14
-10
@@ -53,23 +53,28 @@ $role = $decoded->role ?? 'passenger';
|
||||
// update_complaint — أي أن أي حامل JWT صالح (راكب عادي) يقرأ بيانات
|
||||
// السائقين والركّاب ويغلق الشكاوى. تحققنا أن siro_rider و siro_driver لا
|
||||
// يستدعيان أي نقطة تحت serviceapp/، فالإغلاق لا يكسر تطبيقات الجوال.
|
||||
//
|
||||
// ⚠️ لكل مجلد أدواره: تسجيل دخول تطبيق الخدمة (Admin/jwtService.php:51)
|
||||
// يصدر الدور 'service' لا 'admin'. فرضُ admin على serviceapp/ كان
|
||||
// سيمنع كل موظفي خدمة العملاء من كل نقاطهم.
|
||||
$gatedDirs = array_filter([
|
||||
realpath(__DIR__ . '/Admin'),
|
||||
realpath(__DIR__ . '/serviceapp'),
|
||||
]);
|
||||
$script = realpath($_SERVER['SCRIPT_FILENAME'] ?? '');
|
||||
realpath(__DIR__ . '/Admin') => ['admin', 'super_admin'],
|
||||
realpath(__DIR__ . '/serviceapp') => ['service', 'admin', 'super_admin'],
|
||||
], fn($k) => $k !== false, ARRAY_FILTER_USE_KEY);
|
||||
|
||||
$inGatedDir = false;
|
||||
$script = realpath($_SERVER['SCRIPT_FILENAME'] ?? '');
|
||||
|
||||
$gateRoles = null;
|
||||
if ($script) {
|
||||
foreach ($gatedDirs as $dir) {
|
||||
foreach ($gatedDirs as $dir => $roles) {
|
||||
if (str_starts_with($script, $dir . DIRECTORY_SEPARATOR)) {
|
||||
$inGatedDir = true;
|
||||
$gateRoles = $roles;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($inGatedDir) {
|
||||
if ($gateRoles !== null) {
|
||||
// نقاط تحت Admin/ تستدعيها تطبيقات الجوال بأدوار غير إدارية.
|
||||
// تم التحقق من مواضع الاستدعاء الفعلية في siro_rider و siro_driver:
|
||||
// errorApp.php → التطبيقان يبلّغان عن الأخطاء
|
||||
@@ -81,8 +86,7 @@ if ($inGatedDir) {
|
||||
];
|
||||
|
||||
if (!in_array(basename($script), $adminGateExempt, true)
|
||||
&& $role !== 'admin'
|
||||
&& $role !== 'super_admin'
|
||||
&& !in_array($role, $gateRoles, true)
|
||||
) {
|
||||
securityLog('Admin gate blocked non-admin request', [
|
||||
'script' => basename($script),
|
||||
|
||||
@@ -55,88 +55,9 @@ $driverId = $ride['driver_id'];
|
||||
|
||||
// --- دوال مساعدة لجلب البيانات (تم افتراض أن الدوال تصل إلى $con و $encryptionHelper عبر النطاق global أو تمريرها كـ arguments) ---
|
||||
|
||||
/**
|
||||
* جلب بيانات ومعلومات تقييم السائق
|
||||
*/
|
||||
function getDriverFullProfile($con, $encryptionHelper, $driverId) {
|
||||
$profile = ['info' => null, 'ratings' => null, 'comments' => []];
|
||||
|
||||
// جلب معلومات السائق الأساسية
|
||||
$stmt = $con->prepare("SELECT id, first_name, last_name, created_at FROM driver WHERE id = ?");
|
||||
$stmt->execute([$driverId]);
|
||||
$driverInfo = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// فك تشفير البيانات الحساسة
|
||||
if ($driverInfo) {
|
||||
// التحقق من وجود ودوال فك التشفير قبل الاستخدام
|
||||
if (isset($encryptionHelper) && method_exists($encryptionHelper, 'decryptData')) {
|
||||
$decryptedFirstName = $encryptionHelper->decryptData($driverInfo['first_name']);
|
||||
$decryptedLastName = $encryptionHelper->decryptData($driverInfo['last_name']);
|
||||
$driverInfo['full_name'] = trim($decryptedFirstName . ' ' . $decryptedLastName);
|
||||
} else {
|
||||
$driverInfo['full_name'] = 'Decryption Failed';
|
||||
}
|
||||
|
||||
unset($driverInfo['first_name'], $driverInfo['last_name']); // إزالة الحقول المشفرة
|
||||
$profile['info'] = $driverInfo;
|
||||
}
|
||||
|
||||
// جلب ملخص التقييمات والتعليقات
|
||||
$stmt = $con->prepare("SELECT AVG(rating) as avg_rating, COUNT(id) as total_ratings FROM ratingDriver WHERE driver_id = ?");
|
||||
$stmt->execute([$driverId]);
|
||||
$profile['ratings'] = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$stmt = $con->prepare("SELECT comment FROM ratingDriver WHERE driver_id = ? AND comment IS NOT NULL AND comment != '' ORDER BY created_at DESC LIMIT 5");
|
||||
$stmt->execute([$driverId]);
|
||||
$profile['comments'] = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
return $profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* جلب بيانات ومعلومات تقييم الراكب
|
||||
*/
|
||||
function getPassengerFullProfile($con, $encryptionHelper, $passengerId) {
|
||||
$profile = ['info' => null, 'ratings' => null, 'comments' => []];
|
||||
|
||||
$stmt = $con->prepare("SELECT id, first_name, last_name, created_at FROM passengers WHERE id = ?");
|
||||
$stmt->execute([$passengerId]);
|
||||
$passengerInfo = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// فك تشفير البيانات الحساسة
|
||||
if ($passengerInfo) {
|
||||
if (isset($encryptionHelper) && method_exists($encryptionHelper, 'decryptData')) {
|
||||
$decryptedFirstName = $encryptionHelper->decryptData($passengerInfo['first_name']);
|
||||
$decryptedLastName = $encryptionHelper->decryptData($passengerInfo['last_name']);
|
||||
$passengerInfo['full_name'] = trim($decryptedFirstName . ' ' . $decryptedLastName);
|
||||
} else {
|
||||
$passengerInfo['full_name'] = 'Decryption Failed';
|
||||
}
|
||||
|
||||
unset($passengerInfo['first_name'], $passengerInfo['last_name']);
|
||||
$profile['info'] = $passengerInfo;
|
||||
}
|
||||
|
||||
$stmt = $con->prepare("SELECT AVG(rating) as avg_rating, COUNT(id) as total_ratings FROM ratingPassenger WHERE passenger_id = ?");
|
||||
$stmt->execute([$passengerId]);
|
||||
$profile['ratings'] = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$stmt = $con->prepare("SELECT comment FROM ratingPassenger WHERE passenger_id = ? AND comment IS NOT NULL AND comment != '' ORDER BY created_at DESC LIMIT 5");
|
||||
$stmt->execute([$passengerId]);
|
||||
$profile['comments'] = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
return $profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* جلب بيانات سلوك السائق في الرحلة المحددة
|
||||
*/
|
||||
function getDriverBehavior($con, $rideId, $driverId) {
|
||||
$stmt = $con->prepare("SELECT max_speed, avg_speed, hard_brakes, behavior_score FROM driver_behavior WHERE trip_id = ? AND driver_id = ?");
|
||||
$stmt->execute([$rideId, $driverId]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
|
||||
// الدوال الثلاث انتقلت إلى complaint_context.php ليشاركها مستشار
|
||||
// خدمة العملاء (serviceapp/complaint_ai_suggest.php) بلا تكرار.
|
||||
require_once __DIR__ . '/complaint_context.php';
|
||||
// استدعاء الدوال لجلب البيانات
|
||||
$passengerProfile = getPassengerFullProfile($con, $encryptionHelper, $passengerId);
|
||||
$driverProfile = getDriverFullProfile($con, $encryptionHelper, $driverId);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// complaint_context.php — جمع سياق الشكوى الكامل
|
||||
//
|
||||
// استُخرجت من add_solve_all.php ليشاركها مستشار خدمة العملاء.
|
||||
// كانت هذه الدوال محبوسة في نقطة تقديم الشكوى، فبنيتُ نسخة أضعف منها
|
||||
// لمستشار الدعم قبل أن أنتبه — وهذا الاستخراج يمنع تباعد النسختين.
|
||||
//
|
||||
// ما تجمعه: ملف السائق (تقييماته وتعليقات الركّاب عليه)، ملف الراكب
|
||||
// (تقييماته وتعليقات السائقين عليه)، وسلوك القيادة في الرحلة نفسها.
|
||||
// أي أن النموذج يرى "من هؤلاء" لا الشكوى وحدها.
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* جلب بيانات ومعلومات تقييم السائق
|
||||
*/
|
||||
function getDriverFullProfile($con, $encryptionHelper, $driverId) {
|
||||
$profile = ['info' => null, 'ratings' => null, 'comments' => []];
|
||||
|
||||
// جلب معلومات السائق الأساسية
|
||||
$stmt = $con->prepare("SELECT id, first_name, last_name, created_at FROM driver WHERE id = ?");
|
||||
$stmt->execute([$driverId]);
|
||||
$driverInfo = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// فك تشفير البيانات الحساسة
|
||||
if ($driverInfo) {
|
||||
// التحقق من وجود ودوال فك التشفير قبل الاستخدام
|
||||
if (isset($encryptionHelper) && method_exists($encryptionHelper, 'decryptData')) {
|
||||
$decryptedFirstName = $encryptionHelper->decryptData($driverInfo['first_name']);
|
||||
$decryptedLastName = $encryptionHelper->decryptData($driverInfo['last_name']);
|
||||
$driverInfo['full_name'] = trim($decryptedFirstName . ' ' . $decryptedLastName);
|
||||
} else {
|
||||
$driverInfo['full_name'] = 'Decryption Failed';
|
||||
}
|
||||
|
||||
unset($driverInfo['first_name'], $driverInfo['last_name']); // إزالة الحقول المشفرة
|
||||
$profile['info'] = $driverInfo;
|
||||
}
|
||||
|
||||
// جلب ملخص التقييمات والتعليقات
|
||||
$stmt = $con->prepare("SELECT AVG(rating) as avg_rating, COUNT(id) as total_ratings FROM ratingDriver WHERE driver_id = ?");
|
||||
$stmt->execute([$driverId]);
|
||||
$profile['ratings'] = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$stmt = $con->prepare("SELECT comment FROM ratingDriver WHERE driver_id = ? AND comment IS NOT NULL AND comment != '' ORDER BY created_at DESC LIMIT 5");
|
||||
$stmt->execute([$driverId]);
|
||||
$profile['comments'] = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
return $profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* جلب بيانات ومعلومات تقييم الراكب
|
||||
*/
|
||||
function getPassengerFullProfile($con, $encryptionHelper, $passengerId) {
|
||||
$profile = ['info' => null, 'ratings' => null, 'comments' => []];
|
||||
|
||||
$stmt = $con->prepare("SELECT id, first_name, last_name, created_at FROM passengers WHERE id = ?");
|
||||
$stmt->execute([$passengerId]);
|
||||
$passengerInfo = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// فك تشفير البيانات الحساسة
|
||||
if ($passengerInfo) {
|
||||
if (isset($encryptionHelper) && method_exists($encryptionHelper, 'decryptData')) {
|
||||
$decryptedFirstName = $encryptionHelper->decryptData($passengerInfo['first_name']);
|
||||
$decryptedLastName = $encryptionHelper->decryptData($passengerInfo['last_name']);
|
||||
$passengerInfo['full_name'] = trim($decryptedFirstName . ' ' . $decryptedLastName);
|
||||
} else {
|
||||
$passengerInfo['full_name'] = 'Decryption Failed';
|
||||
}
|
||||
|
||||
unset($passengerInfo['first_name'], $passengerInfo['last_name']);
|
||||
$profile['info'] = $passengerInfo;
|
||||
}
|
||||
|
||||
$stmt = $con->prepare("SELECT AVG(rating) as avg_rating, COUNT(id) as total_ratings FROM ratingPassenger WHERE passenger_id = ?");
|
||||
$stmt->execute([$passengerId]);
|
||||
$profile['ratings'] = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$stmt = $con->prepare("SELECT comment FROM ratingPassenger WHERE passenger_id = ? AND comment IS NOT NULL AND comment != '' ORDER BY created_at DESC LIMIT 5");
|
||||
$stmt->execute([$passengerId]);
|
||||
$profile['comments'] = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
return $profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* جلب بيانات سلوك السائق في الرحلة المحددة
|
||||
*/
|
||||
function getDriverBehavior($con, $rideId, $driverId) {
|
||||
$stmt = $con->prepare("SELECT max_speed, avg_speed, hard_brakes, behavior_score FROM driver_behavior WHERE trip_id = ? AND driver_id = ?");
|
||||
$stmt->execute([$rideId, $driverId]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
|
||||
@@ -166,7 +166,6 @@ function estimateDriverEarningsAdvantage(
|
||||
'competitor_name' => $data['competitor_name'],
|
||||
];
|
||||
}
|
||||
?>
|
||||
|
||||
/**
|
||||
* صف التسعير الصحيح لدولة هذه الرحلة.
|
||||
@@ -230,3 +229,35 @@ function getKazanForRide(PDO $con, $rideId, string $fallbackCountry = 'Syria', $
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* السقف الصلب الأعلى لأي حركة مالية واحدة، بعملة الدولة.
|
||||
*
|
||||
* ⚠️ رقم مجرّد لا معنى له: كان الكود يستخدم 50000 في ثلاثة مواضع، وهو
|
||||
* سقف بلا أثر على الليرة السورية بعد حذف صفرين منها (50000 القديمة =
|
||||
* 500 اليوم)، وكارثي على الدينار الأردني (50000 دينار!).
|
||||
*
|
||||
* هذا ليس سعراً ولا سياسة — هو حزام أمان أخير ضد خطأ مطبعي (صفر زائد)
|
||||
* أو حساب موظف مخترَق. السياسة الحقيقية تُضبط في أعمدة kazan.
|
||||
*/
|
||||
function getMoneyHardCap(string $currency): float
|
||||
{
|
||||
// سقوف حددها المالك: أقصى تعويض واحد ≈ دينار أردني واحد وما يعادله.
|
||||
// رقم صغير عمداً — التعويض استرضاء لا تسوية نزاع، والحالات الكبيرة
|
||||
// تُرفع للإدارة لا تُصرف من شاشة موظف.
|
||||
$caps = [
|
||||
'JOD' => 1.0,
|
||||
'EGP' => 50.0,
|
||||
'SYP' => 100.0, // بعد حذف الصفرين
|
||||
'IQD' => 2000.0,
|
||||
'USD' => 1.5,
|
||||
'TRY' => 50.0,
|
||||
'SAR' => 5.0,
|
||||
'AED' => 5.0,
|
||||
'LBP' => 130000.0,
|
||||
];
|
||||
|
||||
// عملة غير معروفة: نأخذ أدنى سقف في القائمة. الرفض الخاطئ يُراجَع،
|
||||
// والصرف الخاطئ لا يُسترد.
|
||||
return $caps[strtoupper($currency)] ?? min($caps);
|
||||
}
|
||||
|
||||
@@ -12,9 +12,6 @@
|
||||
// • الأسعار ← جدول kazan عبر pricing_helper.php
|
||||
// ============================================================
|
||||
|
||||
// سقف صلب فوق أي قيمة في القاعدة — يمنع خطأً إدارياً من خصم كارثي.
|
||||
const PASSENGER_CANCEL_ABSOLUTE_CAP = 50000.0;
|
||||
|
||||
// حد أقصى للدقائق المحتسبة: سائق قَبِل ونسي الرحلة ساعة لا يتحول
|
||||
// نسيانه إلى فاتورة على الراكب.
|
||||
const PASSENGER_CANCEL_MAX_BILLABLE_MINUTES = 20;
|
||||
@@ -112,7 +109,8 @@ function computePassengerCancelFee(
|
||||
$fee = ($approachKm * getPerKmRate($carType, $kazan))
|
||||
+ ($billableMinutes * getPerMinRate($kazan));
|
||||
|
||||
$fee = round(min($fee, $cap, PASSENGER_CANCEL_ABSOLUTE_CAP), 2);
|
||||
// سقف صلب بعملة الدولة — حزام أمان أخير ضد خطأ في صف kazan.
|
||||
$fee = round(min($fee, $cap, getMoneyHardCap($currency)), 2);
|
||||
|
||||
error_log("[cancel_fee] ride=$rideId رسم إلغاء: {$approachKm}كم"
|
||||
. " + {$billableMinutes}د = $fee $currency (سقف $cap)");
|
||||
|
||||
@@ -30,10 +30,6 @@ if (filterRequest("penalty_fee") !== null) {
|
||||
. " (ride=$rideId driver=$driverId) — التعويض يُحسب على الخادم");
|
||||
}
|
||||
|
||||
// سقف صلب فوق أي قيمة في قاعدة البيانات — يمنع خطأ إدارياً أو صفاً تالفاً
|
||||
// من تحويل التعويض إلى خصم كارثي.
|
||||
const CANCEL_COMPENSATION_ABSOLUTE_CAP = 50000.0;
|
||||
|
||||
// حد أقصى لدقائق الانتظار المحتسبة: سائق نسي الرحلة مفتوحة ساعتين لا
|
||||
// يتحول انتظاره إلى فاتورة على الراكب.
|
||||
const CANCEL_MAX_BILLABLE_WAIT_MINUTES = 20;
|
||||
@@ -261,8 +257,11 @@ try {
|
||||
$perMin = getPerMinRate($kazan);
|
||||
|
||||
$compensation = ($approachKm * $perKm) + ($billableMinutes * $perMin);
|
||||
// سقف صلب بعملة الدولة لا رقماً مجرّداً: 50000 كانت
|
||||
// بلا أثر على الليرة بعد حذف صفرين، وكارثية على الدينار.
|
||||
$currencyCode = (string) ($kazan['currency'] ?? 'SYP');
|
||||
$compensation = round(min($compensation, $compensationCap,
|
||||
CANCEL_COMPENSATION_ABSOLUTE_CAP), 2);
|
||||
getMoneyHardCap($currencyCode)), 2);
|
||||
|
||||
error_log("[cancel_ride_by_driver] تعويض ride=$rideId:"
|
||||
. " {$approachKm}كم×$perKm + {$billableMinutes}د×$perMin"
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// serviceapp/complaint_ai_suggest.php — مستشار الشكاوى
|
||||
//
|
||||
// يقرأ الشكوى وسياق رحلتها ويقترح على الموظف: تصنيف السبب، تحديد
|
||||
// المسؤولية، مبلغ تعويض، ومسودة ردّ للعميل.
|
||||
//
|
||||
// ⚠️ اقتراح لا قرار. لا يُغلق شكوى ولا يصرف ديناراً — الموظف يراجع ثم
|
||||
// يضغط في resolve_complaint.php. نموذج لغوي يقرّر صرف المال وحده هو
|
||||
// أسرع طريق لكارثة تشغيلية، ولأن يتعلّم أحدهم صياغة شكوى تُخرج مالاً.
|
||||
//
|
||||
// يبني على core/Services/SiroGeminiService.php القائم (نفس المفتاح
|
||||
// ونفس آلية النداء المستخدمة في محرك التسويق والتسعير).
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
require_once __DIR__ . '/../core/Services/SiroGeminiService.php';
|
||||
require_once __DIR__ . '/../ride/pricing/pricing_helper.php';
|
||||
// نفس دوال السياق التي يستخدمها add_solve_all.php وقت تقديم الشكوى:
|
||||
// تقييمات الطرفين، تعليقات من ركبوا معه/معها، وسلوك القيادة في الرحلة.
|
||||
require_once __DIR__ . '/../ride/feedBack/complaint_context.php';
|
||||
|
||||
$complaintId = filterRequest("complaint_id", 'int');
|
||||
|
||||
if (!$complaintId) {
|
||||
jsonError("Missing complaint_id");
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $con->prepare("SELECT * FROM complaint WHERE id = ? LIMIT 1");
|
||||
$stmt->execute([$complaintId]);
|
||||
$complaint = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$complaint) {
|
||||
jsonError("Complaint not found", 404);
|
||||
}
|
||||
|
||||
$rideId = $complaint['ride_id'] ?? null;
|
||||
|
||||
// ── سياق الرحلة ─────────────────────────────────────────
|
||||
// بدونه النموذج يخمّن. مع السعر والمسافة والحالة يصير الاقتراح مبنياً
|
||||
// على وقائع.
|
||||
$ride = [];
|
||||
$currency = 'SYP';
|
||||
$hardCap = 0.0;
|
||||
|
||||
if ($rideId) {
|
||||
$stmtRide = $con->prepare("SELECT * FROM ride WHERE id = ? LIMIT 1");
|
||||
$stmtRide->execute([$rideId]);
|
||||
$ride = $stmtRide->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
$kazan = getKazanForRide($con, $rideId);
|
||||
$currency = !empty($kazan['currency'])
|
||||
? (string) $kazan['currency']
|
||||
: getCurrencyByCountry((string) ($kazan['country'] ?? 'Syria'));
|
||||
}
|
||||
$hardCap = getMoneyHardCap($currency);
|
||||
|
||||
// ── سوابق الطرفين ───────────────────────────────────────
|
||||
// شكوى أولى من راكب مختلفة عن الخامسة. والسائق الذي تكررت شكاواه
|
||||
// مختلف عمّن يُشتكى عليه أول مرة.
|
||||
$history = ['passenger_complaints' => 0, 'driver_complaints' => 0];
|
||||
try {
|
||||
if (!empty($complaint['passenger_id'])) {
|
||||
$st = $con->prepare("SELECT COUNT(*) FROM complaint
|
||||
WHERE passenger_id = ? AND id <> ?");
|
||||
$st->execute([$complaint['passenger_id'], $complaintId]);
|
||||
$history['passenger_complaints'] = (int) $st->fetchColumn();
|
||||
}
|
||||
if (!empty($complaint['driver_id'])) {
|
||||
$st = $con->prepare("SELECT COUNT(*) FROM complaint
|
||||
WHERE driver_id = ? AND id <> ?");
|
||||
$st->execute([$complaint['driver_id'], $complaintId]);
|
||||
$history['driver_complaints'] = (int) $st->fetchColumn();
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log("[complaint/ai] تعذّر جلب السوابق: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// ── بناء المُوجِّه ──────────────────────────────────────
|
||||
// لا نمرّر أسماء ولا هواتف ولا بريداً: النموذج خدمة خارجية، وما يخرج
|
||||
// من الخادم لا يعود. المعرّفات وحدها تكفي للتحليل.
|
||||
// الملفان الكاملان: تقييم كل طرف، وتعليقات من تعاملوا معه سابقاً.
|
||||
// هذا ما يجعل النموذج يعرف "من هؤلاء" لا الشكوى وحدها — راكب تكررت
|
||||
// شكاواه الكيدية مختلف عن راكب أول شكوى له، والسائق كذلك.
|
||||
$driverProfile = !empty($complaint['driver_id'])
|
||||
? getDriverFullProfile($con, $encryptionHelper ?? null, $complaint['driver_id'])
|
||||
: null;
|
||||
$passengerProfile = !empty($complaint['passenger_id'])
|
||||
? getPassengerFullProfile($con, $encryptionHelper ?? null, $complaint['passenger_id'])
|
||||
: null;
|
||||
$driverBehavior = ($rideId && !empty($complaint['driver_id']))
|
||||
? getDriverBehavior($con, $rideId, $complaint['driver_id'])
|
||||
: null;
|
||||
|
||||
// نزع الأسماء: الملفات تحمل first_name/last_name، والنموذج خدمة
|
||||
// خارجية. التقييمات والتعليقات تكفي للتحليل بلا هوية.
|
||||
foreach ([&$driverProfile, &$passengerProfile] as &$pr) {
|
||||
if (is_array($pr) && isset($pr['info']) && is_array($pr['info'])) {
|
||||
unset($pr['info']['first_name'], $pr['info']['last_name']);
|
||||
}
|
||||
}
|
||||
unset($pr);
|
||||
|
||||
$context = [
|
||||
'complaint' => [
|
||||
'type' => $complaint['complaint_type'] ?? '',
|
||||
'description' => mb_substr((string) ($complaint['description'] ?? ''), 0, 1500),
|
||||
'passenger_report' => mb_substr((string) ($complaint['passenger_report'] ?? ''), 0, 800),
|
||||
'driver_report' => mb_substr((string) ($complaint['driver_report'] ?? ''), 0, 800),
|
||||
'prior_ai_solutions' => mb_substr((string) ($complaint['cs_solutions'] ?? ''), 0, 800),
|
||||
'nature' => $complaint['complaint_nature'] ?? null,
|
||||
'filed_at' => $complaint['date_filed'] ?? '',
|
||||
],
|
||||
'ride' => [
|
||||
'status' => $ride['status'] ?? null,
|
||||
'price' => $ride['price'] ?? null,
|
||||
'distance_km' => $ride['distance'] ?? null,
|
||||
'car_type' => $ride['carType'] ?? $ride['car_type'] ?? null,
|
||||
],
|
||||
'driver_profile' => $driverProfile,
|
||||
'passenger_profile' => $passengerProfile,
|
||||
'driver_behavior' => $driverBehavior,
|
||||
'complaint_counts' => $history,
|
||||
'currency' => $currency,
|
||||
];
|
||||
|
||||
// التحويل قبل الـheredoc: استدعاء دالة داخله غير مسموح، و$this
|
||||
// لا وجود له في سكربت إجرائي (خطأ قاتل لا يلتقطه php -l).
|
||||
$contextJson = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
|
||||
$reasons = implode(', ', [
|
||||
'driver_behavior', 'passenger_behavior', 'overcharge', 'route_issue',
|
||||
'no_show', 'vehicle_condition', 'app_issue', 'payment_issue', 'safety', 'other',
|
||||
]);
|
||||
|
||||
$prompt = <<<PROMPT
|
||||
أنت مساعد لموظف خدمة عملاء في تطبيق نقل ركّاب اسمه "سيرو".
|
||||
مهمتك اقتراح تسوية لشكوى، لا اتخاذ القرار — الموظف هو من يقرّر.
|
||||
|
||||
بيانات الحالة (JSON):
|
||||
{$contextJson}
|
||||
|
||||
قواعد ملزمة:
|
||||
1. reason_code يجب أن يكون واحداً من: {$reasons}
|
||||
2. المبلغ بعملة {$currency} ولا يتجاوز {$hardCap} إطلاقاً. هذا سقف صغير
|
||||
عمداً: التعويض استرضاء لا تسوية نزاع. ما يستحق أكثر يُرفع للإدارة.
|
||||
3. إن لم تكن المسؤولية واضحة من البيانات، اجعل المبلغ 0 واطلب توضيحاً.
|
||||
وإن بدت الشكوى كيدية (سوابق الراكب وتقييماته تدل)، اجعله 0 وبيّن ذلك.
|
||||
4. نوع التعويض: refund إن كان استرجاعاً لقيمة رحلة، أو goodwill إن كان استرضاءً.
|
||||
5. الرد المقترَح للعميل بالعربية، مهذّب ومختصر، بلا وعود خارج ما اقترحته.
|
||||
|
||||
أجب بـ JSON فقط، بلا أي نص خارجه، بهذا الشكل:
|
||||
{
|
||||
"reason_code": "...",
|
||||
"fault_determination": "driver | passenger | company | unclear",
|
||||
"suggested_status": "In Progress | Resolved",
|
||||
"compensation": {"amount": 0, "kind": "refund|goodwill", "beneficiary": "passenger|driver"},
|
||||
"customer_reply": "...",
|
||||
"internal_note": "...",
|
||||
"confidence": 0.0
|
||||
}
|
||||
PROMPT;
|
||||
|
||||
// ── النداء ──────────────────────────────────────────────
|
||||
$gemini = new SiroGeminiService();
|
||||
$model = getenv('GEMINI_COMPLAINT_MODEL') ?: 'gemini-flash-lite-latest';
|
||||
$raw = $gemini->callGemini($prompt, $model);
|
||||
|
||||
$text = $raw['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||
|
||||
if (!$text) {
|
||||
// غياب الاقتراح لا يعطّل الموظف — الشاشة تعمل بدونه.
|
||||
error_log("[complaint/ai] لا رد من النموذج للشكوى #$complaintId");
|
||||
jsonSuccess(['available' => false], "AI suggestion unavailable");
|
||||
}
|
||||
|
||||
// النموذج يغلّف JSON بأسوار ```json أحياناً رغم التعليمات.
|
||||
$clean = trim(preg_replace('/^```(?:json)?|```$/mu', '', $text));
|
||||
$parsed = json_decode($clean, true);
|
||||
|
||||
if (!is_array($parsed)) {
|
||||
error_log("[complaint/ai] رد غير قابل للتحليل للشكوى #$complaintId: "
|
||||
. mb_substr($clean, 0, 200));
|
||||
jsonSuccess(['available' => false], "AI suggestion unparsable");
|
||||
}
|
||||
|
||||
// ── تحصين المخرجات ─────────────────────────────────────
|
||||
// النموذج قد يتجاهل القواعد. لا نمرّر رقماً منه إلى واجهة تصرف مالاً
|
||||
// دون قصّه هنا أولاً.
|
||||
$amount = (float) ($parsed['compensation']['amount'] ?? 0);
|
||||
$amount = max(0.0, min($amount, $hardCap));
|
||||
|
||||
$kind = $parsed['compensation']['kind'] ?? 'goodwill';
|
||||
if (!in_array($kind, ['refund', 'goodwill'], true)) $kind = 'goodwill';
|
||||
|
||||
$beneficiary = $parsed['compensation']['beneficiary'] ?? 'passenger';
|
||||
if (!in_array($beneficiary, ['passenger', 'driver'], true)) $beneficiary = 'passenger';
|
||||
|
||||
jsonSuccess([
|
||||
'available' => true,
|
||||
'reason_code' => $parsed['reason_code'] ?? null,
|
||||
'fault_determination' => $parsed['fault_determination'] ?? 'unclear',
|
||||
'suggested_status' => $parsed['suggested_status'] ?? 'In Progress',
|
||||
'compensation' => [
|
||||
'amount' => $amount,
|
||||
'kind' => $kind,
|
||||
'beneficiary' => $beneficiary,
|
||||
'currency' => $currency,
|
||||
'hard_cap' => $hardCap,
|
||||
],
|
||||
'customer_reply' => $parsed['customer_reply'] ?? '',
|
||||
'internal_note' => $parsed['internal_note'] ?? '',
|
||||
'confidence' => (float) ($parsed['confidence'] ?? 0),
|
||||
], "ok");
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log("[complaint/ai] " . $e->getMessage());
|
||||
jsonError("DB Error", 500);
|
||||
} catch (Throwable $e) {
|
||||
error_log("[complaint/ai] " . $e->getMessage());
|
||||
jsonSuccess(['available' => false], "AI suggestion unavailable");
|
||||
}
|
||||
@@ -15,13 +15,9 @@ require_once __DIR__ . '/../ride/pricing/pricing_helper.php';
|
||||
// فحص الدور صراحةً — مجلد serviceapp ليس تحت بوّابة Admin/ المسارية في
|
||||
// connect.php، وكل ملف هنا مسؤول عن حماية نفسه. (update_complaint.php
|
||||
// الأصلي كان بلا فحص، فكان أي حامل JWT يغلق شكوى ويكتب قرارها.)
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
exit(json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Support access required.',
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
// البوّابة المسارية في connect.php تفرض بالفعل service/admin/super_admin
|
||||
// على كل ما تحت serviceapp/. لا نكرّرها هنا: نسختان من نفس الفحص تتباعدان
|
||||
// بصمت، وقد كتبتُ هذه أولاً بـ admin وحده فمنعت كل موظفي الخدمة.
|
||||
|
||||
// ── قوائم مغلقة ─────────────────────────────────────────────
|
||||
// نص حر في التصنيف يجعل التقارير عديمة المعنى بعد شهر.
|
||||
@@ -41,10 +37,6 @@ const COMPLAINT_REASONS = [
|
||||
const COMPENSATION_KINDS = ['refund', 'goodwill'];
|
||||
const COMPENSATION_BENEFICIARIES = ['passenger', 'driver'];
|
||||
|
||||
// سقف صلب على التعويض الواحد. صلاحية صرف المال بيد موظف دعم تحتاج حداً
|
||||
// أعلى لا يتجاوزه خطأ مطبعي (صفر زائد) ولا حساب مخترَق.
|
||||
const COMPENSATION_ABSOLUTE_CAP = 50000.0;
|
||||
|
||||
$complaintId = filterRequest("complaint_id", 'int');
|
||||
$newStatus = filterRequest("status");
|
||||
$reasonCode = filterRequest("reason_code");
|
||||
@@ -125,10 +117,6 @@ try {
|
||||
if (!in_array($compBeneficiary, COMPENSATION_BENEFICIARIES, true)) {
|
||||
jsonError("Invalid compensation_beneficiary");
|
||||
}
|
||||
if ($compAmount > COMPENSATION_ABSOLUTE_CAP) {
|
||||
jsonError("Compensation exceeds the allowed limit");
|
||||
}
|
||||
|
||||
$beneficiaryId = $compBeneficiary === 'passenger'
|
||||
? ($complaint['passenger_id'] ?? '')
|
||||
: ($complaint['driver_id'] ?? '');
|
||||
@@ -144,6 +132,13 @@ try {
|
||||
? (string) $kazan['currency']
|
||||
: getCurrencyByCountry((string) ($kazan['country'] ?? 'Syria'));
|
||||
|
||||
// السقف الصلب بعملة الدولة — يُفحص بعد معرفة العملة لا قبلها.
|
||||
// صلاحية صرف المال بيد موظف دعم تحتاج حداً لا يتجاوزه خطأ مطبعي.
|
||||
$hardCap = getMoneyHardCap($currency);
|
||||
if ($compAmount > $hardCap) {
|
||||
jsonError("Compensation exceeds the allowed limit ($hardCap $currency)");
|
||||
}
|
||||
|
||||
if ($compKind === 'refund' && $rideId) {
|
||||
$stmtRide = $con->prepare("SELECT price FROM ride WHERE id = ? LIMIT 1");
|
||||
$stmtRide->execute([$rideId]);
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
// فحص الدور — كان غائباً تماماً بينما شقيقه getComplaintAllData.php يفحصه.
|
||||
// مجلد serviceapp خارج بوّابة Admin/ المسارية، فكل ملف يحمي نفسه، وهذا
|
||||
// كان مكشوفاً: أي حامل JWT صالح يقدر يغلق أي شكوى ويكتب نص قرارها.
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
exit(json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Support access required.',
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
// كان هذا الملف بلا أي فحص دور. الحماية الآن من البوّابة المسارية في
|
||||
// connect.php (service/admin/super_admin لكل ما تحت serviceapp/).
|
||||
|
||||
$id = filterRequest("id");
|
||||
$status = filterRequest("statusComplaint");
|
||||
|
||||
Reference in New Issue
Block a user