@fleetbase/fleetops-engine 0.6.64 → 0.6.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. package/RELEASE.md +7 -5
  2. package/addon/components/attachment-list.hbs +65 -0
  3. package/addon/components/attachment-list.js +8 -0
  4. package/addon/components/cell/attached-vehicle.hbs +1 -1
  5. package/addon/components/cell/attached-vehicle.js +3 -3
  6. package/addon/components/cell/driver-identity.js +4 -4
  7. package/addon/components/cell/driver-name.hbs +1 -1
  8. package/addon/components/cell/translated-value.hbs +9 -0
  9. package/addon/components/cell/translated-value.js +57 -0
  10. package/addon/components/contact/card.hbs +58 -0
  11. package/addon/components/contact/card.js +19 -0
  12. package/addon/components/contact/form.hbs +1 -1
  13. package/addon/components/contact/panel-header.hbs +1 -1
  14. package/addon/components/customer/form.hbs +2 -2
  15. package/addon/components/device/manager.hbs +26 -28
  16. package/addon/components/device/manager.js +28 -2
  17. package/addon/components/device/panel-tabs/vehicle.hbs +13 -13
  18. package/addon/components/device/panel-tabs/vehicle.js +8 -0
  19. package/addon/components/driver/card.hbs +24 -13
  20. package/addon/components/driver/card.js +20 -6
  21. package/addon/components/driver/form.hbs +3 -3
  22. package/addon/components/driver/panel-header.hbs +2 -2
  23. package/addon/components/driver/pill.hbs +2 -2
  24. package/addon/components/equipment/form.js +11 -4
  25. package/addon/components/equipment/pill.hbs +20 -0
  26. package/addon/components/equipment/pill.js +3 -0
  27. package/addon/components/filter/multi-model.hbs +19 -0
  28. package/addon/components/filter/multi-model.js +90 -0
  29. package/addon/components/fleet/driver-listing.hbs +1 -1
  30. package/addon/components/fleet/vehicle-listing.hbs +1 -1
  31. package/addon/components/fleet-panel/driver-listing.hbs +1 -1
  32. package/addon/components/fleet-panel/vehicle-listing.hbs +1 -1
  33. package/addon/components/layout/fleet-ops-sidebar.js +9 -7
  34. package/addon/components/maintenance/form.js +12 -4
  35. package/addon/components/maintenance-schedule/form.js +13 -7
  36. package/addon/components/map/leaflet-live-map.hbs +52 -0
  37. package/addon/components/map/order-list-overlay/order.hbs +4 -4
  38. package/addon/components/modals/attach-equipment.hbs +22 -0
  39. package/addon/components/modals/attach-equipment.js +3 -0
  40. package/addon/components/modals/attach-telematic-device.hbs +13 -8
  41. package/addon/components/modals/attach-telematic-device.js +11 -1
  42. package/addon/components/modals/attach-trailer.hbs +30 -0
  43. package/addon/components/modals/attach-trailer.js +3 -0
  44. package/addon/components/modals/contact-form.hbs +2 -2
  45. package/addon/components/modals/convert-customer-to-vendor.hbs +1 -1
  46. package/addon/components/modals/driver-form.hbs +2 -2
  47. package/addon/components/modals/select-trailer.hbs +35 -0
  48. package/addon/components/modals/select-trailer.js +3 -0
  49. package/addon/components/modals/vehicle-form.hbs +2 -2
  50. package/addon/components/orchestrator/resource-panel.hbs +2 -2
  51. package/addon/components/order/customer-avatar-stack.hbs +2 -2
  52. package/addon/components/order/details/detail.hbs +4 -4
  53. package/addon/components/order/details/payload.hbs +2 -2
  54. package/addon/components/order/kanban-card.hbs +4 -4
  55. package/addon/components/order-list-overlay/order.hbs +4 -4
  56. package/addon/components/trailer/card.hbs +68 -0
  57. package/addon/components/trailer/card.js +22 -0
  58. package/addon/components/trailer/details/connections.hbs +57 -0
  59. package/addon/components/trailer/details/connections.js +59 -0
  60. package/addon/components/trailer/details/equipment.hbs +25 -0
  61. package/addon/components/trailer/details/equipment.js +52 -0
  62. package/addon/components/trailer/details/maintenance-history.hbs +47 -0
  63. package/addon/components/trailer/details/maintenance-history.js +40 -0
  64. package/addon/components/trailer/details/schedules.hbs +45 -0
  65. package/addon/components/trailer/details/schedules.js +40 -0
  66. package/addon/components/trailer/details/work-orders.hbs +47 -0
  67. package/addon/components/trailer/details/work-orders.js +40 -0
  68. package/addon/components/trailer/details.hbs +399 -0
  69. package/addon/components/trailer/details.js +118 -0
  70. package/addon/components/trailer/form.hbs +447 -0
  71. package/addon/components/trailer/form.js +165 -0
  72. package/addon/components/trailer/panel-header.hbs +45 -0
  73. package/addon/components/trailer/panel-header.js +3 -0
  74. package/addon/components/trailer/pill.hbs +21 -0
  75. package/addon/components/trailer/pill.js +3 -0
  76. package/addon/components/vehicle/card.hbs +42 -11
  77. package/addon/components/vehicle/card.js +31 -0
  78. package/addon/components/vehicle/details/equipment.hbs +25 -0
  79. package/addon/components/vehicle/details/equipment.js +55 -0
  80. package/addon/components/vehicle/details/trailers.hbs +35 -0
  81. package/addon/components/vehicle/details/trailers.js +55 -0
  82. package/addon/components/vehicle/form.hbs +2 -2
  83. package/addon/components/vehicle/panel-header.hbs +2 -2
  84. package/addon/components/vehicle/pill.hbs +2 -2
  85. package/addon/components/vendor/card.hbs +58 -0
  86. package/addon/components/vendor/card.js +6 -0
  87. package/addon/components/vendor/panel-header.hbs +2 -2
  88. package/addon/components/vendor/personnel-manager.hbs +2 -2
  89. package/addon/components/work-order/form.js +12 -4
  90. package/addon/controllers/connectivity/devices/index/details/vehicle.js +11 -0
  91. package/addon/controllers/connectivity/devices/index/details.js +3 -3
  92. package/addon/controllers/connectivity/devices/index.js +1 -0
  93. package/addon/controllers/connectivity/telematics/details/attachments.js +28 -47
  94. package/addon/controllers/connectivity/telematics/details/devices.js +1 -0
  95. package/addon/controllers/management/contacts/customers.js +4 -0
  96. package/addon/controllers/management/contacts/index.js +32 -0
  97. package/addon/controllers/management/trailers/index/details.js +119 -0
  98. package/addon/controllers/management/trailers/index/edit.js +67 -0
  99. package/addon/controllers/management/trailers/index/new.js +43 -0
  100. package/addon/controllers/management/trailers/index.js +599 -0
  101. package/addon/controllers/management/vehicles/index/details.js +16 -0
  102. package/addon/controllers/management/vehicles/index.js +78 -4
  103. package/addon/controllers/management/vendors/index.js +32 -0
  104. package/addon/controllers/operations/orders/index.js +3 -1
  105. package/addon/controllers/settings/custom-fields.js +8 -0
  106. package/addon/extension.js +7 -0
  107. package/addon/helpers/placeholder-image.js +6 -0
  108. package/addon/helpers/resource-image.js +10 -0
  109. package/addon/helpers/trailer-placeholder-image.js +6 -0
  110. package/addon/routes/management/trailers/index/details/connections.js +2 -0
  111. package/addon/routes/management/trailers/index/details/devices.js +2 -0
  112. package/addon/routes/management/trailers/index/details/equipment.js +2 -0
  113. package/addon/routes/management/trailers/index/details/index.js +2 -0
  114. package/addon/routes/management/trailers/index/details/maintenance-history.js +2 -0
  115. package/addon/routes/management/trailers/index/details/positions.js +2 -0
  116. package/addon/routes/management/trailers/index/details/schedules.js +2 -0
  117. package/addon/routes/management/trailers/index/details/work-orders.js +2 -0
  118. package/addon/routes/management/trailers/index/details.js +30 -0
  119. package/addon/routes/management/trailers/index/edit.js +23 -0
  120. package/addon/routes/management/trailers/index/new.js +14 -0
  121. package/addon/routes/management/trailers/index.js +42 -0
  122. package/addon/routes/management/vehicles/index/details/trailers.js +2 -0
  123. package/addon/routes/management/vehicles/index.js +7 -1
  124. package/addon/routes.js +17 -0
  125. package/addon/services/device-actions.js +26 -16
  126. package/addon/services/trailer-actions.js +394 -0
  127. package/addon/services/vehicle-actions.js +111 -0
  128. package/addon/styles/fleetops-engine.css +349 -0
  129. package/addon/templates/connectivity/devices/index/details/vehicle.hbs +19 -19
  130. package/addon/templates/connectivity/telematics/details/attachments.hbs +15 -15
  131. package/addon/templates/management/contacts/customers.hbs +48 -29
  132. package/addon/templates/management/contacts/index.hbs +52 -33
  133. package/addon/templates/management/trailers/index/details/connections.hbs +1 -0
  134. package/addon/templates/management/trailers/index/details/devices.hbs +1 -0
  135. package/addon/templates/management/trailers/index/details/equipment.hbs +1 -0
  136. package/addon/templates/management/trailers/index/details/index.hbs +1 -0
  137. package/addon/templates/management/trailers/index/details/maintenance-history.hbs +1 -0
  138. package/addon/templates/management/trailers/index/details/positions.hbs +1 -0
  139. package/addon/templates/management/trailers/index/details/schedules.hbs +1 -0
  140. package/addon/templates/management/trailers/index/details/work-orders.hbs +1 -0
  141. package/addon/templates/management/trailers/index/details.hbs +15 -0
  142. package/addon/templates/management/trailers/index/edit.hbs +12 -0
  143. package/addon/templates/management/trailers/index/new.hbs +11 -0
  144. package/addon/templates/management/trailers/index.hbs +67 -0
  145. package/addon/templates/management/vehicles/index/details/equipment.hbs +1 -1
  146. package/addon/templates/management/vehicles/index/details/trailers.hbs +1 -0
  147. package/addon/templates/management/vehicles/index/details.hbs +1 -1
  148. package/addon/templates/management/vendors/index.hbs +66 -32
  149. package/addon/utils/device-table-columns.js +10 -6
  150. package/addon/utils/live-map-card-content.js +56 -0
  151. package/addon/utils/placeholder-images.js +71 -0
  152. package/addon/utils/trailer-placeholder-image.js +11 -0
  153. package/app/components/attachment-list.js +1 -0
  154. package/app/components/cell/translated-value.js +1 -0
  155. package/app/components/contact/card.js +1 -0
  156. package/app/components/equipment/pill.js +1 -0
  157. package/app/components/filter/multi-model.js +1 -0
  158. package/app/components/modals/attach-equipment.js +1 -0
  159. package/app/components/modals/attach-trailer.js +1 -0
  160. package/app/components/modals/select-trailer.js +1 -0
  161. package/app/components/trailer/card.js +1 -0
  162. package/app/components/trailer/details/connections.js +1 -0
  163. package/app/components/trailer/details/equipment.js +1 -0
  164. package/app/components/trailer/details/maintenance-history.js +1 -0
  165. package/app/components/trailer/details/schedules.js +1 -0
  166. package/app/components/trailer/details/work-orders.js +1 -0
  167. package/app/components/trailer/details.js +1 -0
  168. package/app/components/trailer/form.js +1 -0
  169. package/app/components/trailer/panel-header.js +1 -0
  170. package/app/components/trailer/pill.js +1 -0
  171. package/app/components/vehicle/details/equipment.js +1 -0
  172. package/app/components/vehicle/details/trailers.js +1 -0
  173. package/app/components/vendor/card.js +1 -0
  174. package/app/controllers/management/trailers/index/details.js +1 -0
  175. package/app/controllers/management/trailers/index/edit.js +1 -0
  176. package/app/controllers/management/trailers/index/new.js +1 -0
  177. package/app/controllers/management/trailers/index.js +1 -0
  178. package/app/helpers/placeholder-image.js +1 -0
  179. package/app/helpers/resource-image.js +1 -0
  180. package/app/helpers/trailer-placeholder-image.js +1 -0
  181. package/app/routes/management/trailers/index/details/connections.js +1 -0
  182. package/app/routes/management/trailers/index/details/devices.js +1 -0
  183. package/app/routes/management/trailers/index/details/equipment.js +1 -0
  184. package/app/routes/management/trailers/index/details/index.js +1 -0
  185. package/app/routes/management/trailers/index/details/maintenance-history.js +1 -0
  186. package/app/routes/management/trailers/index/details/positions.js +1 -0
  187. package/app/routes/management/trailers/index/details/schedules.js +1 -0
  188. package/app/routes/management/trailers/index/details/work-orders.js +1 -0
  189. package/app/routes/management/trailers/index/details.js +1 -0
  190. package/app/routes/management/trailers/index/edit.js +1 -0
  191. package/app/routes/management/trailers/index/new.js +1 -0
  192. package/app/routes/management/trailers/index.js +1 -0
  193. package/app/routes/management/vehicles/index/details/trailers.js +1 -0
  194. package/app/services/trailer-actions.js +1 -0
  195. package/app/templates/management/trailers/index/details/connections.js +1 -0
  196. package/app/templates/management/trailers/index/details/devices.js +1 -0
  197. package/app/templates/management/trailers/index/details/equipment.js +1 -0
  198. package/app/templates/management/trailers/index/details/index.js +1 -0
  199. package/app/templates/management/trailers/index/details/maintenance-history.js +1 -0
  200. package/app/templates/management/trailers/index/details/positions.js +1 -0
  201. package/app/templates/management/trailers/index/details/schedules.js +1 -0
  202. package/app/templates/management/trailers/index/details/work-orders.js +1 -0
  203. package/app/templates/management/trailers/index/details.js +1 -0
  204. package/app/templates/management/trailers/index/edit.js +1 -0
  205. package/app/templates/management/trailers/index/new.js +1 -0
  206. package/app/templates/management/trailers/index.js +1 -0
  207. package/app/templates/management/vehicles/index/details/trailers.js +1 -0
  208. package/composer.json +1 -1
  209. package/extension.json +1 -1
  210. package/package.json +1 -1
  211. package/server/migrations/2026_09_03_000001_add_first_class_trailer_columns_to_assets_table.php +58 -0
  212. package/server/migrations/2026_09_03_000002_create_asset_connections_table.php +43 -0
  213. package/server/migrations/2026_09_03_000003_create_device_installations_table.php +33 -0
  214. package/server/src/Auth/Schemas/FleetOps.php +7 -0
  215. package/server/src/Events/TrailerLocationChanged.php +70 -0
  216. package/server/src/Exceptions/DeviceAlreadyAttachedException.php +18 -0
  217. package/server/src/Exports/TrailerExport.php +31 -0
  218. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +8 -2
  219. package/server/src/Http/Controllers/Api/v1/DeviceController.php +22 -15
  220. package/server/src/Http/Controllers/Api/v1/EquipmentController.php +47 -0
  221. package/server/src/Http/Controllers/Api/v1/TrailerController.php +362 -0
  222. package/server/src/Http/Controllers/Api/v1/VehicleController.php +1 -0
  223. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +3 -0
  224. package/server/src/Http/Controllers/Internal/v1/LiveController.php +1 -1
  225. package/server/src/Http/Controllers/Internal/v1/SearchController.php +16 -0
  226. package/server/src/Http/Controllers/Internal/v1/TrailerController.php +360 -0
  227. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +74 -0
  228. package/server/src/Http/Filter/EquipmentFilter.php +51 -0
  229. package/server/src/Http/Filter/OrderFilter.php +15 -0
  230. package/server/src/Http/Filter/TrailerFilter.php +269 -0
  231. package/server/src/Http/Filter/VehicleFilter.php +70 -3
  232. package/server/src/Http/Requests/CreateDeviceRequest.php +1 -1
  233. package/server/src/Http/Requests/CreateEquipmentRequest.php +1 -1
  234. package/server/src/Http/Requests/CreateTrailerRequest.php +54 -0
  235. package/server/src/Http/Requests/UpdateTrailerRequest.php +7 -0
  236. package/server/src/Http/Resources/v1/AssetConnection.php +90 -0
  237. package/server/src/Http/Resources/v1/Device.php +3 -1
  238. package/server/src/Http/Resources/v1/Equipment.php +3 -1
  239. package/server/src/Http/Resources/v1/Index/Vehicle.php +10 -0
  240. package/server/src/Http/Resources/v1/Trailer.php +199 -0
  241. package/server/src/Http/Resources/v1/Vehicle.php +2 -0
  242. package/server/src/Http/Resources/v1/VehicleWithoutDriver.php +3 -0
  243. package/server/src/Imports/TrailerImport.php +33 -0
  244. package/server/src/Models/Asset.php +39 -10
  245. package/server/src/Models/AssetConnection.php +43 -0
  246. package/server/src/Models/Device.php +39 -8
  247. package/server/src/Models/DeviceInstallation.php +27 -0
  248. package/server/src/Models/Equipment.php +2 -0
  249. package/server/src/Models/Trailer.php +181 -0
  250. package/server/src/Models/Vehicle.php +13 -0
  251. package/server/src/Providers/FleetOpsServiceProvider.php +4 -2
  252. package/server/src/Support/Ai/FleetOpsAiQueryResources.php +22 -0
  253. package/server/src/Support/Reporting/FleetOpsReportSchema.php +38 -0
  254. package/server/src/Support/Telematics/TelematicService.php +35 -0
  255. package/server/src/Support/TrailerSummary.php +35 -0
  256. package/server/src/routes.php +29 -0
  257. package/server/tests/CompactResourceSerializationTest.php +36 -0
  258. package/server/tests/ControllerFilterContractsTest.php +29 -0
  259. package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +30 -13
  260. package/server/tests/Feature/Http/Api/TrailerControllerLifecycleTest.php +640 -0
  261. package/server/tests/Feature/Http/Internal/DeviceControllerContractsTest.php +20 -5
  262. package/server/tests/Feature/Http/Internal/LiveControllerEndpointsTest.php +19 -0
  263. package/server/tests/ModelAccessorContractsTest.php +10 -0
  264. package/server/tests/OrderFilterExecutionTest.php +5 -0
  265. package/server/tests/PublicApiControllerInputContractsTest.php +11 -0
  266. package/server/tests/PublicFleetOpsRestEndpointsTest.php +15 -3
  267. package/server/tests/ReportSchemaContractsTest.php +4 -0
  268. package/server/tests/TelematicsHardeningTest.php +4 -2
  269. package/server/tests/TrailerSupportCoverageTest.php +524 -0
  270. package/server/tests/Unit/Http/Resources/IndexVehicleResourceTest.php +35 -0
  271. package/server/tests/Unit/Models/EquipmentTest.php +22 -0
  272. package/server/tests/Unit/Models/ModelLookupFallbackSeamsTest.php +7 -3
  273. package/server/tests/Unit/Models/TrailerTest.php +204 -0
  274. package/server/tests/Unit/Support/Telematics/TelematicServiceTelemetryTest.php +93 -0
  275. package/server/tests/VehicleControllerHelperContractsTest.php +14 -0
  276. package/translations/ar-ae.yml +396 -0
  277. package/translations/bg-bg.yaml +424 -0
  278. package/translations/en-us.yaml +423 -0
  279. package/translations/es-pa.yaml +425 -0
  280. package/translations/fr-fr.yaml +424 -0
  281. package/translations/mn-mn.yaml +424 -0
  282. package/translations/pt-br.yaml +424 -0
  283. package/translations/ru-ru.yaml +424 -0
  284. package/translations/vi-vn.yaml +435 -0
