@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
@@ -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 () {
@@ -0,0 +1,204 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Events\TrailerLocationChanged;
4
+ use Fleetbase\FleetOps\Models\AssetConnection;
5
+ use Fleetbase\FleetOps\Models\Device;
6
+ use Fleetbase\FleetOps\Models\Equipment;
7
+ use Fleetbase\FleetOps\Models\Maintenance;
8
+ use Fleetbase\FleetOps\Models\MaintenanceSchedule;
9
+ use Fleetbase\FleetOps\Models\Position;
10
+ use Fleetbase\FleetOps\Models\Trailer;
11
+ use Fleetbase\FleetOps\Models\Vehicle;
12
+ use Fleetbase\FleetOps\Models\WorkOrder;
13
+ use Illuminate\Database\ConnectionResolver;
14
+ use Illuminate\Database\Eloquent\Model as EloquentModel;
15
+ use Illuminate\Database\Eloquent\Relations\BelongsTo;
16
+ use Illuminate\Database\Eloquent\Relations\HasMany;
17
+ use Illuminate\Database\Eloquent\Relations\HasManyThrough;
18
+ use Illuminate\Database\Eloquent\Relations\HasOne;
19
+ use Illuminate\Database\SQLiteConnection;
20
+ use Illuminate\Support\Carbon;
21
+
22
+ // Model identifier hooks bind to the dispatcher present when the class boots.
23
+ if (!EloquentModel::getEventDispatcher()) {
24
+ EloquentModel::setEventDispatcher(new Illuminate\Events\Dispatcher());
25
+ }
26
+
27
+ function fleetOpsTrailerUseInMemoryConnection(): SQLiteConnection
28
+ {
29
+ $pdo = new PDO('sqlite::memory:');
30
+ $pdo->sqliteCreateFunction('ST_PointFromText', fn ($wkt, $srid = 0, $axisOrder = null) => $wkt);
31
+ $pdo->sqliteCreateFunction('ST_GeomFromText', fn ($wkt, $srid = 0, $axisOrder = null) => $wkt);
32
+ $connection = new SQLiteConnection($pdo);
33
+ $connection->statement('create table assets (
34
+ id integer primary key autoincrement,
35
+ uuid varchar(64),
36
+ public_id varchar(64) null unique,
37
+ company_uuid varchar(64),
38
+ name varchar(255),
39
+ code varchar(64) null,
40
+ type varchar(64) null,
41
+ asset_class varchar(64) null,
42
+ status varchar(64) null,
43
+ slug varchar(255) null,
44
+ last_online_at datetime null,
45
+ created_at datetime null,
46
+ updated_at datetime null,
47
+ deleted_at datetime null
48
+ )');
49
+ $connection->statement('create table positions (
50
+ id integer primary key autoincrement,
51
+ uuid varchar(64), public_id varchar(64), company_uuid varchar(64),
52
+ subject_uuid varchar(64), subject_type varchar(255), destination_uuid varchar(64),
53
+ coordinates text, heading varchar(64), bearing varchar(64), speed varchar(64), altitude varchar(64),
54
+ created_at datetime null, updated_at datetime null, deleted_at datetime null
55
+ )');
56
+
57
+ $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
58
+ $resolver->setDefaultConnection('mysql');
59
+ EloquentModel::setConnectionResolver($resolver);
60
+ app()->instance('responsecache', new class {
61
+ public function __call($method, $arguments)
62
+ {
63
+ return null;
64
+ }
65
+ });
66
+
67
+ return $connection;
68
+ }
69
+
70
+ test('trailer creation enforces its discriminator defaults and public id namespace', function () {
71
+ fleetOpsTrailerUseInMemoryConnection();
72
+
73
+ $trailer = new Trailer([
74
+ 'company_uuid' => 'company-a',
75
+ 'name' => 'Reefer 12',
76
+ 'asset_class' => 'vehicle',
77
+ ]);
78
+ Trailer::getEventDispatcher()->until('eloquent.creating: ' . Trailer::class, $trailer);
79
+
80
+ expect($trailer->asset_class)->toBe(Trailer::ASSET_CLASS)
81
+ ->and($trailer->status)->toBe('available')
82
+ ->and($trailer->public_id)->toStartWith('trailer_')
83
+ ->and($trailer->uuid)->not->toBeEmpty();
84
+
85
+ EloquentModel::unsetEventDispatcher();
86
+ });
87
+
88
+ test('trailer global scope never returns another asset class', function () {
89
+ $connection = fleetOpsTrailerUseInMemoryConnection();
90
+ $connection->table('assets')->insert([
91
+ ['uuid' => 'trailer-uuid', 'public_id' => 'trailer_public', 'company_uuid' => 'company-a', 'name' => 'Trailer', 'asset_class' => 'trailer', 'status' => 'available'],
92
+ ['uuid' => 'vehicle-uuid', 'public_id' => 'asset_public', 'company_uuid' => 'company-a', 'name' => 'Vehicle-like asset', 'asset_class' => 'vehicle', 'status' => 'available'],
93
+ ]);
94
+
95
+ expect(Trailer::query()->pluck('uuid')->all())->toBe(['trailer-uuid'])
96
+ ->and(Trailer::query()->toSql())->toContain('asset_class');
97
+ });
98
+
99
+ test('trailer exposes first class connection device equipment maintenance and position relations', function () {
100
+ fleetOpsTrailerUseInMemoryConnection();
101
+ $trailer = new Trailer();
102
+
103
+ expect($trailer->currentConnection())->toBeInstanceOf(HasOne::class)
104
+ ->and($trailer->currentConnection()->getRelated())->toBeInstanceOf(AssetConnection::class)
105
+ ->and($trailer->connections())->toBeInstanceOf(HasMany::class)
106
+ ->and($trailer->devices()->getRelated())->toBeInstanceOf(Device::class)
107
+ ->and($trailer->equipments()->getRelated())->toBeInstanceOf(Equipment::class)
108
+ ->and($trailer->positions()->getRelated())->toBeInstanceOf(Position::class)
109
+ ->and($trailer->maintenances()->getRelated())->toBeInstanceOf(Maintenance::class)
110
+ ->and($trailer->maintenanceSchedules()->getRelated())->toBeInstanceOf(MaintenanceSchedule::class)
111
+ ->and($trailer->workOrders()->getRelated())->toBeInstanceOf(WorkOrder::class);
112
+ });
113
+
114
+ test('asset connections preserve effective dated vehicle and trailer identities', function () {
115
+ fleetOpsTrailerUseInMemoryConnection();
116
+ $connection = new AssetConnection([
117
+ 'relationship_type' => 'towing',
118
+ 'position' => 2,
119
+ 'connected_at' => '2026-09-03 12:00:00',
120
+ 'disconnected_at' => null,
121
+ ]);
122
+
123
+ expect($connection->vehicle())->toBeInstanceOf(BelongsTo::class)
124
+ ->and($connection->vehicle()->getRelated())->toBeInstanceOf(Vehicle::class)
125
+ ->and($connection->trailer())->toBeInstanceOf(BelongsTo::class)
126
+ ->and($connection->trailer()->getRelated())->toBeInstanceOf(Trailer::class)
127
+ ->and($connection->relationship_type)->toBe('towing')
128
+ ->and($connection->position)->toBe(2)
129
+ ->and($connection->disconnected_at)->toBeNull();
130
+ });
131
+
132
+ test('vehicles expose current and historical trailer relationships', function () {
133
+ fleetOpsTrailerUseInMemoryConnection();
134
+ $vehicle = new Vehicle();
135
+
136
+ expect($vehicle->trailerConnections())->toBeInstanceOf(HasMany::class)
137
+ ->and($vehicle->trailerConnections()->getRelated())->toBeInstanceOf(AssetConnection::class)
138
+ ->and($vehicle->currentTrailers())->toBeInstanceOf(HasManyThrough::class)
139
+ ->and($vehicle->currentTrailers()->getRelated())->toBeInstanceOf(Trailer::class);
140
+ });
141
+
142
+ test('trailer connectivity is distinct from lifecycle and attachment state', function () {
143
+ Carbon::setTestNow('2026-09-03 12:00:00');
144
+
145
+ $trailer = new Trailer(['status' => 'maintenance']);
146
+ expect($trailer->connectivity_status)->toBe('never_connected')
147
+ ->and($trailer->status)->toBe('maintenance')
148
+ ->and($trailer->attachment_state)->toBe('detached');
149
+
150
+ $vehicle = new Vehicle();
151
+ $connection = new AssetConnection();
152
+ $connection->setRelation('vehicle', $vehicle);
153
+ $trailer->setRelation('currentConnection', $connection);
154
+ expect($trailer->current_vehicle)->toBe($vehicle)
155
+ ->and($trailer->attachment_state)->toBe('attached');
156
+
157
+ $trailer->last_online_at = Carbon::now()->subMinutes(5);
158
+ expect($trailer->connectivity_status)->toBe('online');
159
+
160
+ $trailer->last_online_at = Carbon::now()->subHours(2);
161
+ expect($trailer->connectivity_status)->toBe('recently_offline');
162
+
163
+ $trailer->last_online_at = Carbon::now()->subDays(2);
164
+ expect($trailer->connectivity_status)->toBe('offline');
165
+
166
+ Carbon::setTestNow();
167
+ });
168
+
169
+ test('trailer positions require coordinates and preserve model or scalar destinations', function () {
170
+ fleetOpsTrailerUseInMemoryConnection();
171
+ $trailer = new Trailer();
172
+ $trailer->forceFill(['uuid' => 'trailer-position', 'company_uuid' => 'company-a']);
173
+ expect($trailer->createPosition())->toBeNull();
174
+
175
+ $vehicle = new Vehicle();
176
+ $vehicle->forceFill(['uuid' => 'vehicle-destination']);
177
+ $fromModel = $trailer->createPosition(['latitude' => 1.3, 'longitude' => 103.8, 'heading' => 90, 'bearing' => 91, 'speed' => 40, 'altitude' => 12], $vehicle);
178
+ $fromScalar = $trailer->createPosition(['latitude' => 1.4, 'longitude' => 103.9], 'place-destination');
179
+ expect($fromModel)->toBeInstanceOf(Position::class)
180
+ ->and($fromModel->destination_uuid)->toBe('vehicle-destination')
181
+ ->and($fromScalar->destination_uuid)->toBe('place-destination');
182
+ });
183
+
184
+ test('trailer location broadcasts use tenant and trailer channels without exposing internal ids', function () {
185
+ Carbon::setTestNow('2026-09-03 12:00:00');
186
+
187
+ $trailer = new Trailer();
188
+ $trailer->forceFill([
189
+ 'uuid' => 'internal-trailer-uuid',
190
+ 'public_id' => 'trailer_public',
191
+ 'company_uuid' => 'company-a',
192
+ 'speed' => 42,
193
+ 'heading' => 180,
194
+ ]);
195
+ $event = new TrailerLocationChanged($trailer, ['source' => 'telematics']);
196
+
197
+ expect(array_map(fn ($channel) => $channel->name, $event->broadcastOn()))
198
+ ->toBe(['company.company-a', 'trailer.trailer_public', 'trailer.internal-trailer-uuid'])
199
+ ->and($event->broadcastWith()['data']['id'])->toBe('trailer_public')
200
+ ->and($event->broadcastWith()['data'])->not->toHaveKey('uuid')
201
+ ->and($event->broadcastWith()['data']['additionalData'])->toBe(['source' => 'telematics']);
202
+
203
+ Carbon::setTestNow();
204
+ });
@@ -6,6 +6,7 @@ use Fleetbase\FleetOps\Models\DeviceEvent;
6
6
  use Fleetbase\FleetOps\Models\Position;
7
7
  use Fleetbase\FleetOps\Models\Sensor;
8
8
  use Fleetbase\FleetOps\Models\Telematic;
9
+ use Fleetbase\FleetOps\Models\Trailer;
9
10
  use Fleetbase\FleetOps\Models\Vehicle;
10
11
  use Fleetbase\FleetOps\Support\Telematics\TelematicProviderRegistry;
11
12
  use Fleetbase\FleetOps\Support\Telematics\TelematicService;
@@ -105,6 +106,36 @@ class FleetOpsTelematicServiceTelemetryVehicleFake extends Vehicle
105
106
  }
