@fleetbase/fleetops-engine 0.6.46 → 0.6.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/addon/components/admin/routing-settings.hbs +71 -0
  2. package/addon/components/admin/routing-settings.js +124 -0
  3. package/addon/components/customer/orders.js +1 -1
  4. package/addon/components/display-place.hbs +1 -1
  5. package/addon/components/map/order-list-overlay/order.hbs +18 -3
  6. package/addon/components/map/toolbar.hbs +2 -2
  7. package/addon/components/map/toolbar.js +10 -2
  8. package/addon/components/orchestrator/phase-builder.hbs +28 -5
  9. package/addon/components/orchestrator/phase-builder.js +21 -1
  10. package/addon/components/orchestrator/resource-panel.hbs +19 -1
  11. package/addon/components/orchestrator/resource-panel.js +4 -0
  12. package/addon/components/orchestrator-workbench.js +3 -1
  13. package/addon/components/order/details/purchase-rate.hbs +19 -22
  14. package/addon/components/order/details/route.hbs +26 -1
  15. package/addon/components/order/details/route.js +50 -0
  16. package/addon/components/order/details/tracking.hbs +171 -27
  17. package/addon/components/order/details/tracking.js +457 -0
  18. package/addon/components/order/form/details.hbs +1 -2
  19. package/addon/components/order/form/details.js +6 -0
  20. package/addon/components/order/form/route.js +9 -1
  21. package/addon/components/order/kanban-card.hbs +18 -3
  22. package/addon/components/order-list-overlay/order.hbs +18 -3
  23. package/addon/components/order-progress-card.hbs +21 -6
  24. package/addon/components/order-tracking-lookup.hbs +7 -7
  25. package/addon/components/order-tracking-lookup.js +1 -1
  26. package/addon/components/route-list.hbs +78 -8
  27. package/addon/components/route-list.js +48 -6
  28. package/addon/components/tracking-stop-progress.hbs +45 -0
  29. package/addon/components/tracking-stop-progress.js +79 -0
  30. package/addon/controllers/settings/payments/index.js +1 -1
  31. package/addon/controllers/settings/routing.js +98 -1
  32. package/addon/helpers/format-duration.js +38 -0
  33. package/addon/routes/application.js +6 -0
  34. package/addon/services/order-list-overlay.js +2 -2
  35. package/addon/styles/fleetops-engine.css +733 -0
  36. package/addon/templates/settings/routing.hbs +60 -2
  37. package/app/components/tracking-stop-progress.js +1 -0
  38. package/app/helpers/format-duration.js +1 -0
  39. package/composer.json +1 -1
  40. package/extension.json +1 -1
  41. package/package.json +3 -3
  42. package/pnpm-workspace.yaml +7 -0
  43. package/server/config/fleetops.php +32 -11
  44. package/server/migrations/2026_05_13_000001_repair_customer_contact_user_types.php +86 -0
  45. package/server/seeders/VroomOrchestrationSeeder.php +402 -0
  46. package/server/src/Http/Controllers/Api/v1/OrchestrationController.php +64 -0
  47. package/server/src/Http/Controllers/Api/v1/OrderController.php +26 -26
  48. package/server/src/Http/Controllers/Api/v1/PayloadController.php +10 -10
  49. package/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +1 -1
  50. package/server/src/Http/Controllers/Internal/v1/ContactController.php +41 -0
  51. package/server/src/Http/Controllers/Internal/v1/LiveController.php +6 -42
  52. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +1 -1
  53. package/server/src/Http/Controllers/Internal/v1/OrderController.php +69 -15
  54. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +1 -1
  55. package/server/src/Http/Controllers/Internal/v1/SettingController.php +119 -12
  56. package/server/src/Http/Resources/v1/Index/Payload.php +2 -2
  57. package/server/src/Models/Contact.php +122 -13
  58. package/server/src/Models/Order.php +1 -1
  59. package/server/src/Models/Payload.php +8 -8
  60. package/server/src/Models/Place.php +0 -3
  61. package/server/src/Models/ServiceRate.php +1 -1
  62. package/server/src/Observers/ContactObserver.php +8 -0
  63. package/server/src/Orchestration/Engines/CapacityAllocationEngine.php +220 -0
  64. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +330 -38
  65. package/server/src/Orchestration/Support/OrchestrationPayloadBuilder.php +259 -51
  66. package/server/src/Providers/FleetOpsServiceProvider.php +27 -0
  67. package/server/src/Support/LiveOrderQuery.php +75 -0
  68. package/server/src/Support/Metrics.php +15 -0
  69. package/server/src/Support/OSRM.php +11 -6
  70. package/server/src/Support/OrderTracker.php +7 -582
  71. package/server/src/Support/Utils.php +1 -1
  72. package/server/src/Tracking/Contracts/TrackingProviderInterface.php +19 -0
  73. package/server/src/Tracking/Providers/CalculatedTrackingProvider.php +65 -0
  74. package/server/src/Tracking/Providers/GoogleRoutesTrackingProvider.php +118 -0
  75. package/server/src/Tracking/Providers/OsrmTrackingProvider.php +71 -0
  76. package/server/src/Tracking/README.md +25 -0
  77. package/server/src/Tracking/Support/FakeTrackingProvider.php +42 -0
  78. package/server/src/Tracking/TrackingContext.php +63 -0
  79. package/server/src/Tracking/TrackingContextBuilder.php +204 -0
  80. package/server/src/Tracking/TrackingIntelligenceService.php +180 -0
  81. package/server/src/Tracking/TrackingOptions.php +49 -0
  82. package/server/src/Tracking/TrackingProviderCapabilities.php +30 -0
  83. package/server/src/Tracking/TrackingProviderManager.php +89 -0
  84. package/server/src/Tracking/TrackingProviderRegistry.php +35 -0
  85. package/server/src/Tracking/TrackingProviderResult.php +20 -0
  86. package/server/src/Tracking/TrackingStop.php +59 -0
  87. package/server/src/routes.php +11 -0
  88. package/server/tests/CapacityAllocationEngineTest.php +108 -0
  89. package/server/tests/LiveOrderQueryTest.php +34 -0
  90. package/server/tests/OrchestrationPayloadBuilderTest.php +132 -0
  91. package/server/tests/OrchestratorConsumableApiTest.php +74 -0
  92. package/server/tests/PingDriverEndpointTest.php +19 -0
  93. package/server/tests/TrackingIntelligenceTest.php +176 -0
  94. package/server/tests/VroomOrchestrationEngineTest.php +159 -0
  95. package/translations/en-us.yaml +4 -0
