@fleetbase/fleetops-engine 0.6.50 → 0.6.51

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 (159) hide show
  1. package/README.md +185 -67
  2. package/addon/components/contact/panel-header.hbs +11 -48
  3. package/addon/components/customer/details.hbs +1 -2
  4. package/addon/components/customer/form.hbs +63 -51
  5. package/addon/components/customer/form.js +27 -1
  6. package/addon/components/customer/orders.hbs +48 -0
  7. package/addon/components/customer/orders.js +67 -5
  8. package/addon/components/driver/form.hbs +1 -1
  9. package/addon/components/home/getting-started-guidance.hbs +113 -0
  10. package/addon/components/home/getting-started-guidance.js +107 -0
  11. package/addon/components/issue/details/comments.hbs +3 -0
  12. package/addon/components/issue/details/comments.js +3 -0
  13. package/addon/components/issue/details/correspondence.hbs +9 -0
  14. package/addon/components/issue/details/correspondence.js +83 -0
  15. package/addon/components/issue/details/documents.hbs +69 -0
  16. package/addon/components/issue/details/documents.js +85 -0
  17. package/addon/components/issue/details.hbs +220 -59
  18. package/addon/components/issue/details.js +245 -0
  19. package/addon/components/issue/form.hbs +19 -1
  20. package/addon/components/issue/panel-header.hbs +43 -0
  21. package/addon/components/issue/panel-header.js +22 -0
  22. package/addon/components/issue/timeline.hbs +40 -0
  23. package/addon/components/issue/timeline.js +45 -0
  24. package/addon/components/leaflet-tracking-marker.js +3 -0
  25. package/addon/components/map/drawer/position-listing.js +3 -0
  26. package/addon/components/map/leaflet-live-map.hbs +2 -2
  27. package/addon/components/map/leaflet-live-map.js +17 -0
  28. package/addon/components/map/order-list-overlay.hbs +4 -4
  29. package/addon/components/modals/convert-customer-to-vendor.hbs +30 -0
  30. package/addon/components/modals/convert-customer-to-vendor.js +55 -0
  31. package/addon/components/modals/issue-assign.hbs +20 -0
  32. package/addon/components/modals/issue-assign.js +49 -0
  33. package/addon/components/modals/issue-close.hbs +30 -0
  34. package/addon/components/modals/issue-close.js +73 -0
  35. package/addon/components/modals/issue-status.hbs +20 -0
  36. package/addon/components/modals/issue-status.js +43 -0
  37. package/addon/components/modals/reset-customer-credentials.js +12 -6
  38. package/addon/components/modals/update-order-activity.hbs +51 -17
  39. package/addon/components/modals/update-order-activity.js +59 -5
  40. package/addon/components/order/details/activity.js +2 -2
  41. package/addon/components/order/details/proof.hbs +40 -38
  42. package/addon/components/order/details/proof.js +31 -4
  43. package/addon/components/order/details.hbs +5 -5
  44. package/addon/components/order/details.js +19 -1
  45. package/addon/components/order/header.hbs +1 -1
  46. package/addon/components/route-list.hbs +0 -12
  47. package/addon/components/service-rate/details.hbs +1 -1
  48. package/addon/components/service-rate/form.hbs +1 -1
  49. package/addon/components/service-rate/form.js +4 -0
  50. package/addon/components/vendor/personnel-manager.hbs +166 -0
  51. package/addon/components/vendor/personnel-manager.js +143 -0
  52. package/addon/components/widget/live-fleet.hbs +1 -0
  53. package/addon/components/widget/live-fleet.js +2 -7
  54. package/addon/components/widget/revenue-trend.hbs +1 -1
  55. package/addon/components/widget/revenue-trend.js +75 -2
  56. package/addon/controllers/management/contacts/customers/details.js +31 -13
  57. package/addon/controllers/management/contacts/customers.js +16 -5
  58. package/addon/controllers/management/contacts/index/details.js +31 -13
  59. package/addon/controllers/management/contacts/index.js +7 -0
  60. package/addon/controllers/management/issues/index/details.js +52 -6
  61. package/addon/controllers/management/vendors/index/details.js +4 -0
  62. package/addon/controllers/operations/orders/index/details.js +11 -3
  63. package/addon/controllers/operations/orders/index.js +1 -1
  64. package/addon/extension.js +19 -12
  65. package/addon/helpers/format-duration.js +1 -38
  66. package/addon/initializers/load-leaflet-assets.js +5 -6
  67. package/addon/initializers/patch-ember-leaflet-tooltip-layer.js +51 -0
  68. package/addon/routes/management/issues/index/details.js +1 -1
  69. package/addon/routes.js +1 -0
  70. package/addon/services/contact-actions.js +231 -1
  71. package/addon/services/getting-started.js +44 -0
  72. package/addon/services/issue-actions.js +71 -0
  73. package/addon/services/leaflet-map-manager.js +2 -0
  74. package/addon/services/map-adapter/leaflet.js +19 -0
  75. package/addon/services/service-areas.js +3 -0
  76. package/addon/styles/fleetops-engine.css +1036 -0
  77. package/addon/templates/management/contacts/customers/details.hbs +1 -1
  78. package/addon/templates/management/contacts/index/details.hbs +1 -1
  79. package/addon/templates/management/contacts.hbs +2 -2
  80. package/addon/templates/management/issues/index/details/index.hbs +3 -1
  81. package/addon/templates/management/issues/index/details.hbs +3 -2
  82. package/addon/templates/management/vendors/index/details/personnel.hbs +1 -2
  83. package/addon/templates/operations/orders/index/details/index.hbs +2 -2
  84. package/addon/templates/operations/orders/index.hbs +1 -1
  85. package/addon/templates/operations/service-rates/index/edit.hbs +1 -0
  86. package/addon/templates/operations/service-rates/index/new.hbs +1 -0
  87. package/addon/templates/settings/payments/onboard.hbs +8 -8
  88. package/addon/utils/leaflet-draw-namespace-guard.js +22 -0
  89. package/addon/utils/leaflet-plugin-loader.js +219 -0
  90. package/addon/utils/setup-customer-portal.js +4 -64
  91. package/app/components/home/getting-started-guidance.js +1 -0
  92. package/app/components/issue/details/comments.js +1 -0
  93. package/app/components/issue/details/correspondence.js +1 -0
  94. package/app/components/issue/details/documents.js +1 -0
  95. package/app/components/issue/panel-header.js +1 -0
  96. package/app/components/modals/convert-customer-to-vendor.js +1 -0
  97. package/app/components/modals/issue-assign.js +1 -0
  98. package/app/components/modals/issue-close.js +1 -0
  99. package/app/components/modals/issue-status.js +1 -0
  100. package/app/components/vendor/personnel-manager.js +1 -0
  101. package/app/helpers/format-duration.js +1 -1
  102. package/app/services/getting-started.js +1 -0
  103. package/app/utils/leaflet-draw-namespace-guard.js +1 -0
  104. package/app/utils/leaflet-plugin-loader.js +1 -0
  105. package/composer.json +1 -1
  106. package/extension.json +1 -1
  107. package/package.json +5 -5
  108. package/server/migrations/2026_05_31_000001_add_portal_fields_to_vendor_personnels_table.php +30 -0
  109. package/server/migrations/2026_06_03_000000_ensure_tags_column_on_issues_table.php +31 -0
  110. package/server/migrations/2026_06_03_000001_add_order_uuid_to_issues_table.php +25 -0
  111. package/server/migrations/2026_06_04_000001_backfill_order_transaction_subjects.php +39 -0
  112. package/server/migrations/2026_06_04_000002_backfill_order_transaction_directions.php +24 -0
  113. package/server/resources/views/mail/customer-credentials.blade.php +16 -13
  114. package/server/src/Expansions/UserFilterExpansion.php +28 -0
  115. package/server/src/Http/Controllers/Api/v1/IssueController.php +2 -0
  116. package/server/src/Http/Controllers/Api/v1/OrderController.php +26 -25
  117. package/server/src/Http/Controllers/Api/v1/PlaceController.php +10 -58
  118. package/server/src/Http/Controllers/Internal/v1/ContactController.php +135 -0
  119. package/server/src/Http/Controllers/Internal/v1/CustomerController.php +116 -6
  120. package/server/src/Http/Controllers/Internal/v1/GettingStartedController.php +17 -0
  121. package/server/src/Http/Controllers/Internal/v1/IssueController.php +251 -0
  122. package/server/src/Http/Controllers/Internal/v1/OrderController.php +172 -97
  123. package/server/src/Http/Controllers/Internal/v1/PlaceController.php +10 -68
  124. package/server/src/Http/Controllers/Internal/v1/VendorController.php +89 -0
  125. package/server/src/Http/Requests/CreateIssueRequest.php +4 -0
  126. package/server/src/Http/Requests/UpdateIssueRequest.php +4 -0
  127. package/server/src/Http/Resources/v1/Index/Order.php +12 -9
  128. package/server/src/Http/Resources/v1/Issue.php +25 -0
  129. package/server/src/Http/Resources/v1/Order.php +20 -18
  130. package/server/src/Mail/CustomerCredentialsMail.php +5 -6
  131. package/server/src/Models/Contact.php +30 -51
  132. package/server/src/Models/Issue.php +47 -6
  133. package/server/src/Models/Order.php +33 -6
  134. package/server/src/Models/ServiceArea.php +1 -1
  135. package/server/src/Models/VendorPersonnel.php +10 -1
  136. package/server/src/Models/Zone.php +1 -1
  137. package/server/src/Observers/PurchaseRateObserver.php +20 -0
  138. package/server/src/Observers/ZoneObserver.php +56 -0
  139. package/server/src/Providers/FleetOpsServiceProvider.php +1 -0
  140. package/server/src/Support/Analytics/TopDrivers.php +9 -1
  141. package/server/src/Support/GettingStarted.php +144 -0
  142. package/server/src/Support/PlaceSearch.php +282 -0
  143. package/server/src/Support/ResolvesOrderServiceStops.php +22 -12
  144. package/server/src/routes.php +33 -1
  145. package/server/tests/AnalyticsRoutesTest.php +8 -0
  146. package/server/tests/CustomerContactIdentitySafetyTest.php +41 -0
  147. package/server/tests/DriverVehicleStatusDefaultTest.php +2 -2
  148. package/server/tests/GettingStartedStatusTest.php +21 -0
  149. package/server/tests/OrderActivityFlowRegressionTest.php +116 -0
  150. package/server/tests/ServiceAreaZoneApiShapeTest.php +44 -0
  151. package/translations/ar-ae.yml +9 -0
  152. package/translations/bg-bg.yaml +9 -0
  153. package/translations/en-us.yaml +11 -0
  154. package/translations/es-pa.yaml +9 -0
  155. package/translations/fr-fr.yaml +9 -0
  156. package/translations/mn-mn.yaml +9 -0
  157. package/translations/pt-br.yaml +9 -0
  158. package/translations/ru-ru.yaml +9 -0
  159. package/translations/vi-vn.yaml +9 -0
