Update: 2026-07-13 22:23:21

This commit is contained in:
Hamza-Ayed
2026-07-13 22:23:22 +03:00
parent fd3f4a365a
commit c6f48a1f91
45 changed files with 3533 additions and 36 deletions
+23 -6
View File
@@ -1,9 +1,20 @@
<?php
// backend/transit/cron_approaching_alerts.php
// يُنفذ كل دقيقة عبر Cron Job لإرسال تنبيهات اقتراب الباص من المحطات (أقل من 2 كم)
//
// ملاحظة أداء: الاستعلام الأول رخيص (فهرس idx على status)، ولكل رحلة نشطة
// فقط استعلامان إضافيان — ويُتجاهل فوراً أي رحلة بلا موقع GPS حديث (< 3 دقائق)
// لتفادي ضرب القاعدة لرحلات متوقفة أو باصات فقدت الاتصال.
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/connect_transit.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(
@@ -11,17 +22,23 @@ $stTrips = $transit_con->query(
);
$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
// 2. جلب موقع الباص من Redis — تجاهل إن كان قديماً (> 3 دقائق، أي الباص فقد الاتصال)
$pos = transitGetBusPosition($tripId);
if (!$pos || empty($pos['lat'])) continue;
if (empty($pos['ts']) || (time() - (int)$pos['ts']) > 180) continue;
$busLat = (double)$pos['lat'];
$busLng = (double)$pos['lng'];
$busLat = (float)$pos['lat'];
$busLng = (float)$pos['lng'];
// 3. جلب المحطة التالية
$nextSeq = $currentSeq + 1;
@@ -35,8 +52,8 @@ foreach ($trips as $trip) {
if ($nextStop) {
$stopId = (int)$nextStop['id'];
$stopLat = (double)$nextStop['latitude'];
$stopLng = (double)$nextStop['longitude'];
$stopLat = (float)$nextStop['latitude'];
$stopLng = (float)$nextStop['longitude'];
$stopName = $nextStop['name_ar'];
// حساب المسافة تقريبياً
+27
View File
@@ -0,0 +1,27 @@
<?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 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]);
appLog("[TRANSIT][DRIVER][suspend] driver #{$driverId} org#{$transit_org_id} → {$newStatus}");
jsonSuccess(['driver_id' => $driverId, 'status' => $newStatus]);
@@ -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()]);
+28
View File
@@ -137,6 +137,34 @@ 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;
+1 -1
View File
@@ -4,7 +4,7 @@
require_once __DIR__ . '/../../transit/connect_admin.php';
$status = filterRequest('status') ?: 'active';
if (!in_array($status, ['all','draft','active','suspended'])) $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];
+80
View File
@@ -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');
+18
View File
@@ -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');
+25
View File
@@ -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()]);
+3 -1
View File
@@ -138,7 +138,7 @@ CREATE TABLE IF NOT EXISTS `transit_routes` (
`polyline` TEXT DEFAULT NULL COMMENT 'Encoded polyline للمسار',
`distance_km` DECIMAL(7,2) DEFAULT NULL,
`duration_min` SMALLINT DEFAULT NULL,
`status` ENUM('draft','active','suspended') NOT NULL DEFAULT 'draft',
`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,
@@ -337,6 +337,8 @@ SET FOREIGN_KEY_CHECKS = 1;
-- 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 دائماً)
-- -----------------------------------------------------------------
-- -----------------------------------------------------------------
+17 -22
View File
@@ -4,6 +4,8 @@
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');
@@ -11,7 +13,16 @@ if (!$stopId || !$passengerId) {
jsonError('Missing parameters', 400);
}
// 1. تحقق من السائق ورحلته الحالية
// استخرج سجل السائق من JWT — لا نثق بأي معرّف سائق من العميل
$driverRow = $transit_con->prepare(
"SELECT id FROM transit_drivers WHERE main_driver_id=? AND status='active' LIMIT 1"
);
$driverRow->execute([(string)$transit_user_id]);
$driverRec = $driverRow->fetch();
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"
);
@@ -55,27 +66,11 @@ $stInsert = $transit_con->prepare(
);
$stInsert->execute([$tripId, $enrollId, $stopId]);
// 5. إرسال إشعار FCM لولي الأمر (إن وُجد)
$stGuard = $transit_con->prepare(
"SELECT guardian_passenger_id FROM transit_guardians
WHERE enrollment_id=? AND notify_board=1"
);
$stGuard->execute([$enrollId]);
$guardians = $stGuard->fetchAll(PDO::FETCH_COLUMN);
// 5. إرسال إشعار صعود — لولي الأمر (إن وُجد) وللطالب نفسه (تأكيد بمحفظته)
$title = "صعود الحافلة 🚌";
$body = "لقد صعد " . ($enroll['member_name'] ?? 'الطالب') . " إلى الحافلة بنجاح.";
// إذا كان الطالب نفسه يملك التطبيق، أرسل له أيضاً (للتأكيد في محفظته)
$guardians[] = $passengerId;
if (!empty($guardians)) {
$title = "صعود الحافلة 🚌";
$body = "لقد صعد " . ($enroll['member_name'] ?? 'الطالب') . " إلى الحافلة بنجاح.";
// إرسال الإشعار باستخدام دالة الإشعارات الأساسية بالنظام (عبر passenger_id)
foreach ($guardians as $gId) {
// يمكنك هنا مناداة دالة sendFCMToPassenger الموجودة في functions.php الأساسية
// للتبسيط، نكتفي بافتراض وجود دالة transitSendNotificationToPassenger أو نستخدم FCM_Engine
transitSendNotificationToPassenger($gId, $title, $body);
}
}
transitNotifyGuardians($enrollId, 'board', $title, $body);
transitSendNotificationToPassenger((string)$passengerId, $title, $body, ['type' => 'transit_boarded']);
jsonSuccess(['boarded' => true]);
+47
View File
@@ -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');
+18 -1
View File
@@ -18,7 +18,9 @@ if (!$driverRec) jsonError('Driver not found or not active', 403);
$driverId = (int)$driverRec['id'];
$st = $transit_con->prepare(
"SELECT id, route_id, status FROM transit_trips WHERE id=? AND driver_id=? LIMIT 1"
"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();
@@ -38,4 +40,19 @@ 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');
+14
View File
@@ -56,4 +56,18 @@ transitSendTopicNotification(
['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');
+12 -1
View File
@@ -4,10 +4,21 @@
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);
// 1. تحقق من السائق ورحلته
// استخرج سجل السائق من JWT — لا نثق بأي معرّف سائق من العميل
$driverRow = $transit_con->prepare(
"SELECT id FROM transit_drivers WHERE main_driver_id=? AND status='active' LIMIT 1"
);
$driverRow->execute([(string)$transit_user_id]);
$driverRec = $driverRow->fetch();
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"
);
+18
View File
@@ -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]);
+38
View File
@@ -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');