@fleetbase/fleetops-engine 0.6.64 → 0.6.65

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 (284) hide show
  1. package/RELEASE.md +7 -5
  2. package/addon/components/attachment-list.hbs +65 -0
  3. package/addon/components/attachment-list.js +8 -0
  4. package/addon/components/cell/attached-vehicle.hbs +1 -1
  5. package/addon/components/cell/attached-vehicle.js +3 -3
  6. package/addon/components/cell/driver-identity.js +4 -4
  7. package/addon/components/cell/driver-name.hbs +1 -1
  8. package/addon/components/cell/translated-value.hbs +9 -0
  9. package/addon/components/cell/translated-value.js +57 -0
  10. package/addon/components/contact/card.hbs +58 -0
  11. package/addon/components/contact/card.js +19 -0
  12. package/addon/components/contact/form.hbs +1 -1
  13. package/addon/components/contact/panel-header.hbs +1 -1
  14. package/addon/components/customer/form.hbs +2 -2
  15. package/addon/components/device/manager.hbs +26 -28
  16. package/addon/components/device/manager.js +28 -2
  17. package/addon/components/device/panel-tabs/vehicle.hbs +13 -13
  18. package/addon/components/device/panel-tabs/vehicle.js +8 -0
  19. package/addon/components/driver/card.hbs +24 -13
  20. package/addon/components/driver/card.js +20 -6
  21. package/addon/components/driver/form.hbs +3 -3
  22. package/addon/components/driver/panel-header.hbs +2 -2
  23. package/addon/components/driver/pill.hbs +2 -2
  24. package/addon/components/equipment/form.js +11 -4
  25. package/addon/components/equipment/pill.hbs +20 -0
  26. package/addon/components/equipment/pill.js +3 -0
  27. package/addon/components/filter/multi-model.hbs +19 -0
  28. package/addon/components/filter/multi-model.js +90 -0
  29. package/addon/components/fleet/driver-listing.hbs +1 -1
  30. package/addon/components/fleet/vehicle-listing.hbs +1 -1
  31. package/addon/components/fleet-panel/driver-listing.hbs +1 -1
  32. package/addon/components/fleet-panel/vehicle-listing.hbs +1 -1
  33. package/addon/components/layout/fleet-ops-sidebar.js +9 -7
  34. package/addon/components/maintenance/form.js +12 -4
  35. package/addon/components/maintenance-schedule/form.js +13 -7
  36. package/addon/components/map/leaflet-live-map.hbs +52 -0
  37. package/addon/components/map/order-list-overlay/order.hbs +4 -4
  38. package/addon/components/modals/attach-equipment.hbs +22 -0
  39. package/addon/components/modals/attach-equipment.js +3 -0
  40. package/addon/components/modals/attach-telematic-device.hbs +13 -8
  41. package/addon/components/modals/attach-telematic-device.js +11 -1
  42. package/addon/components/modals/attach-trailer.hbs +30 -0
  43. package/addon/components/modals/attach-trailer.js +3 -0
  44. package/addon/components/modals/contact-form.hbs +2 -2
  45. package/addon/components/modals/convert-customer-to-vendor.hbs +1 -1
  46. package/addon/components/modals/driver-form.hbs +2 -2
  47. package/addon/components/modals/select-trailer.hbs +35 -0
  48. package/addon/components/modals/select-trailer.js +3 -0
  49. package/addon/components/modals/vehicle-form.hbs +2 -2
  50. package/addon/components/orchestrator/resource-panel.hbs +2 -2
  51. package/addon/components/order/customer-avatar-stack.hbs +2 -2
  52. package/addon/components/order/details/detail.hbs +4 -4
  53. package/addon/components/order/details/payload.hbs +2 -2
  54. package/addon/components/order/kanban-card.hbs +4 -4
  55. package/addon/components/order-list-overlay/order.hbs +4 -4
  56. package/addon/components/trailer/card.hbs +68 -0
  57. package/addon/components/trailer/card.js +22 -0
  58. package/addon/components/trailer/details/connections.hbs +57 -0
  59. package/addon/components/trailer/details/connections.js +59 -0
  60. package/addon/components/trailer/details/equipment.hbs +25 -0
  61. package/addon/components/trailer/details/equipment.js +52 -0
  62. package/addon/components/trailer/details/maintenance-history.hbs +47 -0
  63. package/addon/components/trailer/details/maintenance-history.js +40 -0
  64. package/addon/components/trailer/details/schedules.hbs +45 -0
  65. package/addon/components/trailer/details/schedules.js +40 -0
  66. package/addon/components/trailer/details/work-orders.hbs +47 -0
  67. package/addon/components/trailer/details/work-orders.js +40 -0
  68. package/addon/components/trailer/details.hbs +399 -0
  69. package/addon/components/trailer/details.js +118 -0
  70. package/addon/components/trailer/form.hbs +447 -0
  71. package/addon/components/trailer/form.js +165 -0
  72. package/addon/components/trailer/panel-header.hbs +45 -0
  73. package/addon/components/trailer/panel-header.js +3 -0
  74. package/addon/components/trailer/pill.hbs +21 -0
  75. package/addon/components/trailer/pill.js +3 -0
  76. package/addon/components/vehicle/card.hbs +42 -11
  77. package/addon/components/vehicle/card.js +31 -0
  78. package/addon/components/vehicle/details/equipment.hbs +25 -0
  79. package/addon/components/vehicle/details/equipment.js +55 -0
  80. package/addon/components/vehicle/details/trailers.hbs +35 -0
  81. package/addon/components/vehicle/details/trailers.js +55 -0
  82. package/addon/components/vehicle/form.hbs +2 -2
  83. package/addon/components/vehicle/panel-header.hbs +2 -2
  84. package/addon/components/vehicle/pill.hbs +2 -2
  85. package/addon/components/vendor/card.hbs +58 -0
  86. package/addon/components/vendor/card.js +6 -0
  87. package/addon/components/vendor/panel-header.hbs +2 -2
  88. package/addon/components/vendor/personnel-manager.hbs +2 -2
  89. package/addon/components/work-order/form.js +12 -4
  90. package/addon/controllers/connectivity/devices/index/details/vehicle.js +11 -0
  91. package/addon/controllers/connectivity/devices/index/details.js +3 -3
  92. package/addon/controllers/connectivity/devices/index.js +1 -0
  93. package/addon/controllers/connectivity/telematics/details/attachments.js +28 -47
  94. package/addon/controllers/connectivity/telematics/details/devices.js +1 -0
  95. package/addon/controllers/management/contacts/customers.js +4 -0
  96. package/addon/controllers/management/contacts/index.js +32 -0
  97. package/addon/controllers/management/trailers/index/details.js +119 -0
  98. package/addon/controllers/management/trailers/index/edit.js +67 -0
  99. package/addon/controllers/management/trailers/index/new.js +43 -0
  100. package/addon/controllers/management/trailers/index.js +599 -0
  101. package/addon/controllers/management/vehicles/index/details.js +16 -0
  102. package/addon/controllers/management/vehicles/index.js +78 -4
  103. package/addon/controllers/management/vendors/index.js +32 -0
  104. package/addon/controllers/operations/orders/index.js +3 -1
  105. package/addon/controllers/settings/custom-fields.js +8 -0
  106. package/addon/extension.js +7 -0
  107. package/addon/helpers/placeholder-image.js +6 -0
  108. package/addon/helpers/resource-image.js +10 -0
  109. package/addon/helpers/trailer-placeholder-image.js +6 -0
  110. package/addon/routes/management/trailers/index/details/connections.js +2 -0
  111. package/addon/routes/management/trailers/index/details/devices.js +2 -0
  112. package/addon/routes/management/trailers/index/details/equipment.js +2 -0
  113. package/addon/routes/management/trailers/index/details/index.js +2 -0
  114. package/addon/routes/management/trailers/index/details/maintenance-history.js +2 -0
  115. package/addon/routes/management/trailers/index/details/positions.js +2 -0
  116. package/addon/routes/management/trailers/index/details/schedules.js +2 -0
  117. package/addon/routes/management/trailers/index/details/work-orders.js +2 -0
  118. package/addon/routes/management/trailers/index/details.js +30 -0
  119. package/addon/routes/management/trailers/index/edit.js +23 -0
  120. package/addon/routes/management/trailers/index/new.js +14 -0
  121. package/addon/routes/management/trailers/index.js +42 -0
  122. package/addon/routes/management/vehicles/index/details/trailers.js +2 -0
  123. package/addon/routes/management/vehicles/index.js +7 -1
  124. package/addon/routes.js +17 -0
  125. package/addon/services/device-actions.js +26 -16
  126. package/addon/services/trailer-actions.js +394 -0
  127. package/addon/services/vehicle-actions.js +111 -0
  128. package/addon/styles/fleetops-engine.css +349 -0
  129. package/addon/templates/connectivity/devices/index/details/vehicle.hbs +19 -19
  130. package/addon/templates/connectivity/telematics/details/attachments.hbs +15 -15
  131. package/addon/templates/management/contacts/customers.hbs +48 -29
  132. package/addon/templates/management/contacts/index.hbs +52 -33
  133. package/addon/templates/management/trailers/index/details/connections.hbs +1 -0
  134. package/addon/templates/management/trailers/index/details/devices.hbs +1 -0
  135. package/addon/templates/management/trailers/index/details/equipment.hbs +1 -0
  136. package/addon/templates/management/trailers/index/details/index.hbs +1 -0
  137. package/addon/templates/management/trailers/index/details/maintenance-history.hbs +1 -0
  138. package/addon/templates/management/trailers/index/details/positions.hbs +1 -0
  139. package/addon/templates/management/trailers/index/details/schedules.hbs +1 -0
  140. package/addon/templates/management/trailers/index/details/work-orders.hbs +1 -0
  141. package/addon/templates/management/trailers/index/details.hbs +15 -0
  142. package/addon/templates/management/trailers/index/edit.hbs +12 -0
  143. package/addon/templates/management/trailers/index/new.hbs +11 -0
  144. package/addon/templates/management/trailers/index.hbs +67 -0
  145. package/addon/templates/management/vehicles/index/details/equipment.hbs +1 -1
  146. package/addon/templates/management/vehicles/index/details/trailers.hbs +1 -0
  147. package/addon/templates/management/vehicles/index/details.hbs +1 -1
  148. package/addon/templates/management/vendors/index.hbs +66 -32
  149. package/addon/utils/device-table-columns.js +10 -6
  150. package/addon/utils/live-map-card-content.js +56 -0
  151. package/addon/utils/placeholder-images.js +71 -0
  152. package/addon/utils/trailer-placeholder-image.js +11 -0
  153. package/app/components/attachment-list.js +1 -0
  154. package/app/components/cell/translated-value.js +1 -0
  155. package/app/components/contact/card.js +1 -0
  156. package/app/components/equipment/pill.js +1 -0
  157. package/app/components/filter/multi-model.js +1 -0
  158. package/app/components/modals/attach-equipment.js +1 -0
  159. package/app/components/modals/attach-trailer.js +1 -0
  160. package/app/components/modals/select-trailer.js +1 -0
  161. package/app/components/trailer/card.js +1 -0
  162. package/app/components/trailer/details/connections.js +1 -0
  163. package/app/components/trailer/details/equipment.js +1 -0
  164. package/app/components/trailer/details/maintenance-history.js +1 -0
  165. package/app/components/trailer/details/schedules.js +1 -0
  166. package/app/components/trailer/details/work-orders.js +1 -0
  167. package/app/components/trailer/details.js +1 -0
  168. package/app/components/trailer/form.js +1 -0
  169. package/app/components/trailer/panel-header.js +1 -0
  170. package/app/components/trailer/pill.js +1 -0
  171. package/app/components/vehicle/details/equipment.js +1 -0
  172. package/app/components/vehicle/details/trailers.js +1 -0
  173. package/app/components/vendor/card.js +1 -0
  174. package/app/controllers/management/trailers/index/details.js +1 -0
  175. package/app/controllers/management/trailers/index/edit.js +1 -0
  176. package/app/controllers/management/trailers/index/new.js +1 -0
  177. package/app/controllers/management/trailers/index.js +1 -0
  178. package/app/helpers/placeholder-image.js +1 -0
  179. package/app/helpers/resource-image.js +1 -0
  180. package/app/helpers/trailer-placeholder-image.js +1 -0
  181. package/app/routes/management/trailers/index/details/connections.js +1 -0
  182. package/app/routes/management/trailers/index/details/devices.js +1 -0
  183. package/app/routes/management/trailers/index/details/equipment.js +1 -0
  184. package/app/routes/management/trailers/index/details/index.js +1 -0
  185. package/app/routes/management/trailers/index/details/maintenance-history.js +1 -0
  186. package/app/routes/management/trailers/index/details/positions.js +1 -0
  187. package/app/routes/management/trailers/index/details/schedules.js +1 -0
  188. package/app/routes/management/trailers/index/details/work-orders.js +1 -0
  189. package/app/routes/management/trailers/index/details.js +1 -0
  190. package/app/routes/management/trailers/index/edit.js +1 -0
  191. package/app/routes/management/trailers/index/new.js +1 -0
  192. package/app/routes/management/trailers/index.js +1 -0
  193. package/app/routes/management/vehicles/index/details/trailers.js +1 -0
  194. package/app/services/trailer-actions.js +1 -0
  195. package/app/templates/management/trailers/index/details/connections.js +1 -0
  196. package/app/templates/management/trailers/index/details/devices.js +1 -0
  197. package/app/templates/management/trailers/index/details/equipment.js +1 -0
  198. package/app/templates/management/trailers/index/details/index.js +1 -0
  199. package/app/templates/management/trailers/index/details/maintenance-history.js +1 -0
  200. package/app/templates/management/trailers/index/details/positions.js +1 -0
  201. package/app/templates/management/trailers/index/details/schedules.js +1 -0
  202. package/app/templates/management/trailers/index/details/work-orders.js +1 -0
  203. package/app/templates/management/trailers/index/details.js +1 -0
  204. package/app/templates/management/trailers/index/edit.js +1 -0
  205. package/app/templates/management/trailers/index/new.js +1 -0
  206. package/app/templates/management/trailers/index.js +1 -0
  207. package/app/templates/management/vehicles/index/details/trailers.js +1 -0
  208. package/composer.json +1 -1
  209. package/extension.json +1 -1
  210. package/package.json +1 -1
  211. package/server/migrations/2026_09_03_000001_add_first_class_trailer_columns_to_assets_table.php +58 -0
  212. package/server/migrations/2026_09_03_000002_create_asset_connections_table.php +43 -0
  213. package/server/migrations/2026_09_03_000003_create_device_installations_table.php +33 -0
  214. package/server/src/Auth/Schemas/FleetOps.php +7 -0
  215. package/server/src/Events/TrailerLocationChanged.php +70 -0
  216. package/server/src/Exceptions/DeviceAlreadyAttachedException.php +18 -0
  217. package/server/src/Exports/TrailerExport.php +31 -0
  218. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +8 -2
  219. package/server/src/Http/Controllers/Api/v1/DeviceController.php +22 -15
  220. package/server/src/Http/Controllers/Api/v1/EquipmentController.php +47 -0
  221. package/server/src/Http/Controllers/Api/v1/TrailerController.php +362 -0
  222. package/server/src/Http/Controllers/Api/v1/VehicleController.php +1 -0
  223. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +3 -0
  224. package/server/src/Http/Controllers/Internal/v1/LiveController.php +1 -1
  225. package/server/src/Http/Controllers/Internal/v1/SearchController.php +16 -0
  226. package/server/src/Http/Controllers/Internal/v1/TrailerController.php +360 -0
  227. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +74 -0
  228. package/server/src/Http/Filter/EquipmentFilter.php +51 -0
  229. package/server/src/Http/Filter/OrderFilter.php +15 -0
  230. package/server/src/Http/Filter/TrailerFilter.php +269 -0
  231. package/server/src/Http/Filter/VehicleFilter.php +70 -3
  232. package/server/src/Http/Requests/CreateDeviceRequest.php +1 -1
  233. package/server/src/Http/Requests/CreateEquipmentRequest.php +1 -1
  234. package/server/src/Http/Requests/CreateTrailerRequest.php +54 -0
  235. package/server/src/Http/Requests/UpdateTrailerRequest.php +7 -0
  236. package/server/src/Http/Resources/v1/AssetConnection.php +90 -0
  237. package/server/src/Http/Resources/v1/Device.php +3 -1
  238. package/server/src/Http/Resources/v1/Equipment.php +3 -1
  239. package/server/src/Http/Resources/v1/Index/Vehicle.php +10 -0
  240. package/server/src/Http/Resources/v1/Trailer.php +199 -0
  241. package/server/src/Http/Resources/v1/Vehicle.php +2 -0
  242. package/server/src/Http/Resources/v1/VehicleWithoutDriver.php +3 -0
  243. package/server/src/Imports/TrailerImport.php +33 -0
  244. package/server/src/Models/Asset.php +39 -10
  245. package/server/src/Models/AssetConnection.php +43 -0
  246. package/server/src/Models/Device.php +39 -8
  247. package/server/src/Models/DeviceInstallation.php +27 -0
  248. package/server/src/Models/Equipment.php +2 -0
  249. package/server/src/Models/Trailer.php +181 -0
  250. package/server/src/Models/Vehicle.php +13 -0
  251. package/server/src/Providers/FleetOpsServiceProvider.php +4 -2
  252. package/server/src/Support/Ai/FleetOpsAiQueryResources.php +22 -0
  253. package/server/src/Support/Reporting/FleetOpsReportSchema.php +38 -0
  254. package/server/src/Support/Telematics/TelematicService.php +35 -0
  255. package/server/src/Support/TrailerSummary.php +35 -0
  256. package/server/src/routes.php +29 -0
  257. package/server/tests/CompactResourceSerializationTest.php +36 -0
  258. package/server/tests/ControllerFilterContractsTest.php +29 -0
  259. package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +30 -13
  260. package/server/tests/Feature/Http/Api/TrailerControllerLifecycleTest.php +640 -0
  261. package/server/tests/Feature/Http/Internal/DeviceControllerContractsTest.php +20 -5
  262. package/server/tests/Feature/Http/Internal/LiveControllerEndpointsTest.php +19 -0
  263. package/server/tests/ModelAccessorContractsTest.php +10 -0
  264. package/server/tests/OrderFilterExecutionTest.php +5 -0
  265. package/server/tests/PublicApiControllerInputContractsTest.php +11 -0
  266. package/server/tests/PublicFleetOpsRestEndpointsTest.php +15 -3
  267. package/server/tests/ReportSchemaContractsTest.php +4 -0
  268. package/server/tests/TelematicsHardeningTest.php +4 -2
  269. package/server/tests/TrailerSupportCoverageTest.php +524 -0
  270. package/server/tests/Unit/Http/Resources/IndexVehicleResourceTest.php +35 -0
  271. package/server/tests/Unit/Models/EquipmentTest.php +22 -0
  272. package/server/tests/Unit/Models/ModelLookupFallbackSeamsTest.php +7 -3
  273. package/server/tests/Unit/Models/TrailerTest.php +204 -0
  274. package/server/tests/Unit/Support/Telematics/TelematicServiceTelemetryTest.php +93 -0
  275. package/server/tests/VehicleControllerHelperContractsTest.php +14 -0
  276. package/translations/ar-ae.yml +396 -0
  277. package/translations/bg-bg.yaml +424 -0
  278. package/translations/en-us.yaml +423 -0
  279. package/translations/es-pa.yaml +425 -0
  280. package/translations/fr-fr.yaml +424 -0
  281. package/translations/mn-mn.yaml +424 -0
  282. package/translations/pt-br.yaml +424 -0
  283. package/translations/ru-ru.yaml +424 -0
  284. package/translations/vi-vn.yaml +435 -0