@@ -0,0 +1,56 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Observers;
4
+
5
+ use Fleetbase\FleetOps\Models\ServiceArea;
6
+ use Fleetbase\FleetOps\Models\Zone;
7
+
8
+ class ZoneObserver
9
+ {
10
+ /**
11
+ * Handle the Zone "created" event.
12
+ */
13
+ public function created(Zone $zone): void
14
+ {
15
+ $this->invalidateServiceAreaCache($zone);
16
+ }
17
+
18
+ /**
19
+ * Handle the Zone "updated" event.
20
+ */
21
+ public function updated(Zone $zone): void
22
+ {
23
+ $this->invalidateServiceAreaCache($zone, $zone->getOriginal('service_area_uuid'));
24
+ $this->invalidateServiceAreaCache($zone);
25
+ }
26
+
27
+ /**
28
+ * Handle the Zone "deleted" event.
29
+ */
30
+ public function deleted(Zone $zone): void
31
+ {
32
+ $this->invalidateServiceAreaCache($zone);
33
+ }
34
+
35
+ /**
36
+ * Handle the Zone "restored" event.
37
+ */
38
+ public function restored(Zone $zone): void
39
+ {
40
+ $this->invalidateServiceAreaCache($zone);
41
+ }
42
+
43
+ /**
44
+ * Invalidate service area API cache because service area responses embed zones.
45
+ */
46
+ protected function invalidateServiceAreaCache(Zone $zone, ?string $serviceAreaUuid = null): void
47
+ {
48
+ $serviceAreaUuid ??= $zone->service_area_uuid;
49
+ if (!$serviceAreaUuid) {
50
+ return;
51
+ }
52
+
53
+ ServiceArea::invalidateApiCacheManually($zone->company_uuid);
54
+ ServiceArea::invalidateApiCacheManually();
55
+ }
56
+ }
@@ -29,6 +29,7 @@ class FleetOpsServiceProvider extends CoreServiceProvider
29
29
  \Fleetbase\FleetOps\Models\ServiceRate::class => \Fleetbase\FleetOps\Observers\ServiceRateObserver::class,
