Update: 2026-08-07 04:35:28

This commit is contained in:
Hamza-Ayed
2026-08-07 04:35:28 +03:00
parent f0ea698ab2
commit 8e7c4a7104
5 changed files with 469 additions and 2 deletions
@@ -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);
}
+267
View File
@@ -0,0 +1,267 @@
<?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 يغلق شكوى ويكتب قرارها.)
if ($role !== 'admin' && $role !== 'super_admin') {
http_response_code(403);
exit(json_encode([
'status' => '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;
}
+11
View File
@@ -1,6 +1,17 @@
<?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));
}
$id = filterRequest("id");
$status = filterRequest("statusComplaint");
$resolution = filterRequest("resolution");