263 lines
12 KiB
PHP
263 lines
12 KiB
PHP
<?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;
|
||
}
|