Update codebase

This commit is contained in:
Hamza-Ayed
2026-08-09 16:56:13 +03:00
parent 95e2e4f35d
commit b64debaa88
1058 changed files with 164327 additions and 113928 deletions
+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");
}
@@ -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);
}
+10 -1
View File
@@ -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");
+12 -12
View File
@@ -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
+262
View File
@@ -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;
}
+3
View File
@@ -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");