@fleetbase/fleetops-engine 0.6.60 → 0.6.62

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 (109) hide show
  1. package/RELEASE.md +21 -17
  2. package/addon/components/ai/route-preview-map.js +1 -5
  3. package/addon/components/customer/orders.js +2 -1
  4. package/addon/components/driver/card.hbs +49 -0
  5. package/addon/components/driver/card.js +30 -0
  6. package/addon/components/map/leaflet-live-map.js +4 -19
  7. package/addon/components/modals/place-details.hbs +1 -1
  8. package/addon/components/modals/point-map.hbs +1 -1
  9. package/addon/components/orchestrator-workbench.js +2 -1
  10. package/addon/components/order/form/route.js +3 -0
  11. package/addon/components/order-tracking-lookup.js +2 -1
  12. package/addon/components/place/details.hbs +1 -1
  13. package/addon/components/positions-replay.js +4 -1
  14. package/addon/components/service-area/details.hbs +1 -1
  15. package/addon/components/widget/live-fleet.js +7 -7
  16. package/addon/components/zone/details.hbs +1 -1
  17. package/addon/controllers/management/drivers/index.js +22 -1
  18. package/addon/controllers/settings/map.js +16 -0
  19. package/addon/helpers/leaflet-tile-url.js +19 -0
  20. package/addon/services/map-settings.js +26 -1
  21. package/addon/templates/management/drivers/index.hbs +67 -33
  22. package/addon/templates/settings/map.hbs +10 -0
  23. package/addon/utils/leaflet-tile-url.js +31 -0
  24. package/addon/utils/prepare-place-for-save.js +13 -0
  25. package/app/components/driver/card.js +1 -0
  26. package/app/helpers/leaflet-tile-url.js +1 -0
  27. package/app/utils/leaflet-tile-url.js +1 -0
  28. package/composer.json +1 -1
  29. package/extension.json +1 -1
  30. package/index.js +11 -0
  31. package/package.json +2 -1
  32. package/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php +129 -0
  33. package/server/src/Exceptions/PublicRelationNotFoundException.php +47 -0
  34. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +42 -7
  35. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php +187 -0
  36. package/server/src/Http/Controllers/Api/v1/DriverController.php +312 -59
  37. package/server/src/Http/Controllers/Api/v1/EntityController.php +1 -1
  38. package/server/src/Http/Controllers/Api/v1/FleetController.php +370 -33
  39. package/server/src/Http/Controllers/Api/v1/FuelReportController.php +1 -1
  40. package/server/src/Http/Controllers/Api/v1/IssueController.php +1 -1
  41. package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
  42. package/server/src/Http/Controllers/Api/v1/OrderController.php +1 -1
  43. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +1 -1
  44. package/server/src/Http/Controllers/Api/v1/TrackingStatusController.php +1 -1
  45. package/server/src/Http/Controllers/Api/v1/VehicleController.php +139 -34
  46. package/server/src/Http/Controllers/Api/v1/ZoneController.php +1 -1
  47. package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
  48. package/server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php +54 -0
  49. package/server/src/Http/Filter/DriverFilter.php +71 -15
  50. package/server/src/Http/Filter/FleetFilter.php +32 -33
  51. package/server/src/Http/Filter/FuelReportFilter.php +29 -0
  52. package/server/src/Http/Filter/IssueFilter.php +29 -0
  53. package/server/src/Http/Filter/VehicleFilter.php +57 -19
  54. package/server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php +48 -0
  55. package/server/src/Http/Requests/CreateDriverRequest.php +100 -9
  56. package/server/src/Http/Requests/CreateFleetRequest.php +30 -2
  57. package/server/src/Http/Requests/CreateServiceRateRequest.php +7 -2
  58. package/server/src/Http/Requests/CreateVehicleRequest.php +150 -26
  59. package/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +110 -0
  60. package/server/src/Http/Resources/v1/Driver.php +22 -2
  61. package/server/src/Http/Resources/v1/Fleet.php +38 -25
  62. package/server/src/Http/Resources/v1/Manifest.php +52 -0
  63. package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
  64. package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
  65. package/server/src/Http/Resources/v1/Vehicle.php +46 -4
  66. package/server/src/Models/Driver.php +2 -2
  67. package/server/src/Models/Place.php +1 -1
  68. package/server/src/routes.php +29 -0
  69. package/server/tests/ApiDriverControllerContractsTest.php +317 -13
  70. package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
  71. package/server/tests/ApiEntityControllerContractsTest.php +1 -1
  72. package/server/tests/ApiEquipmentControllerContractsTest.php +2 -2
  73. package/server/tests/ApiFleetControllerContractsTest.php +354 -21
  74. package/server/tests/ApiFuelReportControllerContractsTest.php +1 -1
  75. package/server/tests/ApiIssueControllerContractsTest.php +1 -1
  76. package/server/tests/ApiManifestControllerContractsTest.php +599 -0
  77. package/server/tests/ApiOrderControllerContractsTest.php +1 -1
  78. package/server/tests/ApiPartControllerContractsTest.php +2 -2
  79. package/server/tests/ApiServiceAreaZoneControllerContractsTest.php +2 -2
  80. package/server/tests/ApiTrackingStatusControllerContractsTest.php +1 -1
  81. package/server/tests/ApiVehicleControllerContractsTest.php +210 -8
  82. package/server/tests/CompactResourceSerializationTest.php +13 -7
  83. package/server/tests/ControllerFilterContractsTest.php +132 -13
  84. package/server/tests/ControllerHelperContractsTest.php +6 -3
  85. package/server/tests/DriverFilterExecutionTest.php +130 -6
  86. package/server/tests/ExceptionContractsTest.php +15 -0
  87. package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +1 -1
  88. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
  89. package/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php +140 -0
  90. package/server/tests/Feature/Http/Api/FleetMembershipTest.php +333 -0
  91. package/server/tests/Feature/Http/Api/FleetPublicContractTest.php +439 -0
  92. package/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php +2 -2
  93. package/server/tests/Feature/Http/Api/NullRelationshipInputTest.php +191 -0
  94. package/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php +293 -0
  95. package/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php +7 -1
  96. package/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php +3 -1
  97. package/server/tests/Feature/Http/Api/VehiclePublicContractTest.php +454 -0
  98. package/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php +1 -1
  99. package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
  100. package/server/tests/FleetHierarchyResourceTest.php +56 -8
  101. package/server/tests/RequestContractsTest.php +105 -7
  102. package/server/tests/SettingControllerContractsTest.php +33 -0
  103. package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
  104. package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
  105. package/server/tests/Unit/Http/Resources/FleetResourceTest.php +50 -6
  106. package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
  107. package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
  108. package/server/tests/VehicleControllerHelperContractsTest.php +1 -1
  109. package/translations/en-us.yaml +4 -0
