Update: 2026-08-07 05:03:39

This commit is contained in:
Hamza-Ayed
2026-08-07 05:03:39 +03:00
parent 8e7c4a7104
commit 977d238c84
18 changed files with 1830 additions and 128 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");
}
+10 -15
View File
@@ -15,13 +15,9 @@ 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));
}
// ‏البوّابة المسارية في connect.php تفرض بالفعل service/admin/super_admin
// ‏على كل ما تحت serviceapp/. لا نكرّرها هنا: نسختان من نفس الفحص تتباعدان
// ‏بصمت، وقد كتبتُ هذه أولاً بـ admin وحده فمنعت كل موظفي الخدمة.
// ── قوائم مغلقة ─────────────────────────────────────────────
// ‏نص حر في التصنيف يجعل التقارير عديمة المعنى بعد شهر.
@@ -41,10 +37,6 @@ const COMPLAINT_REASONS = [
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");
@@ -125,10 +117,6 @@ try {
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'] ?? '');
@@ -144,6 +132,13 @@ try {
? (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]);
+2 -10
View File
@@ -1,16 +1,8 @@
<?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));
}
// ‏كان هذا الملف بلا أي فحص دور. الحماية الآن من البوّابة المسارية في
// ‏connect.php (service/admin/super_admin لكل ما تحت serviceapp/).
$id = filterRequest("id");
$status = filterRequest("statusComplaint");