30
30
  \Fleetbase\FleetOps\Models\PurchaseRate::class => \Fleetbase\FleetOps\Observers\PurchaseRateObserver::class,
31
31
  \Fleetbase\FleetOps\Models\ServiceArea::class => \Fleetbase\FleetOps\Observers\ServiceAreaObserver::class,
32
+ \Fleetbase\FleetOps\Models\Zone::class => \Fleetbase\FleetOps\Observers\ZoneObserver::class,
32
33
  \Fleetbase\FleetOps\Models\TrackingNumber::class => \Fleetbase\FleetOps\Observers\TrackingNumberObserver::class,
33
34
  \Fleetbase\FleetOps\Models\Driver::class => \Fleetbase\FleetOps\Observers\DriverObserver::class,
34
35
  \Fleetbase\FleetOps\Models\Vehicle::class => \Fleetbase\FleetOps\Observers\VehicleObserver::class,
@@ -38,7 +38,15 @@ class TopDrivers extends AbstractAnalytics
38
38
 
39
39
  $sortColumn = match ($this->sortBy) {
40
40
  'distance' => 'distance_m',
41
- 'on_time' => 'on_time_pct',
41
+ 'on_time' => 'CASE
42
+ WHEN SUM(CASE WHEN orders.scheduled_at IS NOT NULL THEN 1 ELSE 0 END) > 0
43
+ THEN SUM(CASE
44
+ WHEN orders.scheduled_at IS NOT NULL
45
+ AND TIMESTAMPDIFF(SECOND, orders.scheduled_at, orders.updated_at) <= 1800
46
+ THEN 1 ELSE 0 END)
47
+ / SUM(CASE WHEN orders.scheduled_at IS NOT NULL THEN 1 ELSE 0 END)
48
+ ELSE -1
49
+ END',
42
50
  default => 'orders_completed',
43
51
  };
44
52
 