@@ -0,0 +1,524 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Exports\TrailerExport;
4
+ use Fleetbase\FleetOps\Http\Controllers\Internal\v1\SearchController;
5
+ use Fleetbase\FleetOps\Http\Filter\EquipmentFilter;
6
+ use Fleetbase\FleetOps\Http\Filter\TrailerFilter;
7
+ use Fleetbase\FleetOps\Http\Requests\CreateTrailerRequest;
8
+ use Fleetbase\FleetOps\Http\Requests\UpdateTrailerRequest;
9
+ use Fleetbase\FleetOps\Http\Resources\v1\AssetConnection as AssetConnectionResource;
10
+ use Fleetbase\FleetOps\Http\Resources\v1\Trailer as TrailerResource;
11
+ use Fleetbase\FleetOps\Imports\TrailerImport;
12
+ use Fleetbase\FleetOps\Models\Asset;
13
+ use Fleetbase\FleetOps\Models\AssetConnection;
14
+ use Fleetbase\FleetOps\Models\Device;
15
+ use Fleetbase\FleetOps\Models\DeviceInstallation;
16
+ use Fleetbase\FleetOps\Models\Equipment;
17
+ use Fleetbase\FleetOps\Models\Trailer;
18
+ use Fleetbase\FleetOps\Models\Vehicle;
19
+ use Fleetbase\LaravelMysqlSpatial\Eloquent\SpatialTrait;
20
+ use Illuminate\Config\Repository;
21
+ use Illuminate\Database\ConnectionResolver;
22
+ use Illuminate\Database\Eloquent\Model as EloquentModel;
23
+ use Illuminate\Database\Eloquent\Relations\BelongsTo;
24
+ use Illuminate\Database\Eloquent\Relations\HasMany;
25
+ use Illuminate\Database\Eloquent\Relations\MorphTo;
26
+ use Illuminate\Database\SQLiteConnection;
27
+ use Illuminate\Http\Request;
28
+ use Illuminate\Support\Carbon;
29
+ use Illuminate\Support\Collection;
30
+ use Illuminate\Validation\ValidationException;
31
+
32
+ if (!class_exists('Illuminate\\Validation\\Rule')) {
33
+ eval('namespace Illuminate\\Validation; class Rule { public function __construct(private string $rule = "") {} public static function requiredIf($condition): string { return (is_callable($condition) ? $condition() : $condition) ? "required" : "nullable"; } public static function in(array $values): self { return new self("in:" . implode(",", $values)); } public function __toString(): string { return $this->rule; } }');
34
+ }
35
+
36
+ class FleetOpsTrailerFilterBuilder
37
+ {
38
+ public array $calls = [];
39
+
40
+ public function __call(string $method, array $arguments): static
41
+ {
42
+ $this->calls[] = [$method, ...$arguments];
43
+ foreach ($arguments as $argument) {
44
+ if ($argument instanceof Closure) {
45
+ $argument($this);
46
+ }
47
+ }
48
+
49
+ return $this;
50
+ }
51
+
52
+ public function called(string $method): bool
53
+ {
54
+ return collect($this->calls)->contains(fn ($call) => $call[0] === $method);
55
+ }
56
+ }
57
+
58
+ class FleetOpsTrailerHistoryDevice extends Device
59
+ {
60
+ public function update(array $attributes = [], array $options = []): bool
61
+ {
62
+ $this->forceFill($attributes);
63
+
64
+ return false;
65
+ }
66
+ }
67
+
68
+ function fleetOpsTrailerSupportDatabase(): SQLiteConnection
69
+ {
70
+ $pdo = new PDO('sqlite::memory:');
71
+ $pdo->sqliteCreateFunction('ST_PointFromText', fn ($wkt, $srid = 0, $axisOrder = null) => $wkt);
72
+ $pdo->sqliteCreateFunction('ST_GeomFromText', fn ($wkt, $srid = 0, $axisOrder = null) => $wkt);
73
+ $connection = new SQLiteConnection($pdo);
74
+ $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
75
+ $resolver->setDefaultConnection('mysql');
76
+ EloquentModel::setConnectionResolver($resolver);
77
+ EloquentModel::setEventDispatcher(new Illuminate\Events\Dispatcher());
78
+ // Model hooks bind to the dispatcher present when a class boots; re-boot so the
79
+ // hooks of models booted by earlier test files land on this dispatcher.
80
+ EloquentModel::clearBootedModels();
81
+ $config = new Repository(['activitylog' => ['enabled' => false, 'default_auth_driver' => null, 'default_log_name' => 'default']]);
82
+ app()->instance('config', $config);
83
+ app()->instance(Illuminate\Contracts\Config\Repository::class, $config);
84
+ app()->instance('responsecache', new class {
85
+ public function __call($method, $arguments)
86
+ {
87
+ return null;
88
+ }
89
+ });
90
+ app()->instance('db', new class($connection) {
91
+ public function __construct(public SQLiteConnection $connection)
92
+ {
93
+ }
94
+
95
+ public function connection($name = null): SQLiteConnection
96
+ {
97
+ return $this->connection;
98
+ }
99
+
100
+ public function __call($method, $arguments)
101
+ {
102
+ return $this->connection->{$method}(...$arguments);
103
+ }
104
+ });
105
+ Illuminate\Support\Facades\DB::clearResolvedInstance('db');
106
+
107
+ $schema = $connection->getSchemaBuilder();
108
+ $schema->create('assets', function ($table) {
109
+ $table->increments('id');
110
+ foreach (['uuid', 'public_id', 'company_uuid', 'name', 'code', 'description', 'type', 'asset_class', 'status', 'vin', 'plate_number', 'serial_number', 'make', 'model', 'year', 'length', 'width', 'height', 'tare_weight', 'gvwr', 'payload_capacity', 'cargo_volume', 'axle_count', 'tire_count', 'coupling_type', 'brake_type', 'refrigerated', 'measurement_system', 'odometer', 'odometer_unit', 'ownership_type', 'purchased_at', 'lease_expires_at', 'notes', 'slug', '_key'] as $column) {
111
+ $table->string($column)->nullable();
112
+ }
113
+ $table->timestamps();
114
+ $table->softDeletes();
115
+ });
116
+ foreach (['vehicles', 'devices', 'equipments', 'asset_connections'] as $name) {
117
+ $schema->create($name, function ($table) {
118
+ $table->increments('id');
119
+ foreach (['uuid', 'public_id', 'company_uuid', 'name', 'display_name', 'plate_number', 'device_uuid', 'attachable_type', 'attachable_uuid', 'connector_type', 'connector_uuid', 'connected_type', 'connected_uuid'] as $column) {
120
+ $table->string($column)->nullable();
121
+ }
122
+ $table->timestamps();
123
+ $table->softDeletes();
124
+ });
125
+ }
126
+ $schema->create('device_installations', function ($table) {
127
+ $table->increments('id');
128
+ foreach (['uuid', 'company_uuid', 'device_uuid', 'attachable_type', 'attachable_uuid', 'active_device_uuid', 'installed_at', 'removed_at', 'source', 'metadata'] as $column) {
129
+ $table->string($column)->nullable();
130
+ }
131
+ $table->timestamps();
132
+ $table->softDeletes();
133
+ });
134
+ $schema->create('custom_field_values', function ($table) {
135
+ $table->increments('id');
136
+ foreach (['uuid', 'company_uuid', 'custom_field_uuid', 'subject_uuid', 'subject_type', 'value', 'value_type'] as $column) {
137
+ $table->string($column)->nullable();
138
+ }
139
+ $table->timestamps();
140
+ $table->softDeletes();
141
+ });
142
+ $schema->create('custom_fields', function ($table) {
143
+ $table->increments('id');
144
+ foreach (['uuid', 'company_uuid', 'label', 'name', 'type'] as $column) {
145
+ $table->string($column)->nullable();
146
+ }
147
+ $table->timestamps();
148
+ $table->softDeletes();
149
+ });
150
+ session(['company' => 'company-trailer-support']);
151
+
152
+ return $connection;
153
+ }
154
+
155
+ class FleetOpsTrailerSupportRoute
156
+ {
157
+ public array $action = [];
158
+
159
+ public function __construct(private string $uri)
160
+ {
161
+ }
162
+
163
+ public function uri(): string
164
+ {
165
+ return $this->uri;
166
+ }
167
+ }
168
+
169
+ function fleetOpsTrailerFilter(FleetOpsTrailerFilterBuilder $builder, array $query = [], bool $internal = false): TrailerFilter
170
+ {
171
+ $request = Request::create('/int/v1/trailers', 'GET', $query);
172
+ if ($internal) {
173
+ $request->setRouteResolver(fn () => new FleetOpsTrailerSupportRoute('int/v1/trailers'));
174
+ }
175
+ $session = app('session.store');
176
+ $session->put('company', 'company-trailer-support');
177
+ $request->setLaravelSession($session);
178
+ $filter = new TrailerFilter($request);
179
+ $reflection = new ReflectionClass($filter);
180
+ $property = $reflection->getParentClass()->getProperty('builder');
181
+ $property->setAccessible(true);
182
+ $property->setValue($filter, $builder);
183
+
184
+ return $filter;
185
+ }
186
+
187
+ test('trailer request authorization and validation expose the complete contract', function () {
188
+ $request = CreateTrailerRequest::create('/v1/trailers', 'POST');
189
+ $request->setLaravelSession(app('session.store'));
190
+ app()->instance('request', $request);
191
+ expect($request->authorize())->toBeFalse();
192
+
193
+ $request->session()->put('api_credential', 'credential');
194
+ expect($request->authorize())->toBeTrue()
195
+ ->and($request->rules())->toHaveKeys(['name', 'type', 'status', 'latitude', 'longitude', 'temperature_max', 'ownership_type', 'capacity', 'attributes']);
196
+
197
+ $update = UpdateTrailerRequest::create('/v1/trailers/trailer_one', 'PATCH');
198
+ $update->setLaravelSession($request->session());
199
+ app()->instance('request', $update);
200
+ expect($update->authorize())->toBeTrue()
201
+ ->and($update->rules())->toHaveKey('name');
202
+ });
203
+
204
+ test('trailer filter executes text numeric state relationship and date branches', function () {
205
+ $connection = fleetOpsTrailerSupportDatabase();
206
+ $connection->table('vehicles')->insert(['uuid' => 'vehicle-filter', 'public_id' => 'vehicle_public', 'company_uuid' => 'company-trailer-support']);
207
+ $connection->getSchemaBuilder()->create('vendors', function ($table) {
208
+ $table->increments('id');
209
+ foreach (['uuid', 'public_id', 'company_uuid', 'name', '_key'] as $column) {
210
+ $table->string($column)->nullable();
211
+ }
212
+ $table->timestamps();
213
+ $table->softDeletes();
214
+ });
215
+ $connection->table('vendors')->insert(['uuid' => 'vendor-filter', 'public_id' => 'vendor_public', 'company_uuid' => 'company-trailer-support']);
216
+ $connection->getSchemaBuilder()->create('categories', function ($table) {
217
+ $table->increments('id');
218
+ foreach (['uuid', 'public_id', 'company_uuid', 'owner_uuid', 'owner_type', 'name', 'for', '_key'] as $column) {
219
+ $table->string($column)->nullable();
220
+ }
221
+ $table->timestamps();
222
+ $table->softDeletes();
223
+ });
224
+ $connection->table('categories')->insert(['uuid' => 'category-filter', 'public_id' => 'category_public', 'company_uuid' => 'company-trailer-support']);
225
+
226
+ Carbon::setTestNow('2026-09-03 12:00:00');
227
+ $builder = new FleetOpsTrailerFilterBuilder();
228
+ $filter = fleetOpsTrailerFilter($builder);
229
+ $filter->queryForInternal();
230
+ $filter->queryForPublic();
231
+ $filter->query('needle');
232
+ $filter->publicId('trailer_public');
233
+ $filter->name('Trailer');
234
+ $filter->code('T-1');
235
+ $filter->trailerType('reefer');
236
+ $filter->status('available');
237
+ $filter->trailerMake('Acme');
238
+ $filter->trailerModel('ColdBox');
239
+ $filter->trailerYear('2026');
240
+ $filter->plateNumber('PLATE');
241
+ $filter->vin('VIN');
242
+ $filter->serialNumber('SERIAL');
243
+ $filter->length('12');
244
+ $filter->axleCount('3');
245
+ $filter->gvwr('30000');
246
+ $filter->payloadCapacity('20000');
247
+ $filter->ownershipType('owned');
248
+ $filter->devicesCount('2');
249
+ $filter->equipmentCount('1');
250
+ foreach (['never_connected', 'online', 'recently_offline', 'offline', 'unknown'] as $state) {
251
+ $filter->connectivityStatus($state);
252
+ }
253
+ $filter->attachmentState('attached');
254
+ $filter->attachmentState('detached');
255
+ $filter->vehicle('vehicle_public');
256
+ $filter->vehicle('missing_vehicle');
257
+ $filter->vendor('vendor_public');
258
+ $filter->device('device_public');
259
+ $filter->createdAt('2026-09-03');
260
+ $filter->updatedAt(['2026-09-01', '2026-09-03']);
261
+ $filter->lastOnlineAt('2026-09-03');
262
+ $filter->purchasedAt('2026-09-03');
263
+ $filter->bodyType('van');
264
+ $filter->refrigerated('true');
265
+ $filter->category('category_public');
266
+
267
+ // Console multi-option filters submit arrays; empty and mixed selections are safe.
268
+ $filter->trailerType(['reefer', 'flatbed']);
269
+ $filter->status([]);
270
+ $filter->status(['', 'available']);
271
+ $filter->connectivityStatus(['online', 'offline']);
272
+ $filter->connectivityStatus([]);
273
+ $filter->attachmentState(['attached', 'detached']);
274
+ $filter->attachmentState('');
275
+ $filter->vehicle([]);
276
+
277
+ expect($builder->called('search'))->toBeTrue()
278
+ ->and($builder->called('searchWhere'))->toBeTrue()
279
+ ->and($builder->called('whereNull'))->toBeTrue()
280
+ ->and($builder->called('whereBetween'))->toBeTrue()
281
+ ->and($builder->called('whereHas'))->toBeTrue()
282
+ ->and($builder->called('whereDoesntHave'))->toBeTrue()
283
+ ->and($builder->called('whereIn'))->toBeTrue()
284
+ ->and($builder->called('whereDate'))->toBeTrue()
285
+ ->and(collect($builder->calls)->contains(fn ($call) => $call[0] === 'whereIn' && $call[1] === 'type' && $call[2] === ['reefer', 'flatbed']))->toBeTrue()
286
+ ->and(collect($builder->calls)->contains(fn ($call) => $call[0] === 'where' && $call[1] === 'status' && $call[2] === 'available'))->toBeTrue()
287
+ ->and(collect($builder->calls)->contains(fn ($call) => $call[0] === 'whereIn' && $call[1] === 'category_uuid' && $call[2] === ['category-filter']))->toBeTrue()
288
+ ->and(collect($builder->calls)->contains(fn ($call) => $call[0] === 'whereIn' && $call[1] === 'connector_uuid' && $call[2] === ['vehicle-filter']))->toBeTrue()
289
+ ->and(collect($builder->calls)->contains(fn ($call) => $call[0] === 'whereHas' && $call[1] === 'devices'))->toBeTrue();
290
+
291
+ // Public requests never resolve relations by UUID; the console does.
292
+ $public = fleetOpsTrailerFilter($publicBuilder = new FleetOpsTrailerFilterBuilder());
293
+ $public->vehicle('vehicle-filter');
294
+ $internal = fleetOpsTrailerFilter($internalBuilder = new FleetOpsTrailerFilterBuilder(), [], true);
295
+ $internal->vehicle('vehicle-filter');
296
+ expect(collect($publicBuilder->calls)->contains(fn ($call) => $call[0] === 'whereIn' && $call[1] === 'connector_uuid' && $call[2] === []))->toBeTrue()
297
+ ->and(collect($internalBuilder->calls)->contains(fn ($call) => $call[0] === 'whereIn' && $call[1] === 'connector_uuid' && $call[2] === ['vehicle-filter']))->toBeTrue();
298
+ Carbon::setTestNow();
299
+ });
300
+
301
+ test('equipment filters cover attachment state type aliases and public targets', function () {
302
+ $connection = fleetOpsTrailerSupportDatabase();
303
+ $connection->getSchemaBuilder()->create('drivers', function ($table) {
304
+ $table->increments('id');
305
+ foreach (['uuid', 'public_id', 'internal_id', 'company_uuid', 'user_uuid', 'name', '_key'] as $column) {
306
+ $table->string($column)->nullable();
307
+ }
308
+ $table->timestamps();
309
+ $table->softDeletes();
310
+ });
311
+ // The Driver model scopes itself to drivers whose user still exists.
312
+ $connection->getSchemaBuilder()->create('users', function ($table) {
313
+ $table->increments('id');
314
+ foreach (['uuid', 'public_id', 'company_uuid', 'name', 'email', '_key'] as $column) {
315
+ $table->string($column)->nullable();
316
+ }
317
+ $table->timestamps();
318
+ $table->softDeletes();
319
+ });
320
+ $connection->table('users')->insert(['uuid' => 'user-equipable', 'company_uuid' => 'company-trailer-support', 'name' => 'Dana']);
321
+ $connection->table('drivers')->insert(['uuid' => 'driver-equipable', 'public_id' => 'driver_equipable', 'user_uuid' => 'user-equipable', 'company_uuid' => 'company-trailer-support']);
322
+ $connection->table('vehicles')->insert(['uuid' => 'vehicle-equipable', 'public_id' => 'vehicle_equipable', 'company_uuid' => 'company-trailer-support']);
323
+ $connection->table('assets')->insert(['uuid' => 'trailer-equipable', 'public_id' => 'trailer_public', 'company_uuid' => 'company-trailer-support', 'asset_class' => 'trailer', 'name' => 'Trailer']);
324
+ $builder = new FleetOpsTrailerFilterBuilder();
325
+ $filter = fleetOpsTrailerFilter($builder);
326
+ $request = Request::create('/int/v1/equipment');
327
+ $request->setRouteResolver(fn () => new FleetOpsTrailerSupportRoute('int/v1/equipment'));
328
+ $request->setLaravelSession(app('session.store'));
329
+ $equipmentFilter = new EquipmentFilter($request);
330
+ $reflection = new ReflectionClass($equipmentFilter);
331
+ $property = $reflection->getParentClass()->getProperty('builder');
332
+ $property->setAccessible(true);
333
+ $property->setValue($equipmentFilter, $builder);
334
+
335
+ $equipmentFilter->attachmentState('attached');
336
+ $equipmentFilter->attachmentState('detached');
337
+ foreach (['vehicle', 'fleet-ops:vehicle', 'trailer', 'fleet-ops:trailer', 'unknown'] as $type) {
338
+ $equipmentFilter->equipableType($type);
339
+ }
340
+ $equipmentFilter->equipable('trailer_public');
341
+ $equipmentFilter->equipable('vehicle-equipable');
342
+ $equipmentFilter->equipable(['trailer_public', 'vehicle_equipable']);
343
+ $equipmentFilter->equipable('driver_equipable');
344
+ $equipmentFilter->equipable('');
345
+ expect($builder->called('whereNotNull'))->toBeTrue()
346
+ ->and($builder->called('whereNull'))->toBeTrue()
347
+ ->and($builder->called('whereRaw'))->toBeTrue()
348
+ ->and(collect($builder->calls)->contains(fn ($call) => $call[0] === 'whereIn' && $call[1] === 'equipable_uuid' && $call[2] === ['trailer-equipable']))->toBeTrue()
349
+ ->and(collect($builder->calls)->contains(fn ($call) => $call[0] === 'whereIn' && $call[1] === 'equipable_uuid' && $call[2] === ['vehicle-equipable']))->toBeTrue()
350
+ ->and(collect($builder->calls)->contains(fn ($call) => $call[0] === 'whereIn' && $call[1] === 'equipable_uuid' && sort($call[2]) && $call[2] === ['trailer-equipable', 'vehicle-equipable']))->toBeTrue()
351
+ ->and(collect($builder->calls)->contains(fn ($call) => $call[0] === 'whereIn' && $call[1] === 'equipable_uuid' && $call[2] === ['driver-equipable']))->toBeTrue();
352
+
353
+ // Public requests only resolve public ids, never UUIDs.
354
+ $publicBuilder = new FleetOpsTrailerFilterBuilder();
355
+ $publicRequest = Request::create('/v1/equipment');
356
+ $publicRequest->setLaravelSession(app('session.store'));
357
+ $publicFilter = new EquipmentFilter($publicRequest);
358
+ $property->setValue($publicFilter, $publicBuilder);
359
+ $publicFilter->equipable('vehicle-equipable');
360
+ expect(collect($publicBuilder->calls)->contains(fn ($call) => $call[0] === 'whereIn' && $call[1] === 'equipable_uuid' && $call[2] === []))->toBeTrue();
361
+ });
362
+
363
+ test('trailer import validates rows and export scopes and maps spreadsheet data', function () {
364
+ $connection = fleetOpsTrailerSupportDatabase();
365
+ $import = new TrailerImport();
366
+ $import->collection(collect([
367
+ new Collection([]),
368
+ new Collection(['name' => 'Cold Trailer', 'type' => 'reefer', 'status' => 'available', 'ignored' => 'value']),
369
+ ]));
370
+ expect($import->imported)->toBe(1)
371
+ ->and($connection->table('assets')->value('asset_class'))->toBe('trailer');
372
+
373
+ expect(fn () => (new TrailerImport())->collection(collect([['type' => 'reefer']])))->toThrow(ValidationException::class)
374
+ ->and(fn () => (new TrailerImport())->collection(collect([['name' => 'Bad', 'type' => 'spaceship']])))->toThrow(ValidationException::class);
375
+
376
+ $trailer = Trailer::withoutGlobalScopes()->first();
377
+ $trailer->forceFill(['public_id' => 'trailer_export', 'refrigerated' => true]);
378
+ $export = new TrailerExport([$trailer->uuid]);
379
+ expect($export->headings())->toContain('Plate Number')
380
+ ->and($export->map($trailer))->toContain('Yes')
381
+ ->and($export->collection())->toHaveCount(1)
382
+ ->and((new TrailerExport())->collection())->toHaveCount(1);
383
+ });
384
+
385
+ test('trailer and connection resources serialize loaded public relationships', function () {
386
+ fleetOpsTrailerSupportDatabase();
387
+ $vehicle = new Vehicle();
388
+ $vehicle->forceFill(['uuid' => 'vehicle-resource', 'public_id' => 'vehicle_public', 'name' => 'Road Tractor', 'plate_number' => 'TRUCK']);
389
+ $trailer = new Trailer();
390
+ $trailer->setAppends([]);
391
+ $trailer->forceFill(['uuid' => 'trailer-resource', 'public_id' => 'trailer_public', 'company_uuid' => 'company-trailer-support', 'name' => 'Reefer', 'type' => 'reefer', 'status' => 'available', 'last_online_at' => now(), 'telematics' => ['last_provider' => 'flespi', 'last_device_uuid' => 'device-uuid']]);
392
+ $connection = new AssetConnection(['relationship_type' => 'towing', 'position' => 1, 'connected_at' => now(), 'source' => 'manual']);
393
+ $connection->forceFill(['uuid' => 'connection-resource', 'public_id' => 'connection_public', 'connector_uuid' => 'vehicle-resource', 'connected_uuid' => 'trailer-resource', 'connector_type' => Vehicle::class, 'connected_type' => Trailer::class]);
394
+ $connection->setRelation('vehicle', $vehicle);
395
+ $connection->setRelation('trailer', $trailer);
396
+ $trailer->setRelation('currentConnection', $connection);
397
+ $trailer->setRelation('connections', collect([$connection]));
398
+ $trailer->setRelation('devices', collect([new Device()]));
399
+ $trailer->setRelation('equipments', collect([new Equipment()]));
400
+ $trailer->setRelation('positions', collect());
401
+
402
+ $request = Request::create('/v1/trailers/trailer_public');
403
+ app()->instance('request', $request);
404
+ $serialized = (new TrailerResource($trailer))->resolve($request);
405
+ $connected = (new AssetConnectionResource($connection))->resolve($request);
406
+ expect($serialized['current_vehicle'])->toBe(['id' => 'vehicle_public', 'name' => 'Road Tractor', 'plate_number' => 'TRUCK'])
407
+ ->and($serialized['vehicle_id'])->toBe('vehicle_public')
408
+ ->and($serialized['current_vehicle_name'])->toBe('Road Tractor')
409
+ ->and($serialized['connections'])->not->toBeNull()
410
+ ->and($serialized['devices'])->not->toBeNull()
411
+ ->and($serialized['equipment'])->not->toBeNull()
412
+ ->and($serialized)->not->toHaveKeys(['equipments', 'uuid', 'category_name'])
413
+ ->and($serialized['telematics'])->toBe(['last_provider' => 'flespi'])
414
+ ->and($connected['vehicle'])->toBe(['id' => 'vehicle_public', 'name' => 'Road Tractor', 'plate_number' => 'TRUCK'])
415
+ ->and($connected['trailer'])->toBe(['id' => 'trailer_public', 'name' => 'Reefer', 'type' => 'reefer', 'plate_number' => null])
416
+ ->and($connected['vehicle_id'])->toBe('vehicle_public')
417
+ ->and($connected['active'])->toBeTrue();
418
+
419
+ // The console receives fully embedded models and internal identifiers.
420
+ $internalRequest = Request::create('/int/v1/trailers/trailer-resource');
421
+ $internalRequest->setRouteResolver(fn () => new FleetOpsTrailerSupportRoute('int/v1/trailers/{id}'));
422
+ app()->instance('request', $internalRequest);
423
+ $internal = (new TrailerResource($trailer))->toArray($internalRequest);
424
+ $internalConnection = (new AssetConnectionResource($connection))->toArray($internalRequest);
425
+ expect($internal['asset_class'])->toBe('trailer')
426
+ ->and($internal['uuid'])->toBe('trailer-resource')
427
+ ->and($internal['current_vehicle'])->toBeInstanceOf(Illuminate\Http\Resources\Json\JsonResource::class)
428
+ ->and($internal['telematics'])->toBe(['last_provider' => 'flespi', 'last_device_uuid' => 'device-uuid'])
429
+ ->and($internalConnection['uuid'])->toBe('connection-resource')
430
+ ->and($internalConnection['connected_type'])->toBe('fleet-ops:trailer')
431
+ ->and($internalConnection['vehicle'])->toBeInstanceOf(Illuminate\Http\Resources\Json\JsonResource::class)
432
+ ->and($internalConnection['trailer'])->toBeInstanceOf(Illuminate\Http\Resources\Json\JsonResource::class);
433
+
434
+ // A connection whose vehicle record is gone still serializes, with an empty vehicle side.
435
+ $orphan = new AssetConnection(['relationship_type' => 'towing', 'position' => 1, 'connected_at' => now()]);
436
+ $orphan->setRelation('vehicle', null);
437
+ $orphaned = new Trailer();
438
+ $orphaned->setAppends([]);
439
+ $orphaned->forceFill(['uuid' => 'trailer-orphan', 'public_id' => 'trailer_orphan', 'company_uuid' => 'company-trailer-support', 'name' => 'Orphan']);
440
+ $orphaned->setRelation('currentConnection', $orphan);
441
+ app()->instance('request', $request);
442
+ $orphanSerialized = (new TrailerResource($orphaned))->resolve($request);
443
+ expect($orphanSerialized['current_vehicle'])->toBeNull()
444
+ ->and($orphanSerialized['vehicle_id'])->toBeNull()
445
+ ->and($orphanSerialized['current_vehicle_name'])->toBeNull()
446
+ ->and($orphanSerialized)->not->toHaveKeys(['connections', 'devices', 'equipment'], 'unloaded collections are omitted from the public API');
447
+
448
+ // The console always receives the embedded collections, even when they were not loaded.
449
+ app()->instance('request', $internalRequest);
450
+ $orphanInternal = (new TrailerResource($orphaned))->resolve($internalRequest);
451
+ expect($orphanInternal['connections'])->toBe([])
452
+ ->and($orphanInternal['devices'])->toBe([])
453
+ ->and($orphanInternal['equipments'])->toBe([])
454
+ ->and($orphanInternal)->not->toHaveKey('equipment');
455
+
456
+ $emptyConnection = new AssetConnection(['disconnected_at' => now()]);
457
+ $emptyConnection->setRelation('vehicle', null);
458
+ $emptyConnection->setRelation('trailer', null);
459
+ $empty = (new AssetConnectionResource($emptyConnection))->toArray($request);
460
+ expect($empty['vehicle'])->toBeNull()
461
+ ->and($empty['trailer'])->toBeNull()
462
+ ->and($empty['active'])->toBeFalse();
463
+ });
464
+
465
+ test('connection and installation models expose every polymorphic relation', function () {
466
+ fleetOpsTrailerSupportDatabase();
467
+ $connection = new AssetConnection();
468
+ $device = new Device();
469
+ $installation = new DeviceInstallation();
470
+ expect($connection->connector())->toBeInstanceOf(MorphTo::class)
471
+ ->and($connection->connected())->toBeInstanceOf(MorphTo::class)
472
+ ->and($connection->vehicle())->toBeInstanceOf(BelongsTo::class)
473
+ ->and($connection->trailer())->toBeInstanceOf(BelongsTo::class)
474
+ ->and($device->installations())->toBeInstanceOf(HasMany::class)
475
+ ->and($installation->device())->toBeInstanceOf(BelongsTo::class)
476
+ ->and($installation->attachable())->toBeInstanceOf(MorphTo::class);
477
+ });
478
+
479
+ test('asset model uses the spatial query builder for location updates', function () {
480
+ expect(class_uses_recursive(Asset::class))->toContain(SpatialTrait::class);
481
+ });
482
+
483
+ test('device installation history closes active rows for attach and detach transitions', function () {
484
+ $connection = fleetOpsTrailerSupportDatabase();
485
+ app()->instance('db.schema', $connection->getSchemaBuilder());
486
+ $connection->table('device_installations')->insert(['uuid' => 'installation-old', 'company_uuid' => 'company-trailer-support', 'device_uuid' => 'device-history', 'active_device_uuid' => 'device-history', 'installed_at' => now()]);
487
+ $device = new FleetOpsTrailerHistoryDevice();
488
+ $device->forceFill(['uuid' => 'device-history', 'company_uuid' => 'company-trailer-support']);
489
+ $trailer = new Trailer();
490
+ $trailer->forceFill(['uuid' => 'trailer-history', 'company_uuid' => 'company-trailer-support', 'name' => 'History Trailer']);
491
+
492
+ $idempotent = new FleetOpsTrailerHistoryDevice();
493
+ $idempotent->forceFill(['uuid' => 'device-idempotent', 'company_uuid' => 'company-trailer-support', 'attachable_type' => Trailer::class, 'attachable_uuid' => 'trailer-history']);
494
+ $foreignTrailer = new Trailer();
495
+ $foreignTrailer->forceFill(['uuid' => 'trailer-foreign', 'company_uuid' => 'company-other']);
496
+
497
+ expect($idempotent->attachTo($trailer))->toBeTrue()
498
+ ->and(fn () => $device->attachTo($foreignTrailer))->toThrow(DomainException::class)
499
+ ->and($device->attachTo($trailer))->toBeFalse()
500
+ ->and($connection->table('device_installations')->count())->toBe(2)
501
+ ->and($connection->table('device_installations')->where('uuid', 'installation-old')->value('removed_at'))->not->toBeNull()
502
+ ->and($device->detach())->toBeFalse()
503
+ ->and($connection->table('device_installations')->whereNull('removed_at')->count())->toBe(0);
504
+ });
505
+
506
+ test('device installation migration supports the base model soft delete scope', function () {
507
+ $migration = file_get_contents(__DIR__ . '/../migrations/2026_09_03_000003_create_device_installations_table.php');
508
+
509
+ expect($migration)->toContain('$table->softDeletes()');
510
+ });
511
+
512
+ test('search controller maps first class trailers into navigation results', function () {
513
+ $connection = fleetOpsTrailerSupportDatabase();
514
+ $connection->table('assets')->insert(['uuid' => 'trailer-search', 'public_id' => 'trailer_search', 'company_uuid' => 'company-trailer-support', 'name' => 'Search Reefer', 'description' => 'Cold', 'type' => 'reefer', 'asset_class' => 'trailer', 'status' => 'available', 'plate_number' => 'COLD-1']);
515
+ $controller = new SearchController();
516
+ $method = new ReflectionMethod($controller, 'searchTrailers');
517
+ $method->setAccessible(true);
518
+ $results = $method->invoke($controller, 'Search', 5);
519
+ $dispatch = new ReflectionMethod($controller, 'searchType');
520
+ $dispatch->setAccessible(true);
521
+ expect($results)->toHaveCount(1)
522
+ ->and($results->first())->toMatchArray(['label' => 'Search Reefer', 'type' => 'Trailer', 'route' => 'console.fleet-ops.management.trailers.index.details'])
523
+ ->and($dispatch->invoke($controller, 'trailers', 'Search', 5))->toHaveCount(1);
524
+ });
@@ -21,6 +21,11 @@ class FleetOpsIndexVehicleResourceProbe extends IndexVehicleResource
21
21
  return $this->compactDevices();
