feat: استيراد كود سيرو إلى تريبز (سيرو @ecfe7568) — بلا تعديل
قرار المالك 2026-07-27: باك إند سيرو PHP هو المعتمد، وتطبيقاته المجرّبة ميدانياً تحل محل إعادة البناء المؤرشفة. سيرو نفسه لم يُمسّ. الخريطة: backend · payment_server · loction_server · ride_server · passenger_server · docker · dashboard · stress_test → الجذر siro_rider → apps/rider siro_driver → apps/driver siro_admin → dashboards/admin siro_service → dashboards/service android_bot → apps/android_bot socialBot → apps/socialBot نُسخ المتعقَّب في git سيرو فقط عبر `git archive` (3,198 ملفاً / ~169 م.ب) لا `cp -r` — فاستُثنيت مخلفات البناء تلقائياً. بلا أي تعديل محتوى عمداً: كل ما يلي يصير فرقاً مقروءاً مقابل المصدر. لم يُستورد وسببه: siromove.com (الموقع التسويقي يبقى marketing/ في تريبز، سيرو فيه 8 ملفات) · docs و planning (تريبز له docs/ الخاص) · deploy.sh (ليس نشراً على سيرفر بل `git add . && git push origin --all` — فخّ في مستودع آخر) · transit_dashboard (بانتظار قرار مصير backend-transit و dashboards/transit-web). ⚠️ لا يبني بعد — ثلاثة نواقص متوقعة ومقصودة: 1. `.env` و `lib/env/env.g.dart` غير متعقَّبين في سيرو (أسرار لكل مستأجر): كل تطبيق فلاتر يحتاج .env خاصاً ثم توليد env.g.dart بـ build_runner. 2. إعدادات Firebase (9 ملفات google-services.json و GoogleService-Info.plist) يستبعدها .gitignore تريبز — ولكل مستأجر مشروع Firebase خاص أصلاً. 3. apps/driver في سيرو يشير إلى `../../Intaleq/packages/get` خارج المستودع → يجب ضمّ الحزم داخله أسوة بـ apps/rider. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9909d9b4c1
commit
4d8414c96b
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
// transit/admin/dashboard.php — لوحة اليوم
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
$today = date('Y-m-d');
|
||||
|
||||
$stTrips = $transit_con->prepare(
|
||||
"SELECT t.id, t.status, t.started_at, t.completed_at, t.delay_minutes, t.current_stop_seq,
|
||||
r.name_ar AS route_name, sc.departure_time,
|
||||
v.plate AS vehicle_plate,
|
||||
d.name AS driver_name, d.phone AS driver_phone
|
||||
FROM transit_trips t
|
||||
JOIN transit_routes r ON r.id = t.route_id
|
||||
JOIN transit_schedules sc ON sc.id = t.schedule_id
|
||||
JOIN transit_vehicles v ON v.id = t.vehicle_id
|
||||
JOIN transit_drivers d ON d.id = t.driver_id
|
||||
WHERE t.org_id = ? AND t.trip_date = ?
|
||||
ORDER BY sc.departure_time ASC"
|
||||
);
|
||||
$stTrips->execute([$transit_org_id, $today]);
|
||||
$trips = $stTrips->fetchAll();
|
||||
|
||||
foreach ($trips as &$trip) {
|
||||
$trip['live_position'] = null;
|
||||
if ($trip['status'] === 'started') {
|
||||
$pos = transitGetBusPosition((int)$trip['id']);
|
||||
if ($pos) $trip['live_position'] = ['lat' => (float)$pos['lat'], 'lng' => (float)$pos['lng'], 'ts' => (int)$pos['ts']];
|
||||
}
|
||||
}
|
||||
unset($trip);
|
||||
|
||||
$stStats = $transit_con->prepare(
|
||||
"SELECT COUNT(*) total_trips,
|
||||
SUM(status='started') active_now,
|
||||
SUM(status='completed') completed_today,
|
||||
SUM(status='no_show') no_show_today
|
||||
FROM transit_trips WHERE org_id=? AND trip_date=?"
|
||||
);
|
||||
$stStats->execute([$transit_org_id, $today]);
|
||||
$stats = $stStats->fetch();
|
||||
|
||||
$stMembers = $transit_con->prepare(
|
||||
"SELECT COUNT(*) active_members FROM transit_enrollments WHERE org_id=? AND status='active'"
|
||||
);
|
||||
$stMembers->execute([$transit_org_id]);
|
||||
|
||||
$stBc = $transit_con->prepare(
|
||||
"SELECT id, title_ar, body_ar, sent_at, target_type FROM transit_broadcasts
|
||||
WHERE org_id=? ORDER BY created_at DESC LIMIT 5"
|
||||
);
|
||||
$stBc->execute([$transit_org_id]);
|
||||
|
||||
jsonSuccess([
|
||||
'date' => $today,
|
||||
'trips' => $trips,
|
||||
'stats' => [
|
||||
'total_trips' => (int)$stats['total_trips'],
|
||||
'active_now' => (int)$stats['active_now'],
|
||||
'completed_today' => (int)$stats['completed_today'],
|
||||
'no_show_today' => (int)$stats['no_show_today'],
|
||||
'active_members' => (int)$stMembers->fetchColumn(),
|
||||
],
|
||||
'recent_broadcasts' => $stBc->fetchAll(),
|
||||
]);
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
// transit/admin/login_request.php — الخطوة 1: هاتف المشرف → OTP
|
||||
// POST: phone
|
||||
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
// Rate limiting — حد صارم على طلبات OTP
|
||||
$limiter = new RateLimiter($redis);
|
||||
$limiter->enforce(RateLimiter::identifier(), 'api');
|
||||
|
||||
try { $transit_con = Database::get('transit'); }
|
||||
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||
|
||||
$rawPhone = filterRequest('phone');
|
||||
if (!$rawPhone) jsonError('Phone is required');
|
||||
$phone = normalizePhone($rawPhone);
|
||||
|
||||
if (transitIsOtpLocked($phone)) {
|
||||
// نفس رسالة النجاح — لا نكشف أن الحساب مقفل
|
||||
jsonSuccess(null, 'OTP sent successfully');
|
||||
}
|
||||
|
||||
$phoneEnc = $encryptionHelper->encryptData($phone);
|
||||
// transit_org_admins يعيش في قاعدة المواصلات ولا يملك عمود فهرس بعد،
|
||||
// فتتم المطابقة على الرقم الأصلي بعد فك التشفير عند فشل المقارنة المباشرة.
|
||||
$phoneNorm = normalizePhone($phone);
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT a.id, o.contract_status
|
||||
FROM transit_org_admins a
|
||||
JOIN transit_orgs o ON o.id = a.org_id
|
||||
WHERE a.phone = ? AND a.is_active = 1 LIMIT 1"
|
||||
);
|
||||
$st->execute([$phoneEnc]);
|
||||
$admin = $st->fetch();
|
||||
|
||||
if (!$admin) {
|
||||
// تحت التشفير العشوائي لا تتطابق النصوص المشفّرة، فنقارن الأرقام الأصلية.
|
||||
$all = $transit_con->query(
|
||||
"SELECT a.id, a.phone, o.contract_status
|
||||
FROM transit_org_admins a
|
||||
JOIN transit_orgs o ON o.id = a.org_id
|
||||
WHERE a.is_active = 1"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($all as $row) {
|
||||
$plain = $encryptionHelper->decryptData($row['phone'] ?? null);
|
||||
if ($plain && normalizePhone($plain) === $phoneNorm) {
|
||||
$admin = $row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// لا نكشف إن كان الهاتف موجوداً أم لا — نفس الرد دائماً
|
||||
if (!$admin || $admin['contract_status'] === 'terminated') {
|
||||
jsonSuccess(null, 'OTP sent successfully');
|
||||
}
|
||||
|
||||
transitSendAdminOtp($phone);
|
||||
|
||||
jsonSuccess(null, 'OTP sent successfully');
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
// transit/admin/login_verify.php — الخطوة 2: OTP → session token
|
||||
// POST: phone, otp
|
||||
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
// Rate limiting
|
||||
$limiter = new RateLimiter($redis);
|
||||
$limiter->enforce(RateLimiter::identifier(), 'api');
|
||||
|
||||
try { $transit_con = Database::get('transit'); }
|
||||
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||
|
||||
requireTransitFields(['phone', 'otp']);
|
||||
$phone = normalizePhone(filterRequest('phone'));
|
||||
$otp = preg_replace('/\D/', '', filterRequest('otp'));
|
||||
|
||||
if (!transitVerifyAdminOtp($phone, $otp)) jsonError('Invalid or expired OTP', 401);
|
||||
|
||||
$phoneEnc = $encryptionHelper->encryptData($phone);
|
||||
// transit_org_admins يعيش في قاعدة المواصلات ولا يملك عمود فهرس بعد،
|
||||
// فتتم المطابقة على الرقم الأصلي بعد فك التشفير عند فشل المقارنة المباشرة.
|
||||
$phoneNorm = normalizePhone($phone);
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT a.id, a.org_id, a.name, a.role, a.permissions,
|
||||
o.name_ar, o.name_en, o.type, o.contract_status, o.logo_url
|
||||
FROM transit_org_admins a
|
||||
JOIN transit_orgs o ON o.id = a.org_id
|
||||
WHERE a.phone = ? AND a.is_active = 1 LIMIT 1"
|
||||
);
|
||||
$st->execute([$phoneEnc]);
|
||||
$admin = $st->fetch();
|
||||
|
||||
if (!$admin) {
|
||||
// تحت التشفير العشوائي لا تتطابق النصوص المشفّرة، فنقارن الأرقام الأصلية.
|
||||
$all = $transit_con->query(
|
||||
"SELECT a.id, a.phone, o.contract_status
|
||||
FROM transit_org_admins a
|
||||
JOIN transit_orgs o ON o.id = a.org_id
|
||||
WHERE a.is_active = 1"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($all as $row) {
|
||||
$plain = $encryptionHelper->decryptData($row['phone'] ?? null);
|
||||
if ($plain && normalizePhone($plain) === $phoneNorm) {
|
||||
$admin = $row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$admin) jsonError('Admin not found', 401);
|
||||
|
||||
$token = transitCreateSession((int)$admin['id'], (int)$admin['org_id']);
|
||||
|
||||
jsonSuccess([
|
||||
'token' => $token,
|
||||
'expires_in' => 86400,
|
||||
'admin' => [
|
||||
'id' => (int)$admin['id'],
|
||||
'name' => $admin['name'],
|
||||
'role' => $admin['role'],
|
||||
'permissions' => json_decode($admin['permissions'] ?? '{}', true),
|
||||
],
|
||||
'org' => [
|
||||
'id' => (int)$admin['org_id'],
|
||||
'name_ar' => $admin['name_ar'],
|
||||
'name_en' => $admin['name_en'],
|
||||
'type' => $admin['type'],
|
||||
'contract_status' => $admin['contract_status'],
|
||||
'logo_url' => $admin['logo_url'],
|
||||
],
|
||||
], 'Login successful');
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// transit/admin/logout.php — تسجيل خروج مشرف المؤسسة (حذف الجلسة)
|
||||
// POST: — (التوكن في الهيدر Authorization: Bearer)
|
||||
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
// حد معدّل — يمنع إغراق حذف الجلسات (يحمّل bootstrap مباشرة كباقي مسارات admin/)
|
||||
$limiter = new RateLimiter($redis);
|
||||
$limiter->enforce(RateLimiter::identifier(), 'api');
|
||||
|
||||
try { $transit_con = Database::get('transit'); }
|
||||
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||
|
||||
// استخرج التوكن من الهيدر
|
||||
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
$token = '';
|
||||
if (preg_match('/Bearer\s+(\S+)/i', $authHeader, $m)) {
|
||||
$token = $m[1];
|
||||
} else {
|
||||
$token = filterRequest('token') ?? '';
|
||||
}
|
||||
|
||||
if (!$token) jsonError('No session token provided', 400);
|
||||
|
||||
$hash = hash('sha256', $token);
|
||||
|
||||
// حذف من Redis — بلا بادئة siro: يدوية (العميل يضيفها تلقائياً عبر OPT_PREFIX)
|
||||
// كتابة "siro:transit:..." هنا كانت تنتج siro:siro:... فلا تُحذف الجلسة فعلياً
|
||||
if ($redis) $redis->del("transit:session:{$hash}");
|
||||
|
||||
// حذف من MySQL
|
||||
try {
|
||||
$transit_con->prepare("DELETE FROM transit_sessions WHERE token_hash=?")->execute([$hash]);
|
||||
} catch (Throwable $e) {
|
||||
// لا بأس — الجلسة انتهت بالـ Redis
|
||||
}
|
||||
|
||||
jsonSuccess(null, 'Logged out successfully');
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// transit/broadcast/send.php — المشرف يرسل إعلاناً للطلاب عبر FCM
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
requireTransitFields(['body_ar']);
|
||||
|
||||
$bodyAr = filterRequest('body_ar');
|
||||
$titleAr = filterRequest('title_ar') ?: 'إعلان من الجامعة';
|
||||
$targetType = filterRequest('target_type') ?: 'all';
|
||||
$targetId = filterRequest('target_id', 'int') ?: null;
|
||||
|
||||
if (!in_array($targetType, ['all', 'route'])) $targetType = 'all';
|
||||
|
||||
// عند استهداف خط: تأكد أن الخط يخص مؤسسة هذا المشرف — يمنع البث لخط مؤسسة أخرى
|
||||
if ($targetType === 'route' && $targetId) {
|
||||
$chkRoute = $transit_con->prepare("SELECT id FROM transit_routes WHERE id=? AND org_id=? LIMIT 1");
|
||||
$chkRoute->execute([$targetId, $transit_org_id]);
|
||||
if (!$chkRoute->fetch()) jsonError('Route not found or access denied', 404);
|
||||
}
|
||||
|
||||
$fcmTopic = ($targetType === 'route' && $targetId)
|
||||
? transitRouteTopic($targetId)
|
||||
: transitOrgTopic($transit_org_id);
|
||||
|
||||
$transit_con->prepare(
|
||||
"INSERT INTO transit_broadcasts
|
||||
(org_id, sent_by, target_type, target_id, title_ar, body_ar, fcm_topic, sent_at)
|
||||
VALUES (?,?,?,?,?,?,?,NOW())"
|
||||
)->execute([$transit_org_id, $transit_admin_id, $targetType, $targetId, $titleAr, $bodyAr, $fcmTopic]);
|
||||
|
||||
$broadcastId = (int)$transit_con->lastInsertId();
|
||||
|
||||
transitSendTopicNotification(
|
||||
$fcmTopic, $titleAr, $bodyAr,
|
||||
['type' => 'transit_broadcast', 'broadcast_id' => (string)$broadcastId]
|
||||
);
|
||||
|
||||
jsonSuccess(['broadcast_id' => $broadcastId, 'fcm_topic' => $fcmTopic], 'Broadcast sent');
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// transit/connect_admin.php — بوابة لوحة الويب (مشرف المؤسسة)
|
||||
// المصادقة عبر session token (هاتف + OTP) — مستقلة عن JWT
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
// CORS لواجهة الويب
|
||||
$adminOrigins = array_map('trim', explode(',',
|
||||
getenv('TRANSIT_ADMIN_ORIGINS') ?: 'https://transit.siromove.com,https://admin.siromove.com'
|
||||
));
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
if (in_array($origin, $adminOrigins)) {
|
||||
header("Access-Control-Allow-Origin: $origin");
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
}
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
|
||||
|
||||
// Rate limiting على جلسات لوحة المشرف
|
||||
$limiter = new RateLimiter($redis);
|
||||
$limiter->enforce(RateLimiter::identifier(), 'api');
|
||||
|
||||
// اتصال Transit DB
|
||||
try {
|
||||
$transit_con = Database::get('transit');
|
||||
} catch (Exception $e) {
|
||||
http_response_code(503);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Transit service unavailable']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// التحقق من الـ session (يُعيد ['admin_id'=>X, 'org_id'=>Y])
|
||||
$transit_admin = transitAuthAdmin();
|
||||
$transit_admin_id = (int)$transit_admin['admin_id'];
|
||||
$transit_org_id = (int)$transit_admin['org_id'];
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// transit/connect_app.php — بوابة التطبيق (راكب أو سائق باص)
|
||||
// يستخدم JWT الرئيسي نفسه (الراكب مسجّل بالفعل كـ passenger)
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
// Rate limiting — نفس حد API العادي
|
||||
$limiter = new RateLimiter($redis);
|
||||
$limiter->enforce(RateLimiter::identifier(), 'api');
|
||||
|
||||
// JWT المعتاد — يُستخرج منه passenger_id أو driver_id
|
||||
$jwtService = new JwtService($redis);
|
||||
$decoded = $jwtService->authenticate();
|
||||
|
||||
$transit_user_id = $decoded->user_id ?? null;
|
||||
$transit_user_role = $decoded->role ?? 'passenger'; // 'passenger' أو 'driver'
|
||||
|
||||
// اتصال قاعدة بيانات Transit فقط — ممنوع Database::get('main') في ملفات transit/
|
||||
try {
|
||||
$transit_con = Database::get('transit');
|
||||
} catch (Exception $e) {
|
||||
http_response_code(503);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Transit service unavailable']);
|
||||
exit;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
// backend/transit/cron_approaching_alerts.php
|
||||
// يُنفذ كل دقيقة عبر Cron Job لإرسال تنبيهات اقتراب الباص من المحطات (أقل من 2 كم)
|
||||
//
|
||||
// ملاحظة أداء: الاستعلام الأول يستخدم فهرس idx_status (WHERE status='started')
|
||||
// فيقرأ فقط الرحلات الجارية الآن (عددها = عدد الباصات على الطريق، صغير)، لا كل
|
||||
// الجدول. لكل رحلة نشطة استعلامان إضافيان فقط، ويُتجاهل فوراً أي رحلة بلا موقع
|
||||
// GPS حديث (< 3 دقائق). خارج أوقات الدوام (لا رحلات) يخرج فوراً بلا أي عمل.
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
try {
|
||||
$transit_con = Database::get('transit');
|
||||
} catch (Exception $e) {
|
||||
echo "[ERROR] Cannot connect to siroTransitDb: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// 1. جلب الرحلات النشطة حالياً
|
||||
$stTrips = $transit_con->query(
|
||||
"SELECT id, route_id, current_stop_seq FROM transit_trips WHERE status='started'"
|
||||
);
|
||||
$trips = $stTrips->fetchAll();
|
||||
|
||||
if (empty($trips)) {
|
||||
echo "No active trips. Nothing to check.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
foreach ($trips as $trip) {
|
||||
$tripId = (int)$trip['id'];
|
||||
$routeId = (int)$trip['route_id'];
|
||||
$currentSeq = (int)$trip['current_stop_seq'];
|
||||
|
||||
// 2. جلب موقع الباص من Redis — تجاهل إن كان قديماً (> 3 دقائق، أي الباص فقد الاتصال)
|
||||
$pos = transitGetBusPosition($tripId);
|
||||
if (!$pos || empty($pos['lat'])) continue;
|
||||
if (empty($pos['ts']) || (time() - (int)$pos['ts']) > 180) continue;
|
||||
|
||||
$busLat = (float)$pos['lat'];
|
||||
$busLng = (float)$pos['lng'];
|
||||
|
||||
// 3. جلب المحطة التالية
|
||||
$nextSeq = $currentSeq + 1;
|
||||
$stStop = $transit_con->prepare(
|
||||
"SELECT id, name_ar, latitude, longitude
|
||||
FROM transit_stops
|
||||
WHERE route_id=? AND sequence=? LIMIT 1"
|
||||
);
|
||||
$stStop->execute([$routeId, $nextSeq]);
|
||||
$nextStop = $stStop->fetch();
|
||||
|
||||
if ($nextStop) {
|
||||
$stopId = (int)$nextStop['id'];
|
||||
$stopLat = (float)$nextStop['latitude'];
|
||||
$stopLng = (float)$nextStop['longitude'];
|
||||
$stopName = $nextStop['name_ar'];
|
||||
|
||||
// حساب المسافة تقريبياً
|
||||
$dist = transitCalculateDistance($busLat, $busLng, $stopLat, $stopLng);
|
||||
|
||||
// إذا كانت المسافة أقل من 2 كم
|
||||
if ($dist <= 2000) {
|
||||
// نتحقق من Redis كي لا نرسل التنبيه مراراً لنفس المحطة في نفس الرحلة
|
||||
$alertKey = "transit:trip:{$tripId}:alert_stop:{$stopId}";
|
||||
if (!$redis->exists($alertKey)) {
|
||||
$redis->set($alertKey, '1', 86400); // 24 ساعة
|
||||
|
||||
// 4. جلب الركاب المشتركين الذين محطتهم المفضلة هي هذه
|
||||
$stPass = $transit_con->prepare(
|
||||
"SELECT passenger_id FROM transit_enrollments
|
||||
WHERE preferred_stop_id=? AND status='active'"
|
||||
);
|
||||
$stPass->execute([$stopId]);
|
||||
$passengers = $stPass->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
if (!empty($passengers)) {
|
||||
$title = "الباص يقترب 🚌";
|
||||
$body = "حافلتك أصبحت قريبة جداً من محطة: {$stopName}. استعد!";
|
||||
|
||||
foreach ($passengers as $pId) {
|
||||
transitSendNotificationToPassenger($pId, $title, $body);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* حساب المسافة بين نقطتين بالمتر (Haversine)
|
||||
*/
|
||||
function transitCalculateDistance($lat1, $lon1, $lat2, $lon2) {
|
||||
$earthRadius = 6371000; // Radius of the earth in meters
|
||||
$dLat = deg2rad($lat2 - $lat1);
|
||||
$dLon = deg2rad($lon2 - $lon1);
|
||||
$a = sin($dLat/2) * sin($dLat/2) +
|
||||
cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
|
||||
sin($dLon/2) * sin($dLon/2);
|
||||
$c = 2 * atan2(sqrt($a), sqrt(1-$a));
|
||||
return $earthRadius * $c;
|
||||
}
|
||||
|
||||
echo "Approaching alerts checked.\n";
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
/**
|
||||
* transit/cron_cleanup.php
|
||||
* ─────────────────────────────────────────────────────────────────────
|
||||
* تنظيف دوري لجداول مواصلاتي المؤقتة:
|
||||
* • transit_sessions المنتهية (expires_at < NOW())
|
||||
* • transit_otp_log المنتهية أو المستخدَمة (أقدم من يوم)
|
||||
*
|
||||
* جدولة مقترحة: مرة يومياً (مثلاً 03:30 صباحاً).
|
||||
* 30 3 * * * php /path/to/backend/transit/cron_cleanup.php >> /var/log/transit_cleanup.log 2>&1
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Starting transit cleanup...\n";
|
||||
|
||||
try {
|
||||
$transit_con = Database::get('transit');
|
||||
} catch (Exception $e) {
|
||||
echo "[ERROR] Cannot connect to siroTransitDb: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// 1. جلسات المشرفين المنتهية
|
||||
$sessions = $transit_con->prepare("DELETE FROM transit_sessions WHERE expires_at < NOW()");
|
||||
$sessions->execute();
|
||||
echo "[CLEANUP] Expired sessions removed: " . $sessions->rowCount() . "\n";
|
||||
|
||||
// 2. سجل OTP المنتهي أو المستخدَم (أبقِ يوماً واحداً للتدقيق)
|
||||
$otp = $transit_con->prepare(
|
||||
"DELETE FROM transit_otp_log WHERE expires_at < (NOW() - INTERVAL 1 DAY) OR (used = 1 AND created_at < (NOW() - INTERVAL 1 DAY))"
|
||||
);
|
||||
$otp->execute();
|
||||
echo "[CLEANUP] Old OTP log rows removed: " . $otp->rowCount() . "\n";
|
||||
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Cleanup done.\n";
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
/**
|
||||
* transit/cron_sync_members.php
|
||||
* ─────────────────────────────────────────────────────────────────────
|
||||
* يعيد بناء مجموعات transit:org_members:{orgId} في Redis (location server)
|
||||
* من قاعدة البيانات MySQL — للتشغيل اليدوي أو الكرون اليومي.
|
||||
*
|
||||
* يُستخدَم بعد:
|
||||
* • هجرة قاعدة البيانات (ALTER/INSERT جماعي)
|
||||
* • انقطاع Redis (مسح البيانات بعد إعادة التشغيل)
|
||||
* • مزامنة دورية للتأكد من عدم الانزلاق
|
||||
*
|
||||
* crontab مقترح:
|
||||
* 0 3 * * * php /home/intaleq-rides/backend/transit/cron_sync_members.php >> /var/log/transit_sync_members.log 2>&1
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Starting transit:org_members sync...\n";
|
||||
|
||||
if (!$redisLocation) {
|
||||
echo "[ERROR] \$redisLocation is not available. Check REDIS_LOCATION_HOST in .env\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
$transit_con = Database::get('transit');
|
||||
} catch (Exception $e) {
|
||||
echo "[ERROR] Cannot connect to siroTransitDb: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// جلب كل العضويات النشطة مع org_id
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT org_id, passenger_id
|
||||
FROM transit_enrollments
|
||||
WHERE status = 'active'
|
||||
AND passenger_id IS NOT NULL"
|
||||
);
|
||||
$st->execute();
|
||||
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
echo "[INFO] Found " . count($rows) . " active enrollments to sync\n";
|
||||
|
||||
// تجميع per org
|
||||
$byOrg = [];
|
||||
foreach ($rows as $row) {
|
||||
$byOrg[(int)$row['org_id']][] = (string)$row['passenger_id'];
|
||||
}
|
||||
|
||||
$synced = 0;
|
||||
foreach ($byOrg as $orgId => $passengerIds) {
|
||||
$key = "transit:org_members:{$orgId}";
|
||||
|
||||
// بناء كامل: احذف المجموعة القديمة وأعد بناءها بدفعة واحدة
|
||||
$redisLocation->del($key);
|
||||
if (!empty($passengerIds)) {
|
||||
// sAdd يقبل array ابتداءً من phpredis 5.x
|
||||
$redisLocation->sAdd($key, ...$passengerIds);
|
||||
$redisLocation->expire($key, 7 * 86400);
|
||||
}
|
||||
|
||||
echo "[SYNC] org_id={$orgId} → {$key} ({" . count($passengerIds) . "} members)\n";
|
||||
$synced++;
|
||||
}
|
||||
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Done. Synced {$synced} orgs, " . count($rows) . " total enrollments.\n";
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
// transit/driver/activate_by_app.php — السائق يفعّل حسابه بتوكن الدعوة (عبر JWT)
|
||||
// POST: invite_token
|
||||
// المصادقة: JWT السائق الرئيسي من connect_app.php
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||
|
||||
if ($transit_user_role !== 'driver') jsonError('Only drivers can access this endpoint', 403);
|
||||
|
||||
$inviteToken = filterRequest('invite_token');
|
||||
if (!$inviteToken) jsonError('invite_token is required', 400);
|
||||
|
||||
// البحث بالتوكن فقط — لا نثق بأي معرّف آخر من العميل
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT d.id, d.org_id, d.name, d.status, o.name_ar AS org_name
|
||||
FROM transit_drivers d
|
||||
JOIN transit_orgs o ON o.id = d.org_id
|
||||
WHERE d.invite_token=? LIMIT 1"
|
||||
);
|
||||
$st->execute([$inviteToken]);
|
||||
$driver = $st->fetch();
|
||||
|
||||
if (!$driver) jsonError('Invalid invite token', 404);
|
||||
if ($driver['status'] === 'suspended') jsonError('Account suspended', 403);
|
||||
|
||||
// تحقق أن main_driver_id ليس مضبوطاً لحساب آخر
|
||||
if ($driver['status'] === 'active') {
|
||||
$chk = $transit_con->prepare(
|
||||
"SELECT main_driver_id FROM transit_drivers WHERE id=? LIMIT 1"
|
||||
);
|
||||
$chk->execute([$driver['id']]);
|
||||
$row = $chk->fetch();
|
||||
if ($row && $row['main_driver_id'] && (string)$row['main_driver_id'] !== (string)$transit_user_id) {
|
||||
jsonError('This invite token is already linked to another account', 409);
|
||||
}
|
||||
// إذا كان نفس الحساب — رد نجاح مباشرة
|
||||
if ($row && (string)$row['main_driver_id'] === (string)$transit_user_id) {
|
||||
jsonSuccess([
|
||||
'driver_id' => (int)$driver['id'],
|
||||
'org_id' => (int)$driver['org_id'],
|
||||
'org_name' => $driver['org_name'],
|
||||
'name' => $driver['name'],
|
||||
], 'Already activated');
|
||||
}
|
||||
}
|
||||
|
||||
// ضبط main_driver_id وتفعيل الحساب
|
||||
$transit_con->prepare(
|
||||
"UPDATE transit_drivers
|
||||
SET main_driver_id=?, status='active', activated_at=NOW(), invite_token=NULL, updated_at=NOW()
|
||||
WHERE id=?"
|
||||
)->execute([(string)$transit_user_id, $driver['id']]);
|
||||
|
||||
// إبطال أي كاش قديم لهذا الحساب (قد يكون كُوش كـ null سابقاً أو بحالة أخرى)
|
||||
transitInvalidateDriverCache((string)$transit_user_id);
|
||||
|
||||
appLog("[TRANSIT][DRIVER] activated driver #{$driver['id']} (org#{$driver['org_id']}) linked to main_driver_id={$transit_user_id}", 'INFO');
|
||||
|
||||
jsonSuccess([
|
||||
'driver_id' => (int)$driver['id'],
|
||||
'org_id' => (int)$driver['org_id'],
|
||||
'org_name' => $driver['org_name'],
|
||||
'name' => $driver['name'],
|
||||
], 'Account activated successfully. Welcome to مواصلاتي!');
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
// transit/driver/invite.php — المشرف يضيف سائق جديد
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
requireTransitFields(['name', 'phone']);
|
||||
$name = filterRequest('name');
|
||||
$phone = normalizePhone(filterRequest('phone'));
|
||||
$license = filterRequest('license_number');
|
||||
|
||||
// تشفير الهاتف مثل باقي أرقام الهواتف
|
||||
$phoneEnc = $encryptionHelper->encryptData($phone);
|
||||
|
||||
$chk = $transit_con->prepare(
|
||||
"SELECT id FROM transit_drivers WHERE phone=? AND org_id=? LIMIT 1"
|
||||
);
|
||||
$chk->execute([$phoneEnc, $transit_org_id]);
|
||||
if ($chk->fetch()) jsonError('Driver phone already registered in this organization', 409);
|
||||
|
||||
$inviteToken = bin2hex(random_bytes(24));
|
||||
$mainDriverId = filterRequest('main_driver_id');
|
||||
|
||||
$transit_con->prepare(
|
||||
"INSERT INTO transit_drivers
|
||||
(org_id,name,phone,license_number,main_driver_id,invite_token,invite_sent_at,status)
|
||||
VALUES (?,?,?,?,?,?,NOW(),'invited')"
|
||||
)->execute([$transit_org_id, $name, $phoneEnc, $license, $mainDriverId, $inviteToken]);
|
||||
|
||||
$driverId = (int)$transit_con->lastInsertId();
|
||||
|
||||
$appLink = getenv('APP_DEEP_LINK_BASE') ?: 'https://siromove.com/driver/transit-activate';
|
||||
$message = "مرحباً {$name}، تمت إضافتك كسائق في منصة مواصلاتي.\nفعّل حسابك: {$appLink}?token={$inviteToken}";
|
||||
sendWhatsAppFromServer($phone, $message);
|
||||
|
||||
jsonSuccess(['driver_id' => $driverId, 'invite_token' => $inviteToken], 'Driver invited successfully');
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
// transit/driver/list.php
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
$status = filterRequest('status') ?: 'active';
|
||||
$allowed = ['all','invited','active','suspended'];
|
||||
if (!in_array($status, $allowed)) $status = 'active';
|
||||
|
||||
$sql = "SELECT id, name, phone, status, activated_at, created_at FROM transit_drivers WHERE org_id=?";
|
||||
$params = [$transit_org_id];
|
||||
if ($status !== 'all') { $sql .= " AND status=?"; $params[] = $status; }
|
||||
$sql .= " ORDER BY name ASC";
|
||||
|
||||
$st = $transit_con->prepare($sql);
|
||||
$st->execute($params);
|
||||
$drivers = $st->fetchAll();
|
||||
|
||||
// فك تشفير هواتف السائقين للعرض
|
||||
foreach ($drivers as &$d) {
|
||||
if (!empty($d['phone'])) {
|
||||
$d['phone'] = $encryptionHelper->decryptData($d['phone']) ?: '***';
|
||||
}
|
||||
}
|
||||
unset($d);
|
||||
|
||||
jsonSuccess(['drivers' => $drivers]);
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
// transit/driver/me.php — السائق يتحقق هل هو سائق باص، ويحصل على معرّفه في مواصلاتي
|
||||
// المصادقة: JWT السائق الرئيسي (نفس main_driver_id)
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||
|
||||
if ($transit_user_role !== 'driver') jsonError('Only drivers can access this endpoint', 403);
|
||||
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT d.id, d.org_id, d.name, d.status,
|
||||
o.name_ar AS org_name, o.type AS org_type
|
||||
FROM transit_drivers d
|
||||
JOIN transit_orgs o ON o.id = d.org_id
|
||||
WHERE d.main_driver_id = ? LIMIT 1"
|
||||
);
|
||||
$st->execute([(string)$transit_user_id]);
|
||||
$driver = $st->fetch();
|
||||
|
||||
if (!$driver) {
|
||||
jsonSuccess(['is_bus_driver' => false]);
|
||||
}
|
||||
|
||||
jsonSuccess([
|
||||
'is_bus_driver' => true,
|
||||
'driver_transit_id' => (int)$driver['id'],
|
||||
'org_id' => (int)$driver['org_id'],
|
||||
'org_name' => $driver['org_name'],
|
||||
'org_type' => $driver['org_type'],
|
||||
'status' => $driver['status'],
|
||||
]);
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
// transit/driver/suspend.php — تعليق/إعادة تفعيل سائق
|
||||
// POST: driver_id, action (suspend|activate)
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
$driverId = filterRequest('driver_id', 'int');
|
||||
$action = filterRequest('action');
|
||||
if (!$driverId) jsonError('driver_id is required', 400);
|
||||
if (!in_array($action, ['suspend', 'activate'])) jsonError('action must be suspend or activate', 400);
|
||||
|
||||
$chk = $transit_con->prepare("SELECT id, status, main_driver_id FROM transit_drivers WHERE id=? AND org_id=? LIMIT 1");
|
||||
$chk->execute([$driverId, $transit_org_id]);
|
||||
$driver = $chk->fetch();
|
||||
if (!$driver) jsonError('Driver not found or access denied', 404);
|
||||
|
||||
// إعادة التفعيل غير ممكنة إلا لسائق سبق تفعيله عبر الدعوة (status كان suspended)
|
||||
if ($action === 'activate' && $driver['status'] !== 'suspended') {
|
||||
jsonError('Only a suspended driver can be reactivated', 409);
|
||||
}
|
||||
|
||||
$newStatus = $action === 'suspend' ? 'suspended' : 'active';
|
||||
$transit_con->prepare("UPDATE transit_drivers SET status=? WHERE id=?")->execute([$newStatus, $driverId]);
|
||||
|
||||
// إبطال كاش السائق فوراً — عند التعليق يجب أن تُرفض رحلاته في الطلب التالي مباشرة
|
||||
if (!empty($driver['main_driver_id'])) {
|
||||
transitInvalidateDriverCache((string)$driver['main_driver_id']);
|
||||
}
|
||||
|
||||
appLog("[TRANSIT][DRIVER][suspend] driver #{$driverId} org#{$transit_org_id} → {$newStatus}");
|
||||
|
||||
jsonSuccess(['driver_id' => $driverId, 'status' => $newStatus]);
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
// transit/enrollment/activate.php — الراكب يفعّل عضويته بالرقم الجامعي
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||
|
||||
if ($transit_user_role !== 'passenger') jsonError('Only passengers can enroll', 403);
|
||||
|
||||
requireTransitFields(['org_id', 'student_id']);
|
||||
|
||||
$orgId = filterRequest('org_id', 'int');
|
||||
$studentId = trim(filterRequest('student_id'));
|
||||
|
||||
$chkOrg = $transit_con->prepare(
|
||||
"SELECT id, name_ar, contract_status FROM transit_orgs WHERE id=? LIMIT 1"
|
||||
);
|
||||
$chkOrg->execute([$orgId]);
|
||||
$org = $chkOrg->fetch();
|
||||
if (!$org) jsonError('Organization not found', 404);
|
||||
if ($org['contract_status'] === 'terminated') jsonError('Organization account inactive', 403);
|
||||
|
||||
$chkEnroll = $transit_con->prepare(
|
||||
"SELECT id, status FROM transit_enrollments WHERE org_id=? AND passenger_id=? LIMIT 1"
|
||||
);
|
||||
$chkEnroll->execute([$orgId, $transit_user_id]);
|
||||
$existing = $chkEnroll->fetch();
|
||||
if ($existing && in_array($existing['status'], ['active', 'pending'])) {
|
||||
jsonError('Enrollment already exists', 409);
|
||||
}
|
||||
|
||||
$encryptedId = $encryptionHelper->encryptData($studentId);
|
||||
|
||||
// مطابقة الكشف: هل الرقم موجود في كشف مستورد بدون passenger_id؟
|
||||
$stRoster = $transit_con->prepare(
|
||||
"SELECT id, member_name FROM transit_enrollments
|
||||
WHERE org_id=? AND student_id=? AND passenger_id IS NULL LIMIT 1"
|
||||
);
|
||||
$stRoster->execute([$orgId, $encryptedId]);
|
||||
$rosterRow = $stRoster->fetch();
|
||||
|
||||
if ($rosterRow) {
|
||||
$transit_con->prepare(
|
||||
"UPDATE transit_enrollments
|
||||
SET passenger_id=?, status='active', verified_at=NOW(), verify_method='roster_phone_match'
|
||||
WHERE id=?"
|
||||
)->execute([$transit_user_id, $rosterRow['id']]);
|
||||
|
||||
transitCacheEnrollment($orgId, (string)$transit_user_id);
|
||||
|
||||
jsonSuccess([
|
||||
'enrollment_id' => (int)$rosterRow['id'],
|
||||
'org_name' => $org['name_ar'],
|
||||
'status' => 'active',
|
||||
'verify_method' => 'roster_phone_match',
|
||||
], 'تم التحقق من عضويتك بنجاح!');
|
||||
}
|
||||
|
||||
// طلب يدوي ينتظر موافقة المشرف
|
||||
$transit_con->prepare(
|
||||
"INSERT INTO transit_enrollments
|
||||
(org_id, passenger_id, student_id, verify_method, status)
|
||||
VALUES (?,?,?,'manual_admin','pending')"
|
||||
)->execute([$orgId, $transit_user_id, $encryptedId]);
|
||||
|
||||
jsonSuccess([
|
||||
'enrollment_id' => (int)$transit_con->lastInsertId(),
|
||||
'org_name' => $org['name_ar'],
|
||||
'status' => 'pending',
|
||||
], 'طلبك قيد المراجعة. سيتم إشعارك عند التفعيل.');
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
// transit/enrollment/approve.php — المشرف يوافق على طلب عضوية أو يرفضه
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
requireTransitFields(['enrollment_id', 'action']);
|
||||
|
||||
$enrollId = filterRequest('enrollment_id', 'int');
|
||||
$action = filterRequest('action');
|
||||
$expiresAt = filterRequest('expires_at');
|
||||
|
||||
if (!in_array($action, ['approve', 'reject'])) jsonError('Invalid action. Use: approve or reject', 400);
|
||||
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT id, passenger_id, status FROM transit_enrollments WHERE id=? AND org_id=? LIMIT 1"
|
||||
);
|
||||
$st->execute([$enrollId, $transit_org_id]);
|
||||
$enroll = $st->fetch();
|
||||
|
||||
if (!$enroll) jsonError('Enrollment not found', 404);
|
||||
if ($enroll['status'] !== 'pending') jsonError('Enrollment is not pending', 409);
|
||||
|
||||
$newStatus = ($action === 'approve') ? 'active' : 'suspended';
|
||||
$transit_con->prepare(
|
||||
"UPDATE transit_enrollments
|
||||
SET status=?, verified_at=NOW(), verify_method='manual_admin', expires_at=?
|
||||
WHERE id=?"
|
||||
)->execute([$newStatus, $expiresAt, $enrollId]);
|
||||
|
||||
if ($enroll['passenger_id']) {
|
||||
if ($action === 'approve') {
|
||||
transitCacheEnrollment($transit_org_id, (string)$enroll['passenger_id']);
|
||||
transitSendTopicNotification(
|
||||
'passenger_' . $enroll['passenger_id'],
|
||||
'تم تفعيل عضويتك',
|
||||
'يمكنك الآن متابعة باصات جامعتك عبر مواصلاتي',
|
||||
['type' => 'transit_enrollment_approved']
|
||||
);
|
||||
} else {
|
||||
transitDropEnrollment($transit_org_id, (string)$enroll['passenger_id']);
|
||||
}
|
||||
}
|
||||
|
||||
jsonSuccess(['enrollment_id' => $enrollId, 'status' => $newStatus]);
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
// transit/enrollment/import_roster.php — المشرف يرفع كشف الطلاب (CSV)
|
||||
// POST: file (CSV: student_id, name), semester?, notes?
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||
jsonError('No valid file uploaded', 400);
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, ['csv', 'txt'])) jsonError('Only CSV files are supported', 400);
|
||||
|
||||
$semester = filterRequest('semester') ?: date('Y') . '-S1';
|
||||
$notes = filterRequest('notes');
|
||||
|
||||
$rows = [];
|
||||
if (($handle = fopen($_FILES['file']['tmp_name'], 'r')) !== false) {
|
||||
$headers = null;
|
||||
while (($line = fgetcsv($handle, 0, ',')) !== false) {
|
||||
if ($headers === null) { $headers = array_map('strtolower', array_map('trim', $line)); continue; }
|
||||
$row = array_combine($headers, $line);
|
||||
if ($row) $rows[] = $row;
|
||||
}
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
if (empty($rows)) jsonError('CSV file is empty or invalid', 400);
|
||||
|
||||
$totalRows = count($rows);
|
||||
$transit_con->prepare(
|
||||
"INSERT INTO transit_rosters (org_id, uploaded_by, filename, total_rows, semester, notes)
|
||||
VALUES (?,?,?,?,?,?)"
|
||||
)->execute([$transit_org_id, $transit_admin_id, $_FILES['file']['name'], $totalRows, $semester, $notes]);
|
||||
$rosterId = (int)$transit_con->lastInsertId();
|
||||
|
||||
$insertSt = $transit_con->prepare(
|
||||
"INSERT IGNORE INTO transit_enrollments
|
||||
(org_id, passenger_id, student_id, member_name, verify_method, status, roster_id)
|
||||
VALUES (?,NULL,?,?,'roster_manual','pending',?)"
|
||||
);
|
||||
|
||||
$matchedRows = 0;
|
||||
$newEnroll = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$sid = trim($row['student_id'] ?? $row['id'] ?? '');
|
||||
$name = trim($row['name'] ?? $row['full_name'] ?? '');
|
||||
if (!$sid) continue;
|
||||
|
||||
$encSid = $encryptionHelper->encryptData($sid);
|
||||
|
||||
$chk = $transit_con->prepare(
|
||||
"SELECT id, passenger_id FROM transit_enrollments WHERE org_id=? AND student_id=? LIMIT 1"
|
||||
);
|
||||
$chk->execute([$transit_org_id, $encSid]);
|
||||
$existing = $chk->fetch();
|
||||
|
||||
if ($existing) {
|
||||
$transit_con->prepare(
|
||||
"UPDATE transit_enrollments SET member_name=?, roster_id=? WHERE id=?"
|
||||
)->execute([$name, $rosterId, $existing['id']]);
|
||||
if ($existing['passenger_id']) $matchedRows++;
|
||||
} else {
|
||||
$insertSt->execute([$transit_org_id, $encSid, $name, $rosterId]);
|
||||
$newEnroll++;
|
||||
}
|
||||
}
|
||||
|
||||
$transit_con->prepare(
|
||||
"UPDATE transit_rosters SET matched_rows=?, new_enrollments=? WHERE id=?"
|
||||
)->execute([$matchedRows, $newEnroll, $rosterId]);
|
||||
|
||||
jsonSuccess([
|
||||
'roster_id' => $rosterId,
|
||||
'total_rows' => $totalRows,
|
||||
'new_enrollments' => $newEnroll,
|
||||
'matched' => $matchedRows,
|
||||
], 'Roster imported successfully');
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
// transit/enrollment/list.php — قائمة عضويات المؤسسة (للمشرف)
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
$status = filterRequest('status') ?: 'all';
|
||||
$page = max(1, (int)(filterRequest('page', 'int') ?? 1));
|
||||
$perPage = min(100, max(10, (int)(filterRequest('per_page', 'int') ?? 50)));
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$allowed = ['all', 'pending', 'active', 'suspended', 'expired'];
|
||||
if (!in_array($status, $allowed)) $status = 'all';
|
||||
|
||||
$where = "org_id = ?";
|
||||
$params = [$transit_org_id];
|
||||
if ($status !== 'all') { $where .= " AND status = ?"; $params[] = $status; }
|
||||
|
||||
$countSt = $transit_con->prepare("SELECT COUNT(*) FROM transit_enrollments WHERE $where");
|
||||
$countSt->execute($params);
|
||||
$total = (int)$countSt->fetchColumn();
|
||||
|
||||
$params[] = $perPage;
|
||||
$params[] = $offset;
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT id, passenger_id, member_name, status, verify_method,
|
||||
verified_at, expires_at, created_at
|
||||
FROM transit_enrollments
|
||||
WHERE $where ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
);
|
||||
$st->execute($params);
|
||||
$enrollments = $st->fetchAll();
|
||||
|
||||
jsonSuccess([
|
||||
'enrollments' => $enrollments,
|
||||
'pagination' => ['total' => $total, 'page' => $page, 'per_page' => $perPage],
|
||||
]);
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
// transit/enrollment/my_enrollments.php — الراكب يستعرض عضوياته في كل المؤسسات
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||
|
||||
if ($transit_user_role !== 'passenger') jsonError('Only passengers can view enrollments', 403);
|
||||
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT e.id, e.org_id, e.status, e.verify_method, e.expires_at, e.created_at,
|
||||
o.name_ar AS org_name, o.name_en AS org_name_en, o.type AS org_type, o.logo_url
|
||||
FROM transit_enrollments e
|
||||
JOIN transit_orgs o ON o.id = e.org_id
|
||||
WHERE e.passenger_id = ?
|
||||
ORDER BY e.created_at DESC"
|
||||
);
|
||||
$st->execute([$transit_user_id]);
|
||||
|
||||
jsonSuccess(['enrollments' => $st->fetchAll()]);
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
// transit/enrollment/rosters_list.php — سجل كشوف الطلاب المستوردة للمؤسسة
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT id, filename, total_rows, matched_rows, new_enrollments, expired_count,
|
||||
semester, notes, created_at
|
||||
FROM transit_rosters
|
||||
WHERE org_id=? ORDER BY created_at DESC LIMIT 50"
|
||||
);
|
||||
$st->execute([$transit_org_id]);
|
||||
|
||||
jsonSuccess(['rosters' => $st->fetchAll()]);
|
||||
@@ -0,0 +1,309 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// transit/functions.php — دوال خاصة بمنصة مواصلاتي فقط
|
||||
//
|
||||
// ما لا يوجد هنا (يُستخدم مباشرة من النظام الأصلي بعد bootstrap):
|
||||
// • filterRequest() ← core/helpers.php
|
||||
// • jsonSuccess() / jsonError() ← core/helpers.php
|
||||
// • appLog() / securityLog() ← core/helpers.php
|
||||
// • normalizePhone($phone) ← core/helpers.php — يطبّع الهاتف دائماً قبل التشفير أو الاستعلام
|
||||
// • $encryptionHelper ← مهيَّأ في bootstrap.php
|
||||
// • $redis ← مهيَّأ في bootstrap.php
|
||||
// ============================================================
|
||||
|
||||
// Lazy-loaded services (bootstrap لا يحمّلها لأنها ليست مطلوبة لكل endpoint)
|
||||
require_once __DIR__ . '/../core/Services/OtpService.php';
|
||||
require_once __DIR__ . '/../core/Services/FcmService.php';
|
||||
// sendWhatsAppFromServer — الموجودة في backend/functions.php القديمة
|
||||
if (file_exists(__DIR__ . '/../functions.php')) {
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
}
|
||||
|
||||
// ── حقول مطلوبة (غلاف رفيع يستخدم filterRequest + jsonError) ──
|
||||
function requireTransitFields(array $fields): void
|
||||
{
|
||||
foreach ($fields as $f) {
|
||||
if (filterRequest($f) === null) {
|
||||
jsonError("Missing required field: $f", 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session مشرف الويب (OTP-based — مستقلة عن JWT) ──────────
|
||||
|
||||
function transitCreateSession(int $adminId, int $orgId): string
|
||||
{
|
||||
global $redis;
|
||||
|
||||
$token = bin2hex(random_bytes(32)); // 64 hex chars
|
||||
$hash = hash('sha256', $token);
|
||||
$expiresAt = date('Y-m-d H:i:s', time() + 86400);
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||
|
||||
$con = Database::get('transit');
|
||||
$con->prepare(
|
||||
"INSERT INTO transit_sessions (admin_id, org_id, token_hash, ip, user_agent, expires_at)
|
||||
VALUES (?,?,?,?,?,?)"
|
||||
)->execute([$adminId, $orgId, $hash, $ip, $ua, $expiresAt]);
|
||||
|
||||
if ($redis) {
|
||||
$redis->setEx(
|
||||
"transit:session:{$hash}",
|
||||
86400,
|
||||
json_encode(['admin_id' => $adminId, 'org_id' => $orgId])
|
||||
);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
function transitAuthAdmin(): array
|
||||
{
|
||||
global $redis;
|
||||
|
||||
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['HTTP_X_TRANSIT_TOKEN'] ?? '';
|
||||
$token = str_replace('Bearer ', '', $header);
|
||||
if (!$token) jsonError('Missing admin session token', 401);
|
||||
|
||||
$hash = hash('sha256', $token);
|
||||
|
||||
if ($redis) {
|
||||
$val = $redis->get("transit:session:{$hash}");
|
||||
if ($val) {
|
||||
$data = json_decode($val, true);
|
||||
if ($data) return $data;
|
||||
}
|
||||
jsonError('Session expired or invalid', 401);
|
||||
}
|
||||
|
||||
// Fallback MySQL
|
||||
$con = Database::get('transit');
|
||||
$st = $con->prepare(
|
||||
"SELECT admin_id, org_id FROM transit_sessions
|
||||
WHERE token_hash=? AND expires_at > NOW() LIMIT 1"
|
||||
);
|
||||
$st->execute([$hash]);
|
||||
$row = $st->fetch();
|
||||
if (!$row) jsonError('Session expired or invalid', 401);
|
||||
return $row;
|
||||
}
|
||||
|
||||
// ── OTP مشرف الويب (يستخدم OtpService الموجود) ─────────────
|
||||
|
||||
function transitSendAdminOtp(string $phone): void
|
||||
{
|
||||
global $redis;
|
||||
|
||||
$otpSvc = new OtpService($redis);
|
||||
$otp = $otpSvc->generate($phone, 3); // 3 أرقام فقط (100–999) حسب سياسة مواصلاتي
|
||||
|
||||
$message = "رمز تسجيل الدخول لمنصة مواصلاتي: {$otp}\nصالح لمدة 5 دقائق.";
|
||||
sendWhatsAppFromServer($phone, $message);
|
||||
}
|
||||
|
||||
function transitVerifyAdminOtp(string $phone, string $otp): bool
|
||||
{
|
||||
global $redis;
|
||||
$otpSvc = new OtpService($redis);
|
||||
return $otpSvc->verify($phone, $otp);
|
||||
}
|
||||
|
||||
function transitIsOtpLocked(string $phone): bool
|
||||
{
|
||||
global $redis;
|
||||
$otpSvc = new OtpService($redis);
|
||||
return $otpSvc->isLocked($phone);
|
||||
}
|
||||
|
||||
// ── تشفير الرقم الجامعي / الوظيفي (يستخدم EncryptionHelper) ─
|
||||
|
||||
function transitEncryptStudentId(string $raw): string
|
||||
{
|
||||
global $encryptionHelper;
|
||||
return $encryptionHelper->encryptData($raw);
|
||||
}
|
||||
|
||||
function transitDecryptStudentId(string $enc): string
|
||||
{
|
||||
global $encryptionHelper;
|
||||
return (string)$encryptionHelper->decryptData($enc);
|
||||
}
|
||||
|
||||
// ── إشعارات FCM Topic للخطوط ────────────────────────────────
|
||||
|
||||
function transitRouteTopic(int $routeId): string
|
||||
{
|
||||
return 'transit_route_' . $routeId;
|
||||
}
|
||||
|
||||
function transitOrgTopic(int $orgId): string
|
||||
{
|
||||
return 'transit_org_' . $orgId;
|
||||
}
|
||||
|
||||
// إشعار مباشر لراكب واحد عبر موضوعه الشخصي passenger_{id}
|
||||
// (التطبيق يشترك بهذا الموضوع عند تسجيل الدخول — انظر firbase_messge.dart)
|
||||
function transitSendNotificationToPassenger(string $passengerId, string $title, string $body, array $data = []): void
|
||||
{
|
||||
if (!$passengerId) return;
|
||||
transitSendTopicNotification('passenger_' . $passengerId, $title, $body, $data);
|
||||
}
|
||||
|
||||
// إشعار أولياء الأمور المرتبطين بعضوية طالب — يحترم أعمدة notify_* بالجدول
|
||||
// $event: depart | board | arrive | return
|
||||
function transitNotifyGuardians(int $enrollmentId, string $event, string $title, string $body): void
|
||||
{
|
||||
global $transit_con;
|
||||
if (!$transit_con) return;
|
||||
|
||||
$column = 'notify_' . $event;
|
||||
if (!in_array($event, ['depart', 'board', 'arrive', 'return'])) return;
|
||||
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT guardian_passenger_id FROM transit_guardians
|
||||
WHERE enrollment_id=? AND {$column}=1"
|
||||
);
|
||||
$st->execute([$enrollmentId]);
|
||||
foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $guardianId) {
|
||||
transitSendNotificationToPassenger((string)$guardianId, $title, $body, ['type' => 'transit_guardian_' . $event]);
|
||||
}
|
||||
}
|
||||
|
||||
function transitSendTopicNotification(string $topic, string $title, string $body, array $data = []): void
|
||||
{
|
||||
global $redis;
|
||||
|
||||
$fcm = new FcmService($redis);
|
||||
$result = $fcm->sendToTopic($topic, $title, $body, $data);
|
||||
|
||||
if ($result['status'] !== 'success') {
|
||||
appLog("[TRANSIT][FCM] Topic push failed on '{$topic}': " . json_encode($result), 'WARNING');
|
||||
}
|
||||
}
|
||||
|
||||
// ── موقع الباص في Redis سيرفر الموقع ($redisLocation) ────────
|
||||
// موقع الباص يكتبه driver_socket على Redis سيرفر الموقع (بدون بادئة siro:)
|
||||
// تماماً مثل موقع السائق العادي — لذلك نقرؤه/نكتبه عبر $redisLocation
|
||||
// وليس $redis الرئيسي (الذي يضيف بادئة siro: ولا يراه السوكت).
|
||||
|
||||
function transitUpdateBusPosition(int $tripId, float $lat, float $lng): void
|
||||
{
|
||||
global $redisLocation;
|
||||
if (!$redisLocation) return;
|
||||
|
||||
$redisLocation->hMSet("transit:trip:{$tripId}:pos", ['lat' => $lat, 'lng' => $lng, 'ts' => time()]);
|
||||
$redisLocation->expire("transit:trip:{$tripId}:pos", 86400);
|
||||
}
|
||||
|
||||
function transitGetBusPosition(int $tripId): ?array
|
||||
{
|
||||
global $redisLocation;
|
||||
if (!$redisLocation) return null;
|
||||
|
||||
$pos = $redisLocation->hGetAll("transit:trip:{$tripId}:pos");
|
||||
return ($pos && isset($pos['lat'])) ? $pos : null;
|
||||
}
|
||||
|
||||
// ── ملكية الرحلة (Trip Ownership) ─────────────────────────────
|
||||
// تُكتب عند بدء الرحلة على Redis سيرفر الموقع، ليتحقق منها driver_socket
|
||||
// قبل قبول أي update_bus_location — يمنع أي سائق آخر من انتحال trip_id
|
||||
// أو البث لخط لا يملكه. main_driver_id هو نفسه sub في JWT (الحساب في
|
||||
// جدول driver الرئيسي)، وليس transit_drivers.id.
|
||||
|
||||
function transitSetTripOwner(int $tripId, string $mainDriverId, int $routeId): void
|
||||
{
|
||||
global $redisLocation;
|
||||
if (!$redisLocation || !$mainDriverId) return;
|
||||
|
||||
$key = "transit:trip:{$tripId}:owner";
|
||||
$redisLocation->hMSet($key, ['driver_id' => $mainDriverId, 'route_id' => $routeId]);
|
||||
$redisLocation->expire($key, 86400);
|
||||
}
|
||||
|
||||
function transitClearTripOwner(int $tripId): void
|
||||
{
|
||||
global $redisLocation;
|
||||
if (!$redisLocation) return;
|
||||
|
||||
$redisLocation->del("transit:trip:{$tripId}:owner");
|
||||
}
|
||||
|
||||
// ── عضوية الراكب في Redis (location Redis — بدون بادئة siro:) ──────
|
||||
// يستخدمها passenger_socket.php للتحقق قبل قبول subscribe_transit_route.
|
||||
// تُكتب عند: تفعيل عضوية برقم الكشف، أو موافقة المشرف على الطلب اليدوي.
|
||||
// تُحذف عند: رفض أو تعليق العضوية.
|
||||
|
||||
function transitCacheEnrollment(int $orgId, string $passengerId): void
|
||||
{
|
||||
global $redisLocation;
|
||||
if (!$redisLocation || !$passengerId) return;
|
||||
|
||||
$key = "transit:org_members:{$orgId}";
|
||||
$redisLocation->sAdd($key, $passengerId);
|
||||
$redisLocation->expire($key, 7 * 86400); // تجديد TTL سبعة أيام
|
||||
}
|
||||
|
||||
function transitDropEnrollment(int $orgId, string $passengerId): void
|
||||
{
|
||||
global $redisLocation;
|
||||
if (!$redisLocation || !$passengerId) return;
|
||||
|
||||
$redisLocation->sRem("transit:org_members:{$orgId}", $passengerId);
|
||||
}
|
||||
|
||||
// ── كاش السائق في Redis ($redis الرئيسي — بادئة siro:) ──────────
|
||||
// جدول transit_drivers صغير، لكن endpoints الرحلات تستعلم عنه بكثرة
|
||||
// (today, start, end, delay, board_passenger, stop_passengers) — خصوصاً
|
||||
// board_passenger أثناء مسح تذاكر الصعود. نمط read-through: نقرأ من Redis
|
||||
// أولاً، وإن لم يوجد نستعلم القاعدة ونخزّن. المفتاح بـ main_driver_id
|
||||
// (المعرّف الآتي من JWT). نبطل الكاش عند أي تغيير حالة (تفعيل/تعليق).
|
||||
//
|
||||
// نخزّن فقط السائقين النشطين (status='active'). أي سجل غير نشط لا يُكاش
|
||||
// حتى لا نحتاج إبطالاً عند التفعيل — أول استعلام بعد التفعيل يبني الكاش.
|
||||
|
||||
function transitResolveDriver(string $mainDriverId): ?array
|
||||
{
|
||||
global $redis, $transit_con;
|
||||
if (!$mainDriverId) return null;
|
||||
|
||||
$key = "transit:driver:{$mainDriverId}";
|
||||
|
||||
if ($redis) {
|
||||
$cached = $redis->hGetAll($key);
|
||||
if (!empty($cached) && isset($cached['id'])) {
|
||||
return ['id' => (int)$cached['id'], 'org_id' => (int)$cached['org_id']];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$transit_con) $transit_con = Database::get('transit');
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT id, org_id FROM transit_drivers WHERE main_driver_id=? AND status='active' LIMIT 1"
|
||||
);
|
||||
$st->execute([$mainDriverId]);
|
||||
$driver = $st->fetch();
|
||||
if (!$driver) return null;
|
||||
|
||||
$resolved = ['id' => (int)$driver['id'], 'org_id' => (int)$driver['org_id']];
|
||||
|
||||
if ($redis) {
|
||||
$redis->hMSet($key, $resolved);
|
||||
$redis->expire($key, 3600); // ساعة — طبقة حماية إن فات إبطالٌ ما
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
function transitInvalidateDriverCache(string $mainDriverId): void
|
||||
{
|
||||
global $redis;
|
||||
if ($redis && $mainDriverId) $redis->del("transit:driver:{$mainDriverId}");
|
||||
}
|
||||
|
||||
// ── days_mask helpers ────────────────────────────────────────
|
||||
// bit0=Sun … bit6=Sat | 62 = 0b0111110 = الأحد–الخميس
|
||||
|
||||
function transitDayActive(int $mask): bool
|
||||
{
|
||||
return (bool)(($mask >> (int)date('w')) & 1);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// transit/notification/subscribe.php — الراكب يشترك في إشعارات خط (FCM Topic)
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||
|
||||
requireTransitFields(['route_id', 'fcm_token']);
|
||||
|
||||
$routeId = filterRequest('route_id', 'int');
|
||||
$fcmToken = filterRequest('fcm_token');
|
||||
$stopId = filterRequest('preferred_stop_id', 'int') ?: null;
|
||||
|
||||
// تحقق العضوية النشطة في المؤسسة المالكة للخط
|
||||
$chk = $transit_con->prepare(
|
||||
"SELECT e.id
|
||||
FROM transit_enrollments e
|
||||
JOIN transit_routes r ON r.org_id = e.org_id
|
||||
WHERE r.id=? AND e.passenger_id=? AND e.status='active' LIMIT 1"
|
||||
);
|
||||
$chk->execute([$routeId, $transit_user_id]);
|
||||
if (!$chk->fetch()) jsonError('Active enrollment required to subscribe', 403);
|
||||
|
||||
if ($stopId) {
|
||||
$transit_con->prepare(
|
||||
"UPDATE transit_enrollments
|
||||
SET preferred_stop_id=?, preferred_route_id=?
|
||||
WHERE passenger_id=? AND org_id=(SELECT org_id FROM transit_routes WHERE id=? LIMIT 1)"
|
||||
)->execute([$stopId, $routeId, $transit_user_id, $routeId]);
|
||||
}
|
||||
|
||||
$topic = transitRouteTopic($routeId);
|
||||
|
||||
// اشتراك FCM Topic يتم من التطبيق مباشرة (firebaseMessaging.subscribeToTopic)
|
||||
// هنا نسجّل فقط المحطة المفضلة ونؤكد الموضوع
|
||||
appLog("[TRANSIT][NOTIFY] passenger {$transit_user_id} subscribed to {$topic}");
|
||||
|
||||
jsonSuccess([
|
||||
'topic' => $topic,
|
||||
'preferred_stop_id' => $stopId,
|
||||
], 'Subscribed to route notifications');
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
// transit/org/browse.php — الراكب يتصفح المؤسسات المتاحة للتفعيل
|
||||
// POST: country?, type?, search?
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||
|
||||
$country = filterRequest('country');
|
||||
$type = filterRequest('type');
|
||||
$search = filterRequest('search');
|
||||
|
||||
$where = "contract_status IN ('trial','active')";
|
||||
$params = [];
|
||||
|
||||
if ($country) { $where .= ' AND country = ?'; $params[] = strtoupper(substr($country, 0, 2)); }
|
||||
if ($type) { $where .= ' AND type = ?'; $params[] = $type; }
|
||||
if ($search) { $where .= ' AND (name_ar LIKE ? OR name_en LIKE ?)'; $params[] = "%$search%"; $params[] = "%$search%"; }
|
||||
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT id, type, country, city, name_ar, name_en, logo_url
|
||||
FROM transit_orgs WHERE $where ORDER BY name_ar ASC LIMIT 200"
|
||||
);
|
||||
$st->execute($params);
|
||||
|
||||
jsonSuccess(['orgs' => $st->fetchAll()]);
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
// transit/org/register.php — تسجيل مؤسسة جديدة (داخلي بـ X-Internal-Key)
|
||||
// POST: type, country, city, name_ar, name_en, admin_name, admin_phone, ...
|
||||
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
$internalKey = getInternalSocketKey(); // نفس مفتاح النظام الموحّد INTERNAL_SOCKET_KEY
|
||||
$requestKey = $_SERVER['HTTP_X_INTERNAL_KEY'] ?? filterRequest('internal_key') ?? '';
|
||||
if (!$internalKey || !hash_equals($internalKey, $requestKey)) jsonError('Forbidden', 403);
|
||||
|
||||
try { $transit_con = Database::get('transit'); }
|
||||
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||
|
||||
requireTransitFields(['type','country','city','name_ar','name_en','admin_name','admin_phone']);
|
||||
|
||||
$type = filterRequest('type');
|
||||
$country = strtoupper(substr(filterRequest('country'), 0, 2));
|
||||
$city = filterRequest('city');
|
||||
$nameAr = filterRequest('name_ar');
|
||||
$nameEn = filterRequest('name_en');
|
||||
$adminName = filterRequest('admin_name');
|
||||
$adminPhone = normalizePhone(filterRequest('admin_phone'));
|
||||
$adminRole = filterRequest('admin_role') ?: 'owner';
|
||||
$trialEndsAt = filterRequest('trial_ends_at') ?: date('Y-m-d', strtotime('+90 days'));
|
||||
|
||||
$allowedTypes = ['university','school','hotel','company','transporter'];
|
||||
if (!in_array($type, $allowedTypes)) jsonError('Invalid type. Allowed: ' . implode(', ', $allowedTypes));
|
||||
|
||||
$chk = $transit_con->prepare("SELECT id FROM transit_orgs WHERE name_ar=? AND country=? LIMIT 1");
|
||||
$chk->execute([$nameAr, $country]);
|
||||
if ($chk->fetch()) jsonError('Organization already exists', 409);
|
||||
|
||||
$adminPhoneEnc = $encryptionHelper->encryptData($adminPhone);
|
||||
$contactPhoneRaw = normalizePhone(filterRequest('contact_phone') ?? '');
|
||||
$contactPhoneEnc = $contactPhoneRaw ? $encryptionHelper->encryptData($contactPhoneRaw) : null;
|
||||
|
||||
$transit_con->beginTransaction();
|
||||
try {
|
||||
$transit_con->prepare(
|
||||
"INSERT INTO transit_orgs (type,country,city,name_ar,name_en,contact_phone,contact_email,website,contract_status,trial_ends_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,'trial',?)"
|
||||
)->execute([$type,$country,$city,$nameAr,$nameEn,$contactPhoneEnc,
|
||||
filterRequest('contact_email'),filterRequest('website'),$trialEndsAt]);
|
||||
|
||||
$orgId = (int)$transit_con->lastInsertId();
|
||||
|
||||
$transit_con->prepare(
|
||||
"INSERT INTO transit_org_admins (org_id, name, phone, role) VALUES (?,?,?,?)"
|
||||
)->execute([$orgId, $adminName, $adminPhoneEnc, $adminRole]);
|
||||
|
||||
$transit_con->commit();
|
||||
} catch (Throwable $e) {
|
||||
$transit_con->rollBack();
|
||||
appLog('[TRANSIT][ORG][register] ' . $e->getMessage(), 'ERROR');
|
||||
jsonError('Failed to create organization', 500);
|
||||
}
|
||||
|
||||
jsonSuccess(['org_id' => $orgId, 'name_ar' => $nameAr, 'type' => $type, 'country' => $country], 'Organization registered successfully');
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
// transit/route/add.php — إضافة خط جديد (مسودة)
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
$nameAr = filterRequest('name_ar');
|
||||
if (!$nameAr) jsonError('name_ar is required');
|
||||
|
||||
$direction = filterRequest('direction') ?: 'outbound';
|
||||
if (!in_array($direction, ['outbound','inbound','circular'])) $direction = 'outbound';
|
||||
|
||||
$stops = json_decode(filterRequest('stops') ?? '[]', true) ?: [];
|
||||
|
||||
$transit_con->beginTransaction();
|
||||
try {
|
||||
$transit_con->prepare(
|
||||
"INSERT INTO transit_routes
|
||||
(org_id,name_ar,name_en,direction,polyline,distance_km,duration_min,status,created_by)
|
||||
VALUES (?,?,?,?,?,?,?,'draft',?)"
|
||||
)->execute([
|
||||
$transit_org_id, $nameAr,
|
||||
filterRequest('name_en'),
|
||||
$direction,
|
||||
filterRequest('polyline'),
|
||||
filterRequest('distance_km','float'),
|
||||
filterRequest('duration_min','int'),
|
||||
$transit_admin_id,
|
||||
]);
|
||||
|
||||
$routeId = (int)$transit_con->lastInsertId();
|
||||
|
||||
$insertStop = $transit_con->prepare(
|
||||
"INSERT INTO transit_stops
|
||||
(route_id,org_id,sequence,name_ar,name_en,latitude,longitude,geofence_radius,eta_offset_min,is_major)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)"
|
||||
);
|
||||
foreach ($stops as $i => $s) {
|
||||
if (empty($s['lat']) || empty($s['lng']) || empty($s['name_ar'])) continue;
|
||||
$insertStop->execute([
|
||||
$routeId, $transit_org_id, $i + 1,
|
||||
$s['name_ar'], $s['name_en'] ?? null,
|
||||
(float)$s['lat'], (float)$s['lng'],
|
||||
(int)($s['radius'] ?? 150),
|
||||
isset($s['eta_offset_min']) ? (int)$s['eta_offset_min'] : null,
|
||||
(int)($s['is_major'] ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
$transit_con->commit();
|
||||
} catch (Throwable $e) {
|
||||
$transit_con->rollBack();
|
||||
appLog('[TRANSIT][ROUTE] ' . $e->getMessage(), 'ERROR');
|
||||
jsonError('Failed to save route', 500);
|
||||
}
|
||||
|
||||
jsonSuccess(['route_id' => $routeId, 'stops_added' => count($stops)], 'Route saved as draft');
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
// transit/route/for_org.php — الراكب يستعرض خطوط مؤسسته (يشترط عضوية نشطة)
|
||||
// POST: org_id
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||
|
||||
if ($transit_user_role !== 'passenger') jsonError('Only passengers can browse routes', 403);
|
||||
|
||||
$orgId = filterRequest('org_id', 'int');
|
||||
if (!$orgId) jsonError('org_id is required', 400);
|
||||
|
||||
$chk = $transit_con->prepare(
|
||||
"SELECT id FROM transit_enrollments WHERE org_id=? AND passenger_id=? AND status='active' LIMIT 1"
|
||||
);
|
||||
$chk->execute([$orgId, $transit_user_id]);
|
||||
if (!$chk->fetch()) jsonError('Active enrollment required to view routes', 403);
|
||||
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT id, name_ar, name_en, direction, distance_km, duration_min, status
|
||||
FROM transit_routes WHERE org_id=? AND status='active' ORDER BY name_ar ASC"
|
||||
);
|
||||
$st->execute([$orgId]);
|
||||
$routes = $st->fetchAll();
|
||||
|
||||
if ($routes) {
|
||||
$ids = implode(',', array_map('intval', array_column($routes, 'id')));
|
||||
$counts = $transit_con->query(
|
||||
"SELECT route_id, COUNT(*) c FROM transit_stops WHERE route_id IN ($ids) GROUP BY route_id"
|
||||
)->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
foreach ($routes as &$r) $r['stop_count'] = (int)($counts[$r['id']] ?? 0);
|
||||
unset($r);
|
||||
}
|
||||
|
||||
jsonSuccess(['routes' => $routes]);
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
// transit/route/get.php — تفاصيل خط + محطاته + جداوله
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
$routeId = filterRequest('route_id', 'int');
|
||||
if (!$routeId) jsonError('route_id is required');
|
||||
|
||||
$st = $transit_con->prepare("SELECT * FROM transit_routes WHERE id=? AND org_id=? LIMIT 1");
|
||||
$st->execute([$routeId, $transit_org_id]);
|
||||
$route = $st->fetch();
|
||||
if (!$route) jsonError('Route not found', 404);
|
||||
|
||||
$stStops = $transit_con->prepare(
|
||||
"SELECT id,sequence,name_ar,name_en,latitude,longitude,geofence_radius,eta_offset_min,is_major
|
||||
FROM transit_stops WHERE route_id=? ORDER BY sequence ASC"
|
||||
);
|
||||
$stStops->execute([$routeId]);
|
||||
$route['stops'] = $stStops->fetchAll();
|
||||
|
||||
$stSch = $transit_con->prepare(
|
||||
"SELECT s.id, s.departure_time, s.days_mask, s.valid_from, s.valid_until, s.is_active,
|
||||
d.name AS driver_name, v.plate AS vehicle_plate
|
||||
FROM transit_schedules s
|
||||
LEFT JOIN transit_drivers d ON d.id=s.driver_id
|
||||
LEFT JOIN transit_vehicles v ON v.id=s.vehicle_id
|
||||
WHERE s.route_id=? AND s.org_id=? ORDER BY s.departure_time ASC"
|
||||
);
|
||||
$stSch->execute([$routeId, $transit_org_id]);
|
||||
$route['schedules'] = $stSch->fetchAll();
|
||||
|
||||
jsonSuccess(['route' => $route]);
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
// transit/route/list.php
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
$status = filterRequest('status') ?: 'active';
|
||||
if (!in_array($status, ['all','draft','active','suspended','rejected'])) $status = 'active';
|
||||
|
||||
$sql = "SELECT id, name_ar, name_en, direction, distance_km, duration_min, status, created_at FROM transit_routes WHERE org_id=?";
|
||||
$params = [$transit_org_id];
|
||||
if ($status !== 'all') { $sql .= " AND status=?"; $params[] = $status; }
|
||||
$sql .= " ORDER BY name_ar ASC";
|
||||
|
||||
$st = $transit_con->prepare($sql);
|
||||
$st->execute($params);
|
||||
$routes = $st->fetchAll();
|
||||
|
||||
if ($routes) {
|
||||
$ids = implode(',', array_map('intval', array_column($routes, 'id')));
|
||||
$cntSt = $transit_con->query(
|
||||
"SELECT route_id, COUNT(*) cnt FROM transit_stops WHERE route_id IN ($ids) GROUP BY route_id"
|
||||
);
|
||||
$counts = $cntSt ? array_column($cntSt->fetchAll(), 'cnt', 'route_id') : [];
|
||||
foreach ($routes as &$r) $r['stop_count'] = (int)($counts[$r['id']] ?? 0);
|
||||
unset($r);
|
||||
}
|
||||
|
||||
jsonSuccess(['routes' => $routes]);
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
// transit/route/update.php — تعديل خط قائم (اسم/اتجاه/مسار/محطات)
|
||||
// POST: route_id, name_ar, [name_en, direction, polyline, distance_km, duration_min], stops (JSON)
|
||||
//
|
||||
// أي تعديل على خط كان active أو suspended يُعيده إلى draft — يتطلب اعتماداً
|
||||
// جديداً من فريق سيرو (نفس مسار Admin/transit/route/approve.php)
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
$routeId = filterRequest('route_id', 'int');
|
||||
if (!$routeId) jsonError('route_id is required', 400);
|
||||
|
||||
$nameAr = filterRequest('name_ar');
|
||||
if (!$nameAr) jsonError('name_ar is required', 400);
|
||||
|
||||
$direction = filterRequest('direction') ?: 'outbound';
|
||||
if (!in_array($direction, ['outbound','inbound','circular'])) $direction = 'outbound';
|
||||
|
||||
$stops = json_decode(filterRequest('stops') ?? '[]', true) ?: [];
|
||||
|
||||
$chk = $transit_con->prepare("SELECT id, status FROM transit_routes WHERE id=? AND org_id=? LIMIT 1");
|
||||
$chk->execute([$routeId, $transit_org_id]);
|
||||
$route = $chk->fetch();
|
||||
if (!$route) jsonError('Route not found or access denied', 404);
|
||||
|
||||
// إن كان معتمداً أو موقوفاً، التعديل يعيده لمسودة بانتظار اعتماد جديد
|
||||
$newStatus = in_array($route['status'], ['active', 'suspended']) ? 'draft' : $route['status'];
|
||||
|
||||
$transit_con->beginTransaction();
|
||||
try {
|
||||
$transit_con->prepare(
|
||||
"UPDATE transit_routes
|
||||
SET name_ar=?, name_en=?, direction=?, polyline=?, distance_km=?, duration_min=?,
|
||||
status=?, approved_by=NULL, approved_at=NULL, updated_at=NOW()
|
||||
WHERE id=?"
|
||||
)->execute([
|
||||
$nameAr,
|
||||
filterRequest('name_en'),
|
||||
$direction,
|
||||
filterRequest('polyline'),
|
||||
filterRequest('distance_km', 'float'),
|
||||
filterRequest('duration_min', 'int'),
|
||||
$newStatus,
|
||||
$routeId,
|
||||
]);
|
||||
|
||||
// استبدال كامل لمحطات الخط
|
||||
$transit_con->prepare("DELETE FROM transit_stops WHERE route_id=?")->execute([$routeId]);
|
||||
|
||||
$insertStop = $transit_con->prepare(
|
||||
"INSERT INTO transit_stops
|
||||
(route_id,org_id,sequence,name_ar,name_en,latitude,longitude,geofence_radius,eta_offset_min,is_major)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)"
|
||||
);
|
||||
foreach ($stops as $i => $s) {
|
||||
if (empty($s['lat']) || empty($s['lng']) || empty($s['name_ar'])) continue;
|
||||
$insertStop->execute([
|
||||
$routeId, $transit_org_id, $i + 1,
|
||||
$s['name_ar'], $s['name_en'] ?? null,
|
||||
(float)$s['lat'], (float)$s['lng'],
|
||||
(int)($s['radius'] ?? 150),
|
||||
isset($s['eta_offset_min']) ? (int)$s['eta_offset_min'] : null,
|
||||
(int)($s['is_major'] ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
$transit_con->commit();
|
||||
} catch (Throwable $e) {
|
||||
$transit_con->rollBack();
|
||||
appLog('[TRANSIT][ROUTE][update] ' . $e->getMessage(), 'ERROR');
|
||||
jsonError('Failed to update route', 500);
|
||||
}
|
||||
|
||||
appLog("[TRANSIT][ROUTE][update] route #{$routeId} org#{$transit_org_id} updated, status={$newStatus}");
|
||||
|
||||
jsonSuccess([
|
||||
'route_id' => $routeId,
|
||||
'status' => $newStatus,
|
||||
'stops_count'=> count($stops),
|
||||
], $newStatus === 'draft' ? 'Route updated — pending re-approval' : 'Route updated');
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
// transit/schedule/add.php — ربط جدول زمني بخط + سائق + مركبة
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
requireTransitFields(['route_id', 'departure_time', 'days_mask']);
|
||||
|
||||
$routeId = filterRequest('route_id', 'int');
|
||||
$departureTime = filterRequest('departure_time');
|
||||
$daysMask = filterRequest('days_mask', 'int') ?? 62;
|
||||
$driverId = filterRequest('driver_id', 'int') ?: null;
|
||||
$vehicleId = filterRequest('vehicle_id', 'int') ?: null;
|
||||
$validFrom = filterRequest('valid_from') ?: date('Y-m-d');
|
||||
$validUntil = filterRequest('valid_until');
|
||||
|
||||
$chk = $transit_con->prepare("SELECT id FROM transit_routes WHERE id=? AND org_id=? LIMIT 1");
|
||||
$chk->execute([$routeId, $transit_org_id]);
|
||||
if (!$chk->fetch()) jsonError('Route not found or access denied', 404);
|
||||
|
||||
if ($vehicleId) {
|
||||
$chkV = $transit_con->prepare("SELECT id FROM transit_vehicles WHERE id=? AND org_id=? LIMIT 1");
|
||||
$chkV->execute([$vehicleId, $transit_org_id]);
|
||||
if (!$chkV->fetch()) jsonError('Vehicle not found or access denied', 404);
|
||||
}
|
||||
|
||||
if ($driverId) {
|
||||
$chkD = $transit_con->prepare("SELECT id FROM transit_drivers WHERE id=? AND org_id=? AND status='active' LIMIT 1");
|
||||
$chkD->execute([$driverId, $transit_org_id]);
|
||||
if (!$chkD->fetch()) jsonError('Driver not found or not active', 404);
|
||||
}
|
||||
|
||||
$transit_con->prepare(
|
||||
"INSERT INTO transit_schedules
|
||||
(route_id, org_id, vehicle_id, driver_id, days_mask, departure_time, valid_from, valid_until)
|
||||
VALUES (?,?,?,?,?,?,?,?)"
|
||||
)->execute([$routeId, $transit_org_id, $vehicleId, $driverId, $daysMask, $departureTime, $validFrom, $validUntil]);
|
||||
|
||||
jsonSuccess(['schedule_id' => (int)$transit_con->lastInsertId()], 'Schedule added');
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
// transit/schedule/delete.php — حذف جدول زمني
|
||||
// POST: schedule_id
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
$scheduleId = filterRequest('schedule_id', 'int');
|
||||
if (!$scheduleId) jsonError('schedule_id is required', 400);
|
||||
|
||||
$chk = $transit_con->prepare("SELECT id FROM transit_schedules WHERE id=? AND org_id=? LIMIT 1");
|
||||
$chk->execute([$scheduleId, $transit_org_id]);
|
||||
if (!$chk->fetch()) jsonError('Schedule not found or access denied', 404);
|
||||
|
||||
$transit_con->prepare("DELETE FROM transit_schedules WHERE id=?")->execute([$scheduleId]);
|
||||
|
||||
appLog("[TRANSIT][SCHEDULE][delete] schedule #{$scheduleId} org#{$transit_org_id}");
|
||||
|
||||
jsonSuccess(['schedule_id' => $scheduleId], 'Schedule deleted');
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
// transit/schedule/list.php — جداول خط معيّن
|
||||
// POST: route_id
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
$routeId = filterRequest('route_id', 'int');
|
||||
if (!$routeId) jsonError('route_id is required', 400);
|
||||
|
||||
$chk = $transit_con->prepare("SELECT id FROM transit_routes WHERE id=? AND org_id=? LIMIT 1");
|
||||
$chk->execute([$routeId, $transit_org_id]);
|
||||
if (!$chk->fetch()) jsonError('Route not found or access denied', 404);
|
||||
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT s.id, s.departure_time, s.days_mask, s.valid_from, s.valid_until, s.is_active,
|
||||
s.driver_id, s.vehicle_id, d.name AS driver_name, v.plate AS vehicle_plate
|
||||
FROM transit_schedules s
|
||||
LEFT JOIN transit_drivers d ON d.id = s.driver_id
|
||||
LEFT JOIN transit_vehicles v ON v.id = s.vehicle_id
|
||||
WHERE s.route_id=? AND s.org_id=?
|
||||
ORDER BY s.departure_time ASC"
|
||||
);
|
||||
$st->execute([$routeId, $transit_org_id]);
|
||||
|
||||
jsonSuccess(['schedules' => $st->fetchAll()]);
|
||||
@@ -0,0 +1,358 @@
|
||||
-- =============================================================
|
||||
-- schema_transit.sql — قاعدة بيانات منصة مواصلاتي (siroTransitDb)
|
||||
-- عزل كامل عن main/ride/tracking — ممنوع أي JOIN خارجي
|
||||
-- الربط بالنظام الرئيسي عبر passenger_id / driver_id فقط
|
||||
-- =============================================================
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
|
||||
SET time_zone = "+00:00";
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 1. transit_orgs — المؤسسات (جامعات، مدارس، فنادق، شركات، ناقلون)
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `transit_orgs` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`type` ENUM('university','school','hotel','company','transporter') NOT NULL,
|
||||
`country` CHAR(2) NOT NULL COMMENT 'JO | SY | EG | ...',
|
||||
`city` VARCHAR(80) NOT NULL,
|
||||
`name_ar` VARCHAR(200) NOT NULL,
|
||||
`name_en` VARCHAR(200) NOT NULL,
|
||||
`logo_url` VARCHAR(500) DEFAULT NULL,
|
||||
`campus_polygon` JSON DEFAULT NULL COMMENT 'حدود الحرم — مصفوفة [{lat,lng}]',
|
||||
`website` VARCHAR(300) DEFAULT NULL,
|
||||
`contact_phone` VARCHAR(30) DEFAULT NULL,
|
||||
`contact_email` VARCHAR(150) DEFAULT NULL,
|
||||
`contract_status` ENUM('trial','active','suspended','terminated') NOT NULL DEFAULT 'trial',
|
||||
`trial_ends_at` DATE DEFAULT NULL,
|
||||
`notes` TEXT DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_country_type` (`country`, `type`),
|
||||
KEY `idx_contract_status` (`contract_status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 2. transit_org_links — ربط ناقل ↔ مؤسسة (النمط الثاني)
|
||||
-- جامعة حكومية لا تملك أسطولاً → تُخدَم بناقل مسجّل
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `transit_org_links` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`institution_id` INT UNSIGNED NOT NULL COMMENT 'org_id للمؤسسة (جامعة/مدرسة)',
|
||||
`transporter_id` INT UNSIGNED NOT NULL COMMENT 'org_id للناقل (type=transporter)',
|
||||
`status` ENUM('active','inactive') NOT NULL DEFAULT 'active',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_link` (`institution_id`, `transporter_id`),
|
||||
KEY `idx_transporter` (`transporter_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 3. transit_org_admins — مشرفو المؤسسة (لوحة تحكم الويب)
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `transit_org_admins` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`org_id` INT UNSIGNED NOT NULL,
|
||||
`name` VARCHAR(120) NOT NULL,
|
||||
`phone` VARCHAR(25) NOT NULL,
|
||||
`role` ENUM('owner','transport_manager','dispatcher') NOT NULL DEFAULT 'transport_manager',
|
||||
`permissions` JSON DEFAULT NULL COMMENT '{"routes":true,"drivers":true,"roster":true,"broadcast":true}',
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_phone_org` (`phone`, `org_id`),
|
||||
KEY `idx_org` (`org_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 4. transit_sessions — جلسات تسجيل دخول مشرف الويب (OTP-based)
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `transit_sessions` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`admin_id` INT UNSIGNED NOT NULL,
|
||||
`org_id` INT UNSIGNED NOT NULL,
|
||||
`token_hash` VARCHAR(64) NOT NULL COMMENT 'sha256 للـ session token',
|
||||
`ip` VARCHAR(45) DEFAULT NULL,
|
||||
`user_agent` VARCHAR(300) DEFAULT NULL,
|
||||
`expires_at` TIMESTAMP NOT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_token` (`token_hash`),
|
||||
KEY `idx_admin` (`admin_id`),
|
||||
KEY `idx_expires` (`expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 5. transit_vehicles — الباصات والفانات المملوكة للمؤسسة
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `transit_vehicles` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`org_id` INT UNSIGNED NOT NULL,
|
||||
`plate` VARCHAR(30) NOT NULL,
|
||||
`make` VARCHAR(50) DEFAULT NULL,
|
||||
`model` VARCHAR(50) DEFAULT NULL,
|
||||
`year` SMALLINT DEFAULT NULL,
|
||||
`color` VARCHAR(30) DEFAULT NULL,
|
||||
`capacity` TINYINT NOT NULL DEFAULT 30,
|
||||
`vehicle_type` ENUM('bus','minibus','van','other') NOT NULL DEFAULT 'bus',
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`notes` VARCHAR(300) DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_plate` (`plate`),
|
||||
KEY `idx_org` (`org_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 6. transit_drivers — سائقو الباصات (يُنشئهم مشرف المؤسسة)
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `transit_drivers` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`org_id` INT UNSIGNED NOT NULL,
|
||||
`name` VARCHAR(120) NOT NULL,
|
||||
`phone` VARCHAR(25) NOT NULL,
|
||||
`license_number` VARCHAR(50) DEFAULT NULL,
|
||||
`license_img_url` VARCHAR(500) DEFAULT NULL,
|
||||
`main_driver_id` VARCHAR(100) DEFAULT NULL COMMENT 'معرّف في جدول driver الرئيسي إن كان كابتن مشاوير أيضاً',
|
||||
`invite_token` VARCHAR(64) DEFAULT NULL COMMENT 'رمز الدعوة للتفعيل الأولي',
|
||||
`invite_sent_at` TIMESTAMP DEFAULT NULL,
|
||||
`activated_at` TIMESTAMP DEFAULT NULL,
|
||||
`status` ENUM('invited','active','suspended') NOT NULL DEFAULT 'invited',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_phone_org` (`phone`, `org_id`),
|
||||
KEY `idx_org` (`org_id`),
|
||||
KEY `idx_invite_token` (`invite_token`),
|
||||
KEY `idx_main_driver` (`main_driver_id`) -- يُستعلم في transitResolveDriver + driver/me.php
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 7. transit_routes — خطوط النقل
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `transit_routes` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`org_id` INT UNSIGNED NOT NULL,
|
||||
`name_ar` VARCHAR(150) NOT NULL,
|
||||
`name_en` VARCHAR(150) DEFAULT NULL,
|
||||
`direction` ENUM('outbound','inbound','circular') NOT NULL DEFAULT 'outbound',
|
||||
`polyline` TEXT DEFAULT NULL COMMENT 'Encoded polyline للمسار',
|
||||
`distance_km` DECIMAL(7,2) DEFAULT NULL,
|
||||
`duration_min` SMALLINT DEFAULT NULL,
|
||||
`status` ENUM('draft','active','suspended','rejected') NOT NULL DEFAULT 'draft',
|
||||
`created_by` INT UNSIGNED DEFAULT NULL COMMENT 'admin_id من أنشأه',
|
||||
`approved_by` INT UNSIGNED DEFAULT NULL COMMENT 'admin_id من اعتمده (فريق سيرو)',
|
||||
`approved_at` TIMESTAMP DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_org_status` (`org_id`, `status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 8. transit_stops — محطات الخط مع جيوفينس
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `transit_stops` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`route_id` INT UNSIGNED NOT NULL,
|
||||
`org_id` INT UNSIGNED NOT NULL,
|
||||
`sequence` TINYINT NOT NULL COMMENT 'ترتيب المحطة في الخط (1، 2، 3…)',
|
||||
`name_ar` VARCHAR(120) NOT NULL,
|
||||
`name_en` VARCHAR(120) DEFAULT NULL,
|
||||
`latitude` DECIMAL(10,7) NOT NULL,
|
||||
`longitude` DECIMAL(10,7) NOT NULL,
|
||||
`geofence_radius` SMALLINT NOT NULL DEFAULT 150 COMMENT 'نصف قطر الجيوفينس بالمتر',
|
||||
`eta_offset_min` SMALLINT DEFAULT NULL COMMENT 'الإزاحة الزمنية عن وقت الانطلاق بالدقائق',
|
||||
`is_major` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'محطة رئيسية (تظهر بارزة للطالب)',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_route_seq` (`route_id`, `sequence`),
|
||||
KEY `idx_org` (`org_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 9. transit_schedules — جداول انطلاق الخطوط
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `transit_schedules` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`route_id` INT UNSIGNED NOT NULL,
|
||||
`org_id` INT UNSIGNED NOT NULL,
|
||||
`vehicle_id` INT UNSIGNED DEFAULT NULL COMMENT 'الباص المخصص لهذا الجدول (NULL = يُحدد يومياً)',
|
||||
`driver_id` INT UNSIGNED DEFAULT NULL COMMENT 'السائق المخصص (NULL = يُحدد يومياً)',
|
||||
`days_mask` TINYINT NOT NULL DEFAULT 62 COMMENT 'قناع الأيام: bit0=أحد…bit6=سبت (62=الأحد–الخميس)',
|
||||
`departure_time` TIME NOT NULL,
|
||||
`valid_from` DATE NOT NULL,
|
||||
`valid_until` DATE DEFAULT NULL,
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_route_active` (`route_id`, `is_active`),
|
||||
KEY `idx_org` (`org_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 10. transit_trips — الرحلات اليومية الفعلية
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `transit_trips` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`schedule_id` INT UNSIGNED NOT NULL,
|
||||
`route_id` INT UNSIGNED NOT NULL,
|
||||
`org_id` INT UNSIGNED NOT NULL,
|
||||
`driver_id` INT UNSIGNED NOT NULL,
|
||||
`vehicle_id` INT UNSIGNED DEFAULT NULL COMMENT 'NULL إن كان الجدول بلا باص مخصص',
|
||||
`trip_date` DATE NOT NULL,
|
||||
`status` ENUM('scheduled','started','completed','cancelled','no_show') NOT NULL DEFAULT 'scheduled',
|
||||
`started_at` TIMESTAMP DEFAULT NULL,
|
||||
`completed_at` TIMESTAMP DEFAULT NULL,
|
||||
`delay_minutes` TINYINT DEFAULT 0,
|
||||
`delay_reason` VARCHAR(300) DEFAULT NULL,
|
||||
`current_stop_seq` TINYINT DEFAULT NULL COMMENT 'آخر محطة جيوفينس مرّ عليها الباص',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_schedule_date` (`schedule_id`, `trip_date`),
|
||||
KEY `idx_route_date` (`route_id`, `trip_date`),
|
||||
KEY `idx_org_date` (`org_id`, `trip_date`),
|
||||
KEY `idx_driver_date` (`driver_id`, `trip_date`),
|
||||
KEY `idx_status` (`status`) -- cron_approaching_alerts يستعلم WHERE status='started' كل دقيقة
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 11. transit_enrollments — عضويات الطلاب/الموظفين في المؤسسة
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `transit_enrollments` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`org_id` INT UNSIGNED NOT NULL,
|
||||
`passenger_id` VARCHAR(100) DEFAULT NULL COMMENT 'معرّف في جدول passengers الرئيسي — NULL لصفوف الكشف قبل تفعيل الطالب',
|
||||
`student_id` VARCHAR(50) NOT NULL COMMENT 'الرقم الجامعي/الوظيفي (encrypted)',
|
||||
`member_name` VARCHAR(120) DEFAULT NULL COMMENT 'الاسم من كشف الجامعة',
|
||||
`preferred_stop_id` INT UNSIGNED DEFAULT NULL COMMENT 'محطتي المفضلة',
|
||||
`preferred_route_id` INT UNSIGNED DEFAULT NULL,
|
||||
`verify_method` ENUM('api','roster_phone_match','roster_manual','manual_admin') NOT NULL DEFAULT 'roster_manual',
|
||||
`status` ENUM('pending','active','suspended','expired') NOT NULL DEFAULT 'pending',
|
||||
`verified_at` TIMESTAMP DEFAULT NULL,
|
||||
`expires_at` DATE DEFAULT NULL COMMENT 'نهاية الفصل الدراسي',
|
||||
`roster_id` INT UNSIGNED DEFAULT NULL COMMENT 'الكشف الذي جاءت منه العضوية',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_org_passenger` (`org_id`, `passenger_id`),
|
||||
KEY `idx_org_status` (`org_id`, `status`),
|
||||
KEY `idx_passenger` (`passenger_id`),
|
||||
KEY `idx_preferred_route` (`preferred_route_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 12. transit_rosters — سجلات استيراد كشوف الطلاب
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `transit_rosters` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`org_id` INT UNSIGNED NOT NULL,
|
||||
`uploaded_by` INT UNSIGNED NOT NULL COMMENT 'admin_id',
|
||||
`filename` VARCHAR(200) DEFAULT NULL,
|
||||
`total_rows` INT DEFAULT 0,
|
||||
`matched_rows` INT DEFAULT 0 COMMENT 'طلاب طابق هاتفهم حساباً موجوداً',
|
||||
`new_enrollments` INT DEFAULT 0,
|
||||
`expired_count` INT DEFAULT 0 COMMENT 'طلاب تخرجوا وأُوقفت عضويتهم',
|
||||
`semester` VARCHAR(30) DEFAULT NULL COMMENT 'مثال: 2026-S1',
|
||||
`notes` VARCHAR(300) DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_org` (`org_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 13. transit_guardians — أولياء الأمور (للمدارس)
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `transit_guardians` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`enrollment_id` INT UNSIGNED NOT NULL,
|
||||
`org_id` INT UNSIGNED NOT NULL,
|
||||
`guardian_passenger_id` VARCHAR(100) NOT NULL COMMENT 'معرّف ولي الأمر في passengers الرئيسي',
|
||||
`relation` ENUM('father','mother','guardian') NOT NULL DEFAULT 'guardian',
|
||||
`notify_depart` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'إشعار عند انطلاق الباص',
|
||||
`notify_board` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'إشعار عند صعود الطفل',
|
||||
`notify_arrive` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'إشعار عند وصول الباص للمدرسة',
|
||||
`notify_return` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'إشعار عند العودة للبيت',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_enrollment_guardian` (`enrollment_id`, `guardian_passenger_id`),
|
||||
KEY `idx_org` (`org_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 14. transit_broadcasts — إعلانات المشرف للمشتركين
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `transit_broadcasts` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`org_id` INT UNSIGNED NOT NULL,
|
||||
`sent_by` INT UNSIGNED NOT NULL COMMENT 'admin_id',
|
||||
`target_type` ENUM('all','route','role') NOT NULL DEFAULT 'all',
|
||||
`target_id` INT UNSIGNED DEFAULT NULL COMMENT 'route_id عند target_type=route',
|
||||
`title_ar` VARCHAR(200) DEFAULT NULL,
|
||||
`body_ar` TEXT NOT NULL,
|
||||
`fcm_topic` VARCHAR(150) DEFAULT NULL COMMENT 'الموضوع المستخدم للإرسال',
|
||||
`sent_at` TIMESTAMP DEFAULT NULL,
|
||||
`delivery_count` INT DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_org` (`org_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 15. transit_boardings — إثبات الصعود والنزول (مرحلة ثانية)
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `transit_boardings` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`trip_id` INT UNSIGNED NOT NULL,
|
||||
`enrollment_id` INT UNSIGNED NOT NULL,
|
||||
`stop_id` INT UNSIGNED DEFAULT NULL,
|
||||
`boarded_at` TIMESTAMP DEFAULT NULL,
|
||||
`alighted_at` TIMESTAMP DEFAULT NULL,
|
||||
`method` ENUM('geofence','qr','driver_tap') NOT NULL DEFAULT 'geofence',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_trip` (`trip_id`),
|
||||
KEY `idx_enrollment` (`enrollment_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 16. transit_otp_log — سجل OTPs لمشرفي الويب (تنظيف دوري)
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `transit_otp_log` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`phone` VARCHAR(25) NOT NULL,
|
||||
`otp_hash` VARCHAR(64) NOT NULL COMMENT 'sha256 للكود',
|
||||
`expires_at` TIMESTAMP NOT NULL,
|
||||
`used` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_phone` (`phone`),
|
||||
KEY `idx_expires` (`expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- ترحيل (Migration) لقاعدة أُنشئت قبل 2026-07-12 — نفّذ مرة واحدة:
|
||||
-- ALTER TABLE transit_enrollments MODIFY passenger_id VARCHAR(100) DEFAULT NULL
|
||||
-- COMMENT 'معرّف في جدول passengers الرئيسي — NULL لصفوف الكشف قبل تفعيل الطالب';
|
||||
-- ALTER TABLE transit_trips MODIFY vehicle_id INT UNSIGNED DEFAULT NULL
|
||||
-- COMMENT 'NULL إن كان الجدول بلا باص مخصص';
|
||||
-- ALTER TABLE transit_routes MODIFY status ENUM('draft','active','suspended','rejected') NOT NULL DEFAULT 'draft';
|
||||
-- (بدونها: Admin/transit/route/approve.php بإجراء reject يفشل بخطأ SQL دائماً)
|
||||
-- ALTER TABLE transit_drivers ADD KEY idx_main_driver (main_driver_id);
|
||||
-- (بدونها: كل استعلام transitResolveDriver / driver/me.php = مسح جدول كامل)
|
||||
-- ALTER TABLE transit_trips ADD KEY idx_status (status);
|
||||
-- (بدونها: cron_approaching_alerts كل دقيقة = مسح جدول transit_trips كامل)
|
||||
-- -----------------------------------------------------------------
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- ملاحظات الـ Redis (keys تُكتب من PHP — ليست في SQL)
|
||||
-- transit:trip:{trip_id}:pos → آخر موقع GPS للباص (Hash: lat,lng,ts)
|
||||
-- transit:trip:{trip_id}:status → حالة الرحلة الحية
|
||||
-- transit:otp:{phone} → OTP مشرف الويب (TTL 300s)
|
||||
-- transit:session:{token_hash} → admin_id+org_id (TTL 86400s)
|
||||
-- transit:driver_mode:{driver_id} → وضع السائق الحالي (bus/ride), TTL session
|
||||
-- FCM Topics: transit_route_{route_id} ← كل مشتركي خط
|
||||
-- -----------------------------------------------------------------
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
// transit/trip/board_passenger.php — سائق الباص يسجل صعود راكب (عبر QR أو يدوي)
|
||||
// POST: trip_id (from active trip), stop_id, passenger_id
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||
|
||||
if ($transit_user_role !== 'driver') jsonError('Only drivers can access this endpoint', 403);
|
||||
|
||||
$stopId = (int)filterRequest('stop_id');
|
||||
$passengerId = filterRequest('passenger_id');
|
||||
|
||||
if (!$stopId || !$passengerId) {
|
||||
jsonError('Missing parameters', 400);
|
||||
}
|
||||
|
||||
// استخرج سجل السائق من JWT (Redis read-through) — لا نثق بأي معرّف سائق من العميل
|
||||
$driverRec = transitResolveDriver((string)$transit_user_id);
|
||||
if (!$driverRec) jsonError('Driver not found or not active', 403);
|
||||
$transit_driver_id = (int)$driverRec['id'];
|
||||
|
||||
// 1. تحقق من رحلة السائق الحالية
|
||||
$stTrip = $transit_con->prepare(
|
||||
"SELECT id, org_id FROM transit_trips WHERE driver_id=? AND status='started' LIMIT 1"
|
||||
);
|
||||
$stTrip->execute([$transit_driver_id]);
|
||||
$trip = $stTrip->fetch();
|
||||
|
||||
if (!$trip) {
|
||||
jsonError('لا توجد رحلة نشطة حالياً لتسجيل الصعود', 403);
|
||||
}
|
||||
|
||||
$tripId = (int)$trip['id'];
|
||||
$orgId = (int)$trip['org_id'];
|
||||
|
||||
// 2. تأكد من أن الراكب مشترك وفعال في هذه المؤسسة
|
||||
$stEnroll = $transit_con->prepare(
|
||||
"SELECT id, member_name FROM transit_enrollments
|
||||
WHERE org_id=? AND passenger_id=? AND status='active' LIMIT 1"
|
||||
);
|
||||
$stEnroll->execute([$orgId, $passengerId]);
|
||||
$enroll = $stEnroll->fetch();
|
||||
|
||||
if (!$enroll) {
|
||||
jsonError('هذا الراكب غير مسجل أو اشتراكه غير فعال', 403);
|
||||
}
|
||||
|
||||
$enrollId = (int)$enroll['id'];
|
||||
|
||||
// 3. تأكد أنه لم يُسجل صعوده من قبل في هذه الرحلة
|
||||
$stCheck = $transit_con->prepare(
|
||||
"SELECT id FROM transit_boardings WHERE trip_id=? AND enrollment_id=? LIMIT 1"
|
||||
);
|
||||
$stCheck->execute([$tripId, $enrollId]);
|
||||
if ($stCheck->fetch()) {
|
||||
jsonError('تم تسجيل صعود هذا الراكب مسبقاً في هذه الرحلة', 400);
|
||||
}
|
||||
|
||||
// 4. تسجيل الصعود
|
||||
$stInsert = $transit_con->prepare(
|
||||
"INSERT INTO transit_boardings (trip_id, enrollment_id, stop_id, boarded_at, method)
|
||||
VALUES (?, ?, ?, NOW(), 'qr')"
|
||||
);
|
||||
$stInsert->execute([$tripId, $enrollId, $stopId]);
|
||||
|
||||
// 5. إرسال إشعار صعود — لولي الأمر (إن وُجد) وللطالب نفسه (تأكيد بمحفظته)
|
||||
$title = "صعود الحافلة 🚌";
|
||||
$body = "لقد صعد " . ($enroll['member_name'] ?? 'الطالب') . " إلى الحافلة بنجاح.";
|
||||
|
||||
transitNotifyGuardians($enrollId, 'board', $title, $body);
|
||||
transitSendNotificationToPassenger((string)$passengerId, $title, $body, ['type' => 'transit_boarded']);
|
||||
|
||||
jsonSuccess(['boarded' => true]);
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
// transit/trip/cancel.php — المشرف يلغي رحلة (قبل بدئها عادة — عطل مركبة، غياب سائق...)
|
||||
// POST: trip_id, [reason]
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
$tripId = filterRequest('trip_id', 'int');
|
||||
if (!$tripId) jsonError('trip_id is required', 400);
|
||||
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT t.id, t.status, t.route_id, r.name_ar AS route_name
|
||||
FROM transit_trips t JOIN transit_routes r ON r.id = t.route_id
|
||||
WHERE t.id=? AND t.org_id=? LIMIT 1"
|
||||
);
|
||||
$st->execute([$tripId, $transit_org_id]);
|
||||
$trip = $st->fetch();
|
||||
if (!$trip) jsonError('Trip not found or access denied', 404);
|
||||
|
||||
if (in_array($trip['status'], ['completed', 'cancelled'])) {
|
||||
jsonError('Trip is already ' . $trip['status'], 409);
|
||||
}
|
||||
|
||||
$wasStarted = $trip['status'] === 'started';
|
||||
|
||||
$transit_con->prepare(
|
||||
"UPDATE transit_trips SET status='cancelled', delay_reason=?, updated_at=NOW() WHERE id=?"
|
||||
)->execute([filterRequest('reason'), $tripId]);
|
||||
|
||||
// إن كانت الرحلة قد بدأت فعلياً، أوقف بث الباص وحرّر ملكية الرحلة فوراً
|
||||
if ($wasStarted) {
|
||||
transitClearTripOwner($tripId);
|
||||
global $redisLocation;
|
||||
if ($redisLocation) $redisLocation->del("transit:trip:{$tripId}:pos");
|
||||
global $redis;
|
||||
if ($redis) $redis->del("transit:trip:{$tripId}:status");
|
||||
}
|
||||
|
||||
transitSendTopicNotification(
|
||||
transitRouteTopic((int)$trip['route_id']),
|
||||
'تم إلغاء الرحلة',
|
||||
'أُلغيت رحلة خط ' . $trip['route_name'] . ' اليوم.',
|
||||
['type' => 'transit_cancelled', 'trip_id' => (string)$tripId]
|
||||
);
|
||||
|
||||
appLog("[TRANSIT][TRIP][cancel] trip #{$tripId} org#{$transit_org_id} cancelled by admin #{$transit_admin_id}");
|
||||
|
||||
jsonSuccess(['trip_id' => $tripId, 'status' => 'cancelled'], 'Trip cancelled');
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
// transit/trip/delay.php — السائق يبلغ عن تأخير
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||
|
||||
if ($transit_user_role !== 'driver') jsonError('Only drivers can access this endpoint', 403);
|
||||
|
||||
$tripId = filterRequest('trip_id', 'int');
|
||||
$delay = min((int)filterRequest('delay_minutes'), 120);
|
||||
$reason = filterRequest('reason');
|
||||
if (!$tripId || $delay <= 0) jsonError('trip_id and delay_minutes are required', 400);
|
||||
|
||||
// استخرج سجل السائق من JWT (Redis read-through)
|
||||
$driverRec = transitResolveDriver((string)$transit_user_id);
|
||||
if (!$driverRec) jsonError('Driver not found or not active', 403);
|
||||
$driverId = (int)$driverRec['id'];
|
||||
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT t.id, t.route_id, r.name_ar AS route_name
|
||||
FROM transit_trips t JOIN transit_routes r ON r.id=t.route_id
|
||||
WHERE t.id=? AND t.driver_id=? AND t.status='started' LIMIT 1"
|
||||
);
|
||||
$st->execute([$tripId, $driverId]);
|
||||
$trip = $st->fetch();
|
||||
if (!$trip) jsonError('Active trip not found', 404);
|
||||
|
||||
$transit_con->prepare(
|
||||
"UPDATE transit_trips SET delay_minutes=?, delay_reason=?, updated_at=NOW() WHERE id=?"
|
||||
)->execute([$delay, $reason, $tripId]);
|
||||
|
||||
transitSendTopicNotification(
|
||||
transitRouteTopic((int)$trip['route_id']),
|
||||
'تأخير في الباص',
|
||||
'خط ' . $trip['route_name'] . ' متأخر ' . $delay . ' دقيقة' . ($reason ? " — $reason" : ''),
|
||||
['type' => 'transit_delay', 'trip_id' => (string)$tripId, 'delay' => (string)$delay]
|
||||
);
|
||||
|
||||
jsonSuccess(['delay_minutes' => $delay], 'Delay reported and passengers notified');
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
// transit/trip/end.php — السائق ينهي الرحلة
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||
|
||||
if ($transit_user_role !== 'driver') jsonError('Only drivers can access this endpoint', 403);
|
||||
|
||||
$tripId = filterRequest('trip_id', 'int');
|
||||
if (!$tripId) jsonError('trip_id is required', 400);
|
||||
|
||||
// استخرج سجل السائق من JWT (Redis read-through)
|
||||
$driverRec = transitResolveDriver((string)$transit_user_id);
|
||||
if (!$driverRec) jsonError('Driver not found or not active', 403);
|
||||
$driverId = (int)$driverRec['id'];
|
||||
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT t.id, t.route_id, t.status, r.name_ar AS route_name, r.direction
|
||||
FROM transit_trips t JOIN transit_routes r ON r.id = t.route_id
|
||||
WHERE t.id=? AND t.driver_id=? LIMIT 1"
|
||||
);
|
||||
$st->execute([$tripId, $driverId]);
|
||||
$trip = $st->fetch();
|
||||
|
||||
if (!$trip) jsonError('Trip not found', 404);
|
||||
if ($trip['status'] !== 'started') jsonError('Trip is not in started state', 409);
|
||||
|
||||
$transit_con->prepare(
|
||||
"UPDATE transit_trips SET status='completed', completed_at=NOW(), updated_at=NOW() WHERE id=?"
|
||||
)->execute([$tripId]);
|
||||
|
||||
global $redis;
|
||||
if ($redis) $redis->del("transit:trip:{$tripId}:status");
|
||||
|
||||
// موقع الباص + ملكية الرحلة على Redis سيرفر الموقع
|
||||
transitClearTripOwner($tripId);
|
||||
global $redisLocation;
|
||||
if ($redisLocation) $redisLocation->del("transit:trip:{$tripId}:pos");
|
||||
|
||||
// إشعار أولياء الأمور — وصول (ذهاب) أو عودة (إياب) حسب اتجاه الخط
|
||||
$guardEvent = ($trip['direction'] === 'inbound') ? 'return' : 'arrive';
|
||||
$guardTitle = ($guardEvent === 'return') ? 'عاد الباص 🏠' : 'وصل الباص 🎓';
|
||||
$guardBody = ($guardEvent === 'return')
|
||||
? 'وصل باص خط ' . $trip['route_name'] . ' إلى محطته الأخيرة.'
|
||||
: 'وصل باص خط ' . $trip['route_name'] . ' إلى وجهته.';
|
||||
|
||||
$stGuardEnroll = $transit_con->prepare(
|
||||
"SELECT id FROM transit_enrollments WHERE preferred_route_id=? AND status='active'"
|
||||
);
|
||||
$stGuardEnroll->execute([(int)$trip['route_id']]);
|
||||
foreach ($stGuardEnroll->fetchAll(PDO::FETCH_COLUMN) as $enrollId) {
|
||||
transitNotifyGuardians((int)$enrollId, $guardEvent, $guardTitle, $guardBody);
|
||||
}
|
||||
|
||||
jsonSuccess(['trip_id' => $tripId, 'status' => 'completed'], 'Trip completed');
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
// transit/trip/live.php — الراكب يسأل عن موقع الباص الحي
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||
|
||||
$tripId = filterRequest('trip_id', 'int');
|
||||
$routeId = filterRequest('route_id', 'int');
|
||||
|
||||
if (!$tripId && $routeId) {
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT id FROM transit_trips WHERE route_id=? AND trip_date=? AND status='started' LIMIT 1"
|
||||
);
|
||||
$st->execute([$routeId, date('Y-m-d')]);
|
||||
$row = $st->fetch();
|
||||
$tripId = $row ? (int)$row['id'] : 0;
|
||||
}
|
||||
|
||||
if (!$tripId) jsonError('No active trip found', 404);
|
||||
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT t.id, t.org_id, t.status, t.delay_minutes, t.current_stop_seq, t.started_at,
|
||||
r.name_ar AS route_name, r.polyline AS route_polyline, sc.departure_time, d.name AS driver_name
|
||||
FROM transit_trips t
|
||||
JOIN transit_routes r ON r.id = t.route_id
|
||||
JOIN transit_schedules sc ON sc.id = t.schedule_id
|
||||
JOIN transit_drivers d ON d.id = t.driver_id
|
||||
WHERE t.id=? LIMIT 1"
|
||||
);
|
||||
$st->execute([$tripId]);
|
||||
$trip = $st->fetch();
|
||||
if (!$trip) jsonError('Trip not found', 404);
|
||||
|
||||
// الراكب يحتاج عضوية نشطة في مؤسسة هذا الخط قبل رؤية موقع الباص
|
||||
$chk = $transit_con->prepare(
|
||||
"SELECT id FROM transit_enrollments WHERE org_id=? AND passenger_id=? AND status='active' LIMIT 1"
|
||||
);
|
||||
$chk->execute([$trip['org_id'], $transit_user_id]);
|
||||
if (!$chk->fetch()) jsonError('Active enrollment required', 403);
|
||||
|
||||
$stStops = $transit_con->prepare(
|
||||
"SELECT s.id, s.sequence, s.name_ar, s.latitude, s.longitude,
|
||||
s.geofence_radius, s.eta_offset_min, s.is_major
|
||||
FROM transit_stops s
|
||||
JOIN transit_trips t ON t.route_id = s.route_id
|
||||
WHERE t.id=? ORDER BY s.sequence ASC"
|
||||
);
|
||||
$stStops->execute([$tripId]);
|
||||
|
||||
jsonSuccess([
|
||||
'trip' => $trip,
|
||||
'bus_position' => transitGetBusPosition($tripId),
|
||||
'stops' => $stStops->fetchAll(),
|
||||
]);
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
// transit/trip/start.php — السائق يبدأ الرحلة
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||
|
||||
if ($transit_user_role !== 'driver') jsonError('Only drivers can access this endpoint', 403);
|
||||
|
||||
requireTransitFields(['trip_id', 'lat', 'lng']);
|
||||
$tripId = filterRequest('trip_id', 'int');
|
||||
$lat = (float)filterRequest('lat');
|
||||
$lng = (float)filterRequest('lng');
|
||||
|
||||
// استخرج سجل السائق من JWT (Redis read-through) — تحقق أن الرحلة تخصه
|
||||
$driverRec = transitResolveDriver((string)$transit_user_id);
|
||||
if (!$driverRec) jsonError('Driver not found or not active', 403);
|
||||
$driverId = (int)$driverRec['id'];
|
||||
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT t.id, t.route_id, t.status, r.name_ar AS route_name
|
||||
FROM transit_trips t
|
||||
JOIN transit_routes r ON r.id = t.route_id
|
||||
WHERE t.id=? AND t.driver_id=? LIMIT 1"
|
||||
);
|
||||
$st->execute([$tripId, $driverId]);
|
||||
$trip = $st->fetch();
|
||||
|
||||
if (!$trip) jsonError('Trip not found', 404);
|
||||
if ($trip['status'] === 'started') jsonError('Trip already started', 409);
|
||||
if ($trip['status'] === 'completed') jsonError('Trip already completed', 409);
|
||||
if ($trip['status'] === 'cancelled') jsonError('Trip was cancelled', 409);
|
||||
|
||||
$transit_con->prepare(
|
||||
"UPDATE transit_trips SET status='started', started_at=NOW(), current_stop_seq=1 WHERE id=?"
|
||||
)->execute([$tripId]);
|
||||
|
||||
transitUpdateBusPosition($tripId, $lat, $lng);
|
||||
|
||||
// تخزين ملكية الرحلة — يتحقق منها driver_socket قبل أي بثّ حي للراكبين
|
||||
// transit_user_id هو main_driver_id بالفعل (يُستخرج من JWT السائق)
|
||||
transitSetTripOwner($tripId, (string)$transit_user_id, (int)$trip['route_id']);
|
||||
|
||||
global $redis;
|
||||
if ($redis) {
|
||||
$redis->set("transit:trip:{$tripId}:status", 'started');
|
||||
$redis->expire("transit:trip:{$tripId}:status", 86400);
|
||||
}
|
||||
|
||||
transitSendTopicNotification(
|
||||
transitRouteTopic((int)$trip['route_id']),
|
||||
'الباص انطلق الآن',
|
||||
'خط ' . $trip['route_name'] . ' بدأ رحلته',
|
||||
['type' => 'transit_started', 'trip_id' => (string)$tripId]
|
||||
);
|
||||
|
||||
// إشعار أولياء أمور جميع الأعضاء المسجَّلين على هذا الخط بانطلاق الباص
|
||||
$stGuardEnroll = $transit_con->prepare(
|
||||
"SELECT id FROM transit_enrollments WHERE preferred_route_id=? AND status='active'"
|
||||
);
|
||||
$stGuardEnroll->execute([(int)$trip['route_id']]);
|
||||
foreach ($stGuardEnroll->fetchAll(PDO::FETCH_COLUMN) as $enrollId) {
|
||||
transitNotifyGuardians(
|
||||
(int)$enrollId,
|
||||
'depart',
|
||||
'انطلق الباص 🚌',
|
||||
'انطلق باص خط ' . $trip['route_name'] . ' الآن.'
|
||||
);
|
||||
}
|
||||
|
||||
jsonSuccess(['trip_id' => $tripId, 'status' => 'started', 'started_at' => date('Y-m-d H:i:s')], 'Trip started');
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
// transit/trip/stop_passengers.php — جلب قائمة ركاب المحطة (للسائق)
|
||||
// POST: stop_id
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||
|
||||
if ($transit_user_role !== 'driver') jsonError('Only drivers can access this endpoint', 403);
|
||||
|
||||
$stopId = (int)filterRequest('stop_id');
|
||||
if (!$stopId) jsonError('Missing stop_id', 400);
|
||||
|
||||
// استخرج سجل السائق من JWT (Redis read-through) — لا نثق بأي معرّف سائق من العميل
|
||||
$driverRec = transitResolveDriver((string)$transit_user_id);
|
||||
if (!$driverRec) jsonError('Driver not found or not active', 403);
|
||||
$transit_driver_id = (int)$driverRec['id'];
|
||||
|
||||
// 1. تحقق من رحلة السائق
|
||||
$stTrip = $transit_con->prepare(
|
||||
"SELECT id, org_id FROM transit_trips WHERE driver_id=? AND status='started' LIMIT 1"
|
||||
);
|
||||
$stTrip->execute([$transit_driver_id]);
|
||||
$trip = $stTrip->fetch();
|
||||
|
||||
if (!$trip) {
|
||||
jsonError('لا توجد رحلة نشطة حالياً', 403);
|
||||
}
|
||||
|
||||
$tripId = (int)$trip['id'];
|
||||
$orgId = (int)$trip['org_id'];
|
||||
|
||||
// 2. جلب جميع الركاب المشتركين الذين محطتهم المفضلة هي هذه المحطة (أو جميع ركاب الخط إن لم يكن هناك تخصيص)
|
||||
// سنكتفي بجلب المخصصين لهذه المحطة + عرض حالة صعودهم
|
||||
$stPass = $transit_con->prepare(
|
||||
"SELECT e.passenger_id, e.student_id, e.member_name AS name,
|
||||
(SELECT COUNT(id) FROM transit_boardings b
|
||||
WHERE b.trip_id = ? AND b.enrollment_id = e.id) AS is_boarded
|
||||
FROM transit_enrollments e
|
||||
WHERE e.org_id = ? AND e.status = 'active' AND e.preferred_stop_id = ?"
|
||||
);
|
||||
$stPass->execute([$tripId, $orgId, $stopId]);
|
||||
$passengers = $stPass->fetchAll();
|
||||
|
||||
// تحويل 0/1 إلى true/false
|
||||
foreach ($passengers as &$p) {
|
||||
$p['is_boarded'] = (bool)$p['is_boarded'];
|
||||
}
|
||||
unset($p);
|
||||
|
||||
jsonSuccess($passengers);
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
// transit/trip/today.php — السائق يجلب رحلاته اليوم (تُنشأ تلقائياً من الجداول)
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||
|
||||
if ($transit_user_role !== 'driver') jsonError('Only drivers can access this endpoint', 403);
|
||||
|
||||
// استخرج سجل السائق من JWT (Redis read-through) — لا نثق بـ driver_transit_id من العميل
|
||||
$driver = transitResolveDriver((string)$transit_user_id);
|
||||
if (!$driver) jsonError('Driver not found or not active — activate your account first', 403);
|
||||
$driverTransitId = (int)$driver['id'];
|
||||
|
||||
$today = date('Y-m-d');
|
||||
|
||||
// أنشئ رحلات اليوم من الجداول النشطة إن لم تُنشأ بعد
|
||||
$stScheds = $transit_con->prepare(
|
||||
"SELECT id AS schedule_id, route_id, vehicle_id, days_mask
|
||||
FROM transit_schedules
|
||||
WHERE driver_id=? AND org_id=? AND is_active=1
|
||||
AND valid_from <= ? AND (valid_until IS NULL OR valid_until >= ?)"
|
||||
);
|
||||
$stScheds->execute([$driverTransitId, $driver['org_id'], $today, $today]);
|
||||
|
||||
foreach ($stScheds->fetchAll() as $sch) {
|
||||
if (!transitDayActive((int)$sch['days_mask'])) continue;
|
||||
$ex = $transit_con->prepare("SELECT id FROM transit_trips WHERE schedule_id=? AND trip_date=? LIMIT 1");
|
||||
$ex->execute([$sch['schedule_id'], $today]);
|
||||
if (!$ex->fetch()) {
|
||||
$transit_con->prepare(
|
||||
"INSERT INTO transit_trips
|
||||
(schedule_id, route_id, org_id, driver_id, vehicle_id, trip_date, status)
|
||||
VALUES (?,?,?,?,?,?,'scheduled')"
|
||||
)->execute([
|
||||
$sch['schedule_id'], $sch['route_id'], $driver['org_id'],
|
||||
$driverTransitId, $sch['vehicle_id'], $today,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// اجلب رحلات اليوم مع التفاصيل
|
||||
$stTrips = $transit_con->prepare(
|
||||
"SELECT t.id, t.route_id, t.status, t.delay_minutes, t.current_stop_seq,
|
||||
t.started_at, t.completed_at,
|
||||
r.name_ar AS route_name, r.polyline,
|
||||
sc.departure_time,
|
||||
v.plate AS vehicle_plate, v.capacity
|
||||
FROM transit_trips t
|
||||
JOIN transit_routes r ON r.id = t.route_id
|
||||
JOIN transit_schedules sc ON sc.id = t.schedule_id
|
||||
LEFT JOIN transit_vehicles v ON v.id = t.vehicle_id
|
||||
WHERE t.driver_id=? AND t.trip_date=?
|
||||
ORDER BY sc.departure_time ASC"
|
||||
);
|
||||
$stTrips->execute([$driverTransitId, $today]);
|
||||
$trips = $stTrips->fetchAll();
|
||||
|
||||
$stStops = $transit_con->prepare(
|
||||
"SELECT sequence, name_ar, latitude, longitude, geofence_radius, eta_offset_min
|
||||
FROM transit_stops WHERE route_id=? ORDER BY sequence ASC"
|
||||
);
|
||||
foreach ($trips as &$trip) {
|
||||
$stStops->execute([$trip['route_id']]);
|
||||
$trip['stops'] = $stStops->fetchAll();
|
||||
}
|
||||
unset($trip);
|
||||
|
||||
jsonSuccess(['date' => $today, 'trips' => $trips]);
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
// transit/vehicle/add.php — إضافة باص
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
$plate = strtoupper(filterRequest('plate') ?? '');
|
||||
if (!$plate) jsonError('plate is required');
|
||||
|
||||
$capacity = filterRequest('capacity', 'int') ?? 30;
|
||||
$vType = filterRequest('vehicle_type') ?: 'bus';
|
||||
if (!in_array($vType, ['bus','minibus','van','other'])) $vType = 'bus';
|
||||
|
||||
$chk = $transit_con->prepare("SELECT id FROM transit_vehicles WHERE plate=? LIMIT 1");
|
||||
$chk->execute([$plate]);
|
||||
if ($chk->fetch()) jsonError('Vehicle plate already registered', 409);
|
||||
|
||||
$transit_con->prepare(
|
||||
"INSERT INTO transit_vehicles (org_id,plate,make,model,year,color,capacity,vehicle_type,notes)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)"
|
||||
)->execute([
|
||||
$transit_org_id, $plate,
|
||||
filterRequest('make'), filterRequest('model'),
|
||||
filterRequest('year','int'), filterRequest('color'),
|
||||
$capacity, $vType, filterRequest('notes'),
|
||||
]);
|
||||
|
||||
jsonSuccess(['vehicle_id' => (int)$transit_con->lastInsertId()], 'Vehicle added');
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
// transit/vehicle/list.php
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT id, plate, make, model, year, color, capacity, vehicle_type, is_active, notes, created_at
|
||||
FROM transit_vehicles WHERE org_id=? ORDER BY plate ASC"
|
||||
);
|
||||
$st->execute([$transit_org_id]);
|
||||
|
||||
jsonSuccess(['vehicles' => $st->fetchAll()]);
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
// transit/vehicle/toggle.php — تفعيل/تعطيل مركبة
|
||||
// POST: vehicle_id, is_active (1|0)
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
$vehicleId = filterRequest('vehicle_id', 'int');
|
||||
$isActive = filterRequest('is_active', 'int');
|
||||
if (!$vehicleId || $isActive === null) jsonError('vehicle_id and is_active are required', 400);
|
||||
|
||||
$chk = $transit_con->prepare("SELECT id FROM transit_vehicles WHERE id=? AND org_id=? LIMIT 1");
|
||||
$chk->execute([$vehicleId, $transit_org_id]);
|
||||
if (!$chk->fetch()) jsonError('Vehicle not found or access denied', 404);
|
||||
|
||||
$transit_con->prepare("UPDATE transit_vehicles SET is_active=? WHERE id=?")
|
||||
->execute([$isActive ? 1 : 0, $vehicleId]);
|
||||
|
||||
jsonSuccess(['vehicle_id' => $vehicleId, 'is_active' => (bool)$isActive]);
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
// transit/vehicle/update.php — تعديل بيانات مركبة
|
||||
// POST: vehicle_id, [plate, make, model, year, color, capacity, vehicle_type, notes]
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_admin.php';
|
||||
|
||||
$vehicleId = filterRequest('vehicle_id', 'int');
|
||||
if (!$vehicleId) jsonError('vehicle_id is required', 400);
|
||||
|
||||
$chk = $transit_con->prepare("SELECT id FROM transit_vehicles WHERE id=? AND org_id=? LIMIT 1");
|
||||
$chk->execute([$vehicleId, $transit_org_id]);
|
||||
if (!$chk->fetch()) jsonError('Vehicle not found or access denied', 404);
|
||||
|
||||
$plate = filterRequest('plate');
|
||||
if ($plate) {
|
||||
$plate = strtoupper($plate);
|
||||
$dup = $transit_con->prepare("SELECT id FROM transit_vehicles WHERE plate=? AND id<>? LIMIT 1");
|
||||
$dup->execute([$plate, $vehicleId]);
|
||||
if ($dup->fetch()) jsonError('Vehicle plate already registered', 409);
|
||||
}
|
||||
|
||||
$vType = filterRequest('vehicle_type');
|
||||
if ($vType && !in_array($vType, ['bus','minibus','van','other'])) $vType = null;
|
||||
|
||||
$transit_con->prepare(
|
||||
"UPDATE transit_vehicles
|
||||
SET plate=COALESCE(?, plate), make=COALESCE(?, make), model=COALESCE(?, model),
|
||||
year=COALESCE(?, year), color=COALESCE(?, color), capacity=COALESCE(?, capacity),
|
||||
vehicle_type=COALESCE(?, vehicle_type), notes=COALESCE(?, notes)
|
||||
WHERE id=?"
|
||||
)->execute([
|
||||
$plate, filterRequest('make'), filterRequest('model'),
|
||||
filterRequest('year', 'int'), filterRequest('color'), filterRequest('capacity', 'int'),
|
||||
$vType, filterRequest('notes'),
|
||||
$vehicleId,
|
||||
]);
|
||||
|
||||
jsonSuccess(['vehicle_id' => $vehicleId], 'Vehicle updated');
|
||||
Reference in New Issue
Block a user