@@ -0,0 +1,144 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support;
4
+
5
+ use Fleetbase\FleetOps\Models\Driver;
6
+ use Fleetbase\FleetOps\Models\Order;
7
+ use Fleetbase\FleetOps\Models\TrackingStatus;
8
+ use Fleetbase\Models\Company;
9
+
10
+ class GettingStarted
11
+ {
12
+ protected Company $company;
13
+
14
+ public static function forCompany(Company $company): static
15
+ {
16
+ return new static($company);
17
+ }
18
+
19
+ public function __construct(Company $company)
20
+ {
21
+ $this->company = $company;
22
+ }
23
+
24
+ public function get(): array
25
+ {
26
+ $steps = $this->steps();
27
+ $completed = collect($steps)->filter(fn ($step) => $step['completed'])->count();
28
+ $total = count($steps);
29
+ $nextStep = collect($steps)->first(fn ($step) => !$step['completed']);
30
+ $isCompleted = $completed === $total;
31
+
32
+ return [
33
+ 'profile_source' => 'generic',
34
+ 'profile' => null,
35
+ 'is_completed' => $isCompleted,
36
+ 'progress' => [
37
+ 'completed' => $completed,
38
+ 'total' => $total,
39
+ 'percent' => $total > 0 ? (int) round(($completed / $total) * 100) : 0,
40
+ ],
41
+ 'next_step' => $nextStep['key'] ?? null,
42
+ 'steps' => $steps,
43
+ 'recommendations' => $this->recommendations(),
44
+ ];
45
+ }
46
+
47
+ protected function steps(): array
48
+ {
49
+ $companyUuid = $this->company->uuid;
50
+
51
+ $hasDriver = Driver::where('company_uuid', $companyUuid)->exists();
52
+ $hasOrder = Order::where('company_uuid', $companyUuid)->exists();
53
+
54
+ $hasAssignedOrder = Order::where('company_uuid', $companyUuid)
55
+ ->whereNotNull('driver_assigned_uuid')
56
+ ->exists();
57
+
58
+ $hasOrderActivity = TrackingStatus::where('tracking_statuses.company_uuid', $companyUuid)
59
+ ->whereNotNull('tracking_statuses.tracking_number_uuid')
60
+ ->whereNotIn('tracking_statuses.code', ['CREATED', 'ORDER_CREATED'])
61
+ ->exists();
62
+
63
+ return [
64
+ [
65
+ 'key' => 'add_driver',
66
+ 'title' => 'Add a driver',
67
+ 'description' => 'Create your first driver profile so work can be assigned.',
68
+ 'estimate' => '2 min',
69
+ 'completed' => $hasDriver,
70
+ 'icon' => 'id-card',
71
+ 'route' => 'console.fleet-ops.management.drivers.index.new',
72
+ 'docs_url' => 'https://www.fleetbase.io/docs/fleet-ops/drivers',
73
+ ],
74
+ [
75
+ 'key' => 'create_order',
76
+ 'title' => 'Create an order',
77
+ 'description' => 'Add a delivery or service order to start dispatching.',
78
+ 'estimate' => '3 min',
79
+ 'completed' => $hasOrder,
80
+ 'icon' => 'box',
81
+ 'route' => 'console.fleet-ops.operations.orders.index.new',
82
+ 'docs_url' => 'https://www.fleetbase.io/docs/fleet-ops/orders',
83
+ ],
84
+ [
85
+ 'key' => 'assign_driver',
86
+ 'title' => 'Assign driver to order',
87
+ 'description' => 'Connect the order to a driver for execution.',
88
+ 'estimate' => '1 min',
89
+ 'completed' => $hasAssignedOrder,
90
+ 'icon' => 'user-check',
91
+ 'route' => 'console.fleet-ops.operations.orders.index',
92
+ 'docs_url' => 'https://www.fleetbase.io/docs/fleet-ops/dispatch',
93
+ ],
94
+ [
95
+ 'key' => 'update_activity',
96
+ 'title' => 'Update order activity',
97
+ 'description' => 'Move the order through its workflow and capture progress.',
98
+ 'estimate' => '2 min',
99
+ 'completed' => $hasOrderActivity,
100
+ 'icon' => 'route',
101
+ 'route' => 'console.fleet-ops.operations.orders.index',
102
+ 'docs_url' => 'https://www.fleetbase.io/docs/fleet-ops/tracking',
103
+ ],
104
+ ];
105
+ }
106
+
107
+ protected function recommendations(): array
108
+ {
109
+ return [
110
+ [
111
+ 'key' => 'route_optimization',
112
+ 'title' => 'Route Optimization',
113
+ 'description' => 'Plan efficient multi-stop routes and reduce time on the road.',
114
+ 'icon' => 'route',
115
+ 'accent' => 'blue',
116
+ 'docs_url' => 'https://www.fleetbase.io/docs/fleet-ops/orchestrator',
117
+ ],
118
+ [
119
+ 'key' => 'live_fleet',
120
+ 'title' => 'Live Fleet Map',
121
+ 'description' => 'Watch active drivers, vehicles, and orders in real time.',
122
+ 'icon' => 'map-location-dot',
123
+ 'accent' => 'green',
124
+ 'docs_url' => 'https://www.fleetbase.io/docs/fleet-ops/live-map',
125
+ ],
126
+ [
127
+ 'key' => 'service_rates',
128
+ 'title' => 'Service Rates',
129
+ 'description' => 'Create pricing rules for deliveries, zones, and service areas.',
130
+ 'icon' => 'receipt',
131
+ 'accent' => 'amber',
132
+ 'docs_url' => 'https://www.fleetbase.io/docs/fleet-ops/service-rates',
133
+ ],
134
+ [
135
+ 'key' => 'customer_portal',
136
+ 'title' => 'Customer Portal',
137
+ 'description' => 'Let customers submit orders and track deliveries from a branded portal.',
138
+ 'icon' => 'store',
139
+ 'accent' => 'purple',
140
+ 'docs_url' => 'https://www.fleetbase.io/docs/customer-portal',
141
+ ],
142
+ ];
143
+ }
144
+ }
@@ -0,0 +1,282 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support;
4
+
5
+ use Fleetbase\FleetOps\Models\Place;
6
+ use Fleetbase\LaravelMysqlSpatial\Types\Point;
7
+ use Geocoder\Laravel\Facades\Geocoder;
8
+ use Illuminate\Database\Eloquent\Builder;
9
+ use Illuminate\Support\Collection;
10
+ use Illuminate\Support\Str;
11
+
12
+ class PlaceSearch
13
+ {
14
+ public static function search(Builder $query, ?string $searchQuery = null, array $options = []): Collection
15
+ {
16
+ $searchQuery = static::normalizeSearchQuery($searchQuery);
17
+ $limit = (int) data_get($options, 'limit', 30);
18
+ $geo = filter_var(data_get($options, 'geo', false), FILTER_VALIDATE_BOOLEAN);
19
+ $latitude = data_get($options, 'latitude');
20
+ $longitude = data_get($options, 'longitude');
21
+ $noQueryOrder = data_get($options, 'no_query_order', 'name_desc');
22
+
23
+ static::applySavedPlaceSearch($query, $searchQuery, $latitude, $longitude, $noQueryOrder);
24
+
25
+ if ($limit > 0) {
26
+ $query->limit($limit);
27
+ }
28
+
29
+ $savedPlaces = $query->get();
30
+ $geoPlaces = $geo ? static::geocode($searchQuery, $latitude, $longitude) : collect();
31
+
32
+ return static::mergeResults($geoPlaces, $savedPlaces, $searchQuery);
33
+ }
34
+
35
+ public static function geocode(?string $searchQuery = null, $latitude = null, $longitude = null): Collection
36
+ {
37
+ $searchQuery = static::normalizeSearchQuery($searchQuery);
38
+
39
+ if (!$searchQuery && (!$latitude || !$longitude)) {
40
+ return collect();
41
+ }
42
+
43
+ try {
44
+ if (Geocoding::canGoogleGeocode()) {
45
+ $results = collect();
46
+
47
+ try {
48
+ if ($searchQuery) {
49
+ $results = $results->merge(Geocoding::geocode($searchQuery, $latitude, $longitude));
50
+ } elseif ($latitude && $longitude) {
51
+ $results = $results->merge(Geocoding::reverseFromCoordinates($latitude, $longitude));
52
+ }
53
+ } catch (\Throwable) {
54
+ // Fall through to any nearby results already collected.
55
+ }
56
+
57
+ if ($searchQuery && $latitude && $longitude) {
58
+ try {
59
+ $results = $results->merge(Geocoding::reverseFromQuery($searchQuery, $latitude, $longitude));
60
+ } catch (\Throwable) {
61
+ // Text geocoding results should still be usable if nearby lookup fails.
62
+ }
63
+ }
64
+
65
+ return static::rankPlacesByQuery(static::uniquePlaces($results), $searchQuery);
66
+ }
67
+
68
+ if (!$searchQuery) {
69
+ return collect();
70
+ }
71
+
72
+ $results = Geocoder::geocode($searchQuery)
73
+ ->get()
74
+ ->map(fn ($googleAddress) => Place::createFromGoogleAddress($googleAddress))
75
+ ->values();
76
+
77
+ return static::rankPlacesByQuery($results, $searchQuery);
78
+ } catch (\Throwable) {
79
+ return collect();
80
+ }
81
+ }
82
+
83
+ protected static function applySavedPlaceSearch(Builder $query, ?string $searchQuery, $latitude = null, $longitude = null, string $noQueryOrder = 'name_desc'): void
84
+ {
85
+ $query->addSelect('places.*');
86
+
87
+ if ($searchQuery) {
88
+ $query->where(function ($query) use ($searchQuery) {
89
+ $contains = '%' . $searchQuery . '%';
90
+
91
+ $query->orWhereRaw('LOWER(COALESCE(places.name, "")) LIKE ?', [$contains])
92
+ ->orWhereRaw('LOWER(COALESCE(places.street1, "")) LIKE ?', [$contains])
93
+ ->orWhereRaw('LOWER(COALESCE(places.street2, "")) LIKE ?', [$contains])
94
+ ->orWhereRaw('LOWER(COALESCE(places.city, "")) LIKE ?', [$contains])
95
+ ->orWhereRaw('LOWER(COALESCE(places.province, "")) LIKE ?', [$contains])
96
+ ->orWhereRaw('LOWER(COALESCE(places.postal_code, "")) LIKE ?', [$contains]);
97
+ });
98
+
99
+ $prefix = $searchQuery . '%';
100
+ $contains = '%' . $searchQuery . '%';
101
+
102
+ $query->selectRaw(
103
+ 'CASE
104
+ WHEN LOWER(COALESCE(places.name, "")) = ? OR LOWER(COALESCE(places.street1, "")) = ? OR LOWER(COALESCE(places.postal_code, "")) = ? THEN 0
105
+ WHEN LOWER(COALESCE(places.name, "")) LIKE ? OR LOWER(COALESCE(places.street1, "")) LIKE ? OR LOWER(COALESCE(places.postal_code, "")) LIKE ? THEN 1
106
+ WHEN LOWER(COALESCE(places.name, "")) LIKE ? OR LOWER(COALESCE(places.street1, "")) LIKE ? OR LOWER(COALESCE(places.street2, "")) LIKE ? OR LOWER(COALESCE(places.city, "")) LIKE ? OR LOWER(COALESCE(places.province, "")) LIKE ? OR LOWER(COALESCE(places.postal_code, "")) LIKE ? THEN 2
107
+ ELSE 3
108
+ END AS place_search_relevance',
109
+ [$searchQuery, $searchQuery, $searchQuery, $prefix, $prefix, $prefix, $contains, $contains, $contains, $contains, $contains, $contains]
110
+ );
111
+ $query->orderBy('place_search_relevance', 'asc');
112
+ }
113
+
114
+ if ($latitude && $longitude) {
115
+ $point = new Point($latitude, $longitude);
116
+
117
+ $query->whereNotNull('places.location')->whereRaw('
118
+ ST_Y(places.location) BETWEEN -90 AND 90
119
+ AND ST_X(places.location) BETWEEN -180 AND 180
120
+ AND NOT (ST_X(places.location) = 0 AND ST_Y(places.location) = 0)
121
+ ');
122
+ $query->orderByDistanceSphere('location', $point, 'asc');
123
+ } elseif (!$searchQuery && $noQueryOrder === 'latest') {
124
+ $query->orderBy('places.created_at', 'desc');
125
+ } else {
126
+ $query->orderBy('places.name', 'desc');
127
+ }
128
+ }
129
+
130
+ protected static function mergeResults(Collection $geoPlaces, Collection $savedPlaces, ?string $searchQuery = null): Collection
131
+ {
132
+ $merged = [];
133
+ $indexes = [];
134
+
135
+ foreach ($geoPlaces as $place) {
136
+ static::pushUniquePlace($merged, $indexes, $place);
137
+ }
138
+
139
+ foreach ($savedPlaces as $place) {
140
+ $key = static::placeKey($place);
141
+
142
+ if ($key && isset($indexes[$key])) {
143
+ if (static::isStrongSavedMatch($place, $searchQuery)) {
144
+ $merged[$indexes[$key]] = $place;
145
+ }
146
+
147
+ continue;
148
+ }
149
+
150
+ static::pushUniquePlace($merged, $indexes, $place);
151
+ }
152
+
153
+ return collect($merged)->values();
154
+ }
155
+
156
+ protected static function pushUniquePlace(array &$merged, array &$indexes, ?Place $place): void
157
+ {
158
+ if (!$place instanceof Place) {
159
+ return;
160
+ }
161
+
162
+ $key = static::placeKey($place);
163
+
164
+ if ($key && isset($indexes[$key])) {
165
+ return;
166
+ }
167
+
168
+ if ($key) {
169
+ $indexes[$key] = count($merged);
170
+ }
171
+
172
+ $merged[] = $place;
173
+ }
174
+
175
+ protected static function uniquePlaces(Collection $places): Collection
176
+ {
177
+ $merged = [];
178
+ $indexes = [];
179
+
180
+ foreach ($places as $place) {
181
+ static::pushUniquePlace($merged, $indexes, $place);
182
+ }
183
+
184
+ return collect($merged)->values();
185
+ }
186
+
187
+ protected static function rankPlacesByQuery(Collection $places, ?string $searchQuery = null): Collection
188
+ {
189
+ if (!$searchQuery) {
190
+ return $places->values();
191
+ }
192
+
193
+ return $places
194
+ ->values()
195
+ ->map(fn ($place, $index) => [
196
+ 'place' => $place,
197
+ 'rank' => static::placeQueryRank($place, $searchQuery),
198
+ 'index' => $index,
199
+ ])
200
+ ->sortBy([
201
+ ['rank', 'asc'],
202
+ ['index', 'asc'],
203
+ ])
204
+ ->pluck('place')
205
+ ->values();
206
+ }
207
+
208
+ protected static function placeQueryRank(Place $place, ?string $searchQuery = null): int
209
+ {
210
+ if (!$searchQuery) {
211
+ return 4;
212
+ }
213
+
214
+ $query = static::normalizeSearchQuery($searchQuery);
215
+ $values = collect([
216
+ $place->name,
217
+ $place->street1,
218
+ $place->postal_code,
219
+ $place->toAddressString(['name']),
220
+ $place->address,
221
+ ])
222
+ ->filter()
223
+ ->map(fn ($value) => static::normalizeSearchQuery($value));
224
+
225
+ if ($values->contains($query)) {
226
+ return 0;
227
+ }
228
+
229
+ if ($values->contains(fn ($value) => Str::startsWith($value, $query))) {
230
+ return 1;
231
+ }
232
+
233
+ if ($values->contains(fn ($value) => Str::contains($value, $query))) {
234
+ return 2;
235
+ }
236
+
237
+ return 3;
238
+ }
239
+
240
+ protected static function isStrongSavedMatch(Place $place, ?string $searchQuery = null): bool
241
+ {
242
+ if (!$searchQuery) {
243
+ return false;
244
+ }
245
+
246
+ $query = static::normalizeSearchQuery($searchQuery);
247
+
248
+ return collect([$place->name, $place->street1, $place->postal_code])
249
+ ->filter()
250
+ ->map(fn ($value) => static::normalizeSearchQuery($value))
251
+ ->contains($query);
252
+ }
253
+
254
+ protected static function placeKey(Place $place): ?string
255
+ {
256
+ $address = static::normalizeSearchQuery($place->toAddressString(['name']) ?: $place->address ?: $place->street1 ?: $place->name);
257
+ $lat = data_get($place, 'latitude');
258
+ $lng = data_get($place, 'longitude');
259
+
260
+ if (!$lat && $place->location && method_exists($place->location, 'getLat')) {
261
+ $lat = $place->location->getLat();
262
+ $lng = $place->location->getLng();
263
+ }
264
+
265
+ $coordinates = $lat && $lng ? round((float) $lat, 5) . ',' . round((float) $lng, 5) : null;
266
+
267
+ if (!$address && !$coordinates) {
268
+ return null;
269
+ }
270
+
271
+ return trim($address . '|' . $coordinates, '|');
272
+ }
273
+
274
+ protected static function normalizeSearchQuery(?string $searchQuery): ?string
275
+ {
276
+ if (!is_string($searchQuery)) {
277
+ return null;
278
+ }
279
+
280
+ return (string) Str::of($searchQuery)->lower()->squish();
281
+ }
282
+ }
@@ -32,6 +32,28 @@ trait ResolvesOrderServiceStops
32
32
  return $payload->waypoints->isNotEmpty();
33
33
  }