@@ -0,0 +1,181 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Models;
4
+
5
+ use Fleetbase\LaravelMysqlSpatial\Types\Point;
6
+ use Fleetbase\Models\Model;
7
+ use Illuminate\Database\Eloquent\Builder;
8
+ use Illuminate\Database\Eloquent\Relations\HasMany;
9
+ use Illuminate\Database\Eloquent\Relations\HasOne;
10
+
11
+ class Trailer extends Asset
12
+ {
13
+ public const ASSET_CLASS = 'trailer';
14
+
15
+ /**
16
+ * Lifecycle statuses. Attachment and connectivity are derived separately.
17
+ */
18
+ public const STATUSES = ['available', 'in_use', 'maintenance', 'out_of_service', 'retired'];
19
+
20
+ /**
21
+ * Stable trailer classification values shared by validation, import and the console.
22
+ */
23
+ public const TYPES = ['dry_van', 'reefer', 'flatbed', 'step_deck', 'lowboy', 'tanker', 'bulk', 'dump', 'chassis', 'curtain_side', 'car_carrier', 'livestock', 'logging', 'dolly', 'specialty', 'other'];
24
+
25
+ /**
26
+ * Ownership arrangements accepted for a trailer.
27
+ */
28
+ public const OWNERSHIP_TYPES = ['owned', 'leased', 'financed', 'rented'];
29
+
30
+ /**
31
+ * Keep the API payload identity independent from the shared assets table.
32
+ */
33
+ protected string $payloadKey = 'trailer';
34
+
35
+ protected $publicIdType = 'trailer';
36
+
37
+ protected $appends = ['category_name', 'vendor_name', 'warranty_name', 'current_location', 'photo_url', 'display_name', 'is_online', 'last_maintenance', 'next_maintenance_due', 'attachment_state', 'connectivity_status'];
38
+
39
+ protected $fillable = [
40
+ 'company_uuid', 'category_uuid', 'vendor_uuid', 'warranty_uuid', 'telematic_uuid', 'current_place_uuid', 'photo_uuid',
41
+ 'name', 'description', 'code', 'type', 'body_type', 'location', 'speed', 'heading', 'altitude', 'status', 'online', 'last_online_at',
42
+ 'usage_type', 'vin', 'plate_number', 'make', 'model', 'year', 'color', 'serial_number', 'measurement_system', 'odometer', 'odometer_unit',
43
+ 'ownership_type', 'engine_hours', 'length', 'width', 'height', 'tare_weight', 'gvwr', 'payload_capacity', 'cargo_volume', 'axle_count',
44
+ 'tire_count', 'door_count', 'coupling_type', 'brake_type', 'abs_equipped', 'ebs_equipped', 'refrigerated', 'temperature_min',
45
+ 'temperature_max', 'reefer_engine_hours', 'acquisition_cost', 'current_value', 'depreciation_rate', 'insurance_value', 'currency',
46
+ 'financing_status', 'purchased_at', 'lease_expires_at', 'capacity', 'specs', 'attributes', 'telematics', 'notes', 'slug',
47
+ ];
48
+
49
+ protected $casts = [
50
+ 'year' => 'integer', 'odometer' => 'integer', 'engine_hours' => 'integer', 'axle_count' => 'integer', 'tire_count' => 'integer',
51
+ 'door_count' => 'integer', 'online' => 'boolean', 'abs_equipped' => 'boolean', 'ebs_equipped' => 'boolean', 'refrigerated' => 'boolean',
52
+ 'last_online_at' => 'datetime', 'purchased_at' => 'date', 'lease_expires_at' => 'date', 'location' => \Fleetbase\FleetOps\Casts\Point::class,
53
+ 'capacity' => \Fleetbase\Casts\Json::class, 'specs' => \Fleetbase\Casts\Json::class, 'attributes' => \Fleetbase\Casts\Json::class,
54
+ 'telematics' => \Fleetbase\Casts\Json::class,
55
+ ];
56
+
57
+ protected static function booted(): void
58
+ {
59
+ static::addGlobalScope('trailer', fn (Builder $query) => $query->where($query->qualifyColumn('asset_class'), static::ASSET_CLASS));
60
+ static::creating(function (Trailer $trailer) {
61
+ $trailer->asset_class = static::ASSET_CLASS;
62
+ $trailer->status ??= 'available';
63
+ });
64
+ static::saving(fn (Trailer $trailer) => $trailer->asset_class = static::ASSET_CLASS);
65
+ }
66
+
67
+ /**
68
+ * Bulk removal never deletes a trailer that is still coupled to a vehicle: the
69
+ * active towing connection would be orphaned. Attached trailers are skipped, and
70
+ * the operation is refused outright when every selected trailer is attached.
71
+ */
72
+ public function bulkRemove($ids = [])
73
+ {
74
+ $ids = array_values(array_filter(array_map('strval', (array) $ids), 'strlen'));
75
+ $selected = static::where('company_uuid', session('company'))
76
+ ->where(fn ($query) => $query->whereIn('uuid', $ids)->orWhereIn('public_id', $ids))
77
+ ->get(['uuid', 'public_id']);
78
+ $attachedUuids = AssetConnection::where('company_uuid', session('company'))
79
+ ->whereIn('active_connected_uuid', $selected->pluck('uuid'))
80
+ ->pluck('active_connected_uuid')
81
+ ->all();
82
+ $blocked = $selected
83
+ ->filter(fn (Trailer $trailer) => in_array($trailer->uuid, $attachedUuids, true))
84
+ ->flatMap(fn (Trailer $trailer) => [$trailer->uuid, $trailer->public_id])
85
+ ->all();
86
+ $deletable = array_values(array_filter($ids, fn ($id) => !in_array($id, $blocked, true)));
87
+
88
+ if (empty($deletable)) {
89
+ throw new \RuntimeException('Detach the selected trailers from their vehicles before deleting them.');
90
+ }
91
+
92
+ return parent::bulkRemove($deletable);
93
+ }
94
+
95
+ public function currentConnection(): HasOne
96
+ {
97
+ return $this->hasOne(AssetConnection::class, 'connected_uuid', 'uuid')
98
+ ->where('connected_type', static::class)->whereNotNull('active_connected_uuid')->whereNull('disconnected_at');
99
+ }
100
+
101
+ public function devices(): HasMany
102
+ {
103
+ return $this->hasMany(Device::class, 'attachable_uuid', 'uuid')->where('attachable_type', static::class);
104
+ }
105
+
106
+ public function equipments(): HasMany
107
+ {
108
+ return $this->hasMany(Equipment::class, 'equipable_uuid', 'uuid')->where('equipable_type', static::class);
109
+ }
110
+
111
+ public function positions(): HasMany
112
+ {
113
+ return $this->hasMany(Position::class, 'subject_uuid', 'uuid')->where('subject_type', static::class);
114
+ }
115
+
116
+ public function maintenances(): HasMany
117
+ {
118
+ return $this->hasMany(Maintenance::class, 'maintainable_uuid', 'uuid')->where('maintainable_type', static::class);
119
+ }
120
+
121
+ public function maintenanceSchedules(): HasMany
122
+ {
123
+ return $this->hasMany(MaintenanceSchedule::class, 'subject_uuid', 'uuid')->where('subject_type', static::class);
124
+ }
125
+
126
+ public function workOrders(): HasMany
127
+ {
128
+ return $this->hasMany(WorkOrder::class, 'target_uuid', 'uuid')->where('target_type', static::class);
129
+ }
130
+
131
+ public function connections(): HasMany
132
+ {
133
+ return $this->hasMany(AssetConnection::class, 'connected_uuid', 'uuid')->where('connected_type', static::class)->latest('connected_at');
134
+ }
135
+
136
+ public function createPosition(array $attributes = [], Model|string|null $destination = null): ?Position
137
+ {
138
+ $latitude = $attributes['latitude'] ?? null;
139
+ $longitude = $attributes['longitude'] ?? null;
140
+ if ($latitude === null || $longitude === null) {
141
+ return null;
142
+ }
143
+
144
+ return Position::create([
145
+ 'company_uuid' => $this->company_uuid,
146
+ 'subject_uuid' => $this->uuid,
147
+ 'subject_type' => static::class,
148
+ 'destination_uuid' => $destination instanceof Model ? $destination->uuid : $destination,
149
+ 'coordinates' => new Point((float) $latitude, (float) $longitude),
150
+ 'heading' => $attributes['heading'] ?? null,
151
+ 'bearing' => $attributes['bearing'] ?? null,
152
+ 'speed' => $attributes['speed'] ?? null,
153
+ 'altitude' => $attributes['altitude'] ?? null,
154
+ ]);
155
+ }
156
+
157
+ public function getCurrentVehicleAttribute(): ?Vehicle
158
+ {
159
+ return $this->currentConnection?->vehicle;
160
+ }
161
+
162
+ public function getAttachmentStateAttribute(): string
163
+ {
164
+ return $this->currentConnection ? 'attached' : 'detached';
165
+ }
166
+
167
+ public function getConnectivityStatusAttribute(): string
168
+ {
169
+ if (!$this->last_online_at) {
170
+ return 'never_connected';
171
+ }
172
+ if ($this->last_online_at->gte(now()->subMinutes(10))) {
173
+ return 'online';
174
+ }
175
+ if ($this->last_online_at->gte(now()->subDay())) {
176
+ return 'recently_offline';
177
+ }
178
+
179
+ return 'offline';
180
+ }
181
+ }
@@ -375,6 +375,19 @@ class Vehicle extends Model
375
375
  return $this->hasMany(Equipment::class, 'equipable_uuid', 'uuid')->where('equipable_type', static::class);
