Add RouteHelper — daily route building, nearest-neighbor optimization, GPS breadcrumbs, drive time estimates
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user