From 8e7c4a71046c6d9425185c0f1d5eb8785b8c4e87 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Fri, 7 Aug 2026 04:35:28 +0300 Subject: [PATCH] Update: 2026-08-07 04:35:28 --- backend/connect.php | 23 +- .../2026_08_07_complaint_resolution.sql | 75 +++++ backend/serviceapp/complaint_sla_stats.php | 95 +++++++ backend/serviceapp/resolve_complaint.php | 267 ++++++++++++++++++ backend/serviceapp/update_complaint.php | 11 + 5 files changed, 469 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/2026_08_07_complaint_resolution.sql create mode 100644 backend/serviceapp/complaint_sla_stats.php create mode 100644 backend/serviceapp/resolve_complaint.php diff --git a/backend/connect.php b/backend/connect.php index bf759c92..909601b0 100644 --- a/backend/connect.php +++ b/backend/connect.php @@ -47,10 +47,29 @@ $role = $decoded->role ?? 'passenger'; // // نقاط تسجيل الدخول (Admin/auth/*, jwtService.php) لا تستدعي connect.php // أصلاً، فلا تتأثر بهذه البوّابة ولا يمكن أن تُقفل على نفسها. -$adminDir = realpath(__DIR__ . '/Admin'); +// +// ‏2026-08-07: وُسِّعت البوّابة لتشمل serviceapp/ أيضاً. كان 34 من 39 ملفاً +// ‏هناك بلا أي فحص دور — بينها getDriverByPhone و getPassengersByPhone و +// ‏update_complaint — أي أن أي حامل JWT صالح (راكب عادي) يقرأ بيانات +// ‏السائقين والركّاب ويغلق الشكاوى. تحققنا أن siro_rider و siro_driver لا +// ‏يستدعيان أي نقطة تحت serviceapp/، فالإغلاق لا يكسر تطبيقات الجوال. +$gatedDirs = array_filter([ + realpath(__DIR__ . '/Admin'), + realpath(__DIR__ . '/serviceapp'), +]); $script = realpath($_SERVER['SCRIPT_FILENAME'] ?? ''); -if ($adminDir && $script && str_starts_with($script, $adminDir . DIRECTORY_SEPARATOR)) { +$inGatedDir = false; +if ($script) { + foreach ($gatedDirs as $dir) { + if (str_starts_with($script, $dir . DIRECTORY_SEPARATOR)) { + $inGatedDir = true; + break; + } + } +} + +if ($inGatedDir) { // نقاط تحت Admin/ تستدعيها تطبيقات الجوال بأدوار غير إدارية. // تم التحقق من مواضع الاستدعاء الفعلية في siro_rider و siro_driver: // errorApp.php → التطبيقان يبلّغان عن الأخطاء diff --git a/backend/migrations/2026_08_07_complaint_resolution.sql b/backend/migrations/2026_08_07_complaint_resolution.sql new file mode 100644 index 00000000..15c729c5 --- /dev/null +++ b/backend/migrations/2026_08_07_complaint_resolution.sql @@ -0,0 +1,75 @@ +-- 2026_08_07_complaint_resolution.sql +-- +-- ‏دورة الشكوى وأداة التعويض. +-- +-- ‏الوضع اليوم: serviceapp/update_complaint.php يقلب الحالة ويكتب نصّ +-- ‏قرار. لا تعويض، لا تصنيف سبب، ولا قياس زمن. والموظف الذي يريد +-- ‏تعويض راكب متضرر لا يملك أي أداة — فيَعِد شفهياً أو لا يفعل شيئاً. +-- +-- ‏كلمة "refund" لا ترد إطلاقاً في مسار الرحلات، فقط في وحدة الطعام. +-- +-- ‏هذا الترحيل يحوّل الدعم من "استقبال شكاوى" إلى "إغلاق قضايا". +-- +-- ⚠️ ‏يُنفَّذ على قاعدة primary (jorSiroDB) وحدها. + +-- ══════════════════════════════════════════════════════════════ +-- ١) قياس الزمن (SLA) +-- +-- ‏date_resolved موجود أصلاً، لكن زمن **أول رد** هو المؤشر الذي يشعر به +-- ‏العميل فعلاً — شكوى تُغلق بعد يومين بردٍّ خلال دقائق أفضل بكثير من +-- ‏شكوى تُغلق بعد ساعة بلا أن يعلم صاحبها شيئاً. +-- ══════════════════════════════════════════════════════════════ +ALTER TABLE `complaint` + ADD COLUMN `first_response_at` DATETIME NULL DEFAULT NULL + COMMENT 'لحظة أول رد من الدعم — يُختم مرة واحدة ولا يتغيّر', + ADD COLUMN `assigned_to` VARCHAR(100) NULL DEFAULT NULL + COMMENT 'معرّف الموظف المسؤول عن القضية', + ADD COLUMN `reason_code` VARCHAR(40) NULL DEFAULT NULL + COMMENT 'تصنيف موحّد للسبب — قائمة مغلقة في resolve_complaint.php', + ADD INDEX `idx_status_filed` (`statusComplaint`, `date_filed`), + ADD INDEX `idx_ride` (`ride_id`); + +-- ══════════════════════════════════════════════════════════════ +-- ٢) سجل التعويضات +-- +-- ‏جدول مستقل لا أعمدة في complaint: الشكوى الواحدة قد تُعوَّض أكثر من +-- ‏مرة (استرجاع جزئي ثم رصيد اعتذار)، وكل حركة مالية تحتاج أثراً مستقلاً +-- ‏يُراجَع ويُدقَّق. +-- ══════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS `complaint_compensations` ( + `id` INT NOT NULL AUTO_INCREMENT, + `complaint_id` INT NOT NULL, + `ride_id` VARCHAR(20) NULL DEFAULT NULL, + + `beneficiary_type` VARCHAR(20) NOT NULL + COMMENT 'passenger | driver — لمن ذهب التعويض', + `beneficiary_id` VARCHAR(100) NOT NULL, + + `kind` VARCHAR(20) NOT NULL + COMMENT 'refund (استرجاع من قيمة الرحلة) | goodwill (رصيد اعتذار)', + `amount` DECIMAL(12,3) NOT NULL, + `currency` VARCHAR(5) NOT NULL DEFAULT 'SYP', + + `issued_by` VARCHAR(100) NOT NULL COMMENT 'الموظف الذي صرفه', + `note` VARCHAR(500) NULL DEFAULT NULL, + + -- ‏نتيجة نداء المحفظة. الفشل يبقى مسجَّلاً للتسوية اليدوية بدل أن + -- ‏يختفي في اللوجز — العميل وُعد بمبلغ ويجب أن يصله. + `transfer_status` VARCHAR(20) NOT NULL DEFAULT 'pending' + COMMENT 'pending | success | failed', + `transfer_code` SMALLINT NULL DEFAULT NULL, + + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (`id`), + KEY `idx_complaint` (`complaint_id`), + KEY `idx_beneficiary` (`beneficiary_type`, `beneficiary_id`), + KEY `idx_failed` (`transfer_status`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='تعويضات الشكاوى — كل حركة مالية بأثر مستقل قابل للتدقيق'; + +-- ‏للتراجع: +-- ALTER TABLE `complaint` DROP COLUMN `first_response_at`, +-- DROP COLUMN `assigned_to`, DROP COLUMN `reason_code`, +-- DROP INDEX `idx_status_filed`, DROP INDEX `idx_ride`; +-- DROP TABLE `complaint_compensations`; diff --git a/backend/serviceapp/complaint_sla_stats.php b/backend/serviceapp/complaint_sla_stats.php new file mode 100644 index 00000000..443f7118 --- /dev/null +++ b/backend/serviceapp/complaint_sla_stats.php @@ -0,0 +1,95 @@ +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); +} diff --git a/backend/serviceapp/resolve_complaint.php b/backend/serviceapp/resolve_complaint.php new file mode 100644 index 00000000..9742c917 --- /dev/null +++ b/backend/serviceapp/resolve_complaint.php @@ -0,0 +1,267 @@ + 'failure', + 'message' => 'Forbidden. Support access required.', + ], JSON_UNESCAPED_UNICODE)); +} + +// ── قوائم مغلقة ───────────────────────────────────────────── +// ‏نص حر في التصنيف يجعل التقارير عديمة المعنى بعد شهر. +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']; + +// ‏سقف صلب على التعويض الواحد. صلاحية صرف المال بيد موظف دعم تحتاج حداً +// ‏أعلى لا يتجاوزه خطأ مطبعي (صفر زائد) ولا حساب مخترَق. +const COMPENSATION_ABSOLUTE_CAP = 50000.0; + +$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"); + } + if ($compAmount > COMPENSATION_ABSOLUTE_CAP) { + jsonError("Compensation exceeds the allowed limit"); + } + + $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')); + + 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; +} diff --git a/backend/serviceapp/update_complaint.php b/backend/serviceapp/update_complaint.php index 955dadab..955c05cb 100644 --- a/backend/serviceapp/update_complaint.php +++ b/backend/serviceapp/update_complaint.php @@ -1,6 +1,17 @@ 'failure', + 'message' => 'Forbidden. Support access required.', + ], JSON_UNESCAPED_UNICODE)); +} + $id = filterRequest("id"); $status = filterRequest("statusComplaint"); $resolution = filterRequest("resolution");