376
376
  }
377
377
 
378
+ public function trailerConnections(): HasMany
379
+ {
380
+ return $this->hasMany(AssetConnection::class, 'connector_uuid', 'uuid')->where('connector_type', static::class)->latest('connected_at');
381
+ }
382
+
383
+ public function currentTrailers(): HasManyThrough
384
+ {
385
+ return $this->hasManyThrough(Trailer::class, AssetConnection::class, 'connector_uuid', 'uuid', 'uuid', 'connected_uuid')
386
+ ->where('asset_connections.connector_type', static::class)
387
+ ->where('asset_connections.connected_type', Trailer::class)
388
+ ->whereNull('asset_connections.disconnected_at');
389
+ }
390
+
378
391
  public function maintenances(): HasMany
379
392
  {
380
393
  return $this->hasMany(Maintenance::class, 'maintainable_uuid', 'uuid')->where('maintainable_type', static::class);
@@ -214,8 +214,10 @@ class FleetOpsServiceProvider extends CoreServiceProvider
214
214
  public function registerMorphMap(): void
215
215
  {
216
216
  Relation::morphMap([
217
- 'Fleetbase\\Models\\Vehicle' => \Fleetbase\FleetOps\Models\Vehicle::class,
218
- '\\Fleetbase\\Models\\Vehicle' => \Fleetbase\FleetOps\Models\Vehicle::class,
217
+ 'Fleetbase\\Models\\Vehicle' => \Fleetbase\FleetOps\Models\Vehicle::class,
218
+ '\\Fleetbase\\Models\\Vehicle' => \Fleetbase\FleetOps\Models\Vehicle::class,
219
+ 'fleet-ops:vehicle' => \Fleetbase\FleetOps\Models\Vehicle::class,
220
+ 'fleet-ops:trailer' => \Fleetbase\FleetOps\Models\Trailer::class,
219
221
  ]);
220
222
  }
221
223
 
@@ -9,6 +9,7 @@ use Fleetbase\FleetOps\Models\Driver;
9
9
  use Fleetbase\FleetOps\Models\Fleet;
10
10
  use Fleetbase\FleetOps\Models\Order;
11
11
  use Fleetbase\FleetOps\Models\ServiceArea;
12
+ use Fleetbase\FleetOps\Models\Trailer;
12
13
  use Fleetbase\FleetOps\Models\Vehicle;
13
14
  use Fleetbase\FleetOps\Models\Zone;
14
15
 
@@ -82,6 +83,27 @@ class FleetOpsAiQueryResources
82
83
  maxLimit: 500,
83
84
  ));