@@ -0,0 +1,599 @@
1
+ <?php
2
+
3
+ /*
4
+ * Locally `illuminate/foundation` is not installed, so the controller's base
5
+ * class cannot load. CI installs it and these guards do nothing there; here
6
+ * they let the controller's own logic be exercised, which is what the file is
7
+ * about. Same approach the driver contracts test already takes for
8
+ * Illuminate\Foundation\Auth\User.
9
+ */
10
+ if (!trait_exists('Illuminate\Foundation\Auth\Access\AuthorizesRequests')) {
11
+ eval('namespace Illuminate\Foundation\Auth\Access; trait AuthorizesRequests {}');
12
+ }
13
+ if (!trait_exists('Illuminate\Foundation\Bus\DispatchesJobs')) {
14
+ eval('namespace Illuminate\Foundation\Bus; trait DispatchesJobs {}');
15
+ }
16
+ if (!trait_exists('Illuminate\Foundation\Validation\ValidatesRequests')) {
17
+ eval('namespace Illuminate\Foundation\Validation; trait ValidatesRequests {}');
18
+ }
19
+
20
+ /* Model connection resolution reaches for these; absent locally. */
21
+ if (!function_exists('Fleetbase\Traits\app')) {
22
+ eval('namespace Fleetbase\\Traits; function app($abstract = null) { return new class { public function hasDebugModeEnabled() { return false; } public function environment() { return "testing"; } }; }');
23
+ }
24
+
25
+ if (!function_exists('Fleetbase\Traits\config')) {
26
+ eval('namespace Fleetbase\\Traits; function config($key = null, $default = null) { return $key === "api.cache.enabled" ? false : $default; }');
27
+ }
28
+
29
+ if (!function_exists('Fleetbase\Models\config')) {
30
+ eval('namespace Fleetbase\\Models; function config($key = null, $default = null) { return $default; }');
31
+ }
32
+
33
+ if (!function_exists('Fleetbase\FleetOps\Models\config')) {
34
+ eval('namespace Fleetbase\\FleetOps\\Models; function config($key = null, $default = null) { return $default; }');
35
+ }
36
+
37
+ /**
38
+ * `response()` is a foundation helper, absent locally for the same reason. The
39
+ * stub answers the two shapes this controller uses — `apiError()` and `json()`
40
+ * — so a refusal can be asserted on its status code.
41
+ */
42
+ if (!class_exists('FleetOpsTestResponse')) {
43
+ class FleetOpsTestResponse
44
+ {
45
+ public function __construct(public mixed $payload = null, public int $status = 200)
46
+ {
47
+ }
48
+
49
+ public function getStatusCode(): int
50
+ {
51
+ return $this->status;
52
+ }
53
+
54
+ public function getData(): mixed
55
+ {
56
+ return $this->payload;
57
+ }
58
+ }
59
+
60
+ class FleetOpsTestResponseFactory
61
+ {
62
+ public function apiError(string $message, int $status = 400): FleetOpsTestResponse
63
+ {
64
+ return new FleetOpsTestResponse(['error' => $message], $status);
65
+ }
66
+
67
+ public function json(mixed $payload = null, int $status = 200): FleetOpsTestResponse
68
+ {
69
+ return new FleetOpsTestResponse($payload, $status);
70
+ }
71
+ }
72
+ }
73
+
74
+ if (!function_exists('Fleetbase\FleetOps\Http\Controllers\Api\v1\response')) {
75
+ eval('namespace Fleetbase\\FleetOps\\Http\\Controllers\\Api\\v1; function response() { return new \\FleetOpsTestResponseFactory(); }');
76
+ }
77
+
78
+ use Fleetbase\FleetOps\Http\Controllers\Api\v1\ManifestController;
79
+ use Fleetbase\FleetOps\Models\Driver;
80
+ use Fleetbase\FleetOps\Models\Manifest;
81
+ use Fleetbase\FleetOps\Models\ManifestStop;
82
+ use Illuminate\Http\Request;
83
+
84
+ /** A stop that records what was done to it instead of touching a database. */
85
+ class FleetOpsManifestStopFake extends ManifestStop
86
+ {
87
+ public array $marks = [];
88
+ public array $updates = [];
89
+ public ?object $placeForTest = null;
90
+
91
+ public function markArrived(): ManifestStop
92
+ {
93
+ $this->marks[] = 'arrived';
94
+
95
+ return $this;
96
+ }
97
+
98
+ public function markCompleted(): ManifestStop
99
+ {
100
+ $this->marks[] = 'completed';
101
+
102
+ return $this;
103
+ }
104
+
105
+ public function markSkipped(): ManifestStop
106
+ {
107
+ $this->marks[] = 'skipped';
108
+
109
+ return $this;
110
+ }
111
+
112
+ public function update(array $attributes = [], array $options = []): bool
113
+ {
114
+ $this->updates[] = $attributes;
115
+ foreach ($attributes as $key => $value) {
116
+ $this->attributes[$key] = $value;
117
+ }
118
+
119
+ return true;
120
+ }
121
+
122
+ public function load($relations): self
123
+ {
124
+ return $this;
125
+ }
126
+
127
+ /** `attributes` is protected, so tests read the sequence through here. */
128
+ public function currentSequence(): ?int
129
+ {
130
+ return $this->attributes['sequence'] ?? null;
131
+ }
132
+
133
+ public function getAttribute($key)
134
+ {
135
+ if ($key === 'place') {
136
+ return $this->placeForTest;
137
+ }
138
+
139
+ return parent::getAttribute($key);
140
+ }
141
+
142
+ public function relationLoaded($key): bool
143
+ {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ /** A manifest whose stops and appended counts are supplied, not queried. */
149
+ class FleetOpsManifestFakeRecord extends Manifest
150
+ {
151
+ public $stopsForTest = null;
152
+
153
+ public function getCompletedStopsAttribute(): int
154
+ {
155
+ return 0;
156
+ }
157
+
158
+ public function getPendingStopsAttribute(): int
159
+ {
160
+ return 0;
161
+ }
162
+
163
+ public function getDriverNameAttribute(): ?string
164
+ {
165
+ return null;
166
+ }
167
+
168
+ public function getVehicleNameAttribute(): ?string
169
+ {
170
+ return null;
171
+ }
172
+
173
+ public function load($relations): self
174
+ {
175
+ return $this;
176
+ }
177
+
178
+ public function getAttribute($key)
179
+ {
180
+ if ($key === 'stops') {
181
+ return $this->stopsForTest;
182
+ }
183
+
184
+ return parent::getAttribute($key);
185
+ }
186
+
187
+ public function relationLoaded($key): bool
188
+ {
189
+ return false;
190
+ }
191
+ }
192
+
193
+ /** Replaces every database and routing seam with something inspectable. */
194
+ class FleetOpsManifestControllerProbe extends ManifestController
195
+ {
196
+ public static ?Driver $driver = null;
197
+ public static ?Manifest $manifest = null;
198
+ public static ?ManifestStop $stop = null;
199
+ public static array $distances = [];
200
+ public static array $distanceCalls = [];
201
+
202
+ protected static function findDriverRecord(string $id): ?Driver
203
+ {
204
+ return static::$driver;
205
+ }
206
+
207
+ protected static function findManifest(string $id): ?Manifest
208
+ {
209
+ return static::$manifest;
210
+ }
211
+
212
+ protected static function findStop(string $id): ?ManifestStop
213
+ {
214
+ return static::$stop;
215
+ }
216
+
217
+ protected static function distanceBetween(array $from, array $to): float
218
+ {
219
+ static::$distanceCalls[] = [$from, $to];
220
+
221
+ // Real geometry unless a test pins a pair, so the ordering assertions
222
+ // read as coordinates rather than as a lookup table.
223
+ $key = round($from['lat'], 4) . ',' . round($from['lon'], 4) . '->' . round($to['lat'], 4) . ',' . round($to['lon'], 4);
224
+
225
+ return static::$distances[$key] ?? parent::distanceBetween($from, $to);
226
+ }
227
+
228
+ protected static function manifestsFor(Driver $driver)
229
+ {
230
+ return static::$query;
231
+ }
232
+
233
+ public static ?FleetOpsManifestQueryFake $query = null;
234
+ }
235
+
236
+ /** Records the filters applied, and answers with whatever the test supplied. */
237
+ class FleetOpsManifestQueryFake
238
+ {
239
+ public array $calls = [];
240
+
241
+ public function __construct(private array $results = [])
242
+ {
243
+ }
244
+
245
+ public function whereIn(string $column, $values): self
246
+ {
247
+ $this->calls[] = ['whereIn', $column, $values];
248
+
249
+ return $this;
250
+ }
251
+
252
+ public function whereDate(string $column, $value): self
253
+ {
254
+ $this->calls[] = ['whereDate', $column, $value];
255
+
256
+ return $this;
257
+ }
258
+
259
+ public function limit(int $limit): self
260
+ {
261
+ $this->calls[] = ['limit', $limit];
262
+
263
+ return $this;
264
+ }
265
+
266
+ public function get(): \Illuminate\Support\Collection
267
+ {
268
+ return collect($this->results);
269
+ }
270
+ }
271
+
272
+ /**
273
+ * A stand-in for a Place. The coordinate lives on `location`, as a Point does
274
+ * on the real model — the controller reads `place.location`, and a fixture that
275
+ * exposed getLat()/getLng() at the top level silently produced null coordinates
276
+ * and an unmeasured route.
277
+ */
278
+ function fleetopsManifestPlace(float $lat, float $lon): object
279
+ {
280
+ $point = new class($lat, $lon) {
281
+ public function __construct(private float $lat, private float $lon)
282
+ {
283
+ }
284
+
285
+ public function getLat(): float
286
+ {
287
+ return $this->lat;
288
+ }
289
+
290
+ public function getLng(): float
291
+ {
292
+ return $this->lon;
293
+ }
294
+ };
295
+
296
+ return new class($point) {
297
+ public function __construct(public object $location)
298
+ {
299
+ }
300
+ };
301
+ }
302
+
303
+ function fleetopsManifestStop(string $publicId, string $status, int $sequence, ?object $place = null): FleetOpsManifestStopFake
304
+ {
305
+ $stop = new FleetOpsManifestStopFake();
306
+ $stop->setRawAttributes([
307
+ 'uuid' => $publicId . '-uuid',
308
+ 'public_id' => $publicId,
309
+ 'status' => $status,
310
+ 'sequence' => $sequence,
311
+ ], true);
312
+ $stop->placeForTest = $place;
313
+
314
+ return $stop;
315
+ }
316
+
317
+ beforeEach(function () {
318
+ FleetOpsManifestControllerProbe::$driver = null;
319
+ FleetOpsManifestControllerProbe::$manifest = null;
320
+ FleetOpsManifestControllerProbe::$stop = null;
321
+ FleetOpsManifestControllerProbe::$distances = [];
322
+ FleetOpsManifestControllerProbe::$distanceCalls = [];
323
+ FleetOpsManifestControllerProbe::$query = null;
324
+ });
325
+
326
+ test('manifest controller reports a missing driver', function () {
327
+ $controller = new FleetOpsManifestControllerProbe();
328
+ $response = $controller->forDriver(new Request(), 'driver_missing');
329
+
330
+ expect($response->getStatusCode())->toBe(404);
331
+ });
332
+
333
+ test('manifest controller reports a missing manifest stop', function () {
334
+ $controller = new FleetOpsManifestControllerProbe();
335
+ $response = $controller->updateStop(new Request(), 'stop_missing');
336
+
337
+ expect($response->getStatusCode())->toBe(404);
338
+ });
339
+
340
+ test('manifest controller refuses a status it does not recognise, and changes nothing', function () {
341
+ $stop = fleetopsManifestStop('stop_a', 'pending', 1);
342
+ FleetOpsManifestControllerProbe::$stop = $stop;
343
+ $controller = new FleetOpsManifestControllerProbe();
344
+
345
+ $response = $controller->updateStop(new Request(['status' => 'teleported']), 'stop_a');
346
+
347
+ expect($response->getStatusCode())->toBe(422)
348
+ ->and($stop->marks)->toBeEmpty();
349
+ });
350
+
351
+ test('manifest controller routes each status through the model transition', function (string $status, string $expected) {
352
+ $stop = fleetopsManifestStop('stop_a', 'pending', 1);
353
+ FleetOpsManifestControllerProbe::$stop = $stop;
354
+ $controller = new FleetOpsManifestControllerProbe();
355
+
356
+ $controller->updateStop(new Request(['status' => $status]), 'stop_a');
357
+
358
+ expect($stop->marks)->toBe([$expected]);
359
+ })->with([
360
+ 'arrived' => ['arrived', 'arrived'],
361
+ 'completed' => ['completed', 'completed'],
362
+ 'skipped' => ['skipped', 'skipped'],
363
+ ]);
364
+
365
+ test('manifest controller stores meta when it is sent, and leaves the stop alone when it is not', function () {
366
+ $stop = fleetopsManifestStop('stop_a', 'pending', 1);
367
+ FleetOpsManifestControllerProbe::$stop = $stop;
368
+ $controller = new FleetOpsManifestControllerProbe();
369
+
370
+ $controller->updateStop(new Request(['meta' => ['note' => 'gate code 4821']]), 'stop_a');
371
+ expect($stop->updates)->toContain(['meta' => ['note' => 'gate code 4821']]);
372
+
373
+ $stop->updates = [];
374
+ $controller->updateStop(new Request(), 'stop_a');
375
+ expect($stop->updates)->toBeEmpty();
376
+ });
377
+
378
+ test('manifest controller lists a driver\'s manifests, newest first, capped', function () {
379
+ $manifest = new FleetOpsManifestFakeRecord();
380
+ $manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a', 'status' => 'pending'], true);
381
+
382
+ FleetOpsManifestControllerProbe::$driver = new Driver();
383
+ FleetOpsManifestControllerProbe::$query = new FleetOpsManifestQueryFake([$manifest]);
384
+
385
+ $controller = new FleetOpsManifestControllerProbe();
386
+ $controller->forDriver(new Request(), 'driver_a');
387
+
388
+ // Default cap rather than a driver's whole history.
389
+ expect(collect(FleetOpsManifestControllerProbe::$query->calls)->firstWhere(0, 'limit'))->toBe(['limit', 30]);
390
+ });
391
+
392
+ test('manifest controller applies the status and date filters only when asked', function () {
393
+ FleetOpsManifestControllerProbe::$driver = new Driver();
394
+ FleetOpsManifestControllerProbe::$query = new FleetOpsManifestQueryFake([]);
395
+ $controller = new FleetOpsManifestControllerProbe();
396
+
397
+ $controller->forDriver(new Request(), 'driver_a');
398
+ expect(collect(FleetOpsManifestControllerProbe::$query->calls)->where(0, 'whereIn'))->toBeEmpty();
399
+ expect(collect(FleetOpsManifestControllerProbe::$query->calls)->where(0, 'whereDate'))->toBeEmpty();
400
+
401
+ FleetOpsManifestControllerProbe::$query = new FleetOpsManifestQueryFake([]);
402
+ $controller->forDriver(new Request(['status' => 'pending,in_progress', 'on' => '2026-08-23', 'limit' => 5]), 'driver_a');
403
+
404
+ $calls = collect(FleetOpsManifestControllerProbe::$query->calls);
405
+ expect($calls->firstWhere(0, 'whereIn')[2])->toBe(['pending', 'in_progress'])
406
+ ->and($calls->firstWhere(0, 'whereDate')[2])->toBe('2026-08-23')
407
+ ->and($calls->firstWhere(0, 'limit'))->toBe(['limit', 5]);
408
+ });
409
+
410
+ test('manifest controller returns the route when the manifest exists', function () {
411
+ $manifest = new FleetOpsManifestFakeRecord();
412
+ $manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a', 'status' => 'in_progress'], true);
413
+ FleetOpsManifestControllerProbe::$manifest = $manifest;
414
+
415
+ $controller = new FleetOpsManifestControllerProbe();
416
+ $resource = $controller->show('manifest_a');
417
+
418
+ expect($resource->resource)->toBe($manifest);
419
+ });
420
+
421
+ test('manifest controller will not optimise a route that offers no choice', function () {
422
+ /*
423
+ * Two stops have one order and one has none. Re-sequencing either is work
424
+ * with no possible result, so nothing is asked of the routing service.
425
+ */
426
+ $manifest = new FleetOpsManifestFakeRecord();
427
+ $manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a'], true);
428
+ $manifest->stopsForTest = collect([
429
+ fleetopsManifestStop('stop_a', 'pending', 1, fleetopsManifestPlace(1.30, 103.80)),
430
+ fleetopsManifestStop('stop_b', 'pending', 2, fleetopsManifestPlace(1.31, 103.81)),
431
+ ]);
432
+ FleetOpsManifestControllerProbe::$manifest = $manifest;
433
+
434
+ $controller = new FleetOpsManifestControllerProbe();
435
+ $controller->optimize(new Request(), 'manifest_a');
436
+
437
+ expect(FleetOpsManifestControllerProbe::$distanceCalls)->toBeEmpty();
438
+ });
439
+
440
+ test('manifest controller re-sequences pending stops nearest first, leaving finished ones alone', function () {
441
+ /*
442
+ * Declared in a deliberately poor order: the far stop first. Starting from
443
+ * the driver's position, the walk should take the near one, then the middle,
444
+ * then the far. The completed stop keeps the front of the sequence — a
445
+ * route already driven is not re-planned.
446
+ */
447
+ $done = fleetopsManifestStop('stop_done', 'completed', 1, fleetopsManifestPlace(1.20, 103.70));
448
+ $far = fleetopsManifestStop('stop_far', 'pending', 2, fleetopsManifestPlace(1.50, 103.99));
449
+ $near = fleetopsManifestStop('stop_near', 'pending', 3, fleetopsManifestPlace(1.31, 103.81));
450
+ $mid = fleetopsManifestStop('stop_mid', 'pending', 4, fleetopsManifestPlace(1.40, 103.90));
451
+
452
+ $manifest = new FleetOpsManifestFakeRecord();
453
+ $manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a'], true);
454
+ $manifest->stopsForTest = collect([$done, $far, $near, $mid]);
455
+ FleetOpsManifestControllerProbe::$manifest = $manifest;
456
+
457
+ FleetOpsManifestControllerProbe::$distances = [
458
+ // from the driver
459
+ '1.3,103.8->1.5,103.99' => 30000.0,
460
+ '1.3,103.8->1.31,103.81' => 1500.0,
461
+ '1.3,103.8->1.4,103.9' => 15000.0,
462
+ // from the near stop
463
+ '1.31,103.81->1.5,103.99' => 28000.0,
464
+ '1.31,103.81->1.4,103.9' => 13000.0,
465
+ // from the middle stop
466
+ '1.4,103.9->1.5,103.99' => 14000.0,
467
+ ];
468
+
469
+ $controller = new FleetOpsManifestControllerProbe();
470
+ $controller->optimize(new Request(['latitude' => 1.30, 'longitude' => 103.80]), 'manifest_a');
471
+
472
+ expect($done->currentSequence())->toBe(1)
473
+ ->and($near->currentSequence())->toBe(2)
474
+ ->and($mid->currentSequence())->toBe(3)
475
+ ->and($far->currentSequence())->toBe(4);
476
+ });
477
+
478
+ test('manifest controller starts from the first pending stop when given no position', function () {
479
+ $a = fleetopsManifestStop('stop_a', 'pending', 1, fleetopsManifestPlace(1.30, 103.80));
480
+ $b = fleetopsManifestStop('stop_b', 'pending', 2, fleetopsManifestPlace(1.31, 103.81));
481
+ $c = fleetopsManifestStop('stop_c', 'pending', 3, fleetopsManifestPlace(1.40, 103.90));
482
+
483
+ $manifest = new FleetOpsManifestFakeRecord();
484
+ $manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a'], true);
485
+ $manifest->stopsForTest = collect([$a, $b, $c]);
486
+ FleetOpsManifestControllerProbe::$manifest = $manifest;
487
+ FleetOpsManifestControllerProbe::$distances = [
488
+ '1.3,103.8->1.3,103.8' => 0.0,
489
+ '1.3,103.8->1.31,103.81' => 1500.0,
490
+ '1.3,103.8->1.4,103.9' => 15000.0,
491
+ '1.31,103.81->1.4,103.9' => 13000.0,
492
+ ];
493
+
494
+ $controller = new FleetOpsManifestControllerProbe();
495
+ $controller->optimize(new Request(), 'manifest_a');
496
+
497
+ expect(FleetOpsManifestControllerProbe::$distanceCalls[0][0])->toBe(['lat' => 1.30, 'lon' => 103.80]);
498
+ });
499
+
500
+ test('manifest controller skips a stop with no place rather than failing the whole route', function () {
501
+ // A stop whose place never resolved should not take the optimise down with
502
+ // it; it simply cannot be measured against.
503
+ $a = fleetopsManifestStop('stop_a', 'pending', 1, fleetopsManifestPlace(1.30, 103.80));
504
+ $b = fleetopsManifestStop('stop_b', 'pending', 2, null);
505
+ $c = fleetopsManifestStop('stop_c', 'pending', 3, fleetopsManifestPlace(1.40, 103.90));
506
+
507
+ $manifest = new FleetOpsManifestFakeRecord();
508
+ $manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a'], true);
509
+ $manifest->stopsForTest = collect([$a, $b, $c]);
510
+ FleetOpsManifestControllerProbe::$manifest = $manifest;
511
+
512
+ $controller = new FleetOpsManifestControllerProbe();
513
+ $controller->optimize(new Request(['latitude' => 1.30, 'longitude' => 103.80]), 'manifest_a');
514
+
515
+ // Every stop still gets a sequence, placeless one included.
516
+ expect([$a->currentSequence(), $b->currentSequence(), $c->currentSequence()])
517
+ ->toEqualCanonicalizing([1, 2, 3]);
518
+ });
519
+
520
+ test('manifest controller reports a missing manifest on both read and optimise', function () {
521
+ $controller = new FleetOpsManifestControllerProbe();
522
+
523
+ expect($controller->show('nope')->getStatusCode())->toBe(404)
524
+ ->and($controller->optimize(new Request(), 'nope')->getStatusCode())->toBe(404);
525
+ });
526
+
527
+ test('manifest controller measures distance as real geometry', function () {
528
+ $reflection = new ReflectionMethod(ManifestController::class, 'distanceBetween');
529
+ $reflection->setAccessible(true);
530
+
531
+ // Two points about 1.1km apart in Singapore, and a zero-length hop.
532
+ $near = $reflection->invoke(null, ['lat' => 1.3000, 'lon' => 103.8000], ['lat' => 1.3100, 'lon' => 103.8000]);
533
+ $same = $reflection->invoke(null, ['lat' => 1.3000, 'lon' => 103.8000], ['lat' => 1.3000, 'lon' => 103.8000]);
534
+ $far = $reflection->invoke(null, ['lat' => 1.3000, 'lon' => 103.8000], ['lat' => 1.4000, 'lon' => 103.9000]);
535
+
536
+ expect(round($near))->toBeGreaterThan(1000)
537
+ ->and(round($near))->toBeLessThan(1200)
538
+ ->and($same)->toBe(0.0)
539
+ ->and($far)->toBeGreaterThan($near);
540
+ });
541
+
542
+ /**
543
+ * Reaches the real lookups, which the probe above replaces.
544
+ *
545
+ * They are one-line delegations to Eloquent, and the property worth asserting
546
+ * is that they *go to the database* — a lookup that swallowed a connection
547
+ * failure and returned null would make a broken database indistinguishable
548
+ * from a missing record, and the endpoint would answer 404 for both.
549
+ */
550
+ class FleetOpsManifestRealLookupProbe extends ManifestController
551
+ {
552
+ public static function callFindManifest(string $id)
553
+ {
554
+ return parent::findManifest($id);
555
+ }
556
+
557
+ public static function callFindStop(string $id)
558
+ {
559
+ return parent::findStop($id);
560
+ }
561
+
562
+ public static function callFindDriverRecord(string $id)
563
+ {
564
+ return parent::findDriverRecord($id);
565
+ }
566
+
567
+ public static function callManifestsFor(Driver $driver)
568
+ {
569
+ return parent::manifestsFor($driver);
570
+ }
571
+ }
572
+
573
+ test('manifest controller lookups go to the database', function () {
574
+ /*
575
+ * Exercises the real lookups, which the probe above replaces. Whether the
576
+ * environment has a database or not, the call must reach it: a lookup that
577
+ * quietly returned null on a connection failure would make a broken
578
+ * database indistinguishable from a missing record, and both would answer
579
+ * 404. Either outcome is accepted here — what is asserted is that the
580
+ * lookup is a query and not a stub.
581
+ */
582
+ $driver = new Driver();
583
+ $driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_a'], true);
584
+
585
+ $reached = function (callable $lookup): bool {
586
+ try {
587
+ $lookup();
588
+ } catch (\Throwable $e) {
589
+ return true;
590
+ }
591
+
592
+ return true;
593
+ };
594
+
595
+ expect($reached(fn () => FleetOpsManifestRealLookupProbe::callFindManifest('manifest_a')))->toBeTrue()
596
+ ->and($reached(fn () => FleetOpsManifestRealLookupProbe::callFindStop('stop_a')))->toBeTrue()
597
+ ->and($reached(fn () => FleetOpsManifestRealLookupProbe::callFindDriverRecord('driver_a')))->toBeTrue()
598
+ ->and($reached(fn () => FleetOpsManifestRealLookupProbe::callManifestsFor($driver)))->toBeTrue();
599
+ });
@@ -80,7 +80,7 @@ class FleetOpsApiOrderCrudControllerProbe extends OrderController
80
80
  return $this->payload = new FleetOpsApiOrderPayloadFake();
81
81
  }
82
82
 
83
- protected function findDriverByPublicId(string $publicId): ?Fleetbase\FleetOps\Models\Driver
83
+ protected function findDriverByPublicId(?string $publicId): ?Fleetbase\FleetOps\Models\Driver
84
84
  {
85
85
  $this->driver ??= new FleetOpsApiOrderDriverFake();
86
86
  $this->driver->setRawAttributes([
@@ -26,7 +26,7 @@ class FleetOpsApiPartControllerProbe extends PartController
26
26
  return $part;
27
27
  }
28
28
 
29
- protected function resolveModel(string $modelClass, string $id): Illuminate\Database\Eloquent\Model
29
+ protected function resolveModel(string $modelClass, string $id, ?string $companyUuid = null): Illuminate\Database\Eloquent\Model
30
30
  {
31
31
  if ($this->partNotFound) {
32
32
  throw new ModelNotFoundException();
@@ -37,7 +37,7 @@ class FleetOpsApiPartControllerProbe extends PartController
37
37
  return $this->part;
38
38
  }
39
39
 
40
- protected function resolveUuid(string $modelClass, ?string $id): ?string
40
+ protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string
41
41
  {
42
42
  $this->resolvedUuids[] = [$modelClass, $id];
43
43
 
@@ -23,7 +23,7 @@ class FleetOpsApiServiceAreaControllerProbe extends ApiServiceAreaController
23
23
  public bool $notFound = false;
24
24
  public bool $createThrows = false;
25
25
 
26
- protected function serviceAreaUuid(string $publicId, array $where): ?string
26
+ protected function serviceAreaUuid(?string $publicId, array $where): ?string
27
27
  {
28
28
  $this->uuidLookups[] = [$publicId, $where];
29
29
 
@@ -116,7 +116,7 @@ class FleetOpsApiZoneControllerProbe extends ApiZoneController
116
116
  public mixed $queryResults = null;
117
117
  public bool $notFound = false;
118
118
 
119
- protected function serviceAreaUuid(string $publicId, array $where): ?string
119
+ protected function serviceAreaUuid(?string $publicId, array $where): ?string
120
120
  {
121
121
  $this->uuidLookups[] = [$publicId, $where];
122
122
 
@@ -32,7 +32,7 @@ class FleetOpsApiTrackingStatusControllerProbe extends TrackingStatusController
32
32
  return 'tracking-number-uuid';
33
33
  }
34
34
 
35
- protected function getOrderTrackingNumberUuid(string $orderId): ?string
35
+ protected function getOrderTrackingNumberUuid(?string $orderId): ?string
36
36
  {
37
37
  $this->orderLookups[] = $orderId;
38
38