22
22
  }
23
23
 
24
+ public function compactTrailersForTest(): array
25
+ {
26
+ return $this->compactTrailers();
27
+ }
28
+
24
29
  public function locationCoordinatesForTest(): ?string
25
30
  {
26
31
  return $this->locationCoordinates();
@@ -147,9 +152,39 @@ test('index vehicle resource helper methods resolve devices and position labels'
147
152
  ],
148
153
  ]));
149
154
 
155
+ $vehicle->setRelation('currentTrailers', new Collection([
156
+ (object) [
157
+ 'uuid' => 'trailer-uuid',
158
+ 'public_id' => 'trailer_public',
159
+ 'name' => 'Reefer 12',
160
+ 'display_name' => 'Reefer 12',
161
+ 'type' => 'reefer',
162
+ 'plate_number' => 'TRL-12',
163
+ 'status' => 'in_use',
164
+ 'connectivity_status' => 'online',
165
+ 'online' => 1,
166
+ ],
167
+ ]));
168
+
150
169
  $resource = new FleetOpsIndexVehicleResourceProbe($vehicle);
151
170
  fleetopsIndexVehicleResourceRequest(true);
152
171
 
172
+ expect($resource->compactTrailersForTest())->toBe([
173
+ [
174
+ 'id' => 'trailer-uuid',
175
+ 'uuid' => 'trailer-uuid',
176
+ 'public_id' => 'trailer_public',
177
+ 'name' => 'Reefer 12',
178
+ 'display_name' => 'Reefer 12',
179
+ 'type' => 'reefer',
180
+ 'plate_number' => 'TRL-12',
181
+ 'status' => 'in_use',
182
+ 'attachment_state' => 'attached',
183
+ 'connectivity_status' => 'online',
184
+ 'online' => true,
185
+ ],
186
+ ]);
187
+
153
188
  expect($resource->compactDevicesForTest())->toBe([
154
189
  [
155
190
  'id' => 'device-uuid',
@@ -15,6 +15,7 @@ if (!function_exists('Fleetbase\FleetOps\Models\activity')) {
15
15
  use Fleetbase\FleetOps\Models\Driver;
16
16
  use Fleetbase\FleetOps\Models\Equipment;
17
17
  use Fleetbase\FleetOps\Models\Maintenance;
18
+ use Fleetbase\FleetOps\Models\Trailer;
18
19
  use Fleetbase\FleetOps\Models\Vehicle;
19
20
  use Fleetbase\FleetOps\Models\Warranty;
20
21
  use Fleetbase\FleetOps\Support\Utils;
@@ -270,6 +271,27 @@ test('equipment mutators and appended accessors normalize assignment state', fun
270
271
  ->and($equipment->getReplacementCostEstimate())->toBeNull();
271
272
  });
272
273
 
274
+ test('equipment assignment supports first class trailers without changing vehicle compatibility', function () {
275
+ $trailer = new Trailer();
276
+ $trailer->forceFill(['uuid' => 'trailer-uuid', 'name' => 'Reefer 7']);
277
+
278
+ $trailerEquipment = new Equipment([
279
+ 'equipable_type' => 'fleet-ops:trailer',
280
+ 'equipable_uuid' => 'trailer-uuid',
281
+ ]);
282
+ $trailerEquipment->setRelation('equipable', $trailer);
283
+
284
+ $vehicleEquipment = new Equipment([
285
+ 'equipable_type' => 'fleet-ops:vehicle',
286
+ 'equipable_uuid' => 'vehicle-uuid',
287
+ ]);
288
+
289
+ expect($trailerEquipment->equipable_type)->toBe(Utils::getMutationType('fleet-ops:trailer'))
290
+ ->and($trailerEquipment->equipped_to_name)->toBe('Reefer 7')
291
+ ->and($trailerEquipment->is_equipped)->toBeTrue()
292
+ ->and($vehicleEquipment->equipable_type)->toBe(Utils::getMutationType('fleet-ops:vehicle'));
293
+ });
294
+
273
295
  test('equipment query scopes add expected filters', function () {
274
296
  $equipment = new Equipment();
275
297
  $query = new FleetOpsEquipmentQueryFake();
@@ -3,6 +3,7 @@
3
3
  use Fleetbase\FleetOps\Models\Driver;
4
4
  use Fleetbase\FleetOps\Models\Equipment;
5
5
  use Fleetbase\FleetOps\Models\TrackingNumber;
6
+ use Fleetbase\FleetOps\Models\Trailer;
6
7
  use Fleetbase\FleetOps\Models\Vehicle;
7
8
  use Fleetbase\FleetOps\Models\VehicleDevice;
8
9
  use Illuminate\Database\ConnectionResolver;
@@ -114,16 +115,19 @@ test('vehicle devices fall back to a direct vehicle lookup by uuid', function ()
114
115
  ->and($seen)->toBe(['vehicle_lookupone']);
115
116
  });
116
117
 
117
- test('unrecognised equipable types are stored verbatim', function () {
118
+ test('known equipable aliases are normalized and unrecognised types are stored verbatim', function () {
118
119
  $equipment = new Equipment();
119
120
 
120
121
  // Known short names map onto their fully qualified model class
121
122
  $equipment->equipable_type = 'vehicle';
122
123
  expect($equipment->getAttributes()['equipable_type'])->toContain('Vehicle');
123
124
 
124
- // Anything else is kept as given rather than silently dropped
125
125
  $equipment->equipable_type = 'trailer';
126
- expect($equipment->getAttributes()['equipable_type'])->toBe('trailer');
126
+ expect($equipment->getAttributes()['equipable_type'])->toBe(Trailer::class);
127
+
128
+ // Anything else is kept as given rather than silently dropped
129
+ $equipment->equipable_type = 'cargo-bay';
130
+ expect($equipment->getAttributes()['equipable_type'])->toBe('cargo-bay');
127
131
  });
128
132
 
129
133
  test('tracking number generation redraws when the first number is already taken', function () {