84
85
 
86
+ $registry->register(new AiQueryableResource(
87
+ key: 'fleet-ops.trailers',
88
+ label: 'Fleet-Ops trailers',
89
+ module: 'fleet-ops',
90
+ modelClass: Trailer::class,
91
+ permission: 'fleet-ops see trailer',
92
+ aliases: ['trailers', 'trailer'],
93
+ fields: [
94
+ 'online' => ['column' => 'online', 'type' => 'boolean'],
95
+ 'status' => ['column' => 'status', 'type' => 'string'],
96
+ 'type' => ['column' => 'type', 'type' => 'string'],
97
+ 'plate_number' => ['column' => 'plate_number', 'type' => 'string'],
98
+ 'last_online_at' => ['column' => 'last_online_at', 'type' => 'datetime'],
99
+ 'updated_at' => ['column' => 'updated_at', 'type' => 'datetime'],
100
+ ],
101
+ sampleFields: ['public_id', 'name', 'type', 'status', 'online', 'plate_number', 'last_online_at'],
102
+ locationField: 'location',
103
+ directivePermission: 'fleet-ops list trailer',
104
+ maxLimit: 500,
105
+ ));
106
+
85
107
  $registry->register(new AiQueryableResource(
86
108
  key: 'fleet-ops.orders',
87
109
  label: 'Fleet-Ops orders',
@@ -24,6 +24,9 @@ class FleetOpsReportSchema implements ReportSchema
24
24
  // Register Vehicles table
25
25
  $registry->registerTable($this->createVehiclesTable());
26
26
 
27
+ // Assets includes first-class trailers; filter asset_class = trailer for trailer reports.
28
+ $registry->registerTable($this->createAssetsTable());
29
+
27
30
  // Register Places table
28
31
  $registry->registerTable($this->createPlacesTable());
29
32
 
@@ -592,6 +595,41 @@ class FleetOpsReportSchema implements ReportSchema
592
595
  ]);