34
34
 
35
+ protected function payloadHasWaypointMarkers(?Payload $payload): bool
36
+ {
37
+ if (!$payload) {
38
+ return false;
39
+ }
40
+
41
+ $payload->loadMissing('waypointMarkers');
42
+
43
+ return $payload->waypointMarkers->isNotEmpty();
44
+ }
45
+
46
+ protected function payloadUsesServiceStopActivity(?Payload $payload): bool
47
+ {
48
+ if (!$payload) {
49
+ return false;
50
+ }
51
+
52
+ return $this->payloadHasWaypointMarkers($payload)
53
+ || $this->payloadHasWaypoints($payload)
54
+ || $this->payloadServiceStops($payload)->count() > 2;
55
+ }
56
+
35
57
  protected function resolveProof($proof): ?Proof
36
58
  {
37
59
  if ($proof instanceof Proof) {
@@ -54,7 +76,6 @@ trait ResolvesOrderServiceStops
54
76
  $payload->loadMissing([
55
77
  'pickup',
56
78
  'dropoff',
57
- 'return',
58
79
  'waypoints',
59
80
  'waypointMarkers.place',
60
81
  'waypointMarkers.trackingNumber.status',
@@ -112,16 +133,6 @@ trait ResolvesOrderServiceStops
112
133
  ]);
113
134
  }
114
135
 
115
- if ($payload->return instanceof Place) {
116
- $stops->push([
117
- 'type' => 'return',
118
- 'place' => $payload->return,
119
- 'waypoint' => null,
120
- 'tracking_number_uuid' => $payload->return_tracking_number_uuid,
121
- 'sequence' => ++$sequence,
122
- ]);
123
- }
124
-
125
136
  return $stops->values();
126
137
  }
