Update codebase
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// serviceapp/complaint_sla_stats.php — قياس دورة الشكوى
|
||||
//
|
||||
// دورة بلا قياس تبقى "استقبال شكاوى" لا "إغلاق قضايا". هذه النقطة
|
||||
// تعطي الأرقام الثلاثة التي تدير بها الفريق:
|
||||
// • زمن أول رد ← ما يشعر به العميل فعلاً
|
||||
// • زمن الإغلاق ← كفاءة الفريق
|
||||
// • المفتوح الآن ← عبء اللحظة
|
||||
//
|
||||
// محمية بالبوّابة المسارية في connect.php (كل ما تحت serviceapp/).
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
$days = (int) (filterRequest("days", 'int') ?: 30);
|
||||
// يُقصّ إلى عدد صحيح ضمن مدى آمن قبل أي إقحام في SQL. INTERVAL لا يقبل
|
||||
// متغيراً مربوطاً، فالقصّ هنا هو الضمانة الوحيدة ضد الحقن.
|
||||
$days = max(1, min(365, $days));
|
||||
|
||||
try {
|
||||
// ── الأزمنة ─────────────────────────────────────────────
|
||||
// المتوسط وحده يخدع: شكوى واحدة أُهملت أسبوعاً ترفعه بينما الأغلبية
|
||||
// تُعالَج بسرعة. الوسيط التقريبي عبر أقصى قيمة يكمّل الصورة.
|
||||
$stmt = $con->prepare("
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(first_response_at IS NOT NULL) AS answered,
|
||||
ROUND(AVG(TIMESTAMPDIFF(MINUTE, date_filed, first_response_at)), 1) AS avg_first_response_min,
|
||||
MAX(TIMESTAMPDIFF(MINUTE, date_filed, first_response_at)) AS worst_first_response_min,
|
||||
SUM(statusComplaint = 'Resolved') AS resolved,
|
||||
ROUND(AVG(CASE WHEN statusComplaint = 'Resolved'
|
||||
THEN TIMESTAMPDIFF(MINUTE, date_filed, date_resolved) END), 1) AS avg_resolution_min
|
||||
FROM complaint
|
||||
WHERE date_filed >= NOW() - INTERVAL $days DAY
|
||||
");
|
||||
$stmt->execute();
|
||||
$timing = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
// المفتوح الآن بلا نافذة زمنية: شكوى من الشهر الماضي ما زالت مفتوحة
|
||||
// هي المشكلة، وحصرها في النافذة كان سيخفيها.
|
||||
$openNow = (int) $con->query(
|
||||
"SELECT COUNT(*) FROM complaint WHERE statusComplaint <> 'Resolved'"
|
||||
)->fetchColumn();
|
||||
|
||||
$neverAnswered = (int) $con->query(
|
||||
"SELECT COUNT(*) FROM complaint
|
||||
WHERE statusComplaint <> 'Resolved' AND first_response_at IS NULL"
|
||||
)->fetchColumn();
|
||||
|
||||
// ── التصنيف ─────────────────────────────────────────────
|
||||
$stmtReasons = $con->prepare("
|
||||
SELECT COALESCE(reason_code, 'unclassified') AS reason, COUNT(*) AS cnt
|
||||
FROM complaint
|
||||
WHERE date_filed >= NOW() - INTERVAL $days DAY
|
||||
GROUP BY reason ORDER BY cnt DESC
|
||||
");
|
||||
$stmtReasons->execute();
|
||||
|
||||
// ── التعويضات ───────────────────────────────────────────
|
||||
$compensation = ['total' => 0, 'count' => 0, 'failed' => 0];
|
||||
try {
|
||||
$stmtComp = $con->prepare("
|
||||
SELECT
|
||||
COUNT(*) AS cnt,
|
||||
COALESCE(SUM(CASE WHEN transfer_status = 'success' THEN amount END), 0) AS total,
|
||||
SUM(transfer_status = 'failed') AS failed
|
||||
FROM complaint_compensations
|
||||
WHERE created_at >= NOW() - INTERVAL $days DAY
|
||||
");
|
||||
$stmtComp->execute();
|
||||
$row = $stmtComp->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
$compensation = [
|
||||
'count' => (int) ($row['cnt'] ?? 0),
|
||||
'total' => (float) ($row['total'] ?? 0),
|
||||
// التعويضات الفاشلة أهم رقم هنا: عميل وُعد بمبلغ ولم يصله.
|
||||
'failed' => (int) ($row['failed'] ?? 0),
|
||||
];
|
||||
} catch (PDOException $e) {
|
||||
error_log("[complaint/sla] جدول التعويضات غير مُرحَّل بعد: " . $e->getMessage());
|
||||
}
|
||||
|
||||
jsonSuccess([
|
||||
'window_days' => $days,
|
||||
'timing' => $timing,
|
||||
'open_now' => $openNow,
|
||||
'never_answered' => $neverAnswered,
|
||||
'by_reason' => $stmtReasons->fetchAll(PDO::FETCH_ASSOC),
|
||||
'compensation' => $compensation,
|
||||
], "ok");
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log("[complaint/sla] " . $e->getMessage());
|
||||
jsonError("DB Error", 500);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
// الحماية الآن من البوّابة المسارية في connect.php: كل ما تحت serviceapp/
|
||||
// يتطلب service أو admin أو super_admin. كان هنا فحص admin وحده، وهو ما
|
||||
// منع موظفي خدمة العملاء — وهم أصحاب هذه الشاشة — من قراءة الشكاوى أصلاً.
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
cm.id, cm.ride_id, cm.passenger_id, cm.driver_id,
|
||||
@@ -103,15 +107,20 @@ try {
|
||||
$row = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($row) {
|
||||
$isSuperAdmin = ($role === 'super_admin');
|
||||
foreach ($row as &$item) {
|
||||
foreach (['passengerName', 'driverName', 'driverToken', 'passengerToken'] as $field) {
|
||||
foreach (['passengerName', 'driverName'] as $field) {
|
||||
if (!empty($item[$field])) {
|
||||
$dec = $encryptionHelper->decryptData($item[$field]);
|
||||
if ($dec)
|
||||
$item[$field] = $dec;
|
||||
}
|
||||
}
|
||||
// توكنات الإشعارات لا تستعملها الواجهة إطلاقاً، وتسريبها يتيح
|
||||
// إرسال إشعارات منتحلة إلى سائق أو راكب بعينه.
|
||||
unset($item['driverToken'], $item['passengerToken']);
|
||||
}
|
||||
unset($item);
|
||||
jsonSuccess($row);
|
||||
} else {
|
||||
jsonSuccess([], "No complaints found");
|
||||
|
||||
@@ -28,20 +28,20 @@ WITH RECURSIVE date_series AS (
|
||||
FROM date_series
|
||||
WHERE date < :end_date
|
||||
)
|
||||
SELECT
|
||||
date_series.date AS day,
|
||||
COALESCE(SUM(ride.status = 'Finished'), 0) AS totalRides,
|
||||
|
||||
(SELECT COUNT(*) FROM ride
|
||||
WHERE ride.created_at >= :start_date_total
|
||||
AND ride.created_at <= :end_date_total
|
||||
AND ride.status = 'Finished') AS totalMonthly
|
||||
SELECT
|
||||
date_series.date AS day,
|
||||
COALESCE(SUM(LOWER(ride.status) IN ('finished','completed')), 0) AS totalRides,
|
||||
|
||||
FROM
|
||||
(SELECT COUNT(*) FROM ride
|
||||
WHERE ride.created_at >= :start_date_total
|
||||
AND ride.created_at <= :end_date_total
|
||||
AND LOWER(ride.status) IN ('finished','completed')) AS totalMonthly
|
||||
|
||||
FROM
|
||||
date_series
|
||||
LEFT JOIN
|
||||
ride ON DATE(ride.created_at) = date_series.date
|
||||
AND ride.status = 'Finished'
|
||||
LEFT JOIN
|
||||
ride ON DATE(ride.created_at) = date_series.date
|
||||
AND LOWER(ride.status) IN ('finished','completed')
|
||||
WHERE
|
||||
date_series.date >= :start_date_where
|
||||
AND date_series.date <= :end_date_where
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// serviceapp/resolve_complaint.php — أداة التسوية
|
||||
//
|
||||
// نقطة واحدة يغلق بها الموظف القضية: يصنّف السبب، يحدد الحالة، ويصرف
|
||||
// تعويضاً إن استحق — بدل أن يقلب حالة ويكتب نصاً ثم يَعِد شفهياً بمبلغ
|
||||
// لا يملك أداة لصرفه.
|
||||
//
|
||||
// تعمل مع siro_service و siro_admin معاً: نفس الصلاحية ونفس المنطق.
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
require_once __DIR__ . '/../ride/pricing/pricing_helper.php';
|
||||
|
||||
// فحص الدور صراحةً — مجلد serviceapp ليس تحت بوّابة Admin/ المسارية في
|
||||
// connect.php، وكل ملف هنا مسؤول عن حماية نفسه. (update_complaint.php
|
||||
// الأصلي كان بلا فحص، فكان أي حامل JWT يغلق شكوى ويكتب قرارها.)
|
||||
// البوّابة المسارية في connect.php تفرض بالفعل service/admin/super_admin
|
||||
// على كل ما تحت serviceapp/. لا نكرّرها هنا: نسختان من نفس الفحص تتباعدان
|
||||
// بصمت، وقد كتبتُ هذه أولاً بـ admin وحده فمنعت كل موظفي الخدمة.
|
||||
|
||||
// ── قوائم مغلقة ─────────────────────────────────────────────
|
||||
// نص حر في التصنيف يجعل التقارير عديمة المعنى بعد شهر.
|
||||
const COMPLAINT_STATUSES = ['Open', 'In Progress', 'Resolved'];
|
||||
const COMPLAINT_REASONS = [
|
||||
'driver_behavior', // سلوك السائق
|
||||
'passenger_behavior', // سلوك الراكب
|
||||
'overcharge', // سعر أعلى من المتوقع
|
||||
'route_issue', // مسار خاطئ أو أطول
|
||||
'no_show', // عدم حضور
|
||||
'vehicle_condition', // حالة المركبة
|
||||
'app_issue', // عطل تقني
|
||||
'payment_issue', // مشكلة دفع
|
||||
'safety', // سلامة
|
||||
'other',
|
||||
];
|
||||
const COMPENSATION_KINDS = ['refund', 'goodwill'];
|
||||
const COMPENSATION_BENEFICIARIES = ['passenger', 'driver'];
|
||||
|
||||
$complaintId = filterRequest("complaint_id", 'int');
|
||||
$newStatus = filterRequest("status");
|
||||
$reasonCode = filterRequest("reason_code");
|
||||
$resolution = filterRequest("resolution");
|
||||
$faultOn = filterRequest("fault_determination");
|
||||
|
||||
// التعويض اختياري: قد تُغلق القضية بلا مال.
|
||||
$compAmount = filterRequest("compensation_amount", 'float');
|
||||
$compKind = filterRequest("compensation_kind");
|
||||
$compBeneficiary = filterRequest("compensation_beneficiary");
|
||||
$compNote = filterRequest("compensation_note");
|
||||
|
||||
if (!$complaintId) {
|
||||
jsonError("Missing complaint_id");
|
||||
}
|
||||
if ($newStatus && !in_array($newStatus, COMPLAINT_STATUSES, true)) {
|
||||
jsonError("Invalid status");
|
||||
}
|
||||
if ($reasonCode && !in_array($reasonCode, COMPLAINT_REASONS, true)) {
|
||||
jsonError("Invalid reason_code");
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// ١) تحديث القضية
|
||||
// ══════════════════════════════════════════════════════════
|
||||
$sets = [];
|
||||
$params = [];
|
||||
|
||||
// أول رد يُختم مرة واحدة ولا يتغيّر — هو مؤشر الـSLA الذي يشعر به
|
||||
// العميل. لمسة ثانية من الموظف لا تعيد ضبطه.
|
||||
if (empty($complaint['first_response_at'])) {
|
||||
$sets[] = "first_response_at = NOW()";
|
||||
}
|
||||
|
||||
// من يلمس القضية يملكها. يمنع "الكل مسؤول فلا أحد مسؤول".
|
||||
if (empty($complaint['assigned_to'])) {
|
||||
$sets[] = "assigned_to = ?";
|
||||
$params[] = $user_id ?? 'support';
|
||||
}
|
||||
|
||||
if ($newStatus) {
|
||||
$sets[] = "statusComplaint = ?";
|
||||
$params[] = $newStatus;
|
||||
if ($newStatus === 'Resolved' && empty($complaint['date_resolved'])) {
|
||||
$sets[] = "date_resolved = NOW()";
|
||||
}
|
||||
}
|
||||
if ($reasonCode) { $sets[] = "reason_code = ?"; $params[] = $reasonCode; }
|
||||
if ($resolution) { $sets[] = "resolution = ?"; $params[] = mb_substr($resolution, 0, 2000); }
|
||||
if ($faultOn) { $sets[] = "fault_determination = ?"; $params[] = mb_substr($faultOn, 0, 255); }
|
||||
|
||||
if (!empty($sets)) {
|
||||
$params[] = $complaintId;
|
||||
$con->prepare("UPDATE complaint SET " . implode(', ', $sets) . " WHERE id = ?")
|
||||
->execute($params);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// ٢) التعويض
|
||||
// ══════════════════════════════════════════════════════════
|
||||
$compensation = null;
|
||||
|
||||
if ($compAmount !== null && $compAmount > 0) {
|
||||
if (!in_array($compKind, COMPENSATION_KINDS, true)) {
|
||||
jsonError("Invalid compensation_kind");
|
||||
}
|
||||
if (!in_array($compBeneficiary, COMPENSATION_BENEFICIARIES, true)) {
|
||||
jsonError("Invalid compensation_beneficiary");
|
||||
}
|
||||
$beneficiaryId = $compBeneficiary === 'passenger'
|
||||
? ($complaint['passenger_id'] ?? '')
|
||||
: ($complaint['driver_id'] ?? '');
|
||||
|
||||
if (empty($beneficiaryId)) {
|
||||
jsonError("Complaint has no $compBeneficiary to compensate");
|
||||
}
|
||||
|
||||
// استرجاع لا يتجاوز قيمة الرحلة: "refund" يعني إعادة ما دُفع، وما
|
||||
// فوقه رصيد اعتذار (goodwill) يُصنَّف كذلك ويُراجَع كذلك.
|
||||
$kazan = $rideId ? getKazanForRide($con, $rideId) : [];
|
||||
$currency = !empty($kazan['currency'])
|
||||
? (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]);
|
||||
$ridePrice = (float) ($stmtRide->fetchColumn() ?: 0);
|
||||
|
||||
if ($ridePrice > 0 && $compAmount > $ridePrice) {
|
||||
jsonError("Refund cannot exceed the ride price ($ridePrice $currency)."
|
||||
. " Use goodwill for anything above it.");
|
||||
}
|
||||
}
|
||||
|
||||
// السجل أولاً بحالة pending، ثم التحويل. العكس كان يترك مالاً
|
||||
// مصروفاً بلا أثر إن انقطع التنفيذ بينهما.
|
||||
$insComp = $con->prepare("
|
||||
INSERT INTO complaint_compensations
|
||||
(complaint_id, ride_id, beneficiary_type, beneficiary_id,
|
||||
kind, amount, currency, issued_by, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$insComp->execute([
|
||||
$complaintId, $rideId, $compBeneficiary, $beneficiaryId,
|
||||
$compKind, $compAmount, $currency,
|
||||
$user_id ?? 'support',
|
||||
$compNote ? mb_substr($compNote, 0, 500) : null,
|
||||
]);
|
||||
$compId = (int) $con->lastInsertId();
|
||||
|
||||
$code = transferCompensation(
|
||||
$con, $rideId, $compBeneficiary, $beneficiaryId, $compAmount, $compId
|
||||
);
|
||||
|
||||
$con->prepare("
|
||||
UPDATE complaint_compensations
|
||||
SET transfer_status = ?, transfer_code = ?
|
||||
WHERE id = ?
|
||||
")->execute([$code === 200 ? 'success' : 'failed', $code, $compId]);
|
||||
|
||||
if ($code !== 200) {
|
||||
error_log("[complaint] MONEY: تعويض #$compId بقيمة $compAmount $currency"
|
||||
. " للشكوى #$complaintId لم يصل ($compBeneficiary=$beneficiaryId، رمز=$code)");
|
||||
}
|
||||
|
||||
$compensation = [
|
||||
'id' => $compId,
|
||||
'amount' => $compAmount,
|
||||
'currency' => $currency,
|
||||
'kind' => $compKind,
|
||||
'status' => $code === 200 ? 'success' : 'failed',
|
||||
];
|
||||
}
|
||||
|
||||
error_log("[complaint] #$complaintId → " . ($newStatus ?: 'بلا تغيير حالة')
|
||||
. " بواسطة " . ($user_id ?? 'support')
|
||||
. ($compensation ? " (+تعويض {$compensation['amount']})" : ""));
|
||||
|
||||
jsonSuccess([
|
||||
'complaint_id' => $complaintId,
|
||||
'status' => $newStatus ?: $complaint['statusComplaint'],
|
||||
'compensation' => $compensation,
|
||||
], "Complaint updated");
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log("[complaint/resolve] " . $e->getMessage());
|
||||
jsonError("DB Error", 500);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* يحوّل التعويض إلى محفظة المستفيد. يرجع رمز HTTP من خادم المحفظة.
|
||||
*
|
||||
* paymentID مشتق من رقم التعويض لا من الشكوى: الشكوى الواحدة قد تُعوَّض
|
||||
* مرتين (استرجاع ثم اعتذار)، واشتقاقه من الشكوى كان سيجعل الثانية
|
||||
* تُرفض كمكرّرة.
|
||||
*/
|
||||
function transferCompensation(
|
||||
PDO $con, $rideId, string $beneficiaryType, string $beneficiaryId,
|
||||
float $amount, int $compId
|
||||
): int {
|
||||
$kazan = $rideId ? getKazanForRide($con, $rideId) : [];
|
||||
$country = strtolower((string) ($kazan['country'] ?? 'jordan'));
|
||||
|
||||
$walletServer = "https://walletintaleq.intaleq.xyz";
|
||||
if ($country === 'jordan') {
|
||||
$walletServer = getenv('WALLET_SERVER_JORDAN') ?: $walletServer;
|
||||
} elseif ($country === 'egypt') {
|
||||
$walletServer = getenv('WALLET_SERVER_EGYPT') ?: $walletServer;
|
||||
} else {
|
||||
$walletServer = getenv('WALLET_SERVER_SYRIA') ?: $walletServer;
|
||||
}
|
||||
|
||||
if ($beneficiaryType === 'driver') {
|
||||
$url = "$walletServer/v2/main/ride/driverWallet/add_s2s_reward.php";
|
||||
$fields = [
|
||||
'driverID' => $beneficiaryId,
|
||||
'paymentID' => "complaint_comp_$compId",
|
||||
'amount' => $amount,
|
||||
'paymentMethod' => 'complaint_compensation',
|
||||
];
|
||||
} else {
|
||||
// محفظة الراكب تستقبل الأرصدة كقيمة موجبة على نفس نقطة الدين.
|
||||
$url = "$walletServer/v2/main/ride/passengerWallet/add_s2s_debt.php";
|
||||
$fields = ['passengerID' => $beneficiaryId, 'amount' => $amount];
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($fields),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'X-S2S-Api-Key: ' . getenv('S2S_SHARED_KEY'),
|
||||
],
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return $code;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
// كان هذا الملف بلا أي فحص دور. الحماية الآن من البوّابة المسارية في
|
||||
// connect.php (service/admin/super_admin لكل ما تحت serviceapp/).
|
||||
|
||||
$id = filterRequest("id");
|
||||
$status = filterRequest("statusComplaint");
|
||||
$resolution = filterRequest("resolution");
|
||||
|
||||
Reference in New Issue
Block a user