593
596
  }
594
597
 
598
+ /**
599
+ * Create the independently managed assets definition, including Trailers.
600
+ */
601
+ protected function createAssetsTable(): Table
602
+ {
603
+ return Table::make('assets')
604
+ ->label('Trailers and Assets')
605
+ ->description('Independently managed fleet assets; Trailer rows use asset_class trailer')
606
+ ->category('Fleet')
607
+ ->extension('fleet-ops')
608
+ ->excludeColumns(['uuid', 'company_uuid', 'deleted_at', 'meta', 'attributes'])
609
+ ->maxRows(10000)
610
+ ->columns([
611
+ Column::make('public_id', 'string')->label('Asset ID')->searchable()->filterable()->sortable(),
612
+ Column::make('asset_class', 'string')->label('Asset Class')->filterable()->aggregatable(),
613
+ Column::make('name', 'string')->label('Name')->searchable()->filterable()->sortable(),
614
+ Column::make('code', 'string')->label('Code')->searchable()->filterable()->sortable(),
615
+ Column::make('type', 'string')->label('Trailer Type')->filterable()->aggregatable(),
616
+ Column::make('status', 'string')->label('Lifecycle Status')->filterable()->aggregatable(),
617
+ Column::make('plate_number', 'string')->label('Plate Number')->searchable()->filterable(),
618
+ Column::make('make', 'string')->label('Make')->filterable()->aggregatable(),
619
+ Column::make('model', 'string')->label('Model')->filterable()->aggregatable(),
620
+ Column::make('year', 'integer')->label('Year')->filterable()->sortable(),
621
+ Column::make('gvwr', 'decimal')->label('GVWR')->filterable()->sortable()->aggregatable(),
622
+ Column::make('payload_capacity', 'decimal')->label('Payload Capacity')->filterable()->sortable()->aggregatable(),
623
+ Column::make('axle_count', 'integer')->label('Axle Count')->filterable()->sortable(),
624
+ Column::make('online', 'boolean')->label('Online')->filterable()->aggregatable(),
625
+ Column::make('last_online_at', 'datetime')->label('Last Online')->filterable()->sortable(),
626
+ Column::make('created_at', 'datetime')->label('Created')->filterable()->sortable(),
627
+ ])
628
+ ->computedColumns([
629
+ Column::count('total_assets', 'id')->label('Total Assets')->description('Count of selected trailer or asset rows'),
630
+ ]);
631
+ }
632
+
595
633
  /**
596
634
  * Create the Places table definition.
597
635
  */
