+
-
diff --git a/addon/components/order/details/tracking.js b/addon/components/order/details/tracking.js
index 158ead84e..0579da43c 100644
--- a/addon/components/order/details/tracking.js
+++ b/addon/components/order/details/tracking.js
@@ -5,6 +5,8 @@ import { task } from 'ember-concurrency';
export default class OrderDetailsTrackingComponent extends Component {
@service orderActions;
+ @service fetch;
+ @service notifications;
constructor() {
super(...arguments);
@@ -12,10 +14,399 @@ export default class OrderDetailsTrackingComponent extends Component {
}
@task *loadTrackerData() {
+ if (!this.args.resource || typeof this.args.resource.loadTrackerData !== 'function') {
+ return;
+ }
+
try {
yield this.args.resource.loadTrackerData();
} catch (err) {
debug('Failed to load order tracker data: ' + err.message);
}
}
+
+ get trackerData() {
+ return this.args.resource?.tracker_data;
+ }
+
+ get hasTrackerData() {
+ return Boolean(this.trackerData);
+ }
+
+ get activeEtaSeconds() {
+ return this.trackerData?.eta?.active_stop_seconds;
+ }
+
+ get hasActiveEta() {
+ return this.activeEtaSeconds !== null && this.activeEtaSeconds !== undefined;
+ }
+
+ get smartAdjustedEtaSeconds() {
+ return (
+ this.firstPositiveNumber(this.activeEtaSeconds) ??
+ this.firstPositiveNumber(this.trackerData?.route?.duration_in_traffic_s) ??
+ this.firstPositiveNumber(this.trackerData?.route?.duration_s) ??
+ this.firstPositiveNumber(this.reportedEtaSeconds) ??
+ null
+ );
+ }
+
+ get hasSmartAdjustedEta() {
+ return this.smartAdjustedEtaSeconds !== null && this.smartAdjustedEtaSeconds !== undefined;
+ }
+
+ get smartAdjustedEtaUnavailableLabel() {
+ if (this.driverSignal === 'Missing' || this.driverSignal === 'Stale') {
+ return 'Pending GPS fix';
+ }
+
+ return 'Pending start';
+ }
+
+ get hasCompletionEta() {
+ return Boolean(this.trackerData?.eta?.completion_at);
+ }
+
+ get hasRemainingDistance() {
+ return this.trackerData?.route?.distance_m !== null && this.trackerData?.route?.distance_m !== undefined;
+ }
+
+ get driverSignal() {
+ const trackerData = this.trackerData;
+
+ if (!trackerData?.driver?.location) {
+ return 'Missing';
+ }
+
+ if (trackerData?.insights?.is_location_stale) {
+ return 'Stale';
+ }
+
+ return trackerData?.driver?.online ? 'Live' : 'Offline';
+ }
+
+ get driverStatusLabel() {
+ switch (this.driverSignal) {
+ case 'Live':
+ return 'Driver live';
+ case 'Stale':
+ return 'Driver stale';
+ case 'Missing':
+ return 'Driver missing GPS';
+ default:
+ return 'Driver offline';
+ }
+ }
+
+ get hasDriverLocationAge() {
+ return this.trackerData?.driver?.location_age_seconds !== null && this.trackerData?.driver?.location_age_seconds !== undefined;
+ }
+
+ get driverSignalClass() {
+ switch (this.driverSignal) {
+ case 'Live':
+ return 'text-green-600 dark:text-green-400';
+ case 'Stale':
+ return 'text-yellow-600 dark:text-yellow-400';
+ case 'Missing':
+ return 'text-red-600 dark:text-red-400';
+ default:
+ return 'text-gray-600 dark:text-gray-300';
+ }
+ }
+
+ get routeQualityItems() {
+ const trackerData = this.trackerData;
+
+ if (!trackerData) {
+ return [];
+ }
+
+ const items = [`${this.humanize(trackerData.provider)} route`];
+
+ if (trackerData.confidence) {
+ items.push(`${this.humanize(trackerData.confidence)} confidence`);
+ }
+
+ if (trackerData.fallback_provider) {
+ items.push(`Fallback: ${this.humanize(trackerData.fallback_provider)}`);
+ }
+
+ return items;
+ }
+
+ get confidenceLabel() {
+ return this.humanize(this.trackerData?.confidence || 'unknown');
+ }
+
+ get confidencePercent() {
+ const score = this.trackerData?.confidence_score ?? this.trackerData?.confidence_percent ?? this.trackerData?.confidence_percentage;
+
+ if (score !== null && score !== undefined && !Number.isNaN(Number(score))) {
+ return Math.max(0, Math.min(100, Math.round(Number(score))));
+ }
+
+ switch (this.trackerData?.confidence) {
+ case 'high':
+ return 92;
+ case 'medium':
+ return 68;
+ case 'low':
+ return 34;
+ default:
+ return 0;
+ }
+ }
+
+ get providerLabel() {
+ return this.humanize(this.trackerData?.provider);
+ }
+
+ get fallbackProviderLabel() {
+ return this.humanize(this.trackerData?.fallback_provider);
+ }
+
+ get confidenceToneClass() {
+ switch (this.trackerData?.confidence) {
+ case 'high':
+ return 'tracking-intelligence-pill--good';
+ case 'medium':
+ return 'tracking-intelligence-pill--warn';
+ case 'low':
+ return 'tracking-intelligence-pill--bad';
+ default:
+ return 'tracking-intelligence-pill--muted';
+ }
+ }
+
+ get confidenceSegments() {
+ const confidence = this.trackerData?.confidence;
+ const litCount = confidence === 'high' ? 5 : confidence === 'medium' ? 3 : confidence === 'low' ? 2 : 1;
+
+ return Array.from({ length: 5 }, (_, index) => ({
+ lit: index < litCount,
+ }));
+ }
+
+ get activeStopIndex() {
+ const stops = this.trackerData?.stops ?? [];
+ const activeStop = this.trackerData?.active_stop;
+ const index = stops.findIndex((stop) => this.matchesStop(stop, activeStop));
+
+ return index >= 0 ? index + 1 : null;
+ }
+
+ get totalStops() {
+ return this.trackerData?.stops?.length ?? 0;
+ }
+
+ get activeStopLabel() {
+ if (!this.activeStopIndex || !this.totalStops) {
+ return 'NOW HEADING TO';
+ }
+
+ return `NOW HEADING TO - STOP ${this.activeStopIndex} OF ${this.totalStops}`;
+ }
+
+ get activeStopMarkerLabel() {
+ const activeStop = this.trackerData?.active_stop;
+
+ if (activeStop?.type === 'pickup') {
+ return 'P';
+ }
+
+ if (activeStop?.type === 'dropoff') {
+ return 'D';
+ }
+
+ return this.activeStopIndex ?? '•';
+ }
+
+ get reportedEtaSeconds() {
+ const activeStop = this.trackerData?.active_stop;
+ const eta = this.args.resource?.eta ?? {};
+
+ return activeStop?.eta_seconds ?? eta?.[activeStop?.id] ?? eta?.[activeStop?.uuid] ?? eta?.[activeStop?.public_id] ?? null;
+ }
+
+ get displayedReportedEtaSeconds() {
+ return (
+ this.firstPositiveNumber(this.reportedEtaSeconds) ??
+ this.firstPositiveNumber(this.activeEtaSeconds) ??
+ this.firstPositiveNumber(this.trackerData?.route?.duration_in_traffic_s) ??
+ this.firstPositiveNumber(this.trackerData?.route?.duration_s) ??
+ null
+ );
+ }
+
+ get hasDisplayedReportedEta() {
+ return this.displayedReportedEtaSeconds !== null && this.displayedReportedEtaSeconds !== undefined;
+ }
+
+ get isReportedEtaUntrusted() {
+ const trackerData = this.trackerData;
+
+ if (!trackerData) {
+ return false;
+ }
+
+ return !trackerData?.driver?.location || trackerData?.insights?.is_location_stale || trackerData.fallback_provider || (trackerData.confidence && trackerData.confidence !== 'high');
+ }
+
+ get reportedEtaWarning() {
+ if (!this.isReportedEtaUntrusted) {
+ return 'Reported from existing route data';
+ }
+
+ return this.operatorWarning ?? 'Reported ETA may not reflect the latest tracking signal.';
+ }
+
+ get warningsCount() {
+ return (this.trackerData?.warnings ?? []).length;
+ }
+
+ get diagnosticsSummaryLabel() {
+ return this.warningsCount === 1 ? '1 warning' : `${this.warningsCount} warnings`;
+ }
+
+ get totalProgressPercentage() {
+ const percentage = Number(this.trackerData?.progress?.percentage);
+
+ if (Number.isFinite(percentage)) {
+ return Math.max(0, Math.min(100, percentage));
+ }
+
+ const stops = this.trackerData?.stops ?? [];
+ const completedStops = stops.filter((stop) => stop.completed).length;
+
+ if (stops.length) {
+ return Math.max(0, Math.min(100, Math.round((completedStops / stops.length) * 100)));
+ }
+
+ return 0;
+ }
+
+ get totalProgressStyle() {
+ const percentage = this.hasRemainingDistance && this.totalProgressPercentage === 0 ? 2 : this.totalProgressPercentage;
+
+ return `width: ${percentage}%;`;
+ }
+
+ get currentLeg() {
+ return this.trackerData?.route?.legs?.[0] ?? null;
+ }
+
+ get hasCurrentLegDistance() {
+ return this.currentLeg?.distance_m !== null && this.currentLeg?.distance_m !== undefined;
+ }
+
+ get currentLegProgressPercentage() {
+ const explicit = Number(this.currentLeg?.progress_percentage ?? this.trackerData?.progress?.active_leg_percentage);
+
+ if (Number.isFinite(explicit)) {
+ return Math.max(0, Math.min(100, explicit));
+ }
+
+ if (this.driverSignal === 'Missing') {
+ return 0;
+ }
+
+ if (this.driverSignal === 'Stale') {
+ return 18;
+ }
+
+ return Math.max(8, Math.min(92, this.totalProgressPercentage));
+ }
+
+ get currentLegProgressStyle() {
+ return `width: ${this.currentLegProgressPercentage}%;`;
+ }
+
+ get hasWarnings() {
+ return (this.trackerData?.warnings ?? []).length > 0;
+ }
+
+ get diagnostics() {
+ const trackerData = this.trackerData;
+
+ if (!trackerData) {
+ return [];
+ }
+
+ return [
+ { label: 'Provider', value: this.humanize(trackerData.provider) },
+ { label: 'Fallback', value: trackerData.fallback_provider ? this.humanize(trackerData.fallback_provider) : 'No' },
+ { label: 'Traffic aware', value: trackerData.options?.traffic_enabled ? 'Yes' : 'No' },
+ { label: 'Confidence', value: this.confidenceLabel },
+ { label: 'Driver signal', value: this.driverSignal },
+ { label: 'Warnings', value: String((trackerData.warnings ?? []).length) },
+ ];
+ }
+
+ get operatorWarning() {
+ const trackerData = this.trackerData;
+
+ if (!trackerData) {
+ return null;
+ }
+
+ if (!trackerData?.driver?.location) {
+ return 'Driver location is missing, so ETA accuracy may be limited.';
+ }
+
+ if (trackerData?.insights?.is_location_stale) {
+ return 'Driver location is stale. ETA may not reflect the latest movement.';
+ }
+
+ if (trackerData.fallback_provider) {
+ return `Using ${this.humanize(trackerData.fallback_provider)} fallback because the preferred tracking provider was unavailable.`;
+ }
+
+ if (trackerData.confidence && trackerData.confidence !== 'high') {
+ return `${this.humanize(trackerData.confidence)} confidence ETA. Treat the estimate as directional.`;
+ }
+
+ if ((trackerData.warnings ?? []).some((warning) => String(warning).startsWith('provider_failed'))) {
+ return 'Tracking provider returned an error. Showing the best available estimate.';
+ }
+
+ return null;
+ }
+
+ humanize(value) {
+ return String(value ?? '')
+ .replace(/[_:-]+/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .replace(/\b\w/g, (character) => character.toUpperCase());
+ }
+
+ firstPositiveNumber(value) {
+ const number = Number(value);
+
+ return Number.isFinite(number) && number > 0 ? number : null;
+ }
+
+ matchesStop(stop, activeStop) {
+ if (!stop || !activeStop) {
+ return false;
+ }
+
+ return stop.uuid === activeStop.uuid || stop.public_id === activeStop.public_id || stop.id === activeStop.id;
+ }
+
+ @task *pingDriver() {
+ const order = this.args.resource;
+
+ if (!order) {
+ return;
+ }
+
+ try {
+ yield this.fetch.post(`orders/${order.id}/ping-driver`);
+ this.notifications.success('Driver app ping sent.');
+ } catch (err) {
+ this.notifications.error(err.message ?? 'Unable to ping driver app.');
+ }
+ }
}
diff --git a/addon/components/order/kanban-card.hbs b/addon/components/order/kanban-card.hbs
index 2788dce8c..6dc102093 100644
--- a/addon/components/order/kanban-card.hbs
+++ b/addon/components/order/kanban-card.hbs
@@ -15,10 +15,25 @@
+ {{#if @card.tracker_data.insights.is_location_stale}}
+
+ Stale GPS
+
+ {{else if @card.tracker_data.fallback_provider}}
+
+ Fallback ETA
+
+ {{else if @card.tracker_data.confidence}}
+ {{#unless (eq @card.tracker_data.confidence "high")}}
+
+ Low ETA Confidence
+
+ {{/unless}}
+ {{/if}}
diff --git a/addon/components/route-list.hbs b/addon/components/route-list.hbs
index e2247bdd8..2b596ecbe 100644
--- a/addon/components/route-list.hbs
+++ b/addon/components/route-list.hbs
@@ -2,10 +2,29 @@
{{#if this.firstStop}}
-
{{this.firstStop.label}}
+
{{this.firstStop.label}}
-
+
+ {{#if this.firstStop.place.status_code}}
+
+ {{/if}}
+ {{#if this.firstStop.completed}}
+ Completed
+ {{else if this.firstStop.active}}
+ Current Stop
+ {{/if}}
+ {{#if this.firstStop.etaSeconds}}
+ ETA: {{format-duration this.firstStop.etaSeconds}}
+ {{/if}}
+ {{#if this.firstStop.etaAt}}
+ {{format-date-fns this.firstStop.etaAt "d MMM HH:mm"}}
+ {{/if}}
+
+
{{/if}}
@@ -24,10 +43,26 @@
{{#each this.middleStops as |stop|}}
-
{{stop.label}}
+
{{stop.label}}
-
+
+ {{#if stop.place.status_code}}
+
+ {{/if}}
+ {{#if stop.completed}}
+ Completed
+ {{else if stop.active}}
+ Current Stop
+ {{/if}}
+ {{#if stop.etaSeconds}}
+ ETA: {{format-duration stop.etaSeconds}}
+ {{/if}}
+ {{#if stop.etaAt}}
+ {{format-date-fns stop.etaAt "d MMM HH:mm"}}
+ {{/if}}
+
+
{{/each}}
@@ -36,10 +71,26 @@
{{#each this.middleStops as |stop|}}
-
{{stop.label}}
+
{{stop.label}}
-
+
+ {{#if stop.place.status_code}}
+
+ {{/if}}
+ {{#if stop.completed}}
+ Completed
+ {{else if stop.active}}
+ Current Stop
+ {{/if}}
+ {{#if stop.etaSeconds}}
+ ETA: {{format-duration stop.etaSeconds}}
+ {{/if}}
+ {{#if stop.etaAt}}
+ {{format-date-fns stop.etaAt "d MMM HH:mm"}}
+ {{/if}}
+
+
{{/each}}
@@ -48,10 +99,29 @@
{{#if this.lastStop}}
-
{{this.lastStop.label}}
+
{{this.lastStop.label}}
-
+
+ {{#if this.lastStop.place.status_code}}
+
+ {{/if}}
+ {{#if this.lastStop.completed}}
+ Completed
+ {{else if this.lastStop.active}}
+ Current Stop
+ {{/if}}
+ {{#if this.lastStop.etaSeconds}}
+ ETA: {{format-duration this.lastStop.etaSeconds}}
+ {{/if}}
+ {{#if this.lastStop.etaAt}}
+ {{format-date-fns this.lastStop.etaAt "d MMM HH:mm"}}
+ {{/if}}
+
+
{{/if}}
diff --git a/addon/components/route-list.js b/addon/components/route-list.js
index f066d13db..34c3e5c62 100644
--- a/addon/components/route-list.js
+++ b/addon/components/route-list.js
@@ -16,12 +16,22 @@ export default class RouteListComponent extends Component {
get routeStops() {
const routePoints = buildRoutePointsFromPayload(this.args.order?.payload);
- return routePoints.map((routePoint) => ({
- routePoint,
- place: routePoint.place,
- badgeStyle: this.badgeStyleForStop(routePoint),
- ...describeRoutePoint(routePoint, this.routeColor),
- }));
+ return routePoints
+ .map((routePoint) => ({
+ routePoint,
+ place: routePoint.place,
+ badgeStyle: this.badgeStyleForStop(routePoint),
+ trackingStop: this.trackingStopFor(routePoint.place),
+ routeLeg: this.routeLegFor(routePoint.place),
+ ...describeRoutePoint(routePoint, this.routeColor),
+ }))
+ .map((stop) => ({
+ ...stop,
+ etaSeconds: stop.routeLeg?.eta_seconds ?? stop.routeLeg?.duration_in_traffic_s ?? stop.routeLeg?.duration_s ?? this.legacyEtaFor(stop.place),
+ etaAt: stop.routeLeg?.eta_at,
+ completed: Boolean(stop.trackingStop?.completed),
+ active: this.matchesStop(stop.trackingStop, this.args.order?.tracker_data?.active_stop),
+ }));
}
get firstStop() {
@@ -52,6 +62,38 @@ export default class RouteListComponent extends Component {
return `background-color: ${markerColor}; color: ${textColor};`;
}
+ trackingStopFor(place) {
+ const stops = this.args.order?.tracker_data?.stops ?? [];
+
+ return stops.find((stop) => this.matchesPlace(stop, place)) ?? null;
+ }
+
+ routeLegFor(place) {
+ const legs = this.args.order?.tracker_data?.route?.legs ?? [];
+
+ return legs.find((leg) => this.matchesPlace(leg.stop, place)) ?? null;
+ }
+
+ legacyEtaFor(place) {
+ return this.args.eta?.[place?.id] ?? this.args.eta?.[place?.uuid] ?? this.args.eta?.[place?.public_id] ?? null;
+ }
+
+ matchesPlace(stop, place) {
+ if (!stop || !place) {
+ return false;
+ }
+
+ return stop.uuid === place.uuid || stop.public_id === place.public_id || stop.id === place.id;
+ }
+
+ matchesStop(stop, activeStop) {
+ if (!stop || !activeStop) {
+ return false;
+ }
+
+ return stop.uuid === activeStop.uuid || stop.public_id === activeStop.public_id;
+ }
+
@action toggleWaypointsCollapse() {
this.isWaypointsCollapsed = !this.isWaypointsCollapsed;
}
diff --git a/addon/components/tracking-stop-progress.hbs b/addon/components/tracking-stop-progress.hbs
new file mode 100644
index 000000000..ae1606d0a
--- /dev/null
+++ b/addon/components/tracking-stop-progress.hbs
@@ -0,0 +1,45 @@
+
\ No newline at end of file
diff --git a/addon/components/tracking-stop-progress.js b/addon/components/tracking-stop-progress.js
new file mode 100644
index 000000000..52d31def7
--- /dev/null
+++ b/addon/components/tracking-stop-progress.js
@@ -0,0 +1,79 @@
+import Component from '@glimmer/component';
+
+export default class TrackingStopProgressComponent extends Component {
+ get stops() {
+ const stops = this.args.stops ?? [];
+ const activeStop = this.args.activeStop;
+
+ return stops.map((stop, index) => {
+ const isActive = this.matches(stop, activeStop);
+ const completed = Boolean(stop.completed);
+
+ return {
+ ...stop,
+ index: index + 1,
+ isFirst: index === 0,
+ isLast: index === stops.length - 1,
+ label: this.labelFor(stop, index),
+ title: this.titleFor(stop, index),
+ locationLabel: this.locationLabelFor(stop, index),
+ place: this.placeFor(stop),
+ completed,
+ active: isActive,
+ pending: !completed && !isActive,
+ };
+ });
+ }
+
+ get completedCount() {
+ return this.stops.filter((stop) => stop.completed).length;
+ }
+
+ get totalCount() {
+ return this.stops.length;
+ }
+
+ labelFor(stop, index) {
+ if (stop.type === 'pickup') {
+ return 'P';
+ }
+
+ if (stop.type === 'dropoff') {
+ return 'D';
+ }
+
+ return String(index + 1);
+ }
+
+ titleFor(stop, index) {
+ if (stop.type === 'pickup') {
+ return 'Pickup';
+ }
+
+ if (stop.type === 'dropoff') {
+ return 'Dropoff';
+ }
+
+ return `Stop ${index + 1}`;
+ }
+
+ locationLabelFor(stop, index) {
+ return stop.city || stop.name || stop.address || this.titleFor(stop, index);
+ }
+
+ placeFor(stop) {
+ return {
+ ...stop,
+ street1: stop.street1 || stop.address || stop.name,
+ country_name: stop.country_name || stop.country,
+ };
+ }
+
+ matches(stop, activeStop) {
+ if (!stop || !activeStop) {
+ return false;
+ }
+
+ return stop.uuid === activeStop.uuid || stop.public_id === activeStop.public_id;
+ }
+}
diff --git a/addon/controllers/settings/routing.js b/addon/controllers/settings/routing.js
index 5863975af..2af4b7d89 100644
--- a/addon/controllers/settings/routing.js
+++ b/addon/controllers/settings/routing.js
@@ -1,6 +1,7 @@
import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
import { task } from 'ember-concurrency';
export default class SettingsRoutingController extends Controller {
@@ -12,6 +13,19 @@ export default class SettingsRoutingController extends Controller {
@tracked displayEngine = 'osrm';
@tracked optimizationEngine = 'osrm';
@tracked routingUnit = 'km';
+ @tracked trackingProvider = 'google_routes';
+ @tracked trackingFallbacks = ['osrm', 'calculated'];
+ @tracked trackingTrafficEnabled = true;
+ @tracked trackingCacheTtlSeconds = 60;
+ @tracked trackingRouteCacheTtlSeconds = 600;
+ @tracked trackingStaleLocationThresholdSeconds = 300;
+ @tracked trackingDefaultVehicleSpeedKph = 35;
+ @tracked showTrackingAdvancedSettings = false;
+ @tracked trackingProviderOptions = [
+ { value: 'google_routes', label: 'Google Routes' },
+ { value: 'osrm', label: 'OSRM' },
+ { value: 'calculated', label: 'Calculated' },
+ ];
@tracked routingUnitOptions = [
{ label: 'Kilometers', value: 'km' },
{ label: 'Miles', value: 'mi' },
@@ -39,6 +53,8 @@ export default class SettingsRoutingController extends Controller {
optimization_engine: this.optimizationEngine,
unit: this.routingUnit,
});
+ const trackingSettings = this.trackingSettingsPayload;
+ yield this.fetch.post('fleet-ops/settings/tracking-settings', trackingSettings);
yield this.performAdditionalSaveTasks();
// Save in local memory too
this.currentUser.setOption('routing', {
@@ -47,7 +63,8 @@ export default class SettingsRoutingController extends Controller {
routing_optimization_engine: this.optimizationEngine,
unit: this.routingUnit,
});
- this.notifications.success('Routing setting saved.');
+ this.currentUser.setOption('tracking', trackingSettings);
+ this.notifications.success('Routing and tracking settings saved.');
} catch (error) {
this.notifications.serverError(error);
}
@@ -64,11 +81,91 @@ export default class SettingsRoutingController extends Controller {
this.displayEngine = display_engine ?? router ?? 'osrm';
this.optimizationEngine = optimization_engine ?? display_engine ?? router ?? 'osrm';
this.routingUnit = unit;
+ const trackingSettings = yield this.fetch.get('fleet-ops/settings/tracking-settings');
+ this.trackingProvider = trackingSettings.provider ?? 'google_routes';
+ this.trackingFallbacks = this.normalizeFallbacks(trackingSettings.fallbacks);
+ this.trackingTrafficEnabled = trackingSettings.traffic_enabled ?? true;
+ this.trackingCacheTtlSeconds = trackingSettings.cache_ttl_seconds ?? 60;
+ this.trackingRouteCacheTtlSeconds = trackingSettings.route_cache_ttl_seconds ?? 600;
+ this.trackingStaleLocationThresholdSeconds = trackingSettings.stale_location_threshold_seconds ?? 300;
+ this.trackingDefaultVehicleSpeedKph = trackingSettings.default_vehicle_speed_kph ?? 35;
+ this.trackingProviderOptions = this.normalizeProviderOptions(trackingSettings.providers ?? this.trackingProviderOptions);
} catch (error) {
this.notifications.serverError(error);
}
}
+ get selectedTrackingFallbackOptions() {
+ const selected = new Set(this.normalizeFallbacks(this.trackingFallbacks));
+
+ return this.trackingProviderOptions.filter((option) => selected.has(this.optionValue(option)));
+ }
+
+ get trackingSettingsPayload() {
+ return {
+ provider: this.trackingProvider,
+ fallbacks: this.normalizeFallbacks(this.trackingFallbacks),
+ traffic_enabled: this.trackingTrafficEnabled,
+ cache_ttl_seconds: Number(this.trackingCacheTtlSeconds) || 60,
+ route_cache_ttl_seconds: Number(this.trackingRouteCacheTtlSeconds) || 600,
+ stale_location_threshold_seconds: Number(this.trackingStaleLocationThresholdSeconds) || 300,
+ default_vehicle_speed_kph: Number(this.trackingDefaultVehicleSpeedKph) || 35,
+ };
+ }
+
+ normalizeFallbacks(fallbacks) {
+ if (Array.isArray(fallbacks)) {
+ return fallbacks
+ .map((fallback) => this.optionValue(fallback))
+ .map((fallback) => String(fallback).trim())
+ .filter(Boolean);
+ }
+
+ return String(fallbacks ?? '')
+ .split(',')
+ .map((fallback) => fallback.trim())
+ .filter(Boolean);
+ }
+
+ normalizeProviderOptions(options = []) {
+ return options.map((option) => {
+ const value = this.optionValue(option);
+ const label = option?.label ?? option?.name ?? this.providerLabel(value);
+
+ return {
+ ...option,
+ key: option?.key ?? value,
+ name: option?.name ?? label,
+ value,
+ label,
+ };
+ });
+ }
+
+ providerLabel(value) {
+ if (value === 'osrm') {
+ return 'OSRM';
+ }
+
+ return String(value ?? '')
+ .replace(/[_-]+/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .replace(/\b\w/g, (character) => character.toUpperCase());
+ }
+
+ optionValue(option) {
+ return typeof option === 'object' && option !== null ? (option.value ?? option.key) : option;
+ }
+
+ @action setTrackingFallbacks(options) {
+ this.trackingFallbacks = this.normalizeFallbacks(options);
+ }
+
+ @action toggleTrackingAdvancedSettings() {
+ this.showTrackingAdvancedSettings = !this.showTrackingAdvancedSettings;
+ }
+
registerSaveTask(key, task) {
if (!key || typeof task?.perform !== 'function') {
return;
diff --git a/addon/helpers/format-duration.js b/addon/helpers/format-duration.js
new file mode 100644
index 000000000..6bf47bc02
--- /dev/null
+++ b/addon/helpers/format-duration.js
@@ -0,0 +1,38 @@
+import { helper } from '@ember/component/helper';
+
+export function formatDurationValue(seconds) {
+ const value = Number(seconds);
+
+ if (!Number.isFinite(value)) {
+ return '0s';
+ }
+
+ const totalSeconds = Math.max(0, Math.ceil(value));
+ const days = Math.floor(totalSeconds / 86400);
+ const hours = Math.floor((totalSeconds % 86400) / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const remainingSeconds = totalSeconds % 60;
+ const parts = [];
+
+ if (days) {
+ parts.push(`${days}d`);
+ }
+
+ if (hours) {
+ parts.push(`${hours}h`);
+ }
+
+ if (!days && minutes) {
+ parts.push(`${minutes}m`);
+ }
+
+ if (!days && !hours && parts.length < 2 && remainingSeconds) {
+ parts.push(`${remainingSeconds}s`);
+ }
+
+ return parts.length ? parts.join(' ') : '0s';
+}
+
+export default helper(function formatDuration([seconds]) {
+ return formatDurationValue(seconds);
+});
diff --git a/addon/routes/application.js b/addon/routes/application.js
index 92116ce4e..ceac059c4 100644
--- a/addon/routes/application.js
+++ b/addon/routes/application.js
@@ -29,6 +29,7 @@ export default class ApplicationRoute extends Route {
await this.location.getUserLocation();
await this.#loadRoutingSettings();
+ await this.#loadTrackingSettings();
await this.#loadMapSettings();
}
@@ -37,6 +38,11 @@ export default class ApplicationRoute extends Route {
this.currentUser.setOption('routing', routingSetting);
}
+ async #loadTrackingSettings() {
+ const trackingSettings = await this.fetch.get('fleet-ops/settings/tracking-settings');
+ this.currentUser.setOption('tracking', trackingSettings);
+ }
+
async #loadMapSettings() {
await this.mapSettings.load();
}
diff --git a/addon/services/order-list-overlay.js b/addon/services/order-list-overlay.js
index 8949b5c17..1b31dd9c6 100644
--- a/addon/services/order-list-overlay.js
+++ b/addon/services/order-list-overlay.js
@@ -185,12 +185,12 @@ export default class OrderListOverlayService extends Service {
#peekActiveOrders() {
// Consider an order "active" if it has an assigned driver and is not terminal
- const TERMINAL = ['created', 'completed', 'canceled', 'expired'];
+ const TERMINAL = ['created', 'completed', 'canceled', 'expired', 'order_canceled', 'pending'];
return this.#peekOrders((order) => !!order.driver_assigned && !TERMINAL.includes(order.status));
}
#peekDriverActiveOrders(driver) {
- const TERMINAL = ['created', 'completed', 'canceled', 'expired'];
+ const TERMINAL = ['created', 'completed', 'canceled', 'expired', 'order_canceled', 'pending'];
return this.#peekOrders((order) => !!order.driver_assigned && order.driver_assigned?.id === driver.id && !TERMINAL.includes(order.status));
}
diff --git a/addon/styles/fleetops-engine.css b/addon/styles/fleetops-engine.css
index 622a3e4c4..aa113c004 100644
--- a/addon/styles/fleetops-engine.css
+++ b/addon/styles/fleetops-engine.css
@@ -4064,3 +4064,736 @@ body[data-theme='dark'] .next-google-container-map .fleetops-google-draw-actions
.gm-style .gm-style-iw-tc::after {
background: #111827 !important;
}
+
+/* Tracking intelligence panel */
+.tracking-intelligence {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.tracking-intelligence__status {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.375rem;
+ font-size: 0.72rem;
+}
+
+.tracking-intelligence__updated {
+ margin-left: auto;
+ color: #6b7280;
+ white-space: nowrap;
+}
+
+[data-theme='dark'] .tracking-intelligence__updated {
+ color: #6b7280;
+}
+
+.tracking-intelligence-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.125rem 0.5rem;
+ border: 1px solid transparent;
+ border-radius: 9999px;
+ font-size: 0.7rem;
+ font-weight: 600;
+ line-height: 1.35;
+ white-space: nowrap;
+}
+
+.tracking-intelligence-pill__dot {
+ width: 0.35rem;
+ height: 0.35rem;
+ border-radius: 9999px;
+ background: currentcolor;
+}
+
+.tracking-intelligence-pill--good {
+ color: #16a34a;
+ background: rgba(34, 197, 94, 10%);
+ border-color: rgba(34, 197, 94, 24%);
+}
+
+.tracking-intelligence-pill--warn {
+ color: #d97706;
+ background: rgba(245, 158, 11, 12%);
+ border-color: rgba(245, 158, 11, 28%);
+}
+
+.tracking-intelligence-pill--bad {
+ color: #dc2626;
+ background: rgba(239, 68, 68, 11%);
+ border-color: rgba(239, 68, 68, 28%);
+}
+
+.tracking-intelligence-pill--muted {
+ color: #6b7280;
+ background: rgba(107, 114, 128, 10%);
+ border-color: rgba(107, 114, 128, 22%);
+}
+
+[data-theme='dark'] .tracking-intelligence-pill--good {
+ color: #5bc796;
+ background: rgba(91, 199, 150, 12%);
+}
+
+[data-theme='dark'] .tracking-intelligence-pill--warn {
+ color: #e5a14b;
+ background: rgba(229, 161, 75, 12%);
+ border-color: rgba(229, 161, 75, 32%);
+}
+
+[data-theme='dark'] .tracking-intelligence-pill--bad {
+ color: #e26b6b;
+ background: rgba(226, 107, 107, 13%);
+ border-color: rgba(226, 107, 107, 32%);
+}
+
+[data-theme='dark'] .tracking-intelligence-pill--muted {
+ color: #7c8896;
+ background: rgba(255, 255, 255, 4%);
+ border-color: #232c38;
+}
+
+.tracking-intelligence-alert {
+ display: grid;
+ grid-template-columns: 1.125rem minmax(0, 1fr) auto;
+ gap: 0.625rem;
+ align-items: flex-start;
+ padding: 0.625rem 0.7rem;
+ border: 1px solid rgba(245, 158, 11, 28%);
+ border-radius: 0.5rem;
+ background: rgba(245, 158, 11, 10%);
+ color: #92400e;
+ font-size: 0.75rem;
+}
+
+.tracking-intelligence-alert--critical {
+ border-color: rgba(239, 68, 68, 28%);
+ background: rgba(239, 68, 68, 10%);
+ color: #991b1b;
+}
+
+.tracking-intelligence-alert__icon {
+ display: flex;
+ width: 1.125rem;
+ height: 1.125rem;
+ align-items: center;
+ justify-content: center;
+ border-radius: 0.3125rem;
+ background: rgba(0, 0, 0, 10%);
+}
+
+.tracking-intelligence-alert__title {
+ color: #111827;
+ font-weight: 700;
+}
+
+.tracking-intelligence-alert__body {
+ margin-top: 0.125rem;
+ color: #78350f;
+ line-height: 1.35;
+}
+
+[data-theme='dark'] .tracking-intelligence-alert {
+ border-color: rgba(229, 161, 75, 32%);
+ background: rgba(229, 161, 75, 12%);
+ color: #e5a14b;
+}
+
+[data-theme='dark'] .tracking-intelligence-alert--critical {
+ border-color: rgba(226, 107, 107, 32%);
+ background: rgba(226, 107, 107, 13%);
+ color: #e26b6b;
+}
+
+[data-theme='dark'] .tracking-intelligence-alert__title {
+ color: #eceff4;
+}
+
+[data-theme='dark'] .tracking-intelligence-alert__body {
+ color: #b6bfca;
+}
+
+.tracking-intelligence-alert--critical .tracking-intelligence-alert__cta {
+ border-color: rgba(239, 68, 68, 30%);
+ background: rgba(239, 68, 68, 8%);
+ color: #b91c1c;
+}
+
+[data-theme='dark'] .tracking-intelligence-alert--critical .tracking-intelligence-alert__cta {
+ border-color: rgba(226, 107, 107, 32%);
+ background: rgba(226, 107, 107, 10%);
+ color: #e26b6b;
+}
+
+.tracking-intelligence__eta-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 0.5rem;
+}
+
+.tracking-intelligence-cell,
+.tracking-intelligence-destination,
+.tracking-intelligence-distance,
+.tracking-intelligence-diagnostics,
+.tracking-intelligence-labels,
+.tracking-stop-progress {
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ background: #f9fafb;
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 8%);
+}
+
+[data-theme='dark'] .tracking-intelligence-cell,
+[data-theme='dark'] .tracking-intelligence-destination,
+[data-theme='dark'] .tracking-intelligence-distance,
+[data-theme='dark'] .tracking-intelligence-diagnostics,
+[data-theme='dark'] .tracking-intelligence-labels,
+[data-theme='dark'] .tracking-stop-progress {
+ border-color: #374151;
+ background: #1f2937;
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 18%);
+}
+
+.tracking-intelligence-cell {
+ min-width: 0;
+ padding: 0.6rem 0.7rem;
+}
+
+.tracking-intelligence-cell--accent {
+ border-color: rgba(245, 158, 11, 32%);
+ background: linear-gradient(180deg, rgba(245, 158, 11, 8%), transparent), #f9fafb;
+}
+
+[data-theme='dark'] .tracking-intelligence-cell--accent {
+ border-color: rgba(229, 161, 75, 32%);
+ background: linear-gradient(180deg, rgba(229, 161, 75, 8%), transparent), #1f2937;
+}
+
+.tracking-intelligence-cell__label {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.35rem;
+ margin-bottom: 0.3rem;
+ color: #6b7280;
+ font-size: 0.62rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.tracking-intelligence-cell__value {
+ overflow: hidden;
+ color: #111827;
+ font-size: 1rem;
+ font-weight: 700;
+ line-height: 1.2;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tracking-intelligence-cell--accent .tracking-intelligence-cell__value {
+ color: #d97706;
+}
+
+.tracking-intelligence-cell__value--muted {
+ color: #6b7280;
+ text-decoration: line-through;
+ text-decoration-color: #ef4444;
+ text-decoration-thickness: 1px;
+}
+
+.tracking-intelligence-cell__sub {
+ margin-top: 0.2rem;
+ overflow: hidden;
+ color: #6b7280;
+ font-size: 0.68rem;
+ line-height: 1.25;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tracking-intelligence-cell__sub--warn {
+ color: #b45309;
+ white-space: normal;
+}
+
+[data-theme='dark'] .tracking-intelligence-cell__label,
+[data-theme='dark'] .tracking-intelligence-cell__sub {
+ color: #7c8896;
+}
+
+[data-theme='dark'] .tracking-intelligence-cell__value {
+ color: #eceff4;
+}
+
+[data-theme='dark'] .tracking-intelligence-cell--accent .tracking-intelligence-cell__value {
+ color: #e5a14b;
+}
+
+[data-theme='dark'] .tracking-intelligence-cell__value--muted {
+ color: #7c8896;
+}
+
+[data-theme='dark'] .tracking-intelligence-cell__sub--warn {
+ color: #e5a14b;
+}
+
+.tracking-intelligence-tag {
+ padding: 0.0625rem 0.3rem;
+ border-radius: 0.2rem;
+ background: rgba(245, 158, 11, 12%);
+ color: #d97706;
+ font-size: 0.55rem;
+ font-weight: 800;
+}
+
+.tracking-intelligence-confidence {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ gap: 0.5rem;
+ align-items: center;
+ font-size: 0.7rem;
+}
+
+.tracking-intelligence-confidence__label,
+.tracking-intelligence-confidence__value {
+ color: #6b7280;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+.tracking-intelligence-confidence__segments {
+ display: flex;
+ gap: 0.125rem;
+}
+
+.tracking-intelligence-confidence__segments span {
+ height: 0.25rem;
+ flex: 1;
+ border-radius: 0.125rem;
+ background: #e5e7eb;
+}
+
+.tracking-intelligence-confidence__segments .is-lit {
+ background: #d97706;
+}
+
+.tracking-intelligence-confidence__segments.tracking-intelligence-pill--good .is-lit {
+ background: #16a34a;
+}
+
+.tracking-intelligence-confidence__segments.tracking-intelligence-pill--bad .is-lit {
+ background: #dc2626;
+}
+
+[data-theme='dark'] .tracking-intelligence-confidence__label,
+[data-theme='dark'] .tracking-intelligence-confidence__value {
+ color: #7c8896;
+}
+
+[data-theme='dark'] .tracking-intelligence-confidence__segments span {
+ background: #232c38;
+}
+
+[data-theme='dark'] .tracking-intelligence-confidence__segments .is-lit {
+ background: #e5a14b;
+}
+
+.tracking-intelligence-destination {
+ display: flex;
+ gap: 0.625rem;
+ align-items: flex-start;
+ padding: 0.65rem 0.7rem;
+}
+
+.tracking-intelligence-destination__marker {
+ display: flex;
+ width: 1.5rem;
+ height: 1.5rem;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: center;
+ border-radius: 9999px;
+ background: #f59e0b;
+ color: #1f1300;
+ font-size: 0.7rem;
+ font-weight: 800;
+}
+
+.tracking-intelligence-destination__label {
+ color: #6b7280;
+ font-size: 0.62rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.tracking-intelligence-destination__address {
+ margin-top: 0.15rem;
+ color: #111827;
+ font-size: 0.82rem;
+ font-weight: 700;
+ line-height: 1.3;
+}
+
+.tracking-intelligence-destination__eta {
+ margin-top: 0.2rem;
+ color: #6b7280;
+ font-size: 0.7rem;
+}
+
+[data-theme='dark'] .tracking-intelligence-destination__label,
+[data-theme='dark'] .tracking-intelligence-destination__eta {
+ color: #7c8896;
+}
+
+[data-theme='dark'] .tracking-intelligence-destination__address {
+ color: #eceff4;
+}
+
+.tracking-intelligence-distance {
+ padding: 0.6rem 0.7rem;
+}
+
+.tracking-intelligence-distance__row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 0.75rem;
+ align-items: center;
+ color: #6b7280;
+ font-size: 0.72rem;
+}
+
+.tracking-intelligence-distance__row + .tracking-intelligence-distance__row {
+ margin-top: 0.35rem;
+}
+
+.tracking-intelligence-distance__row strong {
+ color: #111827;
+}
+
+.tracking-intelligence-distance__bar {
+ grid-column: 1 / -1;
+ height: 0.32rem;
+ overflow: hidden;
+ border-radius: 9999px;
+ background: #e5e7eb;
+}
+
+.tracking-intelligence-distance__bar span {
+ display: block;
+ height: 100%;
+ border-radius: inherit;
+ background: #f59e0b;
+}
+
+.tracking-intelligence-distance__bar--secondary span {
+ background: #38bdf8;
+}
+
+.tracking-intelligence-distance__foot {
+ margin-top: 0.5rem;
+ padding-top: 0.45rem;
+ border-top: 1px dashed #d1d5db;
+ color: #9ca3af;
+ font-size: 0.65rem;
+}
+
+[data-theme='dark'] .tracking-intelligence-distance__row {
+ color: #7c8896;
+}
+
+[data-theme='dark'] .tracking-intelligence-distance__row strong {
+ color: #eceff4;
+}
+
+[data-theme='dark'] .tracking-intelligence-distance__bar {
+ background: #20293a;
+}
+
+[data-theme='dark'] .tracking-intelligence-distance__bar span {
+ background: #e5a14b;
+}
+
+[data-theme='dark'] .tracking-intelligence-distance__bar--secondary span {
+ background: #5baec7;
+}
+
+[data-theme='dark'] .tracking-intelligence-distance__foot {
+ border-color: #374151;
+ color: #54616f;
+}
+
+.tracking-intelligence-diagnostics {
+ overflow: hidden;
+}
+
+.tracking-intelligence-diagnostics summary {
+ display: flex;
+ cursor: pointer;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 0.55rem 0.7rem;
+ color: #6b7280;
+ font-size: 0.68rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ list-style: none;
+ text-transform: uppercase;
+}
+
+.tracking-intelligence-diagnostics__summary {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ white-space: nowrap;
+}
+
+.tracking-intelligence-diagnostics__summary svg {
+ transition: transform 160ms ease;
+}
+
+.tracking-intelligence-diagnostics[open] .tracking-intelligence-diagnostics__summary svg {
+ transform: rotate(90deg);
+}
+
+.tracking-intelligence-diagnostics summary::-webkit-details-marker {
+ display: none;
+}
+
+.tracking-intelligence-diagnostics__body {
+ padding: 0 0.7rem 0.7rem;
+}
+
+.tracking-intelligence-diagnostics__row {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.28rem 0;
+ border-top: 1px dashed #e5e7eb;
+ color: #6b7280;
+ font-size: 0.72rem;
+}
+
+.tracking-intelligence-diagnostics__row strong {
+ color: #111827;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+}
+
+.tracking-intelligence-diagnostics__warnings {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ padding-top: 0.5rem;
+}
+
+.tracking-intelligence-diagnostics__warnings span {
+ padding: 0.125rem 0.375rem;
+ border: 1px solid rgba(245, 158, 11, 25%);
+ border-radius: 0.25rem;
+ background: rgba(245, 158, 11, 10%);
+ color: #b45309;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 0.62rem;
+}
+
+[data-theme='dark'] .tracking-intelligence-diagnostics summary,
+[data-theme='dark'] .tracking-intelligence-diagnostics__row {
+ color: #7c8896;
+}
+
+[data-theme='dark'] .tracking-intelligence-diagnostics__row {
+ border-color: #374151;
+}
+
+[data-theme='dark'] .tracking-intelligence-diagnostics__row strong {
+ color: #eceff4;
+}
+
+.tracking-stop-progress {
+ padding: 0.65rem 0.7rem;
+}
+
+.tracking-stop-progress__header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-bottom: 0.55rem;
+}
+
+.tracking-stop-progress__title {
+ color: #6b7280;
+ font-size: 0.65rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.tracking-stop-progress__count {
+ color: #6b7280;
+ font-size: 0.7rem;
+ white-space: nowrap;
+}
+
+.tracking-stop-progress__scroller {
+ overflow: auto hidden;
+ min-height: 2rem;
+ padding: 0.2rem 0 0.05rem;
+}
+
+.tracking-stop-progress__rail {
+ display: flex;
+ min-width: 100%;
+}
+
+.tracking-stop-progress__stop {
+ min-width: 2.5rem;
+ flex: 1;
+}
+
+.tracking-stop-progress__line {
+ display: flex;
+ align-items: center;
+}
+
+.tracking-stop-progress__connector {
+ height: 1px;
+ flex: 1;
+ border-top: 1px dashed #d1d5db;
+}
+
+.tracking-stop-progress__dot {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ width: 1.25rem;
+ height: 1.25rem;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid #d1d5db;
+ border-radius: 9999px;
+ background: #f3f4f6;
+ color: #6b7280;
+ font-size: 0.58rem;
+ font-weight: 800;
+}
+
+.tracking-stop-progress__dot--done {
+ border-color: #22c55e;
+ background: #22c55e;
+ color: #06210f;
+}
+
+.tracking-stop-progress__dot--active {
+ border-color: #f59e0b;
+ background: #f59e0b;
+ color: #1f1300;
+ box-shadow: 0 0 0 3px rgba(245, 158, 11, 14%);
+}
+
+.tracking-stop-progress__dot--active::before,
+.fleetops-route-stop-badge--active::before {
+ position: absolute;
+ inset: -0.45rem;
+ border: 1.5px solid currentcolor;
+ border-radius: 9999px;
+ content: '';
+ opacity: 0.65;
+ animation: fleetops-tracking-pulse 2.2s ease-out infinite;
+}
+
+.fleetops-route-stop-badge--active {
+ position: relative;
+ overflow: visible !important;
+}
+
+@keyframes fleetops-tracking-pulse {
+ 0% {
+ opacity: 0.7;
+ transform: scale(0.72);
+ }
+
+ 100% {
+ opacity: 0;
+ transform: scale(1.65);
+ }
+}
+
+[data-theme='dark'] .tracking-stop-progress__title,
+[data-theme='dark'] .tracking-stop-progress__count {
+ color: #7c8896;
+}
+
+[data-theme='dark'] .tracking-stop-progress__connector {
+ border-color: #374151;
+}
+
+[data-theme='dark'] .tracking-stop-progress__dot {
+ border-color: #374151;
+ background: #20293a;
+ color: #7c8896;
+}
+
+[data-theme='dark'] .tracking-stop-progress__dot--done {
+ border-color: #5bc796;
+ background: #5bc796;
+ color: #0a1f15;
+}
+
+[data-theme='dark'] .tracking-stop-progress__dot--active {
+ border-color: #e5a14b;
+ background: #e5a14b;
+ color: #1a1206;
+}
+
+.tracking-intelligence-alert__cta,
+.tracking-intelligence-footer__button {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ border: 1px solid rgba(245, 158, 11, 32%);
+ border-radius: 0.375rem;
+ background: rgba(245, 158, 11, 8%);
+ color: #b45309;
+ font-size: 0.7rem;
+ font-weight: 700;
+ line-height: 1.2;
+ padding: 0.35rem 0.55rem;
+ white-space: nowrap;
+}
+
+.tracking-intelligence-alert__cta:disabled,
+.tracking-intelligence-footer__button:disabled {
+ cursor: progress;
+ opacity: 0.7;
+}
+
+[data-theme='dark'] .tracking-intelligence-alert__cta,
+[data-theme='dark'] .tracking-intelligence-footer__button {
+ border-color: rgba(229, 161, 75, 32%);
+ background: rgba(229, 161, 75, 10%);
+ color: #e5a14b;
+}
+
+.tracking-intelligence-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ color: #9ca3af;
+ font-size: 0.68rem;
+}
+
+.tracking-intelligence-labels {
+ display: flex;
+ flex-direction: column;
+ padding: 0.75rem;
+}
diff --git a/addon/templates/settings/routing.hbs b/addon/templates/settings/routing.hbs
index 1c36d9a87..8903f2c16 100644
--- a/addon/templates/settings/routing.hbs
+++ b/addon/templates/settings/routing.hbs
@@ -1,4 +1,4 @@
-
+
+
+
+
+
+
+
+
+ {{provider.label}}
+
+
+
+
+
+
+ {{#if this.showTrackingAdvancedSettings}}
+
+ {{/if}}
+
-
+
\ No newline at end of file
diff --git a/app/components/tracking-stop-progress.js b/app/components/tracking-stop-progress.js
new file mode 100644
index 000000000..533a3700c
--- /dev/null
+++ b/app/components/tracking-stop-progress.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/tracking-stop-progress';
diff --git a/app/helpers/format-duration.js b/app/helpers/format-duration.js
new file mode 100644
index 000000000..6b498e34d
--- /dev/null
+++ b/app/helpers/format-duration.js
@@ -0,0 +1 @@
+export { default, formatDurationValue } from '@fleetbase/fleetops-engine/helpers/format-duration';
diff --git a/server/config/fleetops.php b/server/config/fleetops.php
index ce87d7398..2aba7e97d 100644
--- a/server/config/fleetops.php
+++ b/server/config/fleetops.php
@@ -3,7 +3,7 @@
/**
* -------------------------------------------
* FleetOps API Configuration
- * -------------------------------------------
+ * -------------------------------------------.
*/
return [
/*
@@ -14,17 +14,17 @@
'api' => [
'version' => '0.0.1',
'routing' => [
- 'prefix' => null,
- 'internal_prefix' => 'int'
- ]
+ 'prefix' => null,
+ 'internal_prefix' => 'int',
+ ],
],
'connection' => [
- 'db' => env('DB_CONNECTION', 'mysql')
+ 'db' => env('DB_CONNECTION', 'mysql'),
],
/*
|--------------------------------------------------------------------------
- | Facilitator Fee - This is a percentage fee the system admin takes when
+ | Facilitator Fee - This is a percentage fee the system admin takes when
| facilitating any payments in the system.
| Example: if `10` then 10% fee will be taken on all payments.
|--------------------------------------------------------------------------
@@ -37,7 +37,7 @@
|--------------------------------------------------------------------------
*/
'osrm' => [
- 'host' => env('OSRM_HOST', 'https://router.project-osrm.org')
+ 'host' => env('OSRM_HOST', 'https://router.project-osrm.org'),
],
/*
@@ -61,12 +61,33 @@
/*
|--------------------------------------------------------------------------
- | Distance Matrix Calculator
+ | Distance Matrix Calculator
| Options: "calculate", "google", "osrm"
|--------------------------------------------------------------------------
*/
'distance_matrix' => [
- 'provider' => env('DISTANCE_MATRIX_PROVIDER', 'calculate')
+ 'provider' => env('DISTANCE_MATRIX_PROVIDER', 'calculate'),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Order Tracking Intelligence
+ |--------------------------------------------------------------------------
+ |
+ | Provider-neutral tracking data for active orders. The provider registry
+ | can be extended by third-party packages without editing FleetOps core.
+ |
+ | Built-in providers: "google_routes", "osrm", "calculated"
+ |--------------------------------------------------------------------------
+ */
+ 'tracking' => [
+ 'provider' => env('TRACKING_PROVIDER', 'google_routes'),
+ 'fallbacks' => array_filter(explode(',', env('TRACKING_PROVIDER_FALLBACKS', 'osrm,calculated'))),
+ 'traffic_enabled' => env('TRACKING_TRAFFIC_ENABLED', true),
+ 'cache_ttl_seconds' => env('TRACKING_CACHE_TTL_SECONDS', 60),
+ 'route_cache_ttl_seconds' => env('TRACKING_ROUTE_CACHE_TTL_SECONDS', 600),
+ 'stale_location_threshold_seconds' => env('TRACKING_STALE_LOCATION_THRESHOLD_SECONDS', 300),
+ 'default_vehicle_speed_kph' => env('TRACKING_DEFAULT_VEHICLE_SPEED_KPH', 35),
],
/*
@@ -76,7 +97,7 @@
*/
'navigator' => [
'bypass_verification_code' => env('SMS_AUTH_BYPASS_CODE', env('NAVIGATOR_BYPASS_VERIFICATION_CODE')),
- 'app_identifier' => env('NAVIGATOR_APP_IDENTIFIER', 'io.fleetbase.navigator')
+ 'app_identifier' => env('NAVIGATOR_APP_IDENTIFIER', 'io.fleetbase.navigator'),
],
/*
@@ -189,5 +210,5 @@
|--------------------------------------------------------------------------
*/
'versions' => ['2020-09-30', '2024-03-12'],
- 'version' => '2024-03-12',
+ 'version' => '2024-03-12',
];
diff --git a/server/src/Http/Controllers/Api/v1/OrderController.php b/server/src/Http/Controllers/Api/v1/OrderController.php
index bb23364b1..c60a7ecad 100644
--- a/server/src/Http/Controllers/Api/v1/OrderController.php
+++ b/server/src/Http/Controllers/Api/v1/OrderController.php
@@ -93,17 +93,17 @@ public function create(CreateOrderRequest $request)
// create payload
if ($request->has('payload') && $request->isArray('payload')) {
- $payload = new Payload();
- $payloadInput = $request->input('payload');
- $entities = data_get($payloadInput, 'entities', []);
- $waypoints = data_get($payloadInput, 'waypoints', []);
- $pickup = data_get($payloadInput, 'pickup');
- $dropoff = data_get($payloadInput, 'dropoff');
- $return = data_get($payloadInput, 'return');
- $hasPickupField = array_key_exists('pickup', $payloadInput);
- $hasDropoffField = array_key_exists('dropoff', $payloadInput);
- $hasReturnField = array_key_exists('return', $payloadInput);
- $hasWaypointsField = array_key_exists('waypoints', $payloadInput);
+ $payload = new Payload();
+ $payloadInput = $request->input('payload');
+ $entities = data_get($payloadInput, 'entities', []);
+ $waypoints = data_get($payloadInput, 'waypoints', []);
+ $pickup = data_get($payloadInput, 'pickup');
+ $dropoff = data_get($payloadInput, 'dropoff');
+ $return = data_get($payloadInput, 'return');
+ $hasPickupField = array_key_exists('pickup', $payloadInput);
+ $hasDropoffField = array_key_exists('dropoff', $payloadInput);
+ $hasReturnField = array_key_exists('return', $payloadInput);
+ $hasWaypointsField = array_key_exists('waypoints', $payloadInput);
$hasRouteEndpointFields = $hasPickupField || $hasDropoffField || $hasReturnField;
if ($pickup) {
@@ -445,17 +445,17 @@ public function update($id, UpdateOrderRequest $request)
// create a payload if missing payload[] but has pickup/dropoff/etc
if ($request->missing('payload')) {
- $payload = data_get($order, 'payload', new Payload());
- $payloadInput = $request->only(['pickup', 'dropoff', 'return', 'waypoints', 'entities']);
- $entities = data_get($payloadInput, 'entities', []);
- $waypoints = data_get($payloadInput, 'waypoints', []);
- $pickup = data_get($payloadInput, 'pickup');
- $dropoff = data_get($payloadInput, 'dropoff');
- $return = data_get($payloadInput, 'return');
- $hasPickupField = $request->exists('pickup');
- $hasDropoffField = $request->exists('dropoff');
- $hasReturnField = $request->exists('return');
- $hasWaypointsField = $request->exists('waypoints');
+ $payload = data_get($order, 'payload', new Payload());
+ $payloadInput = $request->only(['pickup', 'dropoff', 'return', 'waypoints', 'entities']);
+ $entities = data_get($payloadInput, 'entities', []);
+ $waypoints = data_get($payloadInput, 'waypoints', []);
+ $pickup = data_get($payloadInput, 'pickup');
+ $dropoff = data_get($payloadInput, 'dropoff');
+ $return = data_get($payloadInput, 'return');
+ $hasPickupField = $request->exists('pickup');
+ $hasDropoffField = $request->exists('dropoff');
+ $hasReturnField = $request->exists('return');
+ $hasWaypointsField = $request->exists('waypoints');
$hasRouteEndpointFields = $hasPickupField || $hasDropoffField || $hasReturnField;
// if no pickup and dropoff extract from waypoints
@@ -1328,13 +1328,13 @@ public function optimize(string $id)
*
* @return \Illuminate\Http\Response
*/
- public function trackerData(string $id)
+ public function trackerData(Request $request, string $id)
{
set_time_limit(280);
try {
$order = Order::findRecordOrFail($id);
- $data = $order->tracker()->toArray();
+ $data = $order->tracker()->toArray($request->only(['provider', 'fallbacks', 'traffic_enabled']));
return response()->json($data);
} catch (ModelNotFoundException $e) {
@@ -1351,11 +1351,11 @@ public function trackerData(string $id)
*
* @return \Illuminate\Http\Response
*/
- public function etaData(string $id)
+ public function etaData(Request $request, string $id)
{
try {
$order = Order::findRecordOrFail($id);
- $data = $order->tracker()->eta();
+ $data = $order->tracker()->eta($request->only(['provider', 'fallbacks', 'traffic_enabled']));
return response()->json($data);
} catch (ModelNotFoundException $e) {
diff --git a/server/src/Http/Controllers/Api/v1/PayloadController.php b/server/src/Http/Controllers/Api/v1/PayloadController.php
index 6d6315488..d9732156b 100644
--- a/server/src/Http/Controllers/Api/v1/PayloadController.php
+++ b/server/src/Http/Controllers/Api/v1/PayloadController.php
@@ -25,16 +25,16 @@ class PayloadController extends Controller
*/
public function create(CreatePayloadRequest $request)
{
- $input = $request->all();
- $entities = data_get($input, 'entities', []);
- $waypoints = data_get($input, 'waypoints', []);
- $pickup = data_get($input, 'pickup');
- $dropoff = data_get($input, 'dropoff');
- $return = data_get($input, 'return');
- $hasPickupField = array_key_exists('pickup', $input);
- $hasDropoffField = array_key_exists('dropoff', $input);
- $hasReturnField = array_key_exists('return', $input);
- $hasWaypointsField = array_key_exists('waypoints', $input);
+ $input = $request->all();
+ $entities = data_get($input, 'entities', []);
+ $waypoints = data_get($input, 'waypoints', []);
+ $pickup = data_get($input, 'pickup');
+ $dropoff = data_get($input, 'dropoff');
+ $return = data_get($input, 'return');
+ $hasPickupField = array_key_exists('pickup', $input);
+ $hasDropoffField = array_key_exists('dropoff', $input);
+ $hasReturnField = array_key_exists('return', $input);
+ $hasWaypointsField = array_key_exists('waypoints', $input);
$hasRouteEndpointFields = $hasPickupField || $hasDropoffField || $hasReturnField;
// make sure company is set
diff --git a/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php b/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php
index 9a673b909..cdb43efac 100644
--- a/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php
+++ b/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php
@@ -231,7 +231,7 @@ public function queryFromPreliminary(QueryServiceQuotesRequest $request)
$entities = collect($entities)->mapInto(Entity::class);
// should all be Place like
- $waypoints = collect([$pickup, ...$waypoints, $dropoff])->filter();
+ $waypoints = collect([$pickup, ...$waypoints, $dropoff])->filter();
$endpointCount = (int) ($pickup instanceof Place) + (int) ($dropoff instanceof Place);
// if facilitator is an integrated partner resolve service quotes from bridge
diff --git a/server/src/Http/Controllers/Internal/v1/LiveController.php b/server/src/Http/Controllers/Internal/v1/LiveController.php
index fd8498472..30b7018ef 100644
--- a/server/src/Http/Controllers/Internal/v1/LiveController.php
+++ b/server/src/Http/Controllers/Internal/v1/LiveController.php
@@ -13,6 +13,7 @@
use Fleetbase\FleetOps\Models\Route;
use Fleetbase\FleetOps\Models\Vehicle;
use Fleetbase\FleetOps\Support\LiveCacheService;
+use Fleetbase\FleetOps\Support\LiveOrderQuery;
use Fleetbase\Http\Controllers\Controller;
use Illuminate\Http\Request;
@@ -110,50 +111,13 @@ public function orders(Request $request)
];
return LiveCacheService::remember('orders', $cacheParams, function () use ($exclude, $active, $unassigned) {
- $query = Order::where('company_uuid', session('company'))
- ->whereHas('payload', function ($query) {
- $query->where(
- function ($q) {
- $q->whereHas('waypoints');
- $q->orWhereHas('pickup');
- $q->orWhereHas('dropoff');
- }
- );
- })
- ->whereNotIn('status', ['canceled', 'completed', 'expired'])
- ->whereHas('trackingNumber')
- ->whereHas('trackingStatuses')
- ->whereNotIn('public_id', $exclude)
- ->whereNull('deleted_at')
- ->applyDirectivesForPermissions('fleet-ops list order')
- ->with([
- 'payload.entities',
- 'payload.dropoff',
- 'payload.pickup',
- 'payload.return',
- 'payload.firstWaypointMarker.place',
- 'payload.lastWaypointMarker.place',
- 'trackingNumber',
- 'trackingStatuses',
- 'driverAssigned' => function ($query) {
- $query->without(['jobs', 'currentJob']);
- },
- 'vehicleAssigned' => function ($query) {
- $query->without(['fleets', 'vendor']);
- },
- 'customer',
- 'facilitator',
+ $query = LiveOrderQuery::make(session('company'), [
+ 'exclude' => $exclude,
+ 'active' => $active,
+ 'unassigned' => $unassigned,
+ 'with_relations' => true,
]);
- if ($active) {
- $query->whereHas('driverAssigned');
- $query->whereNotIn('status', ['created', 'completed', 'expired', 'order_canceled', 'canceled', 'pending']);
- }
-
- if ($unassigned) {
- $query->whereNull('driver_assigned_uuid');
- }
-
$query->limit(60); // max 60 latest
$query->latest();
diff --git a/server/src/Http/Controllers/Internal/v1/OrchestrationController.php b/server/src/Http/Controllers/Internal/v1/OrchestrationController.php
index 4220829ff..34278e310 100644
--- a/server/src/Http/Controllers/Internal/v1/OrchestrationController.php
+++ b/server/src/Http/Controllers/Internal/v1/OrchestrationController.php
@@ -573,7 +573,7 @@ public function importOrders(Request $request): JsonResponse
$isMulti = $orderType === 'multi_waypoint';
// ── Resolve OrderConfig ───────────────────────────────────────
- $orderConfigUuid = null;
+ $orderConfigUuid = null;
$resolvedOrderConfig = null;
if (!empty($firstRow['type'])) {
$resolvedOrderConfig = OrderConfig::resolveFromIdentifier($firstRow['type']);
diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php
index 71582b9ab..8ea82659a 100644
--- a/server/src/Http/Controllers/Internal/v1/OrderController.php
+++ b/server/src/Http/Controllers/Internal/v1/OrderController.php
@@ -26,16 +26,17 @@
use Fleetbase\FleetOps\Models\ServiceQuote;
use Fleetbase\FleetOps\Models\TrackingStatus;
use Fleetbase\FleetOps\Models\Waypoint;
+use Fleetbase\FleetOps\Notifications\OrderPing;
use Fleetbase\FleetOps\Support\Utils;
use Fleetbase\Http\Requests\ExportRequest;
use Fleetbase\Http\Requests\Internal\BulkActionRequest;
use Fleetbase\Models\File;
use Fleetbase\Models\Type;
+use Fleetbase\Support\Auth;
use Fleetbase\Support\TemplateString;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
@@ -121,7 +122,7 @@ function ($request, &$input) {
if ($resolvedOrderConfig) {
$input['order_config_uuid'] = $resolvedOrderConfig->uuid;
- $input['type'] = $resolvedOrderConfig->key;
+ $input['type'] = $resolvedOrderConfig->key;
} elseif (!isset($input['type'])) {
$input['type'] = 'transport';
}
@@ -762,7 +763,7 @@ public function nextActivity(string $id, Request $request)
return response()->error('No order found.');
}
- $waypoint = $request->filled('waypoint') ? Waypoint::findByPlace($request->input('waypoint'), $order) : null;
+ $waypoint = $request->filled('waypoint') ? Waypoint::findByPlace($request->input('waypoint'), $order) : null;
$orderConfig = $order->ensureOrderConfig();
if (!$orderConfig) {
return response()->error('No order config found for order.');
@@ -792,33 +793,57 @@ public function nextActivity(string $id, Request $request)
*
* @return \Illuminate\Http\Response
*/
- public function trackerInfo(string $id)
+ public function trackerInfo(Request $request, string $id)
{
$order = Order::findById($id);
if (!$order) {
return response()->error('No order found.');
}
- // Cache tracker data for 30 seconds with order-specific key
- $cacheKey = "order:{$order->uuid}:tracker";
- $trackerInfo = Cache::remember($cacheKey, 30, function () use ($order) {
- return $order->tracker()->toArray();
- });
-
- return response()->json($trackerInfo);
+ return response()->json($order->tracker()->toArray($request->only(['provider', 'fallbacks', 'traffic_enabled'])));
}
- public function waypointEtas(string $id)
+ public function waypointEtas(Request $request, string $id)
{
$order = Order::findById($id);
if (!$order) {
return response()->error('No order found.');
}
- // Get order tracker
- $eta = $order->tracker()->eta();
+ return response()->json($order->tracker()->eta($request->only(['provider', 'fallbacks', 'traffic_enabled'])));
+ }
+
+ /**
+ * Ping the assigned driver to refresh/order attention in the driver app.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function pingDriver(string $id)
+ {
+ if (!Auth::can('fleet-ops update order')) {
+ return response()->error('Unauthorized.', 403);
+ }
+
+ try {
+ $order = Order::findByIdOrFail($id, ['driverAssigned']);
+ } catch (ModelNotFoundException $e) {
+ return response()->error('Order resource not found.', 404);
+ }
+
+ if (!$order->driverAssigned) {
+ return response()->error('Order does not have an assigned driver.', 422);
+ }
+
+ try {
+ $order->driverAssigned->notify(new OrderPing($order));
- return response()->json($eta);
+ return response()->json([
+ 'status' => 'ok',
+ 'message' => 'Driver app ping sent.',
+ ]);
+ } catch (\Throwable $e) {
+ return response()->error('Unable to ping driver app.', 500);
+ }
}
/**
diff --git a/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php b/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php
index c3c8b8436..3e7b31d39 100644
--- a/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php
+++ b/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php
@@ -226,7 +226,7 @@ public function preliminaryQuery(Request $request)
$entities = collect($entities)->mapInto(Entity::class);
// should all be Place like
- $waypoints = collect([$pickup, ...$waypoints, $dropoff])->filter();
+ $waypoints = collect([$pickup, ...$waypoints, $dropoff])->filter();
$endpointCount = (int) ($pickup instanceof Place) + (int) ($dropoff instanceof Place);
// if facilitator is an integrated partner resolve service quotes from bridge
diff --git a/server/src/Http/Controllers/Internal/v1/SettingController.php b/server/src/Http/Controllers/Internal/v1/SettingController.php
index d1e10b2ce..380ae58dd 100644
--- a/server/src/Http/Controllers/Internal/v1/SettingController.php
+++ b/server/src/Http/Controllers/Internal/v1/SettingController.php
@@ -2,6 +2,7 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
+use Fleetbase\FleetOps\Tracking\TrackingProviderRegistry;
use Fleetbase\Http\Controllers\Controller;
use Fleetbase\Models\Setting;
use Fleetbase\Support\Auth;
@@ -211,12 +212,12 @@ public function getRoutingSettings()
{
$routingSettings = Setting::lookupCompany('routing', ['router' => 'osrm', 'unit' => 'km']);
- $displayEngine = data_get($routingSettings, 'display_engine', data_get($routingSettings, 'routing_display_engine', data_get($routingSettings, 'router', 'osrm')));
- $optimizationEngine = data_get($routingSettings, 'optimization_engine', data_get($routingSettings, 'routing_optimization_engine', $displayEngine));
- $routingSettings['router'] = $displayEngine;
- $routingSettings['display_engine'] = $displayEngine;
- $routingSettings['optimization_engine'] = $optimizationEngine;
- $routingSettings['routing_display_engine'] = $displayEngine;
+ $displayEngine = data_get($routingSettings, 'display_engine', data_get($routingSettings, 'routing_display_engine', data_get($routingSettings, 'router', 'osrm')));
+ $optimizationEngine = data_get($routingSettings, 'optimization_engine', data_get($routingSettings, 'routing_optimization_engine', $displayEngine));
+ $routingSettings['router'] = $displayEngine;
+ $routingSettings['display_engine'] = $displayEngine;
+ $routingSettings['optimization_engine'] = $optimizationEngine;
+ $routingSettings['routing_display_engine'] = $displayEngine;
$routingSettings['routing_optimization_engine'] = $optimizationEngine;
// always default to km if no unit is set
@@ -227,6 +228,87 @@ public function getRoutingSettings()
return response()->json($routingSettings);
}
+ /**
+ * Save order tracking intelligence settings.
+ *
+ * @param Request $request the HTTP request object containing tracking settings
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function saveTrackingSettings(Request $request)
+ {
+ $config = $this->trackingDefaults();
+ $fallbacks = $request->input('fallbacks', data_get($config, 'fallbacks', ['osrm', 'calculated']));
+ if (is_string($fallbacks)) {
+ $fallbacks = array_values(array_filter(array_map('trim', explode(',', $fallbacks))));
+ }
+
+ Setting::configureCompany('tracking', [
+ 'provider' => $request->input('provider', data_get($config, 'provider', 'google_routes')),
+ 'fallbacks' => $fallbacks,
+ 'traffic_enabled' => $request->boolean('traffic_enabled', data_get($config, 'traffic_enabled', true)),
+ 'cache_ttl_seconds' => (int) $request->input('cache_ttl_seconds', data_get($config, 'cache_ttl_seconds', 60)),
+ 'route_cache_ttl_seconds' => (int) $request->input('route_cache_ttl_seconds', data_get($config, 'route_cache_ttl_seconds', 600)),
+ 'stale_location_threshold_seconds' => (int) $request->input('stale_location_threshold_seconds', data_get($config, 'stale_location_threshold_seconds', 300)),
+ 'default_vehicle_speed_kph' => (float) $request->input('default_vehicle_speed_kph', data_get($config, 'default_vehicle_speed_kph', 35)),
+ ]);
+
+ return response()->json([
+ 'status' => 'ok',
+ 'message' => 'Tracking settings succesfully saved.',
+ ]);
+ }
+
+ /**
+ * Retrieve order tracking intelligence settings.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function getTrackingSettings()
+ {
+ $config = $this->trackingDefaults();
+ $trackingSettings = Setting::lookupCompany('tracking', [
+ 'provider' => data_get($config, 'provider', 'google_routes'),
+ 'fallbacks' => data_get($config, 'fallbacks', ['osrm', 'calculated']),
+ 'traffic_enabled' => data_get($config, 'traffic_enabled', true),
+ 'cache_ttl_seconds' => data_get($config, 'cache_ttl_seconds', 60),
+ 'route_cache_ttl_seconds' => data_get($config, 'route_cache_ttl_seconds', 600),
+ 'stale_location_threshold_seconds' => data_get($config, 'stale_location_threshold_seconds', 300),
+ 'default_vehicle_speed_kph' => data_get($config, 'default_vehicle_speed_kph', 35),
+ ]);
+ $trackingSettings['providers'] = $this->trackingProviderOptions();
+
+ return response()->json($trackingSettings);
+ }
+
+ public function getAdminTrackingSettings()
+ {
+ return response()->json(array_merge($this->trackingDefaults(), [
+ 'providers' => $this->trackingProviderOptions(),
+ ]));
+ }
+
+ public function saveAdminTrackingSettings(Request $request)
+ {
+ $config = config('fleetops.tracking', []);
+ $fallbacks = $request->input('fallbacks', data_get($config, 'fallbacks', ['osrm', 'calculated']));
+ if (is_string($fallbacks)) {
+ $fallbacks = array_values(array_filter(array_map('trim', explode(',', $fallbacks))));
+ }
+
+ Setting::configure('fleet-ops.tracking-settings', [
+ 'provider' => $request->input('provider', data_get($config, 'provider', 'google_routes')),
+ 'fallbacks' => $fallbacks,
+ 'traffic_enabled' => $request->boolean('traffic_enabled', data_get($config, 'traffic_enabled', true)),
+ 'cache_ttl_seconds' => (int) $request->input('cache_ttl_seconds', data_get($config, 'cache_ttl_seconds', 60)),
+ 'route_cache_ttl_seconds' => (int) $request->input('route_cache_ttl_seconds', data_get($config, 'route_cache_ttl_seconds', 600)),
+ 'stale_location_threshold_seconds' => (int) $request->input('stale_location_threshold_seconds', data_get($config, 'stale_location_threshold_seconds', 300)),
+ 'default_vehicle_speed_kph' => (float) $request->input('default_vehicle_speed_kph', data_get($config, 'default_vehicle_speed_kph', 35)),
+ ]);
+
+ return response()->json($this->getAdminTrackingSettings()->getData(true));
+ }
+
/**
* Retrieve and return the map provider settings for the current company.
*
@@ -244,15 +326,15 @@ public function getMapSettings()
'mapProvider' => 'leaflet',
];
- $systemMapSettings = Setting::lookup('fleet-ops.map-settings', []);
- $mapSettings = Setting::lookupFromCompany('fleet-ops.map-settings', $defaults);
+ $systemMapSettings = Setting::lookup('fleet-ops.map-settings', []);
+ $mapSettings = Setting::lookupFromCompany('fleet-ops.map-settings', $defaults);
$mapSettings['mapProvider'] = data_get($mapSettings, 'mapProvider') ?: data_get($systemMapSettings, 'mapProvider', 'leaflet');
// Source the Google Maps API key from the system-level services config
// that is managed by the core-api admin settings panel. This ensures a
// single source of truth and avoids duplicating key management.
$mapSettings['googleMapsApiKey'] = config('services.google_maps.api_key', env('GOOGLE_MAPS_API_KEY', ''));
- $mapSettings['googleMapsMapId'] = data_get($systemMapSettings, 'googleMapsMapId', '');
+ $mapSettings['googleMapsMapId'] = data_get($systemMapSettings, 'googleMapsMapId', '');
return response()->json($mapSettings);
}
@@ -289,7 +371,7 @@ public function saveMapSettings(Request $request)
public function getAdminMapSettings()
{
$defaults = [
- 'mapProvider' => 'leaflet',
+ 'mapProvider' => 'leaflet',
'googleMapsMapId' => '',
];
@@ -299,13 +381,13 @@ public function getAdminMapSettings()
public function saveAdminMapSettings(Request $request)
{
$allowedProviders = ['leaflet', 'google'];
- $mapProvider = $request->input('mapProvider', 'leaflet');
+ $mapProvider = $request->input('mapProvider', 'leaflet');
if (!in_array($mapProvider, $allowedProviders)) {
$mapProvider = 'leaflet';
}
$settings = [
- 'mapProvider' => $mapProvider,
+ 'mapProvider' => $mapProvider,
'googleMapsMapId' => (string) $request->input('googleMapsMapId', ''),
];
@@ -431,4 +513,29 @@ public function saveOrchestratorCardFields(Request $request)
'settings' => $normalized,
]);
}
+
+ protected function trackingDefaults(): array
+ {
+ $config = config('fleetops.tracking', []);
+ $systemSettings = Setting::lookup('fleet-ops.tracking-settings', []);
+
+ return array_merge($config, is_array($systemSettings) ? $systemSettings : []);
+ }
+
+ protected function trackingProviderOptions(): array
+ {
+ $registry = app(TrackingProviderRegistry::class);
+
+ return collect($registry->all())->map(function ($provider, $key) {
+ $label = $key === 'osrm' ? 'OSRM' : str($key)->replace('_', ' ')->title()->toString();
+
+ return [
+ 'key' => $key,
+ 'name' => $label,
+ 'value' => $key,
+ 'label' => $label,
+ 'capabilities' => $provider->capabilities()->toArray(),
+ ];
+ })->values()->all();
+ }
}
diff --git a/server/src/Http/Resources/v1/Index/Payload.php b/server/src/Http/Resources/v1/Index/Payload.php
index 8509a088e..cf821fe92 100644
--- a/server/src/Http/Resources/v1/Index/Payload.php
+++ b/server/src/Http/Resources/v1/Index/Payload.php
@@ -19,8 +19,8 @@ class Payload extends FleetbaseResource
public function toArray($request): array
{
$isInternal = Http::isInternalRequest();
- $pickup = $this->index_pickup_place;
- $dropoff = $this->index_dropoff_place;
+ $pickup = $this->index_pickup_place;
+ $dropoff = $this->index_dropoff_place;
return [
'id' => $this->when($isInternal, $this->id, $this->public_id),
diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php
index 3a3c97c37..dc021ed53 100644
--- a/server/src/Models/Order.php
+++ b/server/src/Models/Order.php
@@ -1739,7 +1739,7 @@ public function config(): ?OrderConfig
}
}
- $company = $this->relationLoaded('company') ? $this->company : $this->company()->first();
+ $company = $this->relationLoaded('company') ? $this->company : $this->company()->first();
$orderConfig = OrderConfig::defaultOrCreate($company);
if ($orderConfig instanceof OrderConfig) {
$orderConfig->setOrderContext($this);
diff --git a/server/src/Models/Payload.php b/server/src/Models/Payload.php
index 14b6bace0..67787ee94 100644
--- a/server/src/Models/Payload.php
+++ b/server/src/Models/Payload.php
@@ -555,15 +555,15 @@ public function updateWaypoints($waypoints = [])
}
$this->loadMissing('waypointMarkers');
- $keptWaypointIds = [];
+ $keptWaypointIds = [];
$availableWaypointMarkers = $this->waypointMarkers()->get();
foreach ($waypoints as $index => $attributes) {
- $raw = $attributes;
- $type = data_get($raw, 'type', 'dropoff');
- $customerUuidIn = data_get($raw, 'customer_uuid');
+ $raw = $attributes;
+ $type = data_get($raw, 'type', 'dropoff');
+ $customerUuidIn = data_get($raw, 'customer_uuid');
$customerPubIdIn = data_get($raw, 'customer_id');
- $customerType = data_get($raw, 'customer_type', 'fleetops:contact');
+ $customerType = data_get($raw, 'customer_type', 'fleetops:contact');
if (Utils::isset($attributes, 'place') && is_array(Utils::get($attributes, 'place'))) {
$attributes = Utils::get($attributes, 'place');
@@ -599,12 +599,12 @@ public function updateWaypoints($waypoints = [])
$placeUuid = $place->uuid;
}
- $customerUuid = null;
+ $customerUuid = null;
$customerTypeNamespace = null;
if ($customerType) {
$customerTypeNamespace = Utils::getMutationType($customerType);
- $customerModel = app($customerTypeNamespace);
+ $customerModel = app($customerTypeNamespace);
if ($customerUuidIn && $customerModel->where('uuid', $customerUuidIn)->exists()) {
$customerUuid = $customerUuidIn;
@@ -642,7 +642,7 @@ public function updateWaypoints($waypoints = [])
continue;
}
- $waypointRecord = Waypoint::create($values);
+ $waypointRecord = Waypoint::create($values);
$keptWaypointIds[] = $waypointRecord->uuid;
}
diff --git a/server/src/Models/Place.php b/server/src/Models/Place.php
index d51836994..cf919215d 100644
--- a/server/src/Models/Place.php
+++ b/server/src/Models/Place.php
@@ -21,7 +21,6 @@
use Fleetbase\Traits\SendsWebhooks;
use Fleetbase\Traits\TracksApiCredential;
use Illuminate\Support\Carbon;
-use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class Place extends Model
@@ -383,8 +382,6 @@ public static function createFromGeocodingLookup(string $address, $saveInstance
/**
* Create a new Place instance from a geocoding lookup.
- *
- * @return array
*/
public static function getValuesFromGeocodingLookup(string $address): array
{
diff --git a/server/src/Models/ServiceRate.php b/server/src/Models/ServiceRate.php
index e47b1fe9f..7576a699e 100644
--- a/server/src/Models/ServiceRate.php
+++ b/server/src/Models/ServiceRate.php
@@ -740,7 +740,7 @@ public function quoteFromPreliminaryData($entities = [], $waypoints = [], ?int $
if ($this->isAlgorithm()) {
$resolvedEndpointCount = $endpointCount ?? $this->inferEndpointCountFromStops($waypoints);
- $rateFee = $this->normalizeCalculatedMoney(Algo::exec(
+ $rateFee = $this->normalizeCalculatedMoney(Algo::exec(
$this->algorithm,
$this->buildAlgorithmVariables(
$entities,
diff --git a/server/src/Orchestration/Engines/VroomOrchestrationEngine.php b/server/src/Orchestration/Engines/VroomOrchestrationEngine.php
index cbb943f13..b5830871e 100644
--- a/server/src/Orchestration/Engines/VroomOrchestrationEngine.php
+++ b/server/src/Orchestration/Engines/VroomOrchestrationEngine.php
@@ -104,9 +104,9 @@ public function allocate(Collection $orders, Collection $vehicles, array $option
unset($job);
// ── Resolve connection config ─────────────────────────────────────────
- $baseUri = $this->resolveVroomBaseUri();
+ $baseUri = $this->resolveVroomBaseUri();
$endpointMode = $this->resolveVroomEndpointMode();
- $timeout = (int) env('VROOM_TIMEOUT', 30);
+ $timeout = (int) env('VROOM_TIMEOUT', 30);
$apiKey = $this->resolveVroomApiKey();
diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php
index 267e80ef9..715182eb5 100644
--- a/server/src/Providers/FleetOpsServiceProvider.php
+++ b/server/src/Providers/FleetOpsServiceProvider.php
@@ -99,6 +99,13 @@ public function register()
\Fleetbase\FleetOps\Orchestration\OrchestrationEngineRegistry::class,
fn () => new \Fleetbase\FleetOps\Orchestration\OrchestrationEngineRegistry()
);
+
+ // Register the TrackingProviderRegistry as a singleton so FleetOps core
+ // and third-party extensions can share tracking intelligence providers.
+ $this->app->singleton(
+ \Fleetbase\FleetOps\Tracking\TrackingProviderRegistry::class,
+ fn () => new \Fleetbase\FleetOps\Tracking\TrackingProviderRegistry()
+ );
}
/**
@@ -137,6 +144,23 @@ function (\Fleetbase\FleetOps\Orchestration\OrchestrationEngineRegistry $registr
}
}
);
+
+ // Register built-in tracking providers. Third-party extensions can
+ // register additional providers from their own service providers.
+ $this->app->resolving(
+ \Fleetbase\FleetOps\Tracking\TrackingProviderRegistry::class,
+ function (\Fleetbase\FleetOps\Tracking\TrackingProviderRegistry $registry) {
+ if (!$registry->has('google_routes')) {
+ $registry->register(new \Fleetbase\FleetOps\Tracking\Providers\GoogleRoutesTrackingProvider());
+ }
+ if (!$registry->has('osrm')) {
+ $registry->register(new \Fleetbase\FleetOps\Tracking\Providers\OsrmTrackingProvider());
+ }
+ if (!$registry->has('calculated')) {
+ $registry->register(new \Fleetbase\FleetOps\Tracking\Providers\CalculatedTrackingProvider());
+ }
+ }
+ );
$this->loadRoutesFrom(__DIR__ . '/../routes.php');
$this->loadMigrationsFrom(__DIR__ . '/../../migrations');
$this->loadViewsFrom(__DIR__ . '/../../resources/views', 'fleetops');
diff --git a/server/src/Support/LiveOrderQuery.php b/server/src/Support/LiveOrderQuery.php
new file mode 100644
index 000000000..ecda46c78
--- /dev/null
+++ b/server/src/Support/LiveOrderQuery.php
@@ -0,0 +1,75 @@
+whereHas('payload', function ($query) {
+ $query->where(function ($q) {
+ $q->whereHas('waypoints');
+ $q->orWhereHas('pickup');
+ $q->orWhereHas('dropoff');
+ });
+ })
+ ->whereNotIn('status', static::$baseExcludedStatuses)
+ ->whereHas('trackingNumber')
+ ->whereHas('trackingStatuses')
+ ->whereNull('deleted_at');
+
+ if (!empty($exclude)) {
+ $query->whereNotIn('public_id', $exclude);
+ }
+
+ if ($applyPermissions) {
+ $query->applyDirectivesForPermissions('fleet-ops list order');
+ }
+
+ if ($active) {
+ $query->whereHas('driverAssigned');
+ $query->whereNotIn('status', static::$activeExcludedStatuses);
+ }
+
+ if ($unassigned) {
+ $query->whereNull('driver_assigned_uuid');
+ }
+
+ if ($withRelations) {
+ $query->with([
+ 'payload.entities',
+ 'payload.dropoff',
+ 'payload.pickup',
+ 'payload.return',
+ 'payload.firstWaypointMarker.place',
+ 'payload.lastWaypointMarker.place',
+ 'trackingNumber',
+ 'trackingStatuses',
+ 'driverAssigned' => function ($query) {
+ $query->without(['jobs', 'currentJob']);
+ },
+ 'vehicleAssigned' => function ($query) {
+ $query->without(['fleets', 'vendor']);
+ },
+ 'customer',
+ 'facilitator',
+ ]);
+ }
+
+ return $query;
+ }
+}
diff --git a/server/src/Support/Metrics.php b/server/src/Support/Metrics.php
index f9338f50e..5f874e957 100644
--- a/server/src/Support/Metrics.php
+++ b/server/src/Support/Metrics.php
@@ -215,6 +215,21 @@ public function ordersInProgress(?callable $callback = null): Metrics
return $this->set('orders_in_progress', $data);
}
+ public function activeLiveOrders(?callable $callback = null): Metrics
+ {
+ $query = LiveOrderQuery::make($this->company->uuid, [
+ 'active' => true,
+ ]);
+
+ if (is_callable($callback)) {
+ $callback($query);
+ }
+
+ $data = $query->count();
+
+ return $this->set('active_live_orders', $data);
+ }
+
public function ordersScheduled(?callable $callback = null): Metrics
{
$query = Order::where('company_uuid', $this->company->uuid)
diff --git a/server/src/Support/OSRM.php b/server/src/Support/OSRM.php
index 34077424c..4ab8cb2fe 100644
--- a/server/src/Support/OSRM.php
+++ b/server/src/Support/OSRM.php
@@ -88,7 +88,7 @@ public static function getRouteFromCoordinatesString(string $coordinates, array
$cacheKey = 'getRouteFromCoordinatesString:' . md5($coordinates . serialize($queryParameters));
try {
- $url = self::$baseUrl . "/route/v1/driving/{$coordinates}";
+ $url = static::baseUrl() . "/route/v1/driving/{$coordinates}";
$response = Http::timeout(1)->get($url, $queryParameters);
$data = $response->json();
@@ -130,7 +130,7 @@ public static function getNearest(Point $location, array $queryParameters = [])
}
$coordinates = "{$location->getLng()},{$location->getLat()}";
- $url = self::$baseUrl . "/nearest/v1/driving/{$coordinates}";
+ $url = static::baseUrl() . "/nearest/v1/driving/{$coordinates}";
$response = Http::timeout(1)->get($url, $queryParameters);
$result = $response->json();
@@ -163,7 +163,7 @@ public static function getTable(array $points, array $queryParameters = [])
return "{$point->getLng()},{$point->getLat()}";
}, $points));
- $url = self::$baseUrl . "/table/v1/driving/{$coordinates}";
+ $url = static::baseUrl() . "/table/v1/driving/{$coordinates}";
$response = Http::timeout(1)->get($url, $queryParameters);
$result = $response->json();
@@ -196,7 +196,7 @@ public static function getTrip(array $points, array $queryParameters = [])
return "{$point->getLng()},{$point->getLat()}";
}, $points));
- $url = self::$baseUrl . "/trip/v1/driving/{$coordinates}";
+ $url = static::baseUrl() . "/trip/v1/driving/{$coordinates}";
$response = Http::timeout(1)->get($url, $queryParameters);
$data = $response->json();
@@ -222,7 +222,7 @@ public static function getMatch(array $points, array $queryParameters = [])
return "{$point->getLng()},{$point->getLat()}";
}, $points));
- $url = self::$baseUrl . "/match/v1/driving/{$coordinates}";
+ $url = static::baseUrl() . "/match/v1/driving/{$coordinates}";
$response = Http::timeout(1)->get($url, $queryParameters);
@@ -241,7 +241,7 @@ public static function getMatch(array $points, array $queryParameters = [])
*/
public static function getTile(int $z, int $x, int $y, array $queryParameters = [])
{
- $url = self::$baseUrl . "/tile/v1/car/{$z}/{$x}/{$y}.mvt";
+ $url = static::baseUrl() . "/tile/v1/car/{$z}/{$x}/{$y}.mvt";
$response = Http::timeout(1)->get($url, $queryParameters);
@@ -259,4 +259,9 @@ public static function decodePolyline($polyline)
{
return Polyline::decode($polyline);
}
+
+ protected static function baseUrl(): string
+ {
+ return rtrim(config('fleetops.osrm.host', static::$baseUrl), '/');
+ }
}
diff --git a/server/src/Support/OrderTracker.php b/server/src/Support/OrderTracker.php
index 45d1efc6a..2aa5f4743 100644
--- a/server/src/Support/OrderTracker.php
+++ b/server/src/Support/OrderTracker.php
@@ -2,598 +2,23 @@
namespace Fleetbase\FleetOps\Support;
-use Fleetbase\FleetOps\Models\Driver;
use Fleetbase\FleetOps\Models\Order;
-use Fleetbase\FleetOps\Models\Payload;
-use Fleetbase\FleetOps\Models\Place;
-use Fleetbase\FleetOps\Models\Waypoint;
-use Fleetbase\LaravelMysqlSpatial\Types\Point;
-use Illuminate\Support\Arr;
-use Illuminate\Support\Carbon;
-use Illuminate\Support\Collection;
-use Illuminate\Support\Facades\Cache;
-use Illuminate\Support\Facades\Log;
-use Illuminate\Support\Str;
+use Fleetbase\FleetOps\Tracking\TrackingIntelligenceService;
+use Fleetbase\FleetOps\Tracking\TrackingOptions;
-/**
- * Class OrderTracker.
- *
- * Provides functionality to track an order's progress, calculate ETA, and retrieve various order-related data.
- */
class OrderTracker
{
- /** @var Order The order being tracked */
- protected Order $order;
-
- /** @var Payload The payload associated with the order */
- protected Payload $payload;
-
- /** @var Driver The driver assigned to the order */
- protected ?Driver $driver;
-
- /** @var bool Flag to indicate if the order has multiple dropoff points */
- protected bool $isMultipleDropOrder = false;
-
- /**
- * Constructor for OrderTracker.
- *
- * @param Order $order the order to track
- */
- public function __construct(Order $order)
- {
- $order->loadAssignedDriver();
-
- $this->order = $order;
- $this->payload = $order->getPayload(function ($payload) {
- $payload->loadMissing(['pickup', 'dropoff', 'waypoints', 'waypointMarkers']);
- });
- $this->isMultipleDropOrder = $this->payload->isMultipleDropOrder;
- $this->driver = $order->driverAssigned;
- }
-
- /**
- * Get the current location of the driver or starting point.
- *
- * @return Point|null the current location of the driver, or null if unavailable
- */
- public function getDriverCurrentLocation(): ?Point
- {
- if ($this->driver) {
- return $this->driver->location;
- }
-
- $startingPoint = $this->payload->getPickupOrCurrentWaypoint();
- if ($startingPoint) {
- return $startingPoint->location;
- }
-
- return null;
- }
-
- /**
- * Get the percentage progress of the order.
- *
- * @return int|float the percentage of the order progress
- */
- public function getOrderProgressPercentage(): int|float
- {
- $totalDistance = $this->getTotalDistance();
- $completedDistance = $this->getCompletedDistance();
- $cannotUseDistance = $totalDistance == -1 || $completedDistance == -1 || $completedDistance === 0;
-
- // Get order percentage by activity if distance-based progress is not available
- if ($cannotUseDistance) {
- /** @var Collection $activities */
- $activities = $this->order->orderConfig ? $this->order->orderConfig->activities() : collect();
- $totalActivity = $activities->count();
- if ($totalActivity === 0) {
- return 100; // No activities, so treat it as 100% complete
- }
-
- // Calculate completed activities
- $completedActivity = $activities->filter(function ($activity) {
- return $activity->isCompleted($this->order);
- })->count();
-
- // Return progress percentage based on completed activities
- return round(($completedActivity / $totalActivity) * 100, 2);
- }
-
- if ($totalDistance === 0) {
- return 100;
- }
-
- return round(($completedDistance / $totalDistance) * 100, 2);
- }
-
- /**
- * Get the total distance for the order.
- *
- * @return int|float the total distance of the order in meters
- */
- public function getTotalDistance(): int|float
- {
- $points = $this->getAllDestinationPoints()->toArray();
- if (count($points) < 2) {
- return -1;
- }
-
- try {
- $response = OSRM::getRouteFromPoints($points);
- if (isset($response['code']) && $response['code'] === 'Ok') {
- $route = Arr::first($response['routes']);
- if ($route) {
- return data_get($route, 'distance', 0);
- }
- }
- } catch (\Exception $e) {
- Log::warning('Error loading OSRM routes from order points.', [$e]);
-
- return -1;
- }
-
- return -1;
- }
-
- /**
- * Get the completed distance by the driver.
- *
- * @return float the completed distance in meters
- */
- public function getCompletedDistance(): float
- {
- $points = $this->getCompletedDestinationPoints()->toArray();
- if (count($points) < 2) {
- return -1;
- }
-
- try {
- $response = OSRM::getRouteFromPoints($points);
- if (isset($response['code']) && $response['code'] === 'Ok') {
- $route = Arr::first($response['routes']);
- if ($route) {
- return data_get($route, 'distance', 0);
- }
- }
- } catch (\Exception $e) {
- Log::warning('Error loading OSRM routes from order points.', [$e]);
-
- return -1;
- }
-
- return -1;
- }
-
- /**
- * Get the estimated time of arrival (ETA) for the current destination.
- *
- * @return float the ETA in seconds or -1 if unable to calculate
- */
- public function getCurrentDestinationETA(): float
- {
- $start = $this->getDriverCurrentLocation();
- $currentDestination = $this->getCurrentDestination();
- $end = $currentDestination ? $currentDestination->location : null;
- if ($start == $end) {
- $nextDestination = $this->getNextDestination();
- if ($nextDestination) {
- $end = $nextDestination->location;
- }
- }
-
- if (!$start || !$end) {
- return -1;
- }
-
- // Convert SpatialExpression to Point objects
- $start = Utils::getPointFromMixed($start);
- $end = Utils::getPointFromMixed($end);
-
- if (!$start || !$end) {
- return -1;
- }
-
- try {
- $response = OSRM::getRoute($start, $end);
- if (isset($response['code']) && $response['code'] === 'Ok') {
- $route = Arr::first($response['routes']);
- if ($route) {
- return data_get($route, 'duration', -1);
- }
- }
- } catch (\Exception $e) {
- Log::warning('Error loading OSRM route from start and end points.', [$e]);
-
- return -1;
- }
-
- return -1;
- }
-
- public function getWaypointETA(Waypoint|Place $waypoint): float
- {
- if ($waypoint instanceof Waypoint) {
- $waypoint->loadMissing('place');
- $waypoint = $waypoint->place;
- }
-
- $start = $this->getDriverCurrentLocation();
- $end = $waypoint->location;
-
- // Convert SpatialExpression to Point objects
- $start = Utils::getPointFromMixed($start);
- $end = Utils::getPointFromMixed($end);
-
- if (!$start || !$end) {
- return -1;
- }
-
- try {
- $response = OSRM::getRoute($start, $end);
- if (isset($response['code']) && $response['code'] === 'Ok') {
- $route = Arr::first($response['routes']);
- if ($route) {
- return data_get($route, 'duration', -1);
- }
- }
- } catch (\Exception $e) {
- Log::warning('Error loading OSRM routes from start and end points.', [$e]);
-
- return -1;
- }
-
- return -1;
- }
-
- /**
- * Get the estimated time of arrival (ETA) for the completion of the order.
- *
- * @return float the ETA in seconds or -1 if unable to calculate
- */
- public function getCompletionETA(): float
- {
- $start = $this->getDriverCurrentLocation();
- $end = $this->payload->getDropoffOrLastWaypoint()->location;
-
- // Convert SpatialExpression to Point objects
- $start = Utils::getPointFromMixed($start);
- $end = Utils::getPointFromMixed($end);
-
- if (!$start || !$end) {
- return -1;
- }
-
- try {
- $response = OSRM::getRoute($start, $end);
- if (isset($response['code']) && $response['code'] === 'Ok') {
- $route = Arr::first($response['routes']);
- if ($route) {
- return data_get($route, 'duration', -1);
- }
- }
- } catch (\Exception $e) {
- Log::warning('Error loading OSRM routes from start and end points.', [$e]);
-
- return -1;
- }
-
- return -1;
- }
-
- /**
- * Get the estimated completion time of the order.
- *
- * @return Carbon the estimated completion time
- */
- public function getEstimatedCompletionTime(): ?Carbon
- {
- $currentEtaCompletion = $this->getCompletionETA();
-
- return now()->addSeconds($currentEtaCompletion);
- }
-
- /**
- * Get the start time of the order.
- *
- * @return Carbon the time the order started
- */
- public function getOrderStartTime(): ?Carbon
+ public function __construct(protected Order $order)
{
- return $this->order->started_at;
}
- /**
- * Get the completion time of the order, if completed.
- *
- * @return Carbon|null the time the order was completed, or null if not completed
- */
- public function getOrderCompletionTime(): ?Carbon
+ public function eta(array $options = []): array
{
- return $this->order->completed_at;
+ return app(TrackingIntelligenceService::class)->eta($this->order, TrackingOptions::fromArray($options));
}
- /**
- * Get all destinations (pickup, waypoints, dropoff) for the order.
- *
- * @return Collection a collection of all destination places
- */
- public function getAllDestinations(): Collection
+ public function toArray(array $options = []): array
{
- return collect([$this->payload->pickup, ...$this->payload->waypoints, $this->payload->dropoff])->filter();
- }
-
- /**
- * Get all destination points (pickup, waypoints, dropoff) as spatial points.
- *
- * @return Collection a collection of Point objects for each destination
- */
- public function getAllDestinationPoints(): Collection
- {
- return $this->getAllDestinations()->map(function (Place $place) {
- return $place->location;
- })->filter()->values();
- }
-
- /**
- * Get the current destination the driver is heading to.
- *
- * @return Place|null the current destination place object, or null if not available
- */
- public function getCurrentDestination(): ?Place
- {
- if ($this->isMultipleDropOrder) {
- if ($this->payload->waypoints) {
- $destination = null;
-
- if (Str::isUuid($this->payload->current_waypoint_uuid)) {
- $destination = $this->payload->waypoints->firstWhere('uuid', $this->payload->current_waypoint_uuid);
- }
-
- if (!$destination) {
- $destination = $this->payload->waypoints->first();
- }
-
- return $destination;
- }
-
- return null;
- }
-
- if ($this->order->status === 'created' || $this->order->status === 'dispatched' || $this->order->status === 'pending') {
- return $this->payload->pickup;
- }
-
- return $this->payload->dropoff;
- }
-
- /**
- * Get the completed destinations of the order.
- *
- * @return Collection a collection of completed destination places
- */
- public function getCompletedDestinations(): Collection
- {
- if ($this->isMultipleDropOrder) {
- return $this->payload->waypointMarkers->filter(
- function (Waypoint $waypoint) {
- return $this->isWaypointCompleted($waypoint);
- }
- )->map(
- function (Waypoint $waypoint) {
- $waypoint->loadMissing('place');
-
- return $waypoint->place;
- }
- );
- }
-
- if ($this->order->status === 'created' || $this->order->status === 'dispatched' || $this->order->status === 'pending') {
- return collect();
- }
-
- if ($this->order->status === 'completed') {
- return collect([$this->getPickup(), $this->getDropoff()]);
- }
-
- return collect([$this->getPickup()]);
- }
-
- /**
- * Get the completed destination points as spatial points.
- *
- * @return Collection a collection of Point objects representing the completed destination points
- */
- public function getCompletedDestinationPoints(): Collection
- {
- return $this->getCompletedDestinations()->map(function (Place $place) {
- return $place->location;
- })->filter()->values();
- }
-
- /**
- * Get the next destination that the driver should go to.
- *
- * @return Place|null the next destination place, or null if not available
- */
- public function getNextDestination(): ?Place
- {
- if ($this->isMultipleDropOrder) {
- /** @var Waypoint $nextWaypoint */
- $nextWaypoint = $this->payload->waypointMarkers->filter(function (Waypoint $waypoint) {
- return $this->isWaypointNotCompleted($waypoint) && $this->isWaypointNotCurrentDestination($waypoint);
- })->first();
-
- if ($nextWaypoint) {
- $nextWaypoint->loadMissing('place');
-
- return $nextWaypoint->place;
- }
- }
-
- return $this->payload->dropoff;
- }
-
- /**
- * Check if a waypoint has been completed.
- *
- * @param Waypoint $waypoint the waypoint to check
- *
- * @return bool true if the waypoint is completed or canceled, otherwise false
- */
- public function isWaypointCompleted(Waypoint $waypoint): bool
- {
- $waypoint->loadMissing(['trackingNumber', 'trackingNumber.status']);
- $status = strtolower($waypoint->status_code);
-
- return $status === 'completed' || $status === 'canceled';
- }
-
- /**
- * Check if a waypoint is not completed.
- *
- * @param Waypoint $waypoint the waypoint to check
- *
- * @return bool true if the waypoint is not completed, otherwise false
- */
- public function isWaypointNotCompleted(Waypoint $waypoint): bool
- {
- return !$this->isWaypointCompleted($waypoint);
- }
-
- /**
- * Check if a waypoint is the current destination.
- *
- * @param Waypoint $waypoint the waypoint to check
- *
- * @return bool true if the waypoint is the current destination, otherwise false
- */
- public function isWaypointCurrentDestination(Waypoint $waypoint): bool
- {
- if (!$this->payload->current_waypoint_uuid) {
- $currentDestination = $this->getCurrentDestination();
-
- return $currentDestination->uuid === $waypoint->place_uuid;
- }
-
- return $waypoint->place_uuid === $this->payload->current_waypoint_uuid;
- }
-
- /**
- * Check if a waypoint is not the current destination.
- *
- * @param Waypoint $waypoint the waypoint to check
- *
- * @return bool true if the waypoint is not the current destination, otherwise false
- */
- public function isWaypointNotCurrentDestination(Waypoint $waypoint): bool
- {
- return !$this->isWaypointCurrentDestination($waypoint);
- }
-
- /**
- * Get the pickup location of the order.
- *
- * @return Place the pickup place of the order
- */
- public function getPickup(): ?Place
- {
- return $this->payload->pickup;
- }
-
- /**
- * Get the dropoff location of the order.
- *
- * @return Place the dropoff place of the order
- */
- public function getDropoff(): ?Place
- {
- return $this->payload->dropoff;
- }
-
- public function eta(): array
- {
- // Generate cache key based on order UUID and updated_at timestamp
- $cacheKey = 'order_eta:' . $this->order->uuid . ':' . optional($this->order->updated_at)->timestamp;
-
- // Return cached data if available
- return Cache::remember($cacheKey, 60, function () {
- // Load missing waypoints and places
- $waypoints = $this->payload->getAllStops();
-
- // ETA's
- $eta = [];
- foreach ($waypoints as $waypoint) {
- $eta[$waypoint->uuid] = $this->getWaypointETA($waypoint);
- }
-
- return $eta;
- });
- }
-
- /**
- * Get all key tracker information as an array.
- * Cached for 60 seconds to avoid repeated OSRM calls.
- */
- public function toArray(): array
- {
- // Generate cache key based on order UUID and updated_at timestamp
- $cacheKey = 'order_tracker:' . $this->order->uuid . ':' . optional($this->order->updated_at)->timestamp;
-
- // Return cached data if available
- return Cache::remember($cacheKey, 60, function () {
- // Early return for completed orders - skip expensive OSRM calls
- if (in_array($this->order->status, ['completed', 'canceled'])) {
- return [
- 'driver_current_location' => null,
- 'progress_percentage' => 100,
- 'total_distance' => 0,
- 'completed_distance' => 0,
- 'current_destination_eta' => 0,
- 'completion_eta' => 0,
- 'estimated_completion_time' => null,
- 'estimated_completion_time_formatted' => null,
- 'start_time' => $this->getOrderStartTime(),
- 'completion_time' => $this->getOrderCompletionTime(),
- 'current_destination' => null,
- 'next_destination' => null,
- 'first_waypoint_completed' => true,
- 'last_waypoint_completed' => true,
- ];
- }
-
- // Wrap OSRM-dependent calculations in try-catch for graceful degradation
- try {
- $totalDistance = $this->getTotalDistance();
- $completedDistance = $this->getCompletedDistance();
- $currentDestinationEta = $this->getCurrentDestinationETA();
- $completionEta = $this->getCompletionETA();
- } catch (\Exception $e) {
- Log::warning('OrderTracker: Failed to calculate distances/ETAs', ['error' => $e->getMessage()]);
- $totalDistance = 0;
- $completedDistance = 0;
- $currentDestinationEta = 0;
- $completionEta = 0;
- }
-
- $estimatedCompletionTime = $this->getEstimatedCompletionTime();
- $orderProgressPercentage = $this->getOrderProgressPercentage();
-
- return [
- 'driver_current_location' => $this->getDriverCurrentLocation(),
- 'progress_percentage' => $orderProgressPercentage,
- 'total_distance' => $totalDistance,
- 'completed_distance' => $completedDistance,
- 'current_destination_eta' => $currentDestinationEta,
- 'completion_eta' => $completionEta,
- 'estimated_completion_time' => $estimatedCompletionTime,
- 'estimated_completion_time_formatted' => $estimatedCompletionTime instanceof Carbon ? $estimatedCompletionTime->format('M jS, Y H:i') : null,
- 'start_time' => $this->getOrderStartTime(),
- 'completion_time' => $this->getOrderCompletionTime(),
- 'current_destination' => $this->getCurrentDestination(),
- 'next_destination' => $this->getNextDestination(),
- 'first_waypoint_completed' => $orderProgressPercentage > 10,
- 'last_waypoint_completed' => $orderProgressPercentage === 100 || $this->order->status === 'completed',
- ];
- });
+ return app(TrackingIntelligenceService::class)->track($this->order, TrackingOptions::fromArray($options));
}
}
diff --git a/server/src/Support/Utils.php b/server/src/Support/Utils.php
index 8a5160d11..8c67ca43e 100644
--- a/server/src/Support/Utils.php
+++ b/server/src/Support/Utils.php
@@ -392,7 +392,7 @@ protected static function extractPointWktFromQueryExpression(\Illuminate\Databas
return null;
}
- if (preg_match("/POINT\\(\\s*([-+]?\\d*\\.?\\d+)\\s+([-+]?\\d*\\.?\\d+)\\s*\\)/i", $expressionValue, $matches)) {
+ if (preg_match('/POINT\\(\\s*([-+]?\\d*\\.?\\d+)\\s+([-+]?\\d*\\.?\\d+)\\s*\\)/i', $expressionValue, $matches)) {
return sprintf('POINT(%s %s)', $matches[1], $matches[2]);
}
diff --git a/server/src/Tracking/Contracts/TrackingProviderInterface.php b/server/src/Tracking/Contracts/TrackingProviderInterface.php
new file mode 100644
index 000000000..6d74f1ed3
--- /dev/null
+++ b/server/src/Tracking/Contracts/TrackingProviderInterface.php
@@ -0,0 +1,19 @@
+canRoute();
+ }
+
+ public function track(TrackingContext $context, TrackingOptions $options): TrackingProviderResult
+ {
+ $points = $context->routePoints();
+ $distance = 0;
+ $legs = [];
+
+ for ($i = 0; $i < count($points) - 1; $i++) {
+ $legDistance = Utils::vincentyGreatCircleDistance($points[$i], $points[$i + 1]);
+ $legDuration = $this->durationFromDistance($legDistance, $options);
+ $distance += $legDistance;
+ $legs[] = [
+ 'index' => $i,
+ 'distance_m' => $legDistance,
+ 'duration_s' => $legDuration,
+ 'duration_in_traffic_s' => null,
+ 'provider' => $this->key(),
+ ];
+ }
+
+ return new TrackingProviderResult(
+ provider: $this->key(),
+ distanceMeters: $distance,
+ durationSeconds: $this->durationFromDistance($distance, $options),
+ durationInTrafficSeconds: null,
+ legs: $legs,
+ warnings: ['calculated_route_used'],
+ confidence: 'low'
+ );
+ }
+
+ protected function durationFromDistance(float $distanceMeters, TrackingOptions $options): float
+ {
+ $metersPerSecond = max($options->defaultVehicleSpeedKph, 1) * 1000 / 3600;
+
+ return round($distanceMeters / $metersPerSecond);
+ }
+}
diff --git a/server/src/Tracking/Providers/GoogleRoutesTrackingProvider.php b/server/src/Tracking/Providers/GoogleRoutesTrackingProvider.php
new file mode 100644
index 000000000..5f157d3eb
--- /dev/null
+++ b/server/src/Tracking/Providers/GoogleRoutesTrackingProvider.php
@@ -0,0 +1,118 @@
+canRoute() && filled($this->apiKey());
+ }
+
+ public function track(TrackingContext $context, TrackingOptions $options): TrackingProviderResult
+ {
+ $points = $context->routePoints();
+ $origin = array_shift($points);
+ $destination = array_pop($points);
+ $body = [
+ 'origin' => ['location' => ['latLng' => $this->latLng($origin)]],
+ 'destination' => ['location' => ['latLng' => $this->latLng($destination)]],
+ 'travelMode' => 'DRIVE',
+ 'routingPreference' => $options->trafficEnabled ? 'TRAFFIC_AWARE_OPTIMAL' : 'TRAFFIC_UNAWARE',
+ 'computeAlternativeRoutes' => false,
+ 'languageCode' => 'en-US',
+ 'units' => 'METRIC',
+ ];
+
+ if (!empty($points)) {
+ $body['intermediates'] = array_map(fn ($point) => ['location' => ['latLng' => $this->latLng($point)]], $points);
+ }
+
+ $response = Http::timeout(5)
+ ->withHeaders([
+ 'X-Goog-Api-Key' => $this->apiKey(),
+ 'X-Goog-FieldMask' => 'routes.distanceMeters,routes.duration,routes.staticDuration,routes.polyline.encodedPolyline,routes.legs.distanceMeters,routes.legs.duration,routes.legs.staticDuration',
+ ])
+ ->post('https://routes.googleapis.com/directions/v2:computeRoutes', $body);
+
+ if (!$response->successful()) {
+ throw new \RuntimeException('Google Routes request failed with status ' . $response->status());
+ }
+
+ $route = data_get($response->json(), 'routes.0');
+ if (!$route) {
+ throw new \RuntimeException('Google Routes returned no route.');
+ }
+
+ $duration = $this->durationToSeconds(data_get($route, 'staticDuration')) ?? $this->durationToSeconds(data_get($route, 'duration'));
+ $trafficDuration = $this->durationToSeconds(data_get($route, 'duration'));
+
+ return new TrackingProviderResult(
+ provider: $this->key(),
+ distanceMeters: data_get($route, 'distanceMeters'),
+ durationSeconds: $duration,
+ durationInTrafficSeconds: $options->trafficEnabled ? $trafficDuration : null,
+ polyline: data_get($route, 'polyline.encodedPolyline'),
+ legs: $this->legs(data_get($route, 'legs', []), $options),
+ warnings: [],
+ confidence: $options->trafficEnabled ? 'high' : 'medium',
+ raw: $route
+ );
+ }
+
+ protected function legs(array $legs, TrackingOptions $options): array
+ {
+ return collect($legs)->map(function ($leg, $index) use ($options) {
+ $duration = $this->durationToSeconds(data_get($leg, 'staticDuration')) ?? $this->durationToSeconds(data_get($leg, 'duration'));
+ $trafficDuration = $this->durationToSeconds(data_get($leg, 'duration'));
+
+ return [
+ 'index' => $index,
+ 'distance_m' => data_get($leg, 'distanceMeters'),
+ 'duration_s' => $duration,
+ 'duration_in_traffic_s' => $options->trafficEnabled ? $trafficDuration : null,
+ 'provider' => $this->key(),
+ ];
+ })->all();
+ }
+
+ protected function latLng($point): array
+ {
+ return [
+ 'latitude' => $point->getLat(),
+ 'longitude' => $point->getLng(),
+ ];
+ }
+
+ protected function durationToSeconds(?string $duration): ?float
+ {
+ if (!$duration) {
+ return null;
+ }
+
+ return (float) Str::replaceLast('s', '', $duration);
+ }
+
+ protected function apiKey(): ?string
+ {
+ return config('services.google_maps.api_key') ?: env('GOOGLE_MAPS_API_KEY');
+ }
+}
diff --git a/server/src/Tracking/Providers/OsrmTrackingProvider.php b/server/src/Tracking/Providers/OsrmTrackingProvider.php
new file mode 100644
index 000000000..16ee41686
--- /dev/null
+++ b/server/src/Tracking/Providers/OsrmTrackingProvider.php
@@ -0,0 +1,71 @@
+canRoute();
+ }
+
+ public function track(TrackingContext $context, TrackingOptions $options): TrackingProviderResult
+ {
+ $response = OSRM::getRouteFromPoints($context->routePoints(), [
+ 'overview' => 'full',
+ 'geometries' => 'polyline',
+ 'steps' => 'false',
+ 'annotations' => 'false',
+ ]);
+
+ if (data_get($response, 'code') !== 'Ok') {
+ throw new \RuntimeException('OSRM did not return a routable response.');
+ }
+
+ $route = Arr::first(data_get($response, 'routes', []));
+ if (!$route) {
+ throw new \RuntimeException('OSRM returned no route.');
+ }
+
+ $legs = collect(data_get($route, 'legs', []))->map(function ($leg, $index) {
+ return [
+ 'index' => $index,
+ 'distance_m' => data_get($leg, 'distance'),
+ 'duration_s' => data_get($leg, 'duration'),
+ 'duration_in_traffic_s' => null,
+ 'provider' => $this->key(),
+ ];
+ })->all();
+
+ return new TrackingProviderResult(
+ provider: $this->key(),
+ distanceMeters: data_get($route, 'distance'),
+ durationSeconds: data_get($route, 'duration'),
+ durationInTrafficSeconds: null,
+ polyline: data_get($route, 'geometry'),
+ coordinates: data_get($route, 'waypoints', []),
+ legs: $legs,
+ warnings: ['no_live_traffic'],
+ confidence: 'medium',
+ raw: $route
+ );
+ }
+}
diff --git a/server/src/Tracking/README.md b/server/src/Tracking/README.md
new file mode 100644
index 000000000..74fd24690
--- /dev/null
+++ b/server/src/Tracking/README.md
@@ -0,0 +1,25 @@
+# Order Tracking Providers
+
+FleetOps tracking providers adapt third-party routing or tracking systems into the canonical tracking intelligence response.
+
+Providers must implement `Fleetbase\FleetOps\Tracking\Contracts\TrackingProviderInterface`:
+
+```php
+public function key(): string;
+public function capabilities(): TrackingProviderCapabilities;
+public function canTrack(TrackingContext $context): bool;
+public function track(TrackingContext $context, TrackingOptions $options): TrackingProviderResult;
+```
+
+Register providers from a service provider:
+
+```php
+use Fleetbase\FleetOps\Tracking\TrackingProviderRegistry;
+
+public function boot(): void
+{
+ app(TrackingProviderRegistry::class)->register(new TomTomTrackingProvider());
+}
+```
+
+Provider adapters should never expose vendor response shapes directly. Map provider responses into `TrackingProviderResult` and declare capabilities such as traffic-aware ETA, per-leg ETA, route geometry, or map matching.
diff --git a/server/src/Tracking/Support/FakeTrackingProvider.php b/server/src/Tracking/Support/FakeTrackingProvider.php
new file mode 100644
index 000000000..a47a39abd
--- /dev/null
+++ b/server/src/Tracking/Support/FakeTrackingProvider.php
@@ -0,0 +1,42 @@
+providerKey;
+ }
+
+ public function capabilities(): TrackingProviderCapabilities
+ {
+ return new TrackingProviderCapabilities(traffic: true, perLegEta: true);
+ }
+
+ public function canTrack(TrackingContext $context): bool
+ {
+ return $context->canRoute();
+ }
+
+ public function track(TrackingContext $context, TrackingOptions $options): TrackingProviderResult
+ {
+ $result = (new CalculatedTrackingProvider())->track($context, $options);
+ $result->provider = $this->providerKey;
+ $result->confidence = 'high';
+ $result->warnings = [];
+
+ return $result;
+ }
+}
diff --git a/server/src/Tracking/TrackingContext.php b/server/src/Tracking/TrackingContext.php
new file mode 100644
index 000000000..779ac587b
--- /dev/null
+++ b/server/src/Tracking/TrackingContext.php
@@ -0,0 +1,63 @@
+origin) {
+ $points[] = $this->origin;
+ }
+
+ foreach ($this->remainingStops as $stop) {
+ $point = $stop instanceof TrackingStop ? $stop->point() : null;
+ if ($point) {
+ $points[] = $point;
+ }
+ }
+
+ return $points;
+ }
+
+ public function canRoute(): bool
+ {
+ return count($this->routePoints()) >= 2;
+ }
+
+ public function stateSignature(): string
+ {
+ return md5(json_encode($this->stops->map(function (TrackingStop $stop) {
+ return [
+ 'uuid' => $stop->uuid,
+ 'type' => $stop->type,
+ 'status' => $stop->status,
+ 'completed' => $stop->completed,
+ 'sequence' => $stop->sequence,
+ ];
+ })->values()->all()));
+ }
+}
diff --git a/server/src/Tracking/TrackingContextBuilder.php b/server/src/Tracking/TrackingContextBuilder.php
new file mode 100644
index 000000000..4a8d6fbc9
--- /dev/null
+++ b/server/src/Tracking/TrackingContextBuilder.php
@@ -0,0 +1,204 @@
+loadAssignedDriver();
+ $order->loadMissing([
+ 'payload',
+ 'payload.pickup',
+ 'payload.dropoff',
+ 'payload.waypoints',
+ 'payload.waypointMarkers.place',
+ ]);
+
+ $payload = $order->payload;
+ $driver = $order->driverAssigned;
+ $warnings = [];
+ $driverLocationAge = $driver?->updated_at ? now()->diffInSeconds($driver->updated_at) : null;
+
+ $origin = null;
+ $driverLocation = null;
+ if ($driver && $driver->location) {
+ try {
+ $driverLocation = Utils::getPointFromMixed($driver);
+ if (!$this->isValidPoint($driverLocation)) {
+ $driverLocation = null;
+ }
+ } catch (\Throwable) {
+ $driverLocation = null;
+ }
+ }
+
+ if ($driverLocation) {
+ $origin = $driverLocation;
+ } else {
+ $warnings[] = 'missing_driver_location';
+ $origin = $this->fallbackOrigin($payload);
+ }
+
+ if ($driverLocationAge !== null && $driverLocationAge > $options->staleLocationThresholdSeconds) {
+ $warnings[] = 'stale_driver_location';
+ }
+
+ $stops = $this->stops($payload, $order);
+ $completedStops = $stops->filter(fn (TrackingStop $stop) => $stop->completed)->values();
+ $remainingStops = $stops->reject(fn (TrackingStop $stop) => $stop->completed)->values();
+ $activeStop = $this->activeStop($payload, $remainingStops);
+ $nextStop = $remainingStops->first(fn (TrackingStop $stop) => !$activeStop || $stop->uuid !== $activeStop->uuid);
+
+ if (!$activeStop && $remainingStops->isNotEmpty()) {
+ $activeStop = $remainingStops->first();
+ }
+
+ $missingStopLocations = $remainingStops->filter(fn (TrackingStop $stop) => !$stop->point())->count();
+ if ($missingStopLocations > 0) {
+ $warnings[] = 'missing_stop_location';
+ }
+
+ return new TrackingContext(
+ order: $order,
+ payload: $payload,
+ driver: $driver,
+ origin: $origin,
+ driverLocation: $driverLocation,
+ stops: $stops,
+ completedStops: $completedStops,
+ remainingStops: $remainingStops,
+ activeStop: $activeStop,
+ nextStop: $nextStop,
+ driverLocationAgeSeconds: $driverLocationAge,
+ warnings: array_values(array_unique($warnings))
+ );
+ }
+
+ protected function stops($payload, Order $order): Collection
+ {
+ if (!$payload) {
+ return collect();
+ }
+
+ $stops = collect();
+ $sequence = 0;
+
+ if ($payload->pickup instanceof Place) {
+ $stops->push(new TrackingStop(
+ uuid: $payload->pickup->uuid,
+ publicId: $payload->pickup->public_id,
+ type: 'pickup',
+ status: null,
+ place: $payload->pickup,
+ completed: !in_array($order->status, ['created', 'dispatched', 'pending']),
+ sequence: ++$sequence
+ ));
+ }
+
+ $markers = $payload->waypointMarkers instanceof Collection ? $payload->waypointMarkers : collect();
+ if ($markers->isNotEmpty()) {
+ foreach ($markers->sortBy('order')->values() as $waypoint) {
+ $stops->push($this->stopFromWaypoint($waypoint, ++$sequence));
+ }
+ } elseif ($payload->waypoints instanceof Collection) {
+ foreach ($payload->waypoints as $place) {
+ if ($place instanceof Place) {
+ $stops->push(new TrackingStop(
+ uuid: $place->uuid,
+ publicId: $place->public_id,
+ type: 'waypoint',
+ status: null,
+ place: $place,
+ completed: false,
+ sequence: ++$sequence
+ ));
+ }
+ }
+ }
+
+ if ($payload->dropoff instanceof Place) {
+ $stops->push(new TrackingStop(
+ uuid: $payload->dropoff->uuid,
+ publicId: $payload->dropoff->public_id,
+ type: 'dropoff',
+ status: null,
+ place: $payload->dropoff,
+ completed: in_array($order->status, ['completed', 'canceled']),
+ sequence: ++$sequence
+ ));
+ }
+
+ return $stops->values();
+ }
+
+ protected function stopFromWaypoint(Waypoint $waypoint, int $sequence): TrackingStop
+ {
+ $waypoint->loadMissing('place');
+ $status = strtolower((string) $waypoint->status_code);
+
+ return new TrackingStop(
+ uuid: $waypoint->place_uuid,
+ publicId: data_get($waypoint, 'place.public_id'),
+ type: 'waypoint',
+ status: $status ?: null,
+ place: $waypoint->place,
+ waypoint: $waypoint,
+ completed: in_array($status, ['completed', 'canceled']),
+ sequence: $sequence
+ );
+ }
+
+ protected function activeStop($payload, Collection $remainingStops): ?TrackingStop
+ {
+ if (!$payload || !$payload->current_waypoint_uuid || !Str::isUuid($payload->current_waypoint_uuid)) {
+ return $remainingStops->first();
+ }
+
+ return $remainingStops->first(function (TrackingStop $stop) use ($payload) {
+ return $stop->uuid === $payload->current_waypoint_uuid
+ || data_get($stop->waypoint, 'uuid') === $payload->current_waypoint_uuid
+ || data_get($stop->waypoint, 'place_uuid') === $payload->current_waypoint_uuid;
+ });
+ }
+
+ protected function fallbackOrigin($payload)
+ {
+ if (!$payload) {
+ return null;
+ }
+
+ try {
+ $point = Utils::getPointFromMixed($payload->getPickupOrCurrentWaypoint());
+
+ return $this->isValidPoint($point) ? $point : null;
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+
+ protected function isValidPoint($point): bool
+ {
+ if (!$point || !method_exists($point, 'getLat') || !method_exists($point, 'getLng')) {
+ return false;
+ }
+
+ $lat = (float) $point->getLat();
+ $lng = (float) $point->getLng();
+
+ return is_finite($lat)
+ && is_finite($lng)
+ && $lat >= -90
+ && $lat <= 90
+ && $lng >= -180
+ && $lng <= 180
+ && !(abs($lat) < 0.000001 && abs($lng) < 0.000001);
+ }
+}
diff --git a/server/src/Tracking/TrackingIntelligenceService.php b/server/src/Tracking/TrackingIntelligenceService.php
new file mode 100644
index 000000000..5e9a20442
--- /dev/null
+++ b/server/src/Tracking/TrackingIntelligenceService.php
@@ -0,0 +1,180 @@
+contextBuilder->build($order, $options);
+ $cacheKey = $this->cacheKey($context, $options);
+
+ return Cache::remember($cacheKey, $options->cacheTtlSeconds, function () use ($context, $options) {
+ return $this->buildResult($context, $this->providerManager->track($context, $options), $options);
+ });
+ }
+
+ public function eta(Order $order, array|TrackingOptions $options = []): array
+ {
+ $tracker = $this->track($order, $options);
+
+ return [
+ 'active_stop_seconds' => data_get($tracker, 'eta.active_stop_seconds'),
+ 'completion_seconds' => data_get($tracker, 'eta.completion_seconds'),
+ 'active_stop_at' => data_get($tracker, 'eta.active_stop_at'),
+ 'completion_at' => data_get($tracker, 'eta.completion_at'),
+ 'provider' => data_get($tracker, 'provider'),
+ 'confidence' => data_get($tracker, 'confidence'),
+ 'warnings' => data_get($tracker, 'warnings', []),
+ ];
+ }
+
+ protected function buildResult(TrackingContext $context, TrackingProviderResult $providerResult, TrackingOptions $options): array
+ {
+ $now = now();
+ $completionSeconds = $providerResult->durationInTrafficSeconds ?? $providerResult->durationSeconds;
+ $activeStopSeconds = data_get($providerResult->legs, '0.duration_in_traffic_s', data_get($providerResult->legs, '0.duration_s', $completionSeconds));
+ $progress = $this->progress($context, $providerResult);
+ $warnings = array_values(array_unique([...$context->warnings, ...$providerResult->warnings]));
+
+ if ($providerResult->confidence === 'low') {
+ $warnings[] = 'low_confidence_eta';
+ }
+
+ return [
+ 'provider' => $providerResult->provider,
+ 'fallback_provider' => in_array('fallback_used', $warnings) ? $providerResult->provider : null,
+ 'generated_at' => $now->toISOString(),
+ 'confidence' => $providerResult->confidence,
+ 'warnings' => array_values(array_unique($warnings)),
+ 'driver' => [
+ 'location' => $this->pointToGeoJson($context->driverLocation),
+ 'location_age_seconds' => $context->driverLocationAgeSeconds,
+ 'online' => (bool) data_get($context->driver, 'online', false),
+ ],
+ 'progress' => $progress,
+ 'stops' => $context->stops->map(fn (TrackingStop $stop) => $stop->toArray())->values()->all(),
+ 'active_stop' => $context->activeStop?->toArray(),
+ 'next_stop' => $context->nextStop?->toArray(),
+ 'route' => [
+ 'distance_m' => $providerResult->distanceMeters,
+ 'duration_s' => $providerResult->durationSeconds,
+ 'duration_in_traffic_s' => $providerResult->durationInTrafficSeconds,
+ 'polyline' => $providerResult->polyline,
+ 'coordinates' => $providerResult->coordinates,
+ 'legs' => $this->legs($context, $providerResult, $now),
+ ],
+ 'eta' => [
+ 'active_stop_seconds' => $activeStopSeconds,
+ 'completion_seconds' => $completionSeconds,
+ 'active_stop_at' => $this->addSeconds($now, $activeStopSeconds),
+ 'completion_at' => $this->addSeconds($now, $completionSeconds),
+ ],
+ 'insights' => [
+ 'is_delayed' => false,
+ 'delay_seconds' => 0,
+ 'is_location_stale' => in_array('stale_driver_location', $warnings),
+ 'is_off_route' => in_array('off_route', $warnings),
+ ],
+ 'capabilities' => $this->capabilitiesFor($providerResult->provider),
+ ];
+ }
+
+ protected function progress(TrackingContext $context, TrackingProviderResult $providerResult): array
+ {
+ $totalStops = max($context->stops->count(), 1);
+ $completedStops = $context->completedStops->count();
+ $percentage = $context->order->status === 'completed' ? 100 : round(($completedStops / $totalStops) * 100, 2);
+
+ return [
+ 'percentage' => $percentage,
+ 'completed_stops' => $completedStops,
+ 'remaining_stops' => $context->remainingStops->count(),
+ 'total_stops' => $context->stops->count(),
+ 'completed_distance_m' => null,
+ 'remaining_distance_m' => $providerResult->distanceMeters,
+ ];
+ }
+
+ protected function legs(TrackingContext $context, TrackingProviderResult $providerResult, Carbon $now): array
+ {
+ $elapsedSeconds = 0;
+
+ return collect($providerResult->legs)->map(function ($leg, $index) use ($context, $now, &$elapsedSeconds) {
+ $stop = $context->remainingStops->values()->get($index);
+ $legSeconds = data_get($leg, 'duration_in_traffic_s', data_get($leg, 'duration_s'));
+ $etaSeconds = null;
+ $etaAt = null;
+
+ if ($legSeconds !== null) {
+ $elapsedSeconds += (float) $legSeconds;
+ $etaSeconds = $elapsedSeconds;
+ $etaAt = $this->addSeconds($now, $etaSeconds);
+ }
+
+ return array_merge($leg, [
+ 'stop' => $stop instanceof TrackingStop ? $stop->toArray() : null,
+ 'eta_seconds' => $etaSeconds,
+ 'eta_at' => $etaAt,
+ ]);
+ })->all();
+ }
+
+ protected function capabilitiesFor(string $provider): array
+ {
+ $registry = app(TrackingProviderRegistry::class);
+ $registered = $registry->get($provider);
+
+ return $registered ? $registered->capabilities()->toArray() : (new TrackingProviderCapabilities())->toArray();
+ }
+
+ protected function cacheKey(TrackingContext $context, TrackingOptions $options): string
+ {
+ return 'order_tracking_intelligence:' . md5(json_encode([
+ 'order' => $context->order->uuid,
+ 'order_ts' => optional($context->order->updated_at)->timestamp,
+ 'payload' => optional($context->payload)->uuid,
+ 'payload_ts' => optional($context->payload?->updated_at)->timestamp,
+ 'driver' => optional($context->driver)->uuid,
+ 'driver_ts' => optional($context->driver?->updated_at)->timestamp,
+ 'stops' => $context->stateSignature(),
+ 'provider' => $options->provider,
+ 'fallbacks' => $options->fallbacks,
+ 'traffic' => $options->trafficEnabled,
+ 'settings' => [
+ 'summary_ttl' => $options->cacheTtlSeconds,
+ 'route_ttl' => $options->routeCacheTtlSeconds,
+ 'stale_threshold' => $options->staleLocationThresholdSeconds,
+ 'fallback_speed' => $options->defaultVehicleSpeedKph,
+ ],
+ ]));
+ }
+
+ protected function pointToGeoJson($point): ?array
+ {
+ if (!$point) {
+ return null;
+ }
+
+ return [
+ 'type' => 'Point',
+ 'coordinates' => [$point->getLng(), $point->getLat()],
+ ];
+ }
+
+ protected function addSeconds(Carbon $now, ?float $seconds): ?string
+ {
+ return $seconds === null ? null : $now->copy()->addSeconds((int) round($seconds))->toISOString();
+ }
+}
diff --git a/server/src/Tracking/TrackingOptions.php b/server/src/Tracking/TrackingOptions.php
new file mode 100644
index 000000000..e2a7c54ec
--- /dev/null
+++ b/server/src/Tracking/TrackingOptions.php
@@ -0,0 +1,49 @@
+ $this->traffic,
+ 'per_leg_eta' => $this->perLegEta,
+ 'map_matching' => $this->mapMatching,
+ 'route_geometry' => $this->routeGeometry,
+ ], $this->extras);
+ }
+
+ public function jsonSerialize(): mixed
+ {
+ return $this->toArray();
+ }
+}
diff --git a/server/src/Tracking/TrackingProviderManager.php b/server/src/Tracking/TrackingProviderManager.php
new file mode 100644
index 000000000..26c04b72a
--- /dev/null
+++ b/server/src/Tracking/TrackingProviderManager.php
@@ -0,0 +1,89 @@
+providerOrder($options) as $providerKey) {
+ $provider = $this->registry->get($providerKey);
+ if (!$provider instanceof TrackingProviderInterface) {
+ $warnings[] = 'provider_not_registered:' . $providerKey;
+ continue;
+ }
+
+ if (!$provider->canTrack($context)) {
+ $warnings[] = 'provider_unavailable:' . $providerKey;
+ continue;
+ }
+
+ try {
+ $result = $this->trackWithProviderCache($provider, $context, $options);
+ $result->warnings = array_values(array_unique([...$warnings, ...$result->warnings]));
+
+ if (Str::snake($result->provider) !== Str::snake((string) $options->provider)) {
+ $result->warnings[] = 'fallback_used';
+ }
+
+ return $result;
+ } catch (\Throwable $e) {
+ $warnings[] = 'provider_failed:' . $providerKey;
+ Log::warning('Tracking provider failed.', [
+ 'provider' => $providerKey,
+ 'order' => $context->order->public_id ?? $context->order->uuid,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ return new TrackingProviderResult(
+ provider: 'none',
+ warnings: array_values(array_unique([...$warnings, 'no_tracking_provider_available'])),
+ confidence: 'none'
+ );
+ }
+
+ protected function providerOrder(TrackingOptions $options): array
+ {
+ $provider = $options->provider ?: Arr::get(config('fleetops.tracking', []), 'provider', 'google_routes');
+ $fallbacks = $options->fallbacks ?: Arr::get(config('fleetops.tracking', []), 'fallbacks', ['osrm', 'calculated']);
+
+ return collect([$provider, ...$fallbacks])
+ ->filter()
+ ->map(fn ($key) => Str::snake($key))
+ ->unique()
+ ->values()
+ ->all();
+ }
+
+ protected function trackWithProviderCache(TrackingProviderInterface $provider, TrackingContext $context, TrackingOptions $options): TrackingProviderResult
+ {
+ return Cache::remember($this->providerCacheKey($provider, $context, $options), $options->routeCacheTtlSeconds, function () use ($provider, $context, $options) {
+ return $provider->track($context, $options);
+ });
+ }
+
+ protected function providerCacheKey(TrackingProviderInterface $provider, TrackingContext $context, TrackingOptions $options): string
+ {
+ return 'order_tracking_provider_route:' . md5(json_encode([
+ 'provider' => $provider->key(),
+ 'points' => array_map(function ($point) {
+ return [$point->getLat(), $point->getLng()];
+ }, $context->routePoints()),
+ 'traffic' => $options->trafficEnabled,
+ 'speed' => $options->defaultVehicleSpeedKph,
+ ]));
+ }
+}
diff --git a/server/src/Tracking/TrackingProviderRegistry.php b/server/src/Tracking/TrackingProviderRegistry.php
new file mode 100644
index 000000000..dfe76846f
--- /dev/null
+++ b/server/src/Tracking/TrackingProviderRegistry.php
@@ -0,0 +1,35 @@
+key());
+ $this->providers[$providerKey] = $instance;
+
+ return $this;
+ }
+
+ public function has(string $key): bool
+ {
+ return isset($this->providers[Str::snake($key)]);
+ }
+
+ public function get(string $key): ?TrackingProviderInterface
+ {
+ return $this->providers[Str::snake($key)] ?? null;
+ }
+
+ public function all(): array
+ {
+ return $this->providers;
+ }
+}
diff --git a/server/src/Tracking/TrackingProviderResult.php b/server/src/Tracking/TrackingProviderResult.php
new file mode 100644
index 000000000..09736f965
--- /dev/null
+++ b/server/src/Tracking/TrackingProviderResult.php
@@ -0,0 +1,20 @@
+place);
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+
+ public function toArray(): array
+ {
+ $point = $this->point();
+
+ return [
+ 'uuid' => $this->uuid,
+ 'public_id' => $this->publicId,
+ 'type' => $this->type,
+ 'status' => $this->status,
+ 'completed' => $this->completed,
+ 'sequence' => $this->sequence,
+ 'address' => data_get($this->place, 'address'),
+ 'name' => data_get($this->place, 'name'),
+ 'location' => $point ? [
+ 'type' => 'Point',
+ 'coordinates' => [$point->getLng(), $point->getLat()],
+ ] : null,
+ 'latitude' => $point ? $point->getLat() : null,
+ 'longitude' => $point ? $point->getLng() : null,
+ ];
+ }
+
+ public function jsonSerialize(): mixed
+ {
+ return $this->toArray();
+ }
+}
diff --git a/server/src/routes.php b/server/src/routes.php
index d62ce4213..f150149a3 100644
--- a/server/src/routes.php
+++ b/server/src/routes.php
@@ -339,6 +339,7 @@ function ($router, $controller) {
$router->get('next-activity/{id}', $controller('nextActivity'));
$router->get('{id}/tracker', 'OrderController@trackerInfo');
$router->get('{id}/eta', 'OrderController@waypointEtas');
+ $router->post('{id}/ping-driver', $controller('pingDriver'));
$router->post('process-imports', $controller('importFromFiles'));
$router->patch('route/{id}', $controller('editOrderRoute'));
$router->patch('update-activity/{id}', $controller('updateActivity'));
@@ -546,6 +547,10 @@ function ($router) {
$router->post('notification-settings', 'SettingController@saveNotificationSettings');
$router->get('routing-settings', 'SettingController@getRoutingSettings');
$router->post('routing-settings', 'SettingController@saveRoutingSettings');
+ $router->get('tracking-settings', 'SettingController@getTrackingSettings');
+ $router->post('tracking-settings', 'SettingController@saveTrackingSettings');
+ $router->get('admin-tracking-settings', 'SettingController@getAdminTrackingSettings');
+ $router->post('admin-tracking-settings', 'SettingController@saveAdminTrackingSettings');
$router->get('map', 'SettingController@getMapSettings');
$router->post('map', 'SettingController@saveMapSettings');
$router->get('admin-map', 'SettingController@getAdminMapSettings');
diff --git a/server/tests/LiveOrderQueryTest.php b/server/tests/LiveOrderQueryTest.php
new file mode 100644
index 000000000..146370f29
--- /dev/null
+++ b/server/tests/LiveOrderQueryTest.php
@@ -0,0 +1,34 @@
+ true,
+ 'apply_permissions' => false,
+ ]);
+
+ $bindings = $query->getBindings();
+
+ expect($bindings)->toContain('company_test')
+ ->and($bindings)->toContain('created')
+ ->and($bindings)->toContain('pending')
+ ->and($bindings)->toContain('completed')
+ ->and($bindings)->toContain('canceled')
+ ->and($bindings)->toContain('expired')
+ ->and($bindings)->toContain('order_canceled');
+});
+
+test('live order query requires renderable payload and tracking data', function () {
+ $query = LiveOrderQuery::make('company_test', [
+ 'active' => true,
+ 'apply_permissions' => false,
+ ]);
+
+ $sql = $query->toSql();
+
+ expect($sql)->toContain('exists')
+ ->and($sql)->toContain('payload')
+ ->and($sql)->toContain('tracking')
+ ->and($sql)->toContain('driver');
+});
diff --git a/server/tests/PingDriverEndpointTest.php b/server/tests/PingDriverEndpointTest.php
new file mode 100644
index 000000000..e41d2b35b
--- /dev/null
+++ b/server/tests/PingDriverEndpointTest.php
@@ -0,0 +1,19 @@
+not->toContain("\$router->post('{id}/ping-driver', 'OrderController@pingDriver');")
+ ->and($routes)->toContain("\$router->post('{id}/ping-driver', \$controller('pingDriver'));");
+});
+
+test('ping driver handler belongs to the internal order controller', function () {
+ $apiController = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Api/v1/OrderController.php');
+ $internalController = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Internal/v1/OrderController.php');
+
+ expect($apiController)->not->toContain('function pingDriver')
+ ->and($apiController)->not->toContain('Notifications\OrderPing')
+ ->and($internalController)->toContain('function pingDriver')
+ ->and($internalController)->toContain('Notifications\OrderPing')
+ ->and($internalController)->toContain('fleet-ops update order');
+});
diff --git a/server/tests/TrackingIntelligenceTest.php b/server/tests/TrackingIntelligenceTest.php
new file mode 100644
index 000000000..5f05fd186
--- /dev/null
+++ b/server/tests/TrackingIntelligenceTest.php
@@ -0,0 +1,176 @@
+uuid = $uuid;
+ $place->public_id = 'place_' . substr($uuid, 0, 8);
+ $place->address = 'Test address ' . $uuid;
+ $place->location = new Point($lat, $lng);
+
+ return $place;
+}
+
+function trackingOrderWithStops(): Order
+{
+ $pickup = trackingPlace('11111111-1111-1111-1111-111111111111', 1.30, 103.80);
+ $dropoff = trackingPlace('22222222-2222-2222-2222-222222222222', 1.35, 103.85);
+ $payload = new Payload();
+ $payload->uuid = '33333333-3333-3333-3333-333333333333';
+ $payload->current_waypoint_uuid = $pickup->uuid;
+ $payload->setRelation('pickup', $pickup);
+ $payload->setRelation('dropoff', $dropoff);
+ $payload->setRelation('waypoints', collect());
+ $payload->setRelation('waypointMarkers', collect());
+
+ $driver = new Driver();
+ $driver->uuid = '44444444-4444-4444-4444-444444444444';
+ $driver->location = new Point(1.29, 103.79);
+ $driver->online = true;
+ $driver->updated_at = Carbon::now();
+
+ $order = new Order();
+ $order->uuid = '55555555-5555-5555-5555-555555555555';
+ $order->public_id = 'order_test';
+ $order->status = 'started';
+ $order->updated_at = Carbon::now();
+ $order->setRelation('payload', $payload);
+ $order->setRelation('driverAssigned', $driver);
+
+ return $order;
+}
+
+test('tracking context builder normalizes order stops and driver telemetry', function () {
+ $context = (new TrackingContextBuilder())->build(trackingOrderWithStops(), TrackingOptions::fromArray([
+ 'provider' => 'calculated',
+ ]));
+
+ expect($context->stops)->toHaveCount(2)
+ ->and($context->activeStop?->type)->toBe('dropoff')
+ ->and($context->nextStop)->toBeNull()
+ ->and($context->driverLocationAgeSeconds)->toBeInt()
+ ->and($context->warnings)->toBe([]);
+});
+
+test('tracking context ignores zero coordinate driver location and falls back to pickup origin', function () {
+ $order = trackingOrderWithStops();
+ $order->driverAssigned->location = new Point(0, 0);
+
+ $context = (new TrackingContextBuilder())->build($order, TrackingOptions::fromArray([
+ 'provider' => 'calculated',
+ ]));
+
+ expect($context->driverLocation)->toBeNull()
+ ->and($context->origin?->getLat())->toBe(1.30)
+ ->and($context->origin?->getLng())->toBe(103.80)
+ ->and($context->routePoints()[0]->getLat())->toBe(1.30)
+ ->and($context->warnings)->toContain('missing_driver_location');
+});
+
+test('calculated provider returns normalized low confidence route data', function () {
+ $context = (new TrackingContextBuilder())->build(trackingOrderWithStops(), TrackingOptions::fromArray([
+ 'provider' => 'calculated',
+ ]));
+
+ $result = (new CalculatedTrackingProvider())->track($context, TrackingOptions::fromArray([
+ 'provider' => 'calculated',
+ 'default_vehicle_speed_kph' => 36,
+ ]));
+
+ expect($result->provider)->toBe('calculated')
+ ->and($result->distanceMeters)->toBeGreaterThan(0)
+ ->and($result->durationSeconds)->toBeGreaterThan(0)
+ ->and($result->confidence)->toBe('low')
+ ->and($result->warnings)->toContain('calculated_route_used');
+});
+
+test('tracking route legs include cumulative stop eta values', function () {
+ $context = (new TrackingContextBuilder())->build(trackingOrderWithStops(), TrackingOptions::fromArray([
+ 'provider' => 'calculated',
+ ]));
+
+ $result = (new CalculatedTrackingProvider())->track($context, TrackingOptions::fromArray([
+ 'provider' => 'calculated',
+ ]));
+ $service = app(Fleetbase\FleetOps\Tracking\TrackingIntelligenceService::class);
+ $method = new ReflectionMethod($service, 'legs');
+ $method->setAccessible(true);
+ $legs = $method->invoke($service, $context, $result, Carbon::parse('2026-05-12 00:00:00'));
+
+ expect($legs[0]['eta_seconds'])->toBeGreaterThan(0)
+ ->and($legs[0]['eta_at'])->not->toBeNull();
+});
+
+test('provider manager falls back to registered provider and records fallback warning', function () {
+ $registry = new TrackingProviderRegistry();
+ $registry->register(new FakeTrackingProvider('fake'));
+ $manager = new TrackingProviderManager($registry);
+ $context = (new TrackingContextBuilder())->build(trackingOrderWithStops(), TrackingOptions::fromArray([
+ 'provider' => 'missing',
+ 'fallbacks' => ['fake'],
+ ]));
+
+ $result = $manager->track($context, TrackingOptions::fromArray([
+ 'provider' => 'missing',
+ 'fallbacks' => ['fake'],
+ ]));
+
+ expect($result->provider)->toBe('fake')
+ ->and($result->warnings)->toContain('provider_not_registered:missing')
+ ->and($result->warnings)->toContain('fallback_used');
+});
+
+test('third party providers can be registered through the tracking provider contract', function () {
+ $registry = new TrackingProviderRegistry();
+ $registry->register(new FakeTrackingProvider('tomtom'));
+
+ expect($registry->has('tomtom'))->toBeTrue()
+ ->and($registry->get('tomtom')?->capabilities()->traffic)->toBeTrue();
+});
+
+test('tracking options include route cache ttl and fallback provider settings', function () {
+ $options = TrackingOptions::fromArray([
+ 'provider' => 'calculated',
+ 'fallbacks' => 'osrm,calculated',
+ 'route_cache_ttl_seconds' => 900,
+ ]);
+
+ expect($options->provider)->toBe('calculated')
+ ->and($options->fallbacks)->toBe(['osrm', 'calculated'])
+ ->and($options->routeCacheTtlSeconds)->toBe(900);
+});
+
+test('provider cache key varies by route options', function () {
+ $registry = new TrackingProviderRegistry();
+ $manager = new TrackingProviderManager($registry);
+ $provider = new FakeTrackingProvider('fake');
+ $context = (new TrackingContextBuilder())->build(trackingOrderWithStops(), TrackingOptions::fromArray([
+ 'provider' => 'fake',
+ ]));
+ $method = new ReflectionMethod($manager, 'providerCacheKey');
+ $method->setAccessible(true);
+
+ $trafficKey = $method->invoke($manager, $provider, $context, TrackingOptions::fromArray([
+ 'provider' => 'fake',
+ 'traffic_enabled' => true,
+ ]));
+ $nonTrafficKey = $method->invoke($manager, $provider, $context, TrackingOptions::fromArray([
+ 'provider' => 'fake',
+ 'traffic_enabled' => false,
+ ]));
+
+ expect($trafficKey)->not->toBe($nonTrafficKey);
+});
diff --git a/tests/integration/components/map/toolbar-test.js b/tests/integration/components/map/toolbar-test.js
index 4c72d9c24..d30c4e66c 100644
--- a/tests/integration/components/map/toolbar-test.js
+++ b/tests/integration/components/map/toolbar-test.js
@@ -1,26 +1,64 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'dummy/tests/helpers';
+import Service from '@ember/service';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | map/toolbar', function (hooks) {
setupRenderingTest(hooks);
- test('it renders', async function (assert) {
- // Set any properties with this.set('myProperty', 'value');
- // Handle any actions with this.set('myAction', function(val) { ... });
+ hooks.beforeEach(function () {
+ this.fetchCalls = [];
+ const testContext = this;
+ class FetchStubService extends Service {
+ get(url, params) {
+ testContext.fetchCalls.push(`${url}:${params.discover.join(',')}`);
+
+ return Promise.resolve({
+ active_live_orders: 5,
+ });
+ }
+ }
+
+ class MapManagerStubService extends Service {
+ zoomIn() {}
+ zoomOut() {}
+ }
+
+ class OrderListOverlayStubService extends Service {
+ isOpen = false;
+ loaded = false;
+ activeOrdersCount = 0;
+ toggle() {}
+ }
+
+ class ToggleStubService extends Service {
+ toggle() {}
+ }
+
+ this.owner.register('service:fetch', FetchStubService);
+ this.owner.register('service:map-manager', MapManagerStubService);
+ this.owner.register('service:order-list-overlay', OrderListOverlayStubService);
+ this.owner.register('service:map-drawer', ToggleStubService);
+ this.owner.register('service:global-search', ToggleStubService);
+ });
+
+ test('it requests the live active order metric', async function (assert) {
await render(hbs`
`);
- assert.dom().hasText('');
+ assert.deepEqual(this.fetchCalls, ['fleet-ops/metrics:active_live_orders']);
+ assert.dom('.active-orders-count').hasText('5');
+ });
- // Template block usage:
- await render(hbs`
-
- template block text
-
- `);
+ test('it uses the loaded overlay count while the overlay is open', async function (assert) {
+ const overlay = this.owner.lookup('service:order-list-overlay');
+ overlay.isOpen = true;
+ overlay.loaded = true;
+ overlay.activeOrdersCount = 3;
+
+ await render(hbs`
`);
- assert.dom().hasText('template block text');
+ assert.dom('.active-orders-count').hasText('3');
});
});
diff --git a/tests/integration/components/order/details/tracking-test.js b/tests/integration/components/order/details/tracking-test.js
index 7a4fb5205..e8a1d39b4 100644
--- a/tests/integration/components/order/details/tracking-test.js
+++ b/tests/integration/components/order/details/tracking-test.js
@@ -1,3 +1,4 @@
+import Service from '@ember/service';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'dummy/tests/helpers';
import { render } from '@ember/test-helpers';
@@ -6,21 +7,165 @@ import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | order/details/tracking', function (hooks) {
setupRenderingTest(hooks);
- test('it renders', async function (assert) {
- // Set any properties with this.set('myProperty', 'value');
- // Handle any actions with this.set('myAction', function(val) { ... });
+ hooks.beforeEach(function () {
+ this.owner.register('service:order-actions', class OrderActionsService extends Service {});
+ });
+
+ function buildOrder(overrides = {}) {
+ return {
+ tracking: 'FLE2177254646SG',
+ public_id: 'order_test',
+ tracking_number: {
+ qr_code: '',
+ barcode: '',
+ },
+ loadTrackerData() {
+ return Promise.resolve();
+ },
+ tracker_data: {
+ provider: 'google_routes',
+ confidence: 'high',
+ fallback_provider: null,
+ generated_at: '2026-05-12T03:49:26.000000Z',
+ warnings: [],
+ progress: {
+ percentage: 40,
+ completed_stops: 1,
+ remaining_stops: 2,
+ },
+ eta: {
+ active_stop_seconds: 900,
+ completion_at: '2026-05-12T04:49:26.000000Z',
+ },
+ active_stop: {
+ uuid: 'stop_1',
+ public_id: 'stop_1',
+ type: 'waypoint',
+ address: '11807 Broadway Lane, Charlotte, 28273, United States',
+ },
+ stops: [
+ { uuid: 'pickup', type: 'pickup', address: 'Pickup Address', completed: true },
+ { uuid: 'stop_1', public_id: 'stop_1', type: 'waypoint', address: '11807 Broadway Lane, Charlotte, 28273, United States', completed: false },
+ { uuid: 'dropoff', type: 'dropoff', address: 'Dropoff Address', completed: false },
+ ],
+ route: {
+ distance_m: 91872,
+ },
+ driver: {
+ online: true,
+ location: {
+ latitude: 35.22,
+ longitude: -80.84,
+ },
+ },
+ insights: {
+ is_location_stale: false,
+ },
+ ...overrides.tracker_data,
+ },
+ ...overrides,
+ };
+ }
+
+ test('it renders an operator summary instead of provider diagnostics', async function (assert) {
+ this.set('order', buildOrder());
- await render(hbs`
`);
+ await render(hbs`
`);
+
+ assert.dom().containsText('Smart adjusted ETA');
+ assert.dom().containsText('Reported ETA');
+ assert.dom().containsText('ETA confidence');
+ assert.dom().containsText('NOW HEADING TO - STOP 2 OF 3');
+ assert.dom().containsText('Between Stops');
+ assert.dom().containsText('Driver live');
+ assert.dom().containsText('Provider context: Google Routes route');
+ assert.dom().containsText('Diagnostics');
+ assert.dom().doesNotContainText('All Stops');
+ assert.dom().doesNotContainText('Provider:');
+ assert.dom().doesNotContainText('Route Legs');
+ assert.dom().doesNotContainText('2026-05-12T04:49:26');
+ });
+
+ test('it shows due now for zero second eta', async function (assert) {
+ this.set(
+ 'order',
+ buildOrder({
+ tracker_data: {
+ eta: {
+ active_stop_seconds: 0,
+ completion_at: null,
+ },
+ },
+ })
+ );
+
+ await render(hbs`
`);
+
+ assert.dom().containsText('Due now');
+ });
+
+ test('it shows a fallback warning without listing every provider warning', async function (assert) {
+ this.set(
+ 'order',
+ buildOrder({
+ tracker_data: {
+ provider: 'calculated',
+ confidence: 'low',
+ fallback_provider: 'calculated',
+ warnings: ['provider_failed:google_routes', 'provider_failed:osrm', 'fallback_used'],
+ },
+ })
+ );
+
+ await render(hbs`
`);
+
+ assert.dom().containsText('Fallback: Calculated');
+ assert.dom().containsText('Using Calculated fallback');
+ assert.dom().doesNotContainText('Provider Failed Google Routes');
+ });
+
+ test('it prioritizes stale driver location as an operator warning', async function (assert) {
+ this.set(
+ 'order',
+ buildOrder({
+ tracker_data: {
+ driver: {
+ online: true,
+ location: {
+ latitude: 35.22,
+ longitude: -80.84,
+ },
+ },
+ insights: {
+ is_location_stale: true,
+ },
+ },
+ })
+ );
+
+ await render(hbs`
`);
+
+ assert.dom().containsText('Driver stale');
+ assert.dom().containsText('Driver location is stale');
+ });
- assert.dom().hasText('');
+ test('it shows missing driver location as the clearest warning', async function (assert) {
+ this.set(
+ 'order',
+ buildOrder({
+ tracker_data: {
+ driver: {
+ online: false,
+ location: null,
+ },
+ },
+ })
+ );
- // Template block usage:
- await render(hbs`
-
- template block text
-
- `);
+ await render(hbs`
`);
- assert.dom().hasText('template block text');
+ assert.dom().containsText('Driver missing GPS');
+ assert.dom().containsText('Driver location is missing');
+ assert.dom().containsText('Ping driver app');
});
});
diff --git a/tests/integration/components/route-list-test.js b/tests/integration/components/route-list-test.js
index 02a447625..57d1f0978 100644
--- a/tests/integration/components/route-list-test.js
+++ b/tests/integration/components/route-list-test.js
@@ -6,21 +6,52 @@ import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | route-list', function (hooks) {
setupRenderingTest(hooks);
- test('it renders', async function (assert) {
- // Set any properties with this.set('myProperty', 'value');
- // Handle any actions with this.set('myAction', function(val) { ... });
+ function place(uuid, street1, latitude, longitude) {
+ return {
+ id: uuid,
+ uuid,
+ public_id: uuid,
+ street1,
+ address: street1,
+ latitude,
+ longitude,
+ };
+ }
- await render(hbs`
`);
+ test('it renders per-stop route eta from tracker legs', async function (assert) {
+ const pickup = place('pickup', 'Pickup Street', 1.3, 103.8);
+ const dropoff = place('dropoff', 'Dropoff Street', 1.4, 103.9);
- assert.dom(this.element).hasText('');
+ this.set('order', {
+ public_id: 'order_test',
+ payload: {
+ pickup,
+ waypoints: [],
+ dropoff,
+ },
+ tracker_data: {
+ active_stop: { uuid: 'dropoff' },
+ stops: [
+ { uuid: 'pickup', public_id: 'pickup', completed: true },
+ { uuid: 'dropoff', public_id: 'dropoff', completed: false },
+ ],
+ route: {
+ legs: [
+ {
+ stop: { uuid: 'dropoff', public_id: 'dropoff' },
+ eta_seconds: 1800,
+ eta_at: '2026-05-12T04:49:26.000000Z',
+ },
+ ],
+ },
+ },
+ });
- // Template block usage:
- await render(hbs`
-
- template block text
-
- `);
+ await render(hbs`
`);
- assert.dom(this.element).hasText('template block text');
+ assert.dom().containsText('Completed');
+ assert.dom().containsText('Current Stop');
+ assert.dom().containsText('ETA:');
+ assert.dom().containsText('Arrives:');
});
});
diff --git a/tests/integration/components/tracking-stop-progress-test.js b/tests/integration/components/tracking-stop-progress-test.js
new file mode 100644
index 000000000..d585a77d9
--- /dev/null
+++ b/tests/integration/components/tracking-stop-progress-test.js
@@ -0,0 +1,26 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+
+module('Integration | Component | tracking-stop-progress', function (hooks) {
+ setupRenderingTest(hooks);
+
+ test('it renders completed active and pending stops', async function (assert) {
+ this.set('stops', [
+ { uuid: 'pickup', type: 'pickup', address: 'Pickup Address', completed: true },
+ { uuid: 'waypoint', type: 'waypoint', address: 'Active Stop', completed: false },
+ { uuid: 'dropoff', type: 'dropoff', address: 'Dropoff Address', completed: false },
+ ]);
+ this.set('activeStop', { uuid: 'waypoint' });
+
+ await render(hbs`
`);
+
+ assert.dom().containsText('Between Stops');
+ assert.dom().containsText('1 / 3 stops');
+ assert.dom('.tracking-stop-progress__dot').exists({ count: 3 });
+ assert.dom('.tracking-stop-progress__dot--done').exists({ count: 1 });
+ assert.dom('.tracking-stop-progress__dot--active').exists({ count: 1 });
+ assert.dom('.tracking-stop-progress__dot--pending').exists({ count: 1 });
+ });
+});
diff --git a/tests/unit/services/order-list-overlay-test.js b/tests/unit/services/order-list-overlay-test.js
index b2d3d95f6..8155e08ff 100644
--- a/tests/unit/services/order-list-overlay-test.js
+++ b/tests/unit/services/order-list-overlay-test.js
@@ -9,4 +9,12 @@ module('Unit | Service | order-list-overlay', function (hooks) {
let service = this.owner.lookup('service:order-list-overlay');
assert.ok(service);
});
+
+ test('activeOrdersCount reflects loaded active orders', function (assert) {
+ let service = this.owner.lookup('service:order-list-overlay');
+
+ service.activeOrders = [{ public_id: 'order_1' }, { public_id: 'order_2' }];
+
+ assert.strictEqual(service.activeOrdersCount, 2);
+ });
});