106
107
  }
107
108
 
109
+ class FleetOpsTelematicServiceTelemetryTrailerFake extends Trailer
110
+ {
111
+ public int $saveCount = 0;
112
+
113
+ public function __construct(array $attributes = [])
114
+ {
115
+ $this->attributes = $attributes;
116
+ }
117
+
118
+ public function getAttribute($key)
119
+ {
120
+ return $this->attributes[$key] ?? null;
121
+ }
122
+
123
+ public function setAttribute($key, $value)
124
+ {
125
+ $this->attributes[$key] = $value;
126
+
127
+ return $this;
128
+ }
129
+
130
+ public function save(array $options = [])
131
+ {
132
+ $this->saveCount++;
133
+ $this->exists = true;
134
+
135
+ return true;
136
+ }
137
+ }
138
+
108
139
  class FleetOpsTelematicServiceTelemetryProviderFake implements TelematicProviderInterface
109
140
  {
110
141
  public array $normalizedSensors = [];
@@ -317,6 +348,68 @@ test('telematic service skips event positions and vehicle updates when event loc
317
348
  ->and($GLOBALS['fleetops_telematic_service_broadcasts'])->toBe([]);
318
349
  });
319
350
 
351
+ test('telematic service updates trailer telemetry and rejects stale trailer snapshots', function () {
352
+ Carbon::setTestNow(Carbon::parse('2026-09-03 12:00:00'));
353
+ $GLOBALS['fleetops_telematic_service_broadcasts'] = [];
354
+
355
+ $service = new FleetOpsTelematicServiceTelemetryServiceFake();
356
+ $trailer = new FleetOpsTelematicServiceTelemetryTrailerFake([
357
+ 'uuid' => 'trailer-uuid',
358
+ 'public_id' => 'trailer_public',
359
+ 'company_uuid' => 'company-uuid',
360
+ 'telematics' => ['last_event_at' => '2026-09-03T11:00:00.000000Z'],
361
+ ]);
362
+ $device = new FleetOpsTelematicServiceTelemetryDeviceFake(['uuid' => 'device-uuid', 'device_id' => 'device-1']);
363
+ $device->setRelation('attachable', $trailer);
364
+ $event = new FleetOpsTelematicServiceTelemetryEventFake([
365
+ 'uuid' => 'event-new',
366
+ 'public_id' => 'event_new',
367
+ 'device_uuid' => 'device-uuid',
368
+ 'event_type' => 'telemetry_update',
369
+ 'provider' => 'samsara',
370
+ 'occurred_at' => Carbon::parse('2026-09-03 11:30:00'),
371
+ ]);
372
+
373
+ fleetOpsTelematicServiceTelemetryInvoke($service, 'applyDeviceEventTelemetry', [$event, [
374
+ 'location' => ['latitude' => 47.918, 'longitude' => 106.917],
375
+ 'speed' => 42,
376
+ 'heading' => 180,
377
+ 'odometer' => 32000,
378
+ 'reefer_engine_hours' => 800,
379
+ ], $device, true, null]);
380
+
381
+ expect($trailer->saveCount)->toBe(1)
382
+ ->and($trailer->location)->toBeInstanceOf(SpatialPoint::class)
383
+ ->and($trailer->speed)->toBe(42)
384
+ ->and($trailer->heading)->toBe(180)
385
+ ->and($trailer->odometer)->toBe(32000)
386
+ ->and($trailer->reefer_engine_hours)->toBe(800)
387
+ ->and($trailer->telematics['last_event_uuid'])->toBe('event-new')
388
+ ->and($trailer->telematics['last_provider'])->toBe('samsara')
389
+ ->and($GLOBALS['fleetops_telematic_service_broadcasts'])->toHaveCount(1);
390
+
391
+ $staleEvent = new FleetOpsTelematicServiceTelemetryEventFake([
392
+ 'uuid' => 'event-stale',
393
+ 'public_id' => 'event_stale',
394
+ 'device_uuid' => 'device-uuid',
395
+ 'event_type' => 'telemetry_update',
396
+ 'provider' => 'samsara',
397
+ 'occurred_at' => Carbon::parse('2026-09-03 10:30:00'),
398
+ ]);
399
+
400
+ fleetOpsTelematicServiceTelemetryInvoke($service, 'applyDeviceEventTelemetry', [$staleEvent, [
401
+ 'location' => ['latitude' => 1, 'longitude' => 1],
402
+ 'speed' => 5,
403
+ ], $device, true, null]);
404
+
405
+ expect($trailer->saveCount)->toBe(1)
406
+ ->and($trailer->speed)->toBe(42)
407
+ ->and($trailer->telematics['last_event_uuid'])->toBe('event-new')
408
+ ->and($GLOBALS['fleetops_telematic_service_broadcasts'])->toHaveCount(1);
409
+
410
+ Carbon::setTestNow();
411
+ });
412
+
320
413
  test('telematic service stores list snapshot sensors and skips provider failures', function () {
321
414
  $service = new FleetOpsTelematicServiceTelemetryServiceFake();
322
415
  $provider = new FleetOpsTelematicServiceTelemetryProviderFake();
@@ -1,5 +1,6 @@
1
1
  <?php
2
2
 
3
+ use Fleetbase\FleetOps\Exceptions\DeviceAlreadyAttachedException;
3
4
  use Fleetbase\FleetOps\Http\Controllers\Internal\v1\VehicleController;
4
5
  use Fleetbase\FleetOps\Models\Device;
5
6
  use Fleetbase\FleetOps\Models\Driver;
@@ -485,4 +486,17 @@ test('vehicle controller reports detach lookup failures and attach exceptions',
485
486
  $response = $controller->attachDevice(new Request(['device' => 'device-public']), 'vehicle-public');
486
487
  expect($response->getData(true))->toBe(['error' => 'Unable to attach device to vehicle. Please try again or contact support.'])
487
488
  ->and($response->getStatusCode())->toBe(500);
489
+
490
+ // A device installed on another asset is a conflict the operator must resolve by detaching first
491
+ $controller = new FleetOpsVehicleControllerProbe();
492
+ $controller->vehicle = new FleetOpsVehicleEndpointFake();
493
+ $controller->device = new class extends FleetOpsVehicleDeviceFake {
494
+ public function attachTo(Fleetbase\Models\Model $attachable): bool
495
+ {
496
+ throw DeviceAlreadyAttachedException::for('Truck 2');
497
+ }
498
+ };
499
+ $response = $controller->attachDevice(new Request(['device' => 'device-public']), 'vehicle-public');
500
+ expect($response->getData(true))->toBe(['error' => 'Device is already attached to Truck 2. Detach it before attaching it elsewhere.'])
501
+ ->and($response->getStatusCode())->toBe(409);
488
502
  });
@@ -311,3 +311,42 @@ test('vehicle without driver resource omits driver relationships and serializes
311
311
  ])