@@ -3,6 +3,7 @@
3
3
  namespace Fleetbase\FleetOps\Support\Telematics;
4
4
 
5
5
  use Fleetbase\FleetOps\Contracts\TelematicProviderInterface;
6
+ use Fleetbase\FleetOps\Events\TrailerLocationChanged;
6
7
  use Fleetbase\FleetOps\Events\VehicleLocationChanged;
7
8
  use Fleetbase\FleetOps\Jobs\SyncTelematicDevicesJob;
8
9
  use Fleetbase\FleetOps\Jobs\TestTelematicConnectionJob;
@@ -10,6 +11,7 @@ use Fleetbase\FleetOps\Models\Device;
10
11
  use Fleetbase\FleetOps\Models\DeviceEvent;
11
12
  use Fleetbase\FleetOps\Models\Sensor;
12
13
  use Fleetbase\FleetOps\Models\Telematic;
14
+ use Fleetbase\FleetOps\Models\Trailer;
13
15
  use Fleetbase\FleetOps\Models\Vehicle;
14
16
  use Fleetbase\LaravelMysqlSpatial\Types\Point as SpatialPoint;
15
17
  use Illuminate\Support\Carbon;
@@ -654,9 +656,42 @@ class TelematicService
654
656
  $attachable = $device?->attachable;
655
657
  if ($attachable instanceof Vehicle) {
656
658
  $this->updateVehicleTelemetry($attachable, $location, $eventData, $event);
659
+ } elseif ($attachable instanceof Trailer) {
660
+ $this->updateTrailerTelemetry($attachable, $location, $eventData, $event);
657
661
  }
658
662
  }
659
663
 
664
+ protected function updateTrailerTelemetry(Trailer $trailer, array $location, array $eventData, DeviceEvent $event): void
665
+ {
666
+ $observedAt = $event->occurred_at ?? $event->created_at ?? now();
667
+ $currentAt = data_get($trailer->telematics, 'last_event_at');
668
+ if ($currentAt && $observedAt->lt(Carbon::parse($currentAt))) {
669
+ return;
670
+ }
671
+
672
+ $trailer->location = new SpatialPoint($location['latitude'], $location['longitude']);
673
+ $trailer->online = array_key_exists('online', $eventData) && $eventData['online'] !== null ? (bool) $eventData['online'] : true;
674
+ $trailer->last_online_at = $observedAt;
675
+ foreach (['speed', 'heading', 'altitude', 'odometer', 'reefer_engine_hours'] as $field) {
676
+ if (array_key_exists($field, $eventData) && $eventData[$field] !== null) {
677
+ $trailer->{$field} = $eventData[$field];
678
+ }
679
+ }
680
+
681
+ $trailer->telematics = array_merge($trailer->telematics ?? [], [
682
+ 'last_event_uuid' => $event->uuid,
683
+ 'last_event_id' => $event->public_id,
684
+ 'last_event_type' => $event->event_type,
685
+ 'last_event_at' => $observedAt->toISOString(),
686
+ 'last_device_uuid' => $event->device_uuid,
687
+ 'last_provider' => $event->provider,
688
+ 'last_telemetry_data' => array_filter($eventData, fn ($value) => $value !== null),
689
+ ]);
690
+ $trailer->save();
691
+
692
+ broadcast(new TrailerLocationChanged($trailer, ['source' => 'telematics', 'device_event_uuid' => $event->uuid, 'provider' => $event->provider]));
693
+ }
694
+
660
695
  protected function updateVehicleTelemetry(Vehicle $vehicle, array $location, array $eventData, DeviceEvent $event): void
661
696
  {
662
697
  $vehicle->location = new SpatialPoint($location['latitude'], $location['longitude']);
@@ -0,0 +1,35 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support;
4
+
5
+ /**
6
+ * Compact towing summary of a trailer for parent payloads (vehicle listings and map
7
+ * popovers) that only need identity, classification and connectivity.
8
+ */
9
+ class TrailerSummary
10
+ {
11
+ public static function compact($trailer): array
12
+ {
13
+ return [
14
+ 'id' => $trailer->uuid,
15
+ 'uuid' => $trailer->uuid,
16
+ 'public_id' => $trailer->public_id,
17
+ 'name' => $trailer->name,
18
+ 'display_name' => $trailer->display_name,
19
+ 'type' => $trailer->type,
20
+ 'plate_number' => $trailer->plate_number,
21
+ 'status' => $trailer->status,
22
+ 'attachment_state' => 'attached',
23
+ 'connectivity_status' => $trailer->connectivity_status,
24
+ 'online' => (bool) $trailer->online,
25
+ ];
26
+ }
27
+
28
+ /**
29
+ * Compact every trailer in a loaded collection.
30
+ */
31
+ public static function collection(iterable $trailers): array
32
+ {
33
+ return collect($trailers)->map(fn ($trailer) => static::compact($trailer))->values()->all();
34
+ }
35
+ }
@@ -133,6 +133,8 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
133
133
  $router->get('{id}', 'EquipmentController@find');
134
134
  $router->put('{id}', 'EquipmentController@update');
135
135
  $router->delete('{id}', 'EquipmentController@delete');
136
+ $router->post('{id}/attach', 'EquipmentController@attach');
137
+ $router->post('{id}/detach', 'EquipmentController@detach');
136
138
  });
137
139
  // parts routes
