Add RouteHelper — daily route building, nearest-neighbor optimization, GPS breadcrumbs, drive time estimates

This commit is contained in:
Jonathan Miller
2026-06-14 15:00:47 -05:00
parent d08648f2fc
commit 532e514106
@@ -0,0 +1,237 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Route optimization helper — build daily routes for technicians, reorder stops, calculate drive time.
*/
class RouteHelper
{
/**
* Get today's route for a technician (work orders sorted by scheduled time).
*/
public static function getTechRoute(int $techId, string $date = ''): array
{
$db = Factory::getContainer()->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);
}
}