diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/RouteHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/RouteHelper.php new file mode 100644 index 0000000..ba4d113 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/RouteHelper.php @@ -0,0 +1,237 @@ +get(DatabaseInterface::class); + $date = $date ?: date('Y-m-d'); + + $db->setQuery($db->getQuery(true) + ->select('wo.id, wo.wo_number, wo.priority, wo.status, wo.trade') + ->select('wo.scheduled_date, wo.scheduled_time, wo.estimated_duration') + ->select('wo.route_order') + ->select('l.name AS location_name, l.address, l.city, l.state, l.zip') + ->select('l.latitude, l.longitude') + ->select('cd.name AS customer_name, cd.telephone AS customer_phone') + ->from($db->quoteName('#__mokosuitefield_work_orders', 'wo')) + ->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'l') . ' ON l.id = wo.location_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id') + ->where($db->quoteName('wo.technician_id') . ' = ' . (int) $techId) + ->where($db->quoteName('wo.scheduled_date') . ' = ' . $db->quote($date)) + ->where($db->quoteName('wo.status') . ' NOT IN (' . $db->quote('cancelled') . ',' . $db->quote('completed') . ')') + ->order('wo.route_order ASC, wo.scheduled_time ASC')); + + return $db->loadObjectList() ?: []; + } + + /** + * Auto-assign route order based on geographic proximity (nearest-neighbor heuristic). + * Starts from the tech's home base or first WO location. + */ + public static function optimizeRoute(int $techId, string $date = ''): array + { + $stops = self::getTechRoute($techId, $date); + if (count($stops) <= 1) return $stops; + + // Get tech home location + $db = Factory::getContainer()->get(DatabaseInterface::class); + $db->setQuery($db->getQuery(true) + ->select('home_latitude, home_longitude') + ->from('#__mokosuitefield_technicians') + ->where('id = ' . (int) $techId)); + $tech = $db->loadObject(); + + $currentLat = (float) ($tech->home_latitude ?? 0); + $currentLng = (float) ($tech->home_longitude ?? 0); + + // Nearest-neighbor sort + $ordered = []; + $remaining = $stops; + + while (!empty($remaining)) { + $bestIdx = 0; + $bestDist = PHP_FLOAT_MAX; + + foreach ($remaining as $idx => $stop) { + $lat = (float) ($stop->latitude ?? 0); + $lng = (float) ($stop->longitude ?? 0); + + if ($lat === 0.0 && $lng === 0.0) { + // No coordinates — keep in original position + $dist = PHP_FLOAT_MAX - 1; + } else { + $dist = self::haversine($currentLat, $currentLng, $lat, $lng); + } + + if ($dist < $bestDist) { + $bestDist = $dist; + $bestIdx = $idx; + } + } + + $next = $remaining[$bestIdx]; + $ordered[] = $next; + $currentLat = (float) ($next->latitude ?? $currentLat); + $currentLng = (float) ($next->longitude ?? $currentLng); + array_splice($remaining, $bestIdx, 1); + $remaining = array_values($remaining); + } + + // Save route order + foreach ($ordered as $i => $stop) { + $update = (object) [ + 'id' => $stop->id, + 'route_order' => $i + 1, + ]; + $db->updateObject('#__mokosuitefield_work_orders', $update, 'id'); + $ordered[$i]->route_order = $i + 1; + } + + return $ordered; + } + + /** + * Manually reorder a stop within a tech's route. + */ + public static function reorderStop(int $woId, int $newPosition): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('technician_id, scheduled_date') + ->from('#__mokosuitefield_work_orders') + ->where('id = ' . (int) $woId)); + $wo = $db->loadObject(); + if (!$wo) return false; + + $stops = self::getTechRoute((int) $wo->technician_id, $wo->scheduled_date); + + // Find and remove the target WO + $target = null; + $filtered = []; + foreach ($stops as $stop) { + if ((int) $stop->id === $woId) { + $target = $stop; + } else { + $filtered[] = $stop; + } + } + + if (!$target) return false; + + // Insert at new position + $newPosition = max(1, min($newPosition, count($filtered) + 1)); + array_splice($filtered, $newPosition - 1, 0, [$target]); + + // Save new order + foreach ($filtered as $i => $stop) { + $update = (object) [ + 'id' => $stop->id, + 'route_order' => $i + 1, + ]; + $db->updateObject('#__mokosuitefield_work_orders', $update, 'id'); + } + + return true; + } + + /** + * Estimate total drive time and distance for a route (using straight-line approximation). + */ + public static function estimateRouteMetrics(int $techId, string $date = ''): object + { + $stops = self::getTechRoute($techId, $date); + + $totalDistance = 0.0; + $totalJobTime = 0; + $legs = []; + + $db = Factory::getContainer()->get(DatabaseInterface::class); + $db->setQuery($db->getQuery(true) + ->select('home_latitude, home_longitude') + ->from('#__mokosuitefield_technicians') + ->where('id = ' . (int) $techId)); + $tech = $db->loadObject(); + + $prevLat = (float) ($tech->home_latitude ?? 0); + $prevLng = (float) ($tech->home_longitude ?? 0); + + foreach ($stops as $stop) { + $lat = (float) ($stop->latitude ?? 0); + $lng = (float) ($stop->longitude ?? 0); + $dist = 0; + + if ($lat && $lng && ($prevLat || $prevLng)) { + $dist = self::haversine($prevLat, $prevLng, $lat, $lng); + } + + $totalDistance += $dist; + $totalJobTime += (int) ($stop->estimated_duration ?? 60); + + $legs[] = (object) [ + 'wo_id' => $stop->id, + 'location' => $stop->location_name ?? $stop->address, + 'distance' => round($dist, 1), + 'drive_min'=> round($dist / 0.5, 0), // ~30 mph avg in service areas + ]; + + $prevLat = $lat ?: $prevLat; + $prevLng = $lng ?: $prevLng; + } + + $totalDriveMin = $totalDistance > 0 ? round($totalDistance / 0.5) : 0; + + return (object) [ + 'stop_count' => count($stops), + 'total_distance' => round($totalDistance, 1), + 'total_drive_min'=> $totalDriveMin, + 'total_job_min' => $totalJobTime, + 'total_day_min' => $totalDriveMin + $totalJobTime, + 'legs' => $legs, + ]; + } + + /** + * Haversine distance in miles. + */ + private static function haversine(float $lat1, float $lon1, float $lat2, float $lon2): float + { + $R = 3959; // Earth radius in miles + $dLat = deg2rad($lat2 - $lat1); + $dLon = deg2rad($lon2 - $lon1); + $a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLon / 2) ** 2; + $c = 2 * atan2(sqrt($a), sqrt(1 - $a)); + return $R * $c; + } + + /** + * Log a GPS breadcrumb for a tech (called from mobile app). + */ + public static function logGpsBreadcrumb(int $techId, float $lat, float $lng, ?int $woId = null): void + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $crumb = (object) [ + 'technician_id' => $techId, + 'latitude' => $lat, + 'longitude' => $lng, + 'wo_id' => $woId, + 'recorded_at' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuitefield_dispatch_log', $crumb); + } +}