138
140
  $router->group(['prefix' => 'parts'], function () use ($router) {
@@ -297,6 +299,19 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
297
299
  $router->put('{id}', 'VehicleController@update');
298
300
  $router->delete('{id}', 'VehicleController@delete');
299
301
  $router->match(['put', 'patch', 'post'], '{id}/track', 'VehicleController@track');
302
+ $router->get('{id}/trailers', 'TrailerController@vehicleTrailers');
303
+ });
304
+ // trailer routes
305
+ $router->group(['prefix' => 'trailers'], function () use ($router) {
306
+ $router->post('/', 'TrailerController@create');
307
+ $router->get('/', 'TrailerController@query');
308
+ $router->get('{id}', 'TrailerController@find');
309
+ $router->put('{id}', 'TrailerController@update');
310
+ $router->delete('{id}', 'TrailerController@delete');
311
+ $router->match(['put', 'patch', 'post'], '{id}/track', 'TrailerController@track');
312
+ $router->post('{id}/attach', 'TrailerController@attach');
313
+ $router->post('{id}/detach', 'TrailerController@detach');
314
+ $router->get('{id}/connections', 'TrailerController@connections');
300
315
  });
301
316
  // fleets routes
302
317
  $router->group(['prefix' => 'fleets'], function () use ($router) {
@@ -563,10 +578,24 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
563
578
  $router->post('{id}/unassign-orders', $controller('unassignOrders'));
564
579
  $router->post('{id}/attach-device', $controller('attachDevice'));
565
580
  $router->post('{id}/detach-device', $controller('detachDevice'));
581
+ $router->post('{id}/attach-equipment', $controller('attachEquipment'));
582
+ $router->post('{id}/detach-equipment', $controller('detachEquipment'));
566
583
  $router->match(['get', 'post'], 'export', $controller('export'));
567
584
  $router->post('import', $controller('import'));
568
585
  }
569
586
  );
587
+ $router->fleetbaseRoutes('trailers', function ($router, $controller) {
588
+ $router->get('statuses', $controller('statuses'));
589
+ $router->get('types', $controller('types'));
590
+ $router->post('{id}/attach', $controller('attach'));
591
+ $router->post('{id}/detach', $controller('detach'));
592
+ $router->post('{id}/attach-device', $controller('attachDevice'));
593
+ $router->post('{id}/detach-device', $controller('detachDevice'));
594
+ $router->post('{id}/attach-equipment', $controller('attachEquipment'));
595
+ $router->post('{id}/detach-equipment', $controller('detachEquipment'));
596
+ $router->match(['get', 'post'], 'export', $controller('export'));
597
+ $router->post('import', $controller('import'));
598
+ });
570
599
  $router->fleetbaseRoutes('vehicle-devices');
571
600
  $router->fleetbaseRoutes(
572
601
  'vendors',
@@ -526,6 +526,20 @@ test('index vehicle resource returns compact fleet state metadata', function ()
526
526
  'altitude' => 20,
527
527
  'speed' => 54,
528
528
  'online' => 1,
529
+ ], [
530
+ 'currentTrailers' => new Collection([
531
+ (object) [
532
+ 'uuid' => 'trailer-uuid',
533
+ 'public_id' => 'trailer_public',
534
+ 'name' => 'Dry Van 7',
535
+ 'display_name' => 'Dry Van 7',
536
+ 'type' => 'dry_van',
537
+ 'plate_number' => 'TRL-7',
538
+ 'status' => 'in_use',
539
+ 'connectivity_status' => 'never_connected',
540
+ 'online' => 0,
541
+ ],
542
+ ]),
529
543
  ]);
530
544
 
531
545
  $payload = (new TestFleetOpsIndexVehicleResource($vehicle))->resolve($request);
