Update: 2026-08-07 05:03:39

This commit is contained in:
Hamza-Ayed
2026-08-07 05:03:39 +03:00
parent 8e7c4a7104
commit 977d238c84
18 changed files with 1830 additions and 128 deletions
+14 -10
View File
@@ -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),
+3 -82
View File
@@ -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;
}
+32 -1
View File
@@ -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);
}
+2 -4
View File
@@ -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)");
+4 -5
View File
@@ -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"
+223
View File
@@ -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");
}
+10 -15
View File
@@ -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]);
+2 -10
View File
@@ -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");