@fleetbase/fleetops-engine 0.6.63 → 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 (289) hide show
  1. package/RELEASE.md +7 -6
  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/Concerns/ResolvesPublicRelationFields.php +29 -3
  238. package/server/src/Http/Resources/v1/Device.php +3 -1
  239. package/server/src/Http/Resources/v1/Equipment.php +3 -1
  240. package/server/src/Http/Resources/v1/Fleet.php +3 -1
  241. package/server/src/Http/Resources/v1/Index/Vehicle.php +10 -0
  242. package/server/src/Http/Resources/v1/Trailer.php +199 -0
  243. package/server/src/Http/Resources/v1/Vehicle.php +2 -0
  244. package/server/src/Http/Resources/v1/VehicleWithoutDriver.php +3 -0
  245. package/server/src/Imports/TrailerImport.php +33 -0
  246. package/server/src/Models/Asset.php +39 -10
  247. package/server/src/Models/AssetConnection.php +43 -0
  248. package/server/src/Models/Device.php +39 -8
  249. package/server/src/Models/DeviceInstallation.php +27 -0
  250. package/server/src/Models/Equipment.php +2 -0
  251. package/server/src/Models/Trailer.php +181 -0
  252. package/server/src/Models/Vehicle.php +13 -0
  253. package/server/src/Providers/FleetOpsServiceProvider.php +4 -2
  254. package/server/src/Support/Ai/FleetOpsAiQueryResources.php +22 -0
  255. package/server/src/Support/Reporting/FleetOpsReportSchema.php +38 -0
  256. package/server/src/Support/Telematics/TelematicService.php +35 -0
  257. package/server/src/Support/TrailerSummary.php +35 -0
  258. package/server/src/routes.php +29 -0
  259. package/server/tests/CompactResourceSerializationTest.php +36 -0
  260. package/server/tests/ControllerFilterContractsTest.php +29 -0
  261. package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +30 -13
  262. package/server/tests/Feature/Http/Api/TrailerControllerLifecycleTest.php +640 -0
  263. package/server/tests/Feature/Http/Api/VehiclePublicContractTest.php +23 -0
  264. package/server/tests/Feature/Http/Internal/DeviceControllerContractsTest.php +20 -5
  265. package/server/tests/Feature/Http/Internal/LiveControllerEndpointsTest.php +19 -0
  266. package/server/tests/ModelAccessorContractsTest.php +10 -0
  267. package/server/tests/OrderFilterExecutionTest.php +5 -0
  268. package/server/tests/PublicApiControllerInputContractsTest.php +11 -0
  269. package/server/tests/PublicFleetOpsRestEndpointsTest.php +15 -3
  270. package/server/tests/ReportSchemaContractsTest.php +4 -0
  271. package/server/tests/TelematicsHardeningTest.php +4 -2
  272. package/server/tests/TrailerSupportCoverageTest.php +524 -0
  273. package/server/tests/Unit/Http/Resources/FleetResourceTest.php +17 -0
  274. package/server/tests/Unit/Http/Resources/IndexVehicleResourceTest.php +35 -0
  275. package/server/tests/Unit/Models/EquipmentTest.php +22 -0
  276. package/server/tests/Unit/Models/ModelLookupFallbackSeamsTest.php +7 -3
  277. package/server/tests/Unit/Models/TrailerTest.php +204 -0
  278. package/server/tests/Unit/Support/Telematics/TelematicServiceTelemetryTest.php +93 -0
  279. package/server/tests/VehicleControllerHelperContractsTest.php +14 -0
  280. package/server/tests/VehicleResourceContractsTest.php +39 -0
  281. package/translations/ar-ae.yml +396 -0
  282. package/translations/bg-bg.yaml +424 -0
  283. package/translations/en-us.yaml +423 -0
  284. package/translations/es-pa.yaml +425 -0
  285. package/translations/fr-fr.yaml +424 -0
  286. package/translations/mn-mn.yaml +424 -0
  287. package/translations/pt-br.yaml +424 -0
  288. package/translations/ru-ru.yaml +424 -0
  289. 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
+ });
@@ -144,3 +144,20 @@ test('fleet resource accepts the explicit nested expansion spelling', function (
144
144
  expect($subFleet->relationLoaded('drivers'))->toBeTrue()
145
145
  ->and($subFleet->relationLoaded('vehicles'))->toBeFalse();
146
146
  });
147
+
148
+ test('fleet resource ignores expansions that describe a parent resource', function () {
149
+ fleetopsFleetResourceBoot();
150
+
151
+ $fleet = Fleet::where('uuid', 'fleet-parent-1')->first();
152
+
153
+ // Rendered inside another response, the request's `with` is the parent's
154
+ // list. `payload` is not a fleet relation and used to raise from load();
155
+ // the fleet's own relation in the same list still loads.
156
+ $request = Request::create('/v1/orders', 'GET', ['with' => ['payload', 'drivers']]);
157
+ $resolved = (new FleetResource($fleet))->resolve($request);
158
+
159
+ expect($resolved['name'])->toBe('Parent Fleet')
160
+ ->and($resolved)->not->toHaveKey('payload')
161
+ ->and($fleet->relationLoaded('payload'))->toBeFalse()
162
+ ->and($fleet->relationLoaded('drivers'))->toBeTrue();
163
+ });
@@ -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();