@@ -546,6 +560,21 @@ test('index vehicle resource returns compact fleet state metadata', function ()
546
560
  'altitude' => 20,
547
561
  'speed' => 54,
548
562
  'online' => true,
563
+ 'trailers' => [
564
+ [
565
+ 'id' => 'trailer-uuid',
566
+ 'uuid' => 'trailer-uuid',
567
+ 'public_id' => 'trailer_public',
568
+ 'name' => 'Dry Van 7',
569
+ 'display_name' => 'Dry Van 7',
570
+ 'type' => 'dry_van',
571
+ 'plate_number' => 'TRL-7',
572
+ 'status' => 'in_use',
573
+ 'attachment_state' => 'attached',
574
+ 'connectivity_status' => 'never_connected',
575
+ 'online' => false,
576
+ ],
577
+ ],
549
578
  'assigned_orders_count' => 6,
550
579
  'meta' => [
551
580
  '_index_resource' => true,
@@ -2563,6 +2592,10 @@ test('internal vehicle vendor and waypoint resources expose internal compact fie
2563
2592
  'status_code' => 'arrived',
2564
2593
  ]);
2565
2594
 
2595
+ $vehicle->setRelation('currentTrailers', new Collection([
2596
+ (object) ['uuid' => 'trailer-uuid', 'public_id' => 'trailer_public', 'name' => 'Reefer 12', 'display_name' => 'Reefer 12', 'type' => 'reefer', 'plate_number' => 'TRL-12', 'status' => 'in_use', 'connectivity_status' => 'online', 'online' => 1],
2597
+ ]));
2598
+
2566
2599
  expect((new InternalVehicleResource($vehicle))->resolve($request))->toMatchArray([
2567
2600
  'id' => 101,
2568
2601
  'uuid' => 'fixture-uuid',
@@ -2591,6 +2624,9 @@ test('internal vehicle vendor and waypoint resources expose internal compact fie
2591
2624
  'altitude' => 4,
2592
2625
  'speed' => 18,
2593
2626
  'meta' => ['yard' => 'east'],
2627
+ 'trailers' => [
2628
+ ['id' => 'trailer-uuid', 'uuid' => 'trailer-uuid', 'public_id' => 'trailer_public', 'name' => 'Reefer 12', 'display_name' => 'Reefer 12', 'type' => 'reefer', 'plate_number' => 'TRL-12', 'status' => 'in_use', 'attachment_state' => 'attached', 'connectivity_status' => 'online', 'online' => true],
2629
+ ],
2594
2630
  ])
2595
2631
  ->and((new VehicleWithoutDriverResource($vehicle))->toWebhookPayload())->toMatchArray([
2596
2632
  'id' => 'fixture_public',
@@ -559,6 +559,8 @@ test('vehicle filter records identity relationship fleet and telematic filters',
559
559
  $filter->vehicleYear('2026');
560
560
  $filter->driver('unassigned');
561
561
  $filter->driver($publicIds['drivers']);
562
+ // The console's multi-select sends several drivers at once.
563
+ $filter->driver([$publicIds['drivers'], 'driver_missing']);
562
564
  $filter->vendor(null);
563
565
  $filter->vendor($publicIds['vendors']);
564
566
  $filter->driverUuid('driver-uuid');
@@ -568,6 +570,33 @@ test('vehicle filter records identity relationship fleet and telematic filters',
568
570
  $filter->createdAt(['2026-01-01', '2026-01-31']);
569
571
  $filter->updatedAt('2026-02-01');
570
572
 
573
+ // Batch scans: a comma-separated list of internal ids is an exact match on each.
574
+ $filter->internalId('VEH-1, VEH-2');
575
+
576
+ // Towing, device and presence filters back the console's vehicle index filters.
577
+ $schema = Illuminate\Database\Eloquent\Model::resolveConnection()->getSchemaBuilder();
578
+ foreach (['assets' => 'trailer_filterone', 'devices' => 'device_filterone'] as $table => $publicId) {
579
+ $schema->create($table, function ($blueprint) {
580
+ $blueprint->increments('id');
581
+ foreach (['uuid', 'public_id', 'internal_id', 'company_uuid', 'asset_class', 'name'] as $column) {
582
+ $blueprint->string($column)->nullable();
583
+ }
584
+ $blueprint->timestamps();
585
+ $blueprint->timestamp('deleted_at')->nullable();
586
+ });
587
+ Illuminate\Database\Eloquent\Model::resolveConnection()->table($table)->insert(['uuid' => $table . '-filter-uuid', 'public_id' => $publicId, 'company_uuid' => 'company-uuid', 'asset_class' => 'trailer', 'name' => $table]);
588
+ }
589
+ $filter->trailer('trailer_filterone');
590
+ $filter->device(['device_filterone']);
591
+ $filter->hasTrailer('true');
592
+ $filter->hasDriver('false');
593
+ $filter->hasDevice('');
594
+
595
+ expect($query->calls)->toContain(['whereIn', 'internal_id', ['VEH-1', 'VEH-2']])
596
+ ->and($query->calls)->toContain(['whereDoesntHave', 'driver'])
597
+ ->and(collect($query->calls)->where(0, 'whereHas')->pluck(1)->all())->toContain('currentTrailers')
598
+ ->and(collect($query->calls)->where(0, 'whereHas')->flatMap(fn ($call) => $call[2])->values()->all())->toContain(['whereIn', 'assets.uuid', ['assets-filter-uuid']], ['whereIn', 'uuid', ['devices-filter-uuid']]);
599
+
571
600
  expect($query->calls)->toContain(['where', ['company_uuid', 'company-uuid']])
572
601
  ->and($query->calls)->toContain(['search', 'van'])
573
602
  ->and($query->calls)->toContain(['searchWhere', ['year', 'make', 'model', 'plate_number'], 'sprinter'])
@@ -1,5 +1,6 @@
1
1
  <?php
2
2
 
3
+ use Fleetbase\FleetOps\Exceptions\DeviceAlreadyAttachedException;
3
4
  use Fleetbase\FleetOps\Http\Controllers\Api\v1\DeviceController;
4
5
  use Fleetbase\FleetOps\Http\Requests\CreateDeviceRequest;
5
6
  use Fleetbase\FleetOps\Http\Requests\UpdateDeviceRequest;
@@ -82,22 +83,23 @@ class FleetOpsApiDeviceControllerEndpointProbe extends DeviceController
82
83
  $this->lookupLogs[] = [$action, $deviceId, $vehicleId];
83
84
  }
84
85
 
85
- protected function logDeviceAttachmentFailure(string $action, Device $device, ?Vehicle $vehicle, Throwable $exception): void
86
+ protected function logDeviceAttachmentFailure(string $action, Device $device, ?EloquentModel $attachable, Throwable $exception): void
86
87
  {
87
- $this->failureLogs[] = [$action, $device->uuid, $vehicle?->uuid, $exception->getMessage()];
88
+ $this->failureLogs[] = [$action, $device->uuid, $attachable?->uuid, $exception->getMessage()];
88
89
  }
89
90
  }
90
91
 
91
92
  class FleetOpsApiDeviceEndpointFake extends Device
92
93
  {
93
- public array $loads = [];
94
- public array $loadCounts = [];
95
- public array $updates = [];
96
- public array $attachments = [];
97
- public array $detaches = [];
98
- public bool $deleted = false;
99
- public bool $throwAttach = false;
100
- public bool $throwDetach = false;
94
+ public array $loads = [];
95
+ public array $loadCounts = [];
96
+ public array $updates = [];
97
+ public array $attachments = [];
98
+ public array $detaches = [];
99
+ public bool $deleted = false;
100
+ public bool $throwAttach = false;
101
+ public bool $throwDetach = false;
102
+ public bool $throwAlreadyAttached = false;
101
103
 
102
104
  public function load($relations)
103
105
  {
@@ -139,6 +141,10 @@ class FleetOpsApiDeviceEndpointFake extends Device
139
141
  throw new RuntimeException('attach exploded');
140
142
  }
141
143
 
144
+ if ($this->throwAlreadyAttached) {
145
+ throw DeviceAlreadyAttachedException::for(null);
146
+ }
147
+
142
148
  $this->attachments[] = [$attachable::class, $attachable->uuid];
143
149
 
144
150
  return true;
@@ -316,6 +322,15 @@ test('api device controller attaches detaches and reports failures', function ()
316
322
  ];
317
323
  $attachFailure = $attachFailureController->attach(new Request(['attachable' => 'vehicle_public']), 'attach_failure')->getData(true);
318
324
 
325
+ $attachConflictDevice = fleetopsApiDeviceEndpointDevice('attach-conflict');
326
+ $attachConflictDevice->throwAlreadyAttached = true;
327
+ $attachConflictController = fleetopsApiDeviceControllerEndpoint();
328
+ $attachConflictController->models = [
329
+ Device::class . ':attach_conflict' => $attachConflictDevice,
330
+ Vehicle::class . ':vehicle_public' => $vehicle,
331
+ ];
332
+ $attachConflict = $attachConflictController->attach(new Request(['attachable' => 'vehicle_public']), 'attach_conflict');
333
+
319
334
  $detachFailureDevice = fleetopsApiDeviceEndpointDevice('detach-failure');
320
335
  $detachFailureDevice->throwDetach = true;
321
336
  $detachFailureController = fleetopsApiDeviceControllerEndpoint();
@@ -339,9 +354,11 @@ test('api device controller attaches detaches and reports failures', function ()
339
354
  ])
340
355
  ->and($device->detaches)->toBe(['device-uuid'])
341
356
  ->and($missing)->toBe(['error' => 'Device resource not found.'])
342
- ->and($attachFailure)->toBe(['error' => 'Unable to attach device to vehicle.'])
357
+ ->and($attachFailure)->toBe(['error' => 'Unable to attach device to resource.'])
358
+ ->and($attachConflict->getData(true))->toBe(['error' => 'Device is already attached to another asset. Detach it before attaching it elsewhere.'])
359
+ ->and($attachConflict->getStatusCode())->toBe(409)
343
360
  ->and($attachFailureController->failureLogs)->toBe([['attach', 'attach-failure', 'vehicle-uuid', 'attach exploded']])
344
- ->and($detachFailure)->toBe(['error' => 'Unable to detach device from vehicle.'])
361
+ ->and($detachFailure)->toBe(['error' => 'Unable to detach device from resource.'])
345
362
  ->and($detachFailureController->failureLogs)->toBe([['detach', 'detach-failure', null, 'detach exploded']])
346
- ->and($lookupFailure)->toBe(['error' => 'Device or vehicle resource not found.']);
363
+ ->and($lookupFailure)->toBe(['error' => 'Device or attachable resource not found.']);
347
364
  });