diff --git a/addon/components/admin/routing-settings.hbs b/addon/components/admin/routing-settings.hbs index d5934467b..a3a210033 100644 --- a/addon/components/admin/routing-settings.hbs +++ b/addon/components/admin/routing-settings.hbs @@ -3,9 +3,80 @@ Configure system-wide routing engine defaults here. Organizations can optionally override these values from their own routing settings. + + + + + + + + + + + + + + + + {{/if}} + +
+ + + + {{/if}} + + {{/if}} + +
+
+
+ Smart adjusted ETA + Live +
+
+ {{#if this.hasSmartAdjustedEta}} + {{format-duration this.smartAdjustedEtaSeconds}} + {{else}} + {{this.smartAdjustedEtaUnavailableLabel}} + {{/if}} +
+
Based on provider route and driver signal
+
+
+
Reported ETA
+
+ {{#if this.hasDisplayedReportedEta}} + {{format-duration this.displayedReportedEtaSeconds}} + {{else}} + Pending GPS fix + {{/if}} +
+
{{this.reportedEtaWarning}}
+
+
+ +
+
ETA confidence
+
+ {{#each this.confidenceSegments as |segment|}} + + {{/each}} +
+
{{this.confidencePercent}}%
+
+ +
+
{{this.activeStopMarkerLabel}}
+
+
{{this.activeStopLabel}}
+
{{n-a @resource.tracker_data.active_stop.address}}
+
+ ETA: + + {{#if this.hasActiveEta}} + {{format-duration this.activeEtaSeconds}} + {{else}} + Pending GPS fix + {{/if}} + +
+
+
+ +
+
+ Reported total + + {{#if this.hasRemainingDistance}} + {{format-meters @resource.tracker_data.route.distance_m}} + {{else}} + - + {{/if}} + +
+ +
+
+
+ Between stops + + {{#if this.hasCurrentLegDistance}} + {{format-meters this.currentLeg.distance_m}} + {{else}} + - + {{/if}} + +
+ +
+
+
Provider context: + {{this.providerLabel}} + route
+
+ +
+ + Diagnostics + + {{this.diagnosticsSummaryLabel}} + + + +
+ {{#each this.diagnostics as |item|}} +
+ {{item.label}} + {{item.value}} +
+ {{/each}} + {{#if this.hasWarnings}} +
+ {{#each @resource.tracker_data.warnings as |warning|}} + {{warning}} + {{/each}} +
+ {{/if}}
+
+ + {{/if}} -
-
+ +
Labels
+
+
- {{@resource.public_id}} + {{@resource.public_id}}
- {{@resource.public_id}} + {{@resource.public_id}}
-
+
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 @@ +
+
+
Between Stops
+
+ {{this.completedCount}} + / + {{this.totalCount}} + stops +
+
+ +
+
+ {{#each this.stops as |stop|}} +
+
+ {{#unless stop.isFirst}} +
+ {{/unless}} + +
+ {{stop.label}} +
+ + +
{{stop.title}}
+ +
+
+ + {{#unless stop.isLast}} +
+ {{/unless}} +
+
+ {{/each}} +
+
+
\ 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 @@ - +
+
+ {{/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); + }); });