127
138
 
@@ -340,7 +351,6 @@ trait ResolvesOrderServiceStops
340
351
  return match ($type) {
341
352
  'pickup' => 'pickup_tracking_number_uuid',
342
353
  'dropoff' => 'dropoff_tracking_number_uuid',
343
- 'return' => 'return_tracking_number_uuid',
344
354
  default => null,
345
355
  };
346
356
  }
@@ -12,7 +12,7 @@ use Illuminate\Support\Facades\Route;
12
12
  | is assigned the "api" middleware group. Enjoy building your API!
13
13
  |
14
14
  */
15
- Route::prefix(config('fleetops.api.routing.prefix', null))->namespace('Fleetbase\FleetOps\Http\Controllers')->group(
15
+ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\FleetOps\Http\Controllers')->group(
16
16
  function ($router) {
17
17
  /*
18
18
  |--------------------------------------------------------------------------
@@ -312,6 +312,7 @@ Route::prefix(config('fleetops.api.routing.prefix', null))->namespace('Fleetbase
312
312
  $router->post('import', $controller('import'));
313
313
  $router->get('facilitators/{id}', $controller('getAsFacilitator'));
314
314
  $router->get('customers/{id}', $controller('getAsCustomer'));
315
+ $router->post('{id}/convert-to-vendor', $controller('convertToVendor'));
315
316
  $router->delete('bulk-delete', $controller('bulkDelete'));
316
317
  }
317
318
  );
@@ -351,6 +352,7 @@ Route::prefix(config('fleetops.api.routing.prefix', null))->namespace('Fleetbase
351
352
  $router->delete('bulk-delete', $controller('bulkDelete'));
352
353
  }
353
354
  );
355
+ $router->get('issues/{id}/timeline', 'IssueController@timeline');
354
356
  $router->fleetbaseRoutes(
355
357
  'issues',
356
358
  function ($router, $controller) {
@@ -470,6 +472,9 @@ Route::prefix(config('fleetops.api.routing.prefix', null))->namespace('Fleetbase
470
472
  $router->match(['get', 'post'], 'export', $controller('export'));
471
473
  $router->get('facilitators/{id}', $controller('getAsFacilitator'));
472
474
  $router->get('customers/{id}', $controller('getAsCustomer'));
475
+ $router->get('{id}/personnels', $controller('vendorPersonnels'));
476
+ $router->post('{id}/personnels', $controller('addVendorPersonnel'));
477
+ $router->delete('{id}/personnels/{contact}', $controller('removeVendorPersonnel'));
473
478
  $router->post('{id}/assign-driver', $controller('assignDriver'));
474
479
  $router->post('{id}/remove-driver', $controller('removeDriver'));
475
480
  $router->delete('bulk-delete', $controller('bulkDelete'));
@@ -523,7 +528,11 @@ Route::prefix(config('fleetops.api.routing.prefix', null))->namespace('Fleetbase
523
528
  ['prefix' => 'customers'],
524
529
  function () use ($router) {
525
530
  $router->get('/', 'MorphController@queryCustomers');
531
+ $router->post('create-portal-login', 'CustomerController@createPortalLogin');
526
532
  $router->post('reset-credentials', 'CustomerController@resetCredentials');
533
+ $router->post('send-credentials', 'CustomerController@sendCredentials');
534
+ $router->post('deactivate-portal-login', 'CustomerController@deactivatePortalLogin');
535
+ $router->post('reactivate-portal-login', 'CustomerController@reactivatePortalLogin');
527
536
  }
528
537
  );
529
538
  $router->group(
@@ -625,6 +634,12 @@ Route::prefix(config('fleetops.api.routing.prefix', null))->namespace('Fleetbase
625
634
  $router->get('live-fleet', 'AnalyticsController@liveFleet');
626
635
  }
627
636
  );
637
+ $router->group(
638
+ ['prefix' => 'getting-started'],
639
+ function ($router) {
640
+ $router->get('status', 'GettingStartedController@status');
641
+ }
642
+ );
628
643
  $router->group(
629
644
  ['prefix' => 'orchestrator'],
630
645
  function ($router) {
@@ -661,3 +676,20 @@ Route::prefix(config('fleetops.api.routing.prefix', null))->namespace('Fleetbase
661
676
  );
662
677
  }
663
678
  );
679
+
680
+ if (filled(config('fleetops.api.routing.prefix'))) {
681
+ Route::prefix(config('fleetops.api.routing.internal_prefix', 'int') . '/v1')
682
+ ->namespace('Fleetbase\FleetOps\Http\Controllers\Internal\v1')
683
+ ->middleware([
684
+ 'fleetbase.protected',
685
+ Fleetbase\FleetOps\Http\Middleware\TransformLocationMiddleware::class,
686
+ Fleetbase\FleetOps\Http\Middleware\SetupDriverSession::class,
687
+ ])
688
+ ->group(function ($router) {
689
+ $router->get('issues/{id}/timeline', 'IssueController@timeline');
690
+ $router->get('vendors/{id}/personnels', 'VendorController@vendorPersonnels');
691
+ $router->post('vendors/{id}/personnels', 'VendorController@addVendorPersonnel');
692
+ $router->delete('vendors/{id}/personnels/{contact}', 'VendorController@removeVendorPersonnel');
693
+ $router->post('contacts/{id}/convert-to-vendor', 'ContactController@convertToVendor');
694
+ });
695
+ }
@@ -39,3 +39,11 @@ test('analytics controller has one method per widget', function () {
39
39
  expect($controller)->toContain("public function {$method}(");
40
40
  }
41
41
  });
42
+
43
+ test('top drivers on-time sorting uses a sql aggregate expression', function () {
44
+ $widget = file_get_contents(dirname(__DIR__) . '/src/Support/Analytics/TopDrivers.php');
45
+
46
+ expect($widget)->toContain("'on_time' => \"CASE");
47
+ expect($widget)->toContain('TIMESTAMPDIFF(SECOND, orders.scheduled_at, orders.updated_at) <= 1800');
48
+ expect($widget)->not->toContain("'on_time' => 'on_time_pct'");
49
+ });