@@ -3,9 +3,80 @@
3
3
  Configure system-wide routing engine defaults here. Organizations can optionally override these values from their own routing settings.
4
4
  </InfoBlock>
5
5
 
6
+ <ContentPanel @title="Tracking Defaults" @open={{true}} @wrapperClass="bordered-classic">
7
+ <InputGroup @name="Default Tracking Provider" @helpText="Default provider for organization-level tracking intelligence unless an organization overrides it.">
8
+ <Select
9
+ class="w-full"
10
+ @value={{this.trackingProvider}}
11
+ @options={{this.trackingProviderOptions}}
12
+ @optionLabel="label"
13
+ @optionValue="value"
14
+ @placeholder="Select default tracking provider"
15
+ @onSelect={{fn (mut this.trackingProvider)}}
16
+ />
17
+ </InputGroup>
18
+ <InputGroup @name="Default Fallback Providers" @helpText="Providers used when the selected tracking provider is unavailable.">
19
+ <div class="fleetbase-model-select fleetbase-power-select ember-model-select">
20
+ <PowerSelectMultiple
21
+ @searchEnabled={{true}}
22
+ @options={{this.trackingProviderOptions}}
23
+ @selected={{this.selectedTrackingFallbackOptions}}
24
+ @onChange={{this.setTrackingFallbacks}}
25
+ @placeholder="Select default fallback providers"
26
+ @triggerClass="form-select form-input form-input-sm flex-1"
27
+ as |provider|
28
+ >
29
+ {{provider.label}}
30
+ </PowerSelectMultiple>
31
+ </div>
32
+ </InputGroup>
33
+ <div class="flex justify-end">
34
+ <Button
35
+ @type="default"
36
+ @size="xs"
37
+ @icon={{if this.showTrackingAdvancedSettings "chevron-up" "chevron-down"}}
38
+ @text="Advanced Settings"
39
+ @onClick={{this.toggleTrackingAdvancedSettings}}
40
+ />
41
+ </div>
42
+ {{#if this.showTrackingAdvancedSettings}}
43
+ <div class="mt-4 space-y-4 rounded-md border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800">
44
+ <InputGroup @name="Traffic-Aware ETA" @helpText="Use traffic-aware provider calculations when supported.">
45
+ <Toggle @isToggled={{this.trackingTrafficEnabled}} @onToggle={{fn (mut this.trackingTrafficEnabled)}} />
46
+ </InputGroup>
47
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
48
+ <InputGroup @name="Summary Cache TTL">
49
+ <Input @type="number" @value={{this.trackingCacheTtlSeconds}} min="15" max="300" class="form-input w-full" />
50
+ </InputGroup>
51
+ <InputGroup @name="Route Cache TTL">
52
+ <Input @type="number" @value={{this.trackingRouteCacheTtlSeconds}} min="60" max="1800" class="form-input w-full" />
53
+ </InputGroup>
54
+ <InputGroup @name="Stale Location Threshold">
55
+ <Input @type="number" @value={{this.trackingStaleLocationThresholdSeconds}} min="30" max="3600" class="form-input w-full" />
56
+ </InputGroup>
57
+ <InputGroup @name="Fallback Speed">
58
+ <Input @type="number" @value={{this.trackingDefaultVehicleSpeedKph}} min="1" max="160" class="form-input w-full" />
59
+ </InputGroup>
60
+ </div>
61
+ </div>
62
+ {{/if}}
63
+ </ContentPanel>
64
+
6
65
  <RegistryYield @registry="fleet-ops:component:admin:routing-settings" as |RegistryComponent|>
7
66
  <div>
8
67
  <RegistryComponent />
9
68
  </div>
10
69
  </RegistryYield>
11
70
  </div>
71
+
72
+ <EmberWormhole @to="next-view-section-subheader-actions">
73
+ <Button
74
+ @type="primary"
75
+ @size="sm"
76
+ @icon="save"
77
+ @text="Save Changes"
78
+ @onClick={{perform this.saveSettings}}
79
+ @isLoading={{or this.saveSettings.isRunning this.loadSettings.isRunning}}
80
+ @disabled={{this.saveSettings.isRunning}}
81
+ />
82
+ </EmberWormhole>
@@ -0,0 +1,124 @@
1
+ import Component from '@glimmer/component';
2
+ import { tracked } from '@glimmer/tracking';
3
+ import { inject as service } from '@ember/service';
4
+ import { action } from '@ember/object';
5
+ import { task } from 'ember-concurrency';
6
+
7
+ export default class AdminRoutingSettingsComponent extends Component {
8
+ @service fetch;
9
+ @service notifications;
10
+ @tracked trackingProvider = 'google_routes';
11
+ @tracked trackingFallbacks = ['osrm', 'calculated'];
12
+ @tracked trackingTrafficEnabled = true;
13
+ @tracked trackingCacheTtlSeconds = 60;
14
+ @tracked trackingRouteCacheTtlSeconds = 600;
15
+ @tracked trackingStaleLocationThresholdSeconds = 300;
16
+ @tracked trackingDefaultVehicleSpeedKph = 35;
17
+ @tracked showTrackingAdvancedSettings = false;
18
+ @tracked trackingProviderOptions = [
19
+ { value: 'google_routes', label: 'Google Routes' },
20
+ { value: 'osrm', label: 'OSRM' },
21
+ { value: 'calculated', label: 'Calculated' },
22
+ ];
23
+
24
+ constructor() {
25
+ super(...arguments);
26
+ this.loadSettings.perform();
27
+ }
28
+
29
+ @task *loadSettings() {
30
+ try {
31
+ const settings = yield this.fetch.get('fleet-ops/settings/admin-tracking-settings');
32
+ this.trackingProvider = settings.provider ?? 'google_routes';
33
+ this.trackingFallbacks = this.normalizeFallbacks(settings.fallbacks);
34
+ this.trackingTrafficEnabled = settings.traffic_enabled ?? true;
35
+ this.trackingCacheTtlSeconds = settings.cache_ttl_seconds ?? 60;
36
+ this.trackingRouteCacheTtlSeconds = settings.route_cache_ttl_seconds ?? 600;
37
+ this.trackingStaleLocationThresholdSeconds = settings.stale_location_threshold_seconds ?? 300;
38
+ this.trackingDefaultVehicleSpeedKph = settings.default_vehicle_speed_kph ?? 35;
39
+ this.trackingProviderOptions = this.normalizeProviderOptions(settings.providers ?? this.trackingProviderOptions);
40
+ } catch (error) {
41
+ this.notifications.serverError(error);
42
+ }
43
+ }
44
+
45
+ @task *saveSettings() {
46
+ try {
47
+ yield this.fetch.post('fleet-ops/settings/admin-tracking-settings', this.trackingSettingsPayload);
48
+ this.notifications.success('Tracking defaults saved.');
49
+ } catch (error) {
50
+ this.notifications.serverError(error);
51
+ }
52
+ }
53
+
54
+ get trackingSettingsPayload() {
55
+ return {
56
+ provider: this.trackingProvider,
57
+ fallbacks: this.normalizeFallbacks(this.trackingFallbacks),
58
+ traffic_enabled: this.trackingTrafficEnabled,
59
+ cache_ttl_seconds: Number(this.trackingCacheTtlSeconds) || 60,
60
+ route_cache_ttl_seconds: Number(this.trackingRouteCacheTtlSeconds) || 600,
61
+ stale_location_threshold_seconds: Number(this.trackingStaleLocationThresholdSeconds) || 300,
62
+ default_vehicle_speed_kph: Number(this.trackingDefaultVehicleSpeedKph) || 35,
63
+ };
64
+ }
65
+
66
+ normalizeFallbacks(fallbacks) {
67
+ if (Array.isArray(fallbacks)) {
68
+ return fallbacks
69
+ .map((fallback) => this.optionValue(fallback))
70
+ .map((fallback) => String(fallback).trim())
71
+ .filter(Boolean);
72
+ }
73
+
74
+ return String(fallbacks ?? '')
75
+ .split(',')
76
+ .map((fallback) => fallback.trim())
77
+ .filter(Boolean);
78
+ }
79
+
80
+ get selectedTrackingFallbackOptions() {
81
+ const selected = new Set(this.normalizeFallbacks(this.trackingFallbacks));
82
+
83
+ return this.trackingProviderOptions.filter((option) => selected.has(this.optionValue(option)));
84
+ }
85
+
86
+ normalizeProviderOptions(options = []) {
87
+ return options.map((option) => {
88
+ const value = this.optionValue(option);
89
+ const label = option?.label ?? option?.name ?? this.providerLabel(value);
90
+
91
+ return {
92
+ ...option,
93
+ key: option?.key ?? value,
94
+ name: option?.name ?? label,
95
+ value,
96
+ label,
97
+ };
98
+ });
99
+ }
100
+
101
+ providerLabel(value) {
102
+ if (value === 'osrm') {
103
+ return 'OSRM';
104
+ }
105
+
106
+ return String(value ?? '')
107
+ .replace(/[_-]+/g, ' ')
108
+ .replace(/\s+/g, ' ')
109
+ .trim()
110
+ .replace(/\b\w/g, (character) => character.toUpperCase());
111
+ }
112
+
113
+ optionValue(option) {
114
+ return typeof option === 'object' && option !== null ? (option.value ?? option.key) : option;
115
+ }
116
+
117
+ @action setTrackingFallbacks(options) {
118
+ this.trackingFallbacks = this.normalizeFallbacks(options);
119
+ }
120
+
121
+ @action toggleTrackingAdvancedSettings() {
122
+ this.showTrackingAdvancedSettings = !this.showTrackingAdvancedSettings;
123
+ }
124
+ }
@@ -138,7 +138,7 @@ export default class CustomerOrdersComponent extends Component {
138
138
  // start loading order tracking activity
139
139
  order.loadTrackingActivity();
140
140
  this.urlSearchParams.addParamToCurrentUrl('order', order.public_id);
141
- const driverCurrentLocation = order.get('tracker_data.driver_current_location');
141
+ const driverCurrentLocation = order.get('tracker_data.driver.location');
142
142
  if (driverCurrentLocation) {
143
143
  this.latitude = driverCurrentLocation.coordinates[1];
144
144
  this.longitude = driverCurrentLocation.coordinates[0];
@@ -10,7 +10,7 @@
10
10
  </span>
11
11
  </div>
12
12
  {{else}}
13
- {{#if (and @waypointActions (not @hideBadges))}}
13
+ {{#if @waypointActions}}
14
14
  <div class="flex flex-row item-center space-x-2 {{if (or this.place.status_code @eta) 'mb-2'}}">
15
15
  {{#unless @hideBadges}}
16
16
  {{#if this.place.status_code}}
@@ -27,10 +27,25 @@
27
27
  <div class="order-listing-row-progress">
28
28
  <OrderProgressBar
29
29
  @order={{@order}}
30
- @progress={{@order.tracker_data.progress_percentage}}
31
- @firstWaypointCompleted={{@order.tracker_data.first_waypoint_completed}}
32
- @lastWaypointCompleted={{@order.tracker_data.last_waypoint_completed}}
30
+ @progress={{@order.tracker_data.progress.percentage}}
31
+ @firstWaypointCompleted={{gt @order.tracker_data.progress.completed_stops 0}}
32
+ @lastWaypointCompleted={{eq @order.tracker_data.progress.percentage 100}}
33
33
  />
34
+ {{#if @order.tracker_data.insights.is_location_stale}}
35
+ <div class="flex mt-1">
36
+ <Badge @status="warning" @hideStatusDot={{true}}>Stale GPS</Badge>
37
+ </div>
38
+ {{else if @order.tracker_data.fallback_provider}}
39
+ <div class="flex mt-1">
40
+ <Badge @status="warning" @hideStatusDot={{true}}>Fallback ETA</Badge>
41
+ </div>
42
+ {{else if @order.tracker_data.confidence}}
43
+ {{#unless (eq @order.tracker_data.confidence "high")}}
44
+ <div class="flex mt-1">
45
+ <Badge @status="warning" @hideStatusDot={{true}}>Low ETA Confidence</Badge>
46
+ </div>
47
+ {{/unless}}
48
+ {{/if}}
34
49
  </div>
35
50
  <div class="order-listing-row-body">
36
51
  <div class="order-listing-row-body-address start">
@@ -42,8 +42,8 @@
42
42
  <button type="button" id="map-toolbar-view-orders-button" class="toolbar-button relative {{if unauthorized 'unauthorized'}}" {{on "click" this.orderListOverlay.toggle}}>
43
43
  <div class="active-orders-count absolute top-0 right-0 -mr-2.5 -mt-1">
44
44
  <div class="relative flex w-5 h-5 rounded-full">
45
- <div class="absolute inset-0 rounded-full w-5 h-5 text-xs bg-blue-500 opacity-75 {{if this.activeOrderCount 'animate-ping'}}"></div>
46
- <div class="relative inline-flex items-center justify-center rounded-full w-5 h-5 text-xs bg-blue-500 text-white z-10">{{this.activeOrderCount}}</div>
45
+ <div class="absolute inset-0 rounded-full w-5 h-5 text-xs bg-blue-500 opacity-75 {{if this.displayedActiveOrderCount 'animate-ping'}}"></div>
46
+ <div class="relative inline-flex items-center justify-center rounded-full w-5 h-5 text-xs bg-blue-500 text-white z-10">{{this.displayedActiveOrderCount}}</div>
47
47
  </div>
48
48
  </div>
49
49
  <FaIcon @icon="layer-group" />
@@ -38,10 +38,18 @@ export default class MapToolbarComponent extends Component {
38
38
  return this.mapManager.zoomOut?.();
39
39
  }
40
40
 
41
+ get displayedActiveOrderCount() {
42
+ if (this.orderListOverlay.isOpen && this.orderListOverlay.loaded) {
43
+ return this.orderListOverlay.activeOrdersCount;
44
+ }
45
+
46
+ return this.activeOrderCount;
47
+ }
48
+
41
49
  @task *getActiveOrderCount() {
42
50
  try {
43
- const count = yield this.fetch.get('fleet-ops/metrics', { discover: ['orders_in_progress'] });
44
- this.activeOrderCount = count.orders_in_progress;
51
+ const count = yield this.fetch.get('fleet-ops/metrics', { discover: ['active_live_orders'] });
52
+ this.activeOrderCount = count.active_live_orders;
45
53
  return count;
46
54
  } catch (err) {
47
55
  debug('Failed to get active order count: ' + err.message);
@@ -109,6 +109,27 @@
109
109
  />
110
110
  </InputGroup>
111
111
 
112
+ {{#if this.shouldShowAllocationStrategy}}
113
+ <InputGroup @name={{t "orchestrator.allocation-strategy"}}>
114
+ <div class="grid grid-cols-2 gap-1">
115
+ {{#each this.allocationStrategyOptions as |opt|}}
116
+ <button
117
+ type="button"
118
+ class="flex items-center justify-center gap-1.5 px-2 py-1.5 rounded border text-xs transition-colors
119
+ {{if
120
+ (eq this.draftPhase.allocationStrategy opt.value)
121
+ 'bg-indigo-50 dark:bg-indigo-900/30 border-indigo-400 dark:border-indigo-600 text-indigo-700 dark:text-indigo-300'
122
+ 'border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-500'
123
+ }}"
124
+ {{on "click" (fn this.setDraftOption "allocationStrategy" opt.value)}}
125
+ >
126
+ {{opt.label}}
127
+ </button>
128
+ {{/each}}
129
+ </div>
130
+ </InputGroup>
131
+ {{/if}}
132
+
112
133
  {{! Constraint toggles }}
113
134
  <InputGroup @name={{t "orchestrator.constraints"}}>
114
135
  <div class="space-y-1.5">
@@ -124,10 +145,12 @@
124
145
  <span class="text-xs text-gray-600 dark:text-gray-400">{{t "orchestrator.balance-workload"}}</span>
125
146
  <Toggle @isToggled={{this.draftPhase.balanceWorkload}} @onToggle={{fn this.setDraftOption "balanceWorkload"}} />
126
147
  </div>
127
- <div class="flex items-center justify-between">
128
- <span class="text-xs text-gray-600 dark:text-gray-400">{{t "orchestrator.return-to-depot"}}</span>
129
- <Toggle @isToggled={{this.draftPhase.returnToDepot}} @onToggle={{fn this.setDraftOption "returnToDepot"}} />
130
- </div>
148
+ {{#if this.shouldShowRouteOptions}}
149
+ <div class="flex items-center justify-between">
150
+ <span class="text-xs text-gray-600 dark:text-gray-400">{{t "orchestrator.return-to-depot"}}</span>
151
+ <Toggle @isToggled={{this.draftPhase.returnToDepot}} @onToggle={{fn this.setDraftOption "returnToDepot"}} />
152
+ </div>
153
+ {{/if}}
131
154
  <div class="flex items-center justify-between">
132
155
  <span class="text-xs text-gray-600 dark:text-gray-400">{{t "orchestrator.auto-commit"}}</span>
133
156
  <Toggle @isToggled={{this.draftPhase.autoCommit}} @onToggle={{fn this.setDraftOption "autoCommit"}} />
@@ -159,4 +182,4 @@
159
182
  </div>
160
183
  {{/if}}
161
184
 
162
- </div>
185
+ </div>
@@ -50,6 +50,7 @@ export default class OrchestratorPhaseBuilderComponent extends Component {
50
50
  mode,
51
51
  label: this.intl.t(`orchestrator.mode-${mode.replace(/_/g, '-')}`),
52
52
  engine: 'greedy',
53
+ allocationStrategy: 'route_aware',
53
54
  orderStatuses: ['created'],
54
55
  balanceWorkload: false,
55
56
  respectSkills: true,
@@ -125,7 +126,11 @@ export default class OrchestratorPhaseBuilderComponent extends Component {
125
126
  }
126
127
 
127
128
  @action setDraftEngine(engine) {
128
- this.draftPhase = { ...this.draftPhase, engine };
129
+ this.draftPhase = {
130
+ ...this.draftPhase,
131
+ engine,
132
+ allocationStrategy: engine === 'capacity' ? 'capacity_only' : (this.draftPhase.allocationStrategy ?? 'route_aware'),
133
+ };
129
134
  }
130
135
 
131
136
  @action toggleDraftOrderStatus(status) {
@@ -146,6 +151,21 @@ export default class OrchestratorPhaseBuilderComponent extends Component {
146
151
  return this.editingIndex !== null && this.draftPhase !== null;
147
152
  }
148
153
 
154
+ get allocationStrategyOptions() {
155
+ return [
156
+ { value: 'route_aware', label: this.intl.t('orchestrator.allocation-strategy-route-aware') },
157
+ { value: 'capacity_only', label: this.intl.t('orchestrator.allocation-strategy-capacity-only') },
158
+ ];
159
+ }
160
+
161
+ get shouldShowAllocationStrategy() {
162
+ return this.draftPhase?.mode === 'assign_vehicles' && this.draftPhase?.engine === 'vroom';
163
+ }
164
+
165
+ get shouldShowRouteOptions() {
166
+ return this.draftPhase?.engine !== 'capacity' && this.draftPhase?.allocationStrategy !== 'capacity_only';
167
+ }
168
+
149
169
  modeLabel(mode) {
150
170
  return this.modeOptions.find((m) => m.value === mode)?.label ?? mode;
151
171
  }
@@ -187,6 +187,15 @@
187
187
  <Badge @status={{vehicle.status}}>{{smart-humanize vehicle.status}}</Badge>
188
188
  {{/if}}
189
189
  </div>
190
+ {{! Position }}
191
+ <div
192
+ class="flex items-center gap-1 text-xs truncate
193
+ {{if (this.hasPosition vehicle) 'text-green-500 dark:text-green-300' 'text-amber-600 dark:text-amber-300'}}"
194
+ title={{if (this.hasPosition vehicle) (point-coordinates vehicle.location) (t "orchestrator.no-position")}}
195
+ >
196
+ <FaIcon @icon="location-dot" @size="xs" class="w-3 flex-shrink-0" />
197
+ <span class="truncate">{{#if (this.hasPosition vehicle)}}{{point-coordinates vehicle.location}}{{else}}{{t "orchestrator.no-position"}}{{/if}}</span>
198
+ </div>
190
199
  {{! Assigned driver }}
191
200
  <div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
192
201
  <FaIcon @icon="id-card-alt" @size="xs" class="w-3 flex-shrink-0" />
@@ -245,6 +254,15 @@
245
254
  {{! All fields — always shown, "-" when absent }}
246
255
  <div class="flex-1 min-w-0 space-y-0.5">
247
256
  <div class="text-xs font-semibold truncate dark:text-white">{{or driver.name "-"}}</div>
257
+ {{! Position }}
258
+ <div
259
+ class="flex items-center gap-1 text-xs truncate
260
+ {{if (this.hasPosition driver) 'text-green-500 dark:text-green-300' 'text-amber-600 dark:text-amber-300'}}"
261
+ title={{if (this.hasPosition driver) (point-coordinates driver.location) (t "orchestrator.no-position")}}
262
+ >
263
+ <FaIcon @icon="location-dot" @size="xs" class="w-3 flex-shrink-0" />
264
+ <span class="truncate">{{#if (this.hasPosition driver)}}{{point-coordinates driver.location}}{{else}}{{t "orchestrator.no-position"}}{{/if}}</span>
265
+ </div>
248
266
  {{! Vehicle }}
249
267
  <div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
250
268
  <FaIcon @icon="truck" @size="xs" class="w-3 flex-shrink-0" />
@@ -298,4 +316,4 @@
298
316
  </div>
299
317
  {{/if}}
300
318
 
301
- </div>
319
+ </div>
@@ -99,6 +99,10 @@ export default class OrchestratorResourcePanelComponent extends Component {
99
99
  return [...(this.args.selectedDriverIds ?? new Set())];
100
100
  }
101
101
 
102
+ @action hasPosition(resource) {
103
+ return resource?.hasInvalidCoordinates === false;
104
+ }
105
+
102
106
  @action clearAllSelections() {
103
107
  this.args.onClearVehicles?.();
104
108
  this.args.onClearDrivers?.();
@@ -68,7 +68,7 @@ export default class OrchestratorWorkbenchComponent extends Component {
68
68
 
69
69
  /**
70
70
  * User-composed list of phases to execute in sequence.
71
- * Each phase: { id, mode, label, engine, orderStatuses, balanceWorkload,
71
+ * Each phase: { id, mode, label, engine, allocationStrategy, orderStatuses, balanceWorkload,
72
72
  * respectSkills, respectCapacity, returnToDepot, autoCommit }
73
73
  */
74
74
  @tracked phases = [];
@@ -270,6 +270,7 @@ export default class OrchestratorWorkbenchComponent extends Component {
270
270
  order_statuses: phase.orderStatuses ?? ['created'],
271
271
  options: {
272
272
  engine: phase.engine ?? 'greedy',
273
+ allocation_strategy: phase.allocationStrategy ?? 'route_aware',
273
274
  balance_workload: phase.balanceWorkload ?? false,
274
275
  respect_skills: phase.respectSkills ?? true,
275
276
  respect_capacity: phase.respectCapacity ?? true,
@@ -399,6 +400,7 @@ export default class OrchestratorWorkbenchComponent extends Component {
399
400
  mode: 'allocate',
400
401
  label: 'Allocate',
401
402
  engine: 'greedy',
403
+ allocationStrategy: 'route_aware',
402
404
  orderStatuses: ['created'],
403
405
  balanceWorkload: false,
404
406
  respectSkills: true,
@@ -1,38 +1,35 @@
1
1
  {{#if @resource.purchase_rate}}
2
- <ContentPanel @title={{t "order.fields.purchase-rate-panel-title"}} @isLoading={{@isLoading}} @open={{true}} @wrapperClass="bordered-top" @panelBodyWrapperClass="px-0i">
3
- <div class="order-view-purchase-rate-table-wrapper next-table-wrapper no-scroll h-auto table-fluid rounded-b-md">
4
- <table id="order-view-purchase-rate-table" class="table table-fixed flex-1">
2
+ <ContentPanel @title={{t "order.fields.purchase-rate-panel-title"}} @isLoading={{@isLoading}} @open={{true}} @wrapperClass="bordered-top">
3
+ <div class="overflow-x-auto">
4
+ <table class="w-full text-xs">
5
5
  <thead>
6
- <tr>
7
- <th class="text-left">{{t "order.fields.breakdown"}}</th>
8
- <th>
9
- <div class="flex flex-row items-center justify-end">{{@resource.purchase_rate.service_quote.currency}}</div>
10
- </th>
6
+ <tr class="border-b border-gray-200 dark:border-gray-700 text-[11px] uppercase tracking-wide text-gray-500 font-semibold">
7
+ <th class="py-2 pr-2 text-left">{{t "order.fields.breakdown"}}</th>
8
+ <th class="py-2 pl-2 text-right w-32">{{@resource.purchase_rate.service_quote.currency}}</th>
11
9
  </tr>
12
10
  </thead>
13
11
  <tbody>
14
12
  {{#each @resource.purchase_rate.service_quote.items as |item|}}
15
- <tr>
16
- <td>
17
- <span class="font-semibold">{{smart-humanize item.details}}</span>
13
+ <tr class="border-b border-gray-100 dark:border-gray-800 last:border-0">
14
+ <td class="py-1 pr-2 text-gray-700 dark:text-gray-300">
15
+ {{smart-humanize item.details}}
18
16
  </td>
19
- <td>
20
- <div class="flex flex-row items-center justify-end">{{format-currency item.amount @resource.purchase_rate.service_quote.currency}}</div>
17
+ <td class="py-1 pl-2 text-right font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
18
+ {{format-currency item.amount @resource.purchase_rate.service_quote.currency}}
21
19
  </td>
22
20
  </tr>
23
21
  {{/each}}
24
- <tr class="border-b-0">
25
- <td class="border-b-0">
26
- <span class="font-semibold">{{t "common.total"}}</span>
22
+ </tbody>
23
+ <tfoot class="border-t border-gray-200 dark:border-gray-700">
24
+ <tr class="text-xs">
25
+ <td class="pt-2 pb-1 pr-2 text-right text-[11px] uppercase tracking-wide font-bold text-gray-800 dark:text-gray-100">
26
+ {{t "common.total"}}
27
27
  </td>
28
- <td class="border-b-0">
29
- <div class="flex flex-row items-center justify-end">{{format-currency
30
- @resource.purchase_rate.service_quote.amount
31
- @resource.purchase_rate.service_quote.currency
32
- }}</div>
28
+ <td class="pt-2 pb-1 pl-2 text-right font-bold text-gray-800 dark:text-gray-100 whitespace-nowrap">
29
+ {{format-currency @resource.purchase_rate.service_quote.amount @resource.purchase_rate.service_quote.currency}}
33
30
  </td>
34
31
  </tr>
35
- </tbody>
32
+ </tfoot>
36
33
  </table>
37
34
  </div>
38
35
  </ContentPanel>
@@ -1,4 +1,29 @@
1
1
  <ContentPanel @title={{t "order.fields.route-panel-title"}} @open={{true}} @actionButtons={{this.actionButtons}} @wrapperClass="bordered-top">
2
+ {{#if this.hasRouteSummaryLine}}
3
+ <div class="mb-3 flex items-center justify-between gap-3 text-xs">
4
+ <div class="font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">All Stops - {{this.routeStopsCount}}</div>
5
+ <div class="flex min-w-0 items-center gap-2 font-semibold text-gray-700 dark:text-gray-200">
6
+ <span>
7
+ {{#if this.hasTrackingDistance}}
8
+ {{format-meters this.trackerData.route.distance_m}}
9
+ {{else}}
10
+ -
11
+ {{/if}}
12
+ </span>
13
+ <span class="text-gray-300 dark:text-gray-600">-</span>
14
+ <span>
15
+ {{#if this.trackingDurationSeconds}}
16
+ {{format-duration this.trackingDurationSeconds}}
17
+ {{else if this.hasCompletionEta}}
18
+ {{format-date-fns this.trackerData.eta.completion_at "d MMM HH:mm"}}
19
+ {{else}}
20
+ -
21
+ {{/if}}
22
+ </span>
23
+ </div>
24
+ </div>
25
+ {{/if}}
26
+
2
27
  {{#if @resource.hasIntermediateWaypoints}}
3
28
  <div>
4
29
  <RouteList @order={{@resource}} @eta={{@resource.eta}} @waypointActions={{hash viewWaypointLabel=(hash label=(t "order.fields.get-label") fn=this.viewWaypointLabel)}} />
@@ -19,4 +44,4 @@
19
44
  </div>
20
45
  </div>
21
46
  {{/if}}
22
- </ContentPanel>
47
+ </ContentPanel>
@@ -4,6 +4,7 @@ import { action } from '@ember/object';
4
4
  import { debug } from '@ember/debug';
5
5
  import { task } from 'ember-concurrency';
6
6
  import { applyOptimizedIntermediateWaypoints, buildRouteOptimizationInput, canOptimizeIntermediateWaypoints } from '../../../utils/order-route-editing';
7
+ import { buildRoutePointsFromPayload } from '../../../utils/route-visualization';
7
8
 
8
9
  export default class OrderDetailsRouteComponent extends Component {
9
10
  @service orderActions;
@@ -15,6 +16,11 @@ export default class OrderDetailsRouteComponent extends Component {
15
16
  @service hostRouter;
16
17
  @service intl;
17
18
 
19
+ constructor() {
20
+ super(...arguments);
21
+ this.loadTrackerData.perform();
22
+ }
23
+
18
24
  get actionButtons() {
19
25
  return [
20
26
  this.showOptimizationSelect
@@ -56,6 +62,50 @@ export default class OrderDetailsRouteComponent extends Component {
56
62
  return this.routeOptimization.availableEngines.length > 1;
57
63
  }
58
64
 
65
+ get trackerData() {
66
+ return this.args.resource?.tracker_data;
67
+ }
68
+
69
+ get hasTrackingRouteSummary() {
70
+ return Boolean(this.trackerData?.route || this.trackerData?.eta);
71
+ }
72
+
73
+ get hasTrackingDistance() {
74
+ return this.trackerData?.route?.distance_m !== null && this.trackerData?.route?.distance_m !== undefined;
75
+ }
76
+
77
+ get hasCompletionEta() {
78
+ return Boolean(this.trackerData?.eta?.completion_at);
79
+ }
80
+
81
+ get routeStopsCount() {
82
+ return buildRoutePointsFromPayload(this.args.resource?.payload).length;
83
+ }
84
+
85
+ get hasTrackingDuration() {
86
+ return this.trackerData?.route?.duration_in_traffic_s !== null && this.trackerData?.route?.duration_in_traffic_s !== undefined;
87
+ }
88
+
89
+ get trackingDurationSeconds() {
90
+ return this.trackerData?.route?.duration_in_traffic_s ?? this.trackerData?.route?.duration_s;
91
+ }
92
+
93
+ get hasRouteSummaryLine() {
94
+ return this.hasTrackingRouteSummary || this.routeStopsCount > 0;
95
+ }
96
+
97
+ @task *loadTrackerData() {
98
+ if (!this.args.resource || this.args.resource.tracker_data || typeof this.args.resource.loadTrackerData !== 'function') {
99
+ return;
100
+ }
101
+
102
+ try {
103
+ yield this.args.resource.loadTrackerData();
104
+ } catch (err) {
105
+ debug('Failed to load order tracker data for route: ' + err.message);
106
+ }
107
+ }
108
+
59
109
  @task *optimizeRouteWithService(service) {
60
110
  const order = this.args.resource;
61
111
  const optimizationInput = buildRouteOptimizationInput(order);