312
312
  ->and($webhook)->not->toHaveKey('driver');
313
313
  });
314
+
315
+ /**
316
+ * Resolve the request's `with` list the way a nested resource does.
317
+ *
318
+ * @return array<int, string>
319
+ */
320
+ function fleetopsVehicleResourceRequestedRelations(mixed $resource, array $query): array
321
+ {
322
+ $request = Request::create('/api/v1/fleet-ops/orders', 'GET', $query);
323
+ $method = new ReflectionMethod(VehicleResource::class, 'requestedRelations');
324
+ $method->setAccessible(true);
325
+
326
+ return $method->invoke(new VehicleResource($resource), $request);
327
+ }
328
+
329
+ test('requested relations keep only what the wrapped model defines', function () {
330
+ $vehicle = new class(['uuid' => 'vehicle-uuid']) extends FleetOpsVehicleResourceFixture {
331
+ public function driver(): void
332
+ {
333
+ }
334
+ };
335
+
336
+ // Nested under an Order, the request's `with` describes the order:
337
+ // `payload` and `driverAssigned.vehicle` are not vehicle relations and must
338
+ // never reach this model's loader. The vehicle's own relations still pass,
339
+ // including a nested path whose root the vehicle defines.
340
+ expect(fleetopsVehicleResourceRequestedRelations($vehicle, ['with' => ['payload', 'driverAssigned.vehicle', 'driver', 'driver.user', '']]))->toBe(['driver', 'driver.user'])
341
+ ->and(fleetopsVehicleResourceRequestedRelations($vehicle, ['with' => 'payload,driver']))->toBe(['driver'])
342
+ ->and(fleetopsVehicleResourceRequestedRelations($vehicle, ['with' => ['.driver']]))->toBe([])
343
+ ->and(fleetopsVehicleResourceRequestedRelations($vehicle, []))->toBe([]);
344
+ });
345
+
346
+ test('requested relations are empty when the resource cannot load any', function () {
347
+ // Webhook fixtures and compact serializers hand the resource plain data;
348
+ // there is no loader to feed, so nothing is requested from it.
349
+ expect(fleetopsVehicleResourceRequestedRelations(['uuid' => 'vehicle-uuid'], ['with' => ['driver']]))->toBe([])
350
+ ->and(fleetopsVehicleResourceRequestedRelations((object) ['uuid' => 'vehicle-uuid'], ['with' => ['driver']]))->toBe([])
351
+ ->and(fleetopsVehicleResourceRequestedRelations(fleetopsVehicleResourceFixture(), ['with' => ['driver']]))->toBe([]);
352
+ });