@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
@@ -12,6 +12,7 @@ use Fleetbase\FleetOps\Models\Warranty;
12
12
  use Fleetbase\Http\Controllers\Controller;
13
13
  use Fleetbase\Models\File;
14
14
  use Illuminate\Http\Request;
15
+ use Illuminate\Support\Facades\DB;
15
16
 
16
17
  class EquipmentController extends Controller
17
18
  {
@@ -79,6 +80,52 @@ class EquipmentController extends Controller
79
80
  return $this->deletedEquipmentResource($equipment);
80
81
  }
81
82
 
83
+ public function attach(Request $request, string $id)
84
+ {
85
+ $this->rejectUuidIdentifiers($request);
86
+ $request->validate(['attachable_type' => ['required', 'in:fleet-ops:vehicle,fleet-ops:trailer,vehicle,trailer'], 'attachable' => ['required', 'string']]);
87
+ try {
88
+ $equipment = $this->resolveModel(Equipment::class, $id);
89
+ [$type, $uuid] = $this->resolveMorph($request->attachable_type, $request->attachable);
90
+ } catch (\Illuminate\Database\Eloquent\ModelNotFoundException) {
91
+ return response()->json(['error' => 'Equipment or attachable resource not found.'], 404);
92
+ }
93
+ $equipment = DB::transaction(function () use ($equipment, $type, $uuid) {
94
+ $locked = Equipment::where('company_uuid', session('company'))->where('uuid', $equipment->uuid)->lockForUpdate()->firstOrFail();
95
+ if ($locked->equipable_type === $type && $locked->equipable_uuid === $uuid) {
96
+ return $locked;
97
+ }
98
+ $locked->update(['equipable_type' => $type, 'equipable_uuid' => $uuid]);
99
+ activity('equipment_attached')->performedOn($locked)->withProperties(['attachable_type' => $type, 'attachable_uuid' => $uuid])->log('Equipment attached');
100
+
101
+ return $locked;
102
+ });
103
+
104
+ return $this->equipmentResource($equipment->refresh()->load(['warranty', 'photo', 'equipable']));
105
+ }
106
+
107
+ public function detach(string $id)
108
+ {
109
+ try {
110
+ $equipment = $this->resolveModel(Equipment::class, $id);
111
+ } catch (\Illuminate\Database\Eloquent\ModelNotFoundException) {
112
+ return response()->json(['error' => 'Equipment resource not found.'], 404);
113
+ }
114
+ $equipment = DB::transaction(function () use ($equipment) {
115
+ $locked = Equipment::where('company_uuid', session('company'))->where('uuid', $equipment->uuid)->lockForUpdate()->firstOrFail();
116
+ if (!$locked->equipable_uuid) {
117
+ return $locked;
118
+ }
119
+ $previous = ['attachable_type' => $locked->equipable_type, 'attachable_uuid' => $locked->equipable_uuid];
120
+ $locked->update(['equipable_type' => null, 'equipable_uuid' => null]);
121
+ activity('equipment_detached')->performedOn($locked)->withProperties($previous)->log('Equipment detached');
122
+
123
+ return $locked;
124
+ });
125
+
126
+ return $this->equipmentResource($equipment->refresh()->load(['warranty', 'photo', 'equipable']));
127
+ }
128
+
82
129
  protected function input(Request $request): array
83
130
  {
84
131
  $input = $request->only([
@@ -0,0 +1,362 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Http\Controllers\Api\v1;
4
+
5
+ use Fleetbase\FleetOps\Events\TrailerLocationChanged;
6
+ use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesFleetOpsApiResources;
7
+ use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesPublicExpansions;
8
+ use Fleetbase\FleetOps\Http\Requests\CreateTrailerRequest;
9
+ use Fleetbase\FleetOps\Http\Requests\UpdateTrailerRequest;
10
+ use Fleetbase\FleetOps\Http\Resources\v1\AssetConnection as AssetConnectionResource;
11
+ use Fleetbase\FleetOps\Http\Resources\v1\DeletedResource;
12
+ use Fleetbase\FleetOps\Http\Resources\v1\Trailer as TrailerResource;
13
+ use Fleetbase\FleetOps\Models\AssetConnection;
14
+ use Fleetbase\FleetOps\Models\Trailer;
15
+ use Fleetbase\FleetOps\Models\Vehicle;
16
+ use Fleetbase\FleetOps\Models\Vendor;
17
+ use Fleetbase\FleetOps\Models\Warranty;
18
+ use Fleetbase\FleetOps\Support\Utils;
19
+ use Fleetbase\Http\Controllers\Controller;
20
+ use Fleetbase\LaravelMysqlSpatial\Types\Point;
21
+ use Fleetbase\Models\Category;
22
+ use Fleetbase\Models\File;
23
+ use Illuminate\Database\Eloquent\ModelNotFoundException;
24
+ use Illuminate\Http\Request;
25
+ use Illuminate\Support\Carbon;
26
+ use Illuminate\Support\Facades\DB;
27
+
28
+ class TrailerController extends Controller
29
+ {
30
+ use ResolvesFleetOpsApiResources;
31
+ use ResolvesPublicExpansions;
32
+
33
+ /**
34
+ * Relations that are part of the base Trailer representation. The towing
35
+ * connection, attached devices and attached equipment are first-class trailer
36
+ * state, so they are always present rather than opt-in expansions.
37
+ */
38
+ private const RELATIONS = ['currentConnection.vehicle', 'connections.vehicle', 'devices', 'equipments'];
39
+
40
+ /**
41
+ * Optional expansions accepted through `with` / `expand`.
42
+ */
43
+ public const EXPANDABLE = [
44
+ 'category' => 'category',
45
+ 'vendor' => 'vendor',
46
+ 'warranty' => 'warranty',
47
+ 'photo' => 'photo',
48
+ 'positions' => 'positions',
49
+ ];
50
+
51
+ public function create(CreateTrailerRequest $request)
52
+ {
53
+ $this->rejectUuidIdentifiers($request);
54
+ $this->applyPublicExpansions($request, static::EXPANDABLE);
55
+
56
+ $input = $this->input($request);
57
+ $input['company_uuid'] = session('company');
58
+
59
+ return new TrailerResource($this->load(Trailer::create($input), $request));
60
+ }
61
+
62
+ public function query(Request $request)
63
+ {
64
+ $this->rejectUuidIdentifiers($request);
65
+ $expansions = $this->applyPublicExpansions($request, static::EXPANDABLE);
66
+
67
+ return TrailerResource::collection($this->queryTrailers($request, fn (&$query) => $query->with(array_merge(self::RELATIONS, $expansions))->withCount(['devices', 'equipments'])));
68
+ }
69
+
70
+ public function find(string $id, ?Request $request = null)
71
+ {
72
+ // The parameter carries a default, so Laravel's dispatcher does not inject it;
73
+ // fall back to the container request so expansions are still mapped.
74
+ $request = $request instanceof Request ? $request : request();
75
+ $this->applyPublicExpansions($request, static::EXPANDABLE);
76
+
77
+ try {
78
+ return new TrailerResource($this->load($this->resolveModel(Trailer::class, $id), $request));
79
+ } catch (ModelNotFoundException) {
80
+ return $this->notFound();
81
+ }
82
+ }
83
+
84
+ public function update(string $id, UpdateTrailerRequest $request)
85
+ {
86
+ $this->rejectUuidIdentifiers($request);
87
+ $this->applyPublicExpansions($request, static::EXPANDABLE);
88
+
89
+ try {
90
+ $trailer = $this->resolveModel(Trailer::class, $id);
91
+ } catch (ModelNotFoundException) {
92
+ return $this->notFound();
93
+ }
94
+
95
+ $trailer->update($this->input($request));
96
+
97
+ return new TrailerResource($this->load($trailer->refresh(), $request));
98
+ }
99
+
100
+ public function delete(string $id)
101
+ {
102
+ try {
103
+ $trailer = $this->resolveModel(Trailer::class, $id);
104
+ } catch (ModelNotFoundException) {
105
+ return $this->notFound();
106
+ }
107
+
108
+ if ($this->hasActiveConnection($trailer)) {
109
+ return response()->json(['error' => 'Detach the trailer before deleting it.'], 409);
110
+ }
111
+
112
+ $trailer->delete();
113
+
114
+ return new DeletedResource($trailer);
115
+ }
116
+
117
+ /**
118
+ * Record a direct Trailer observation. Stale observations (older than the last
119
+ * recorded telemetry event) and null-island coordinates are acknowledged without
120
+ * mutating live state.
121
+ */
122
+ public function track(string $id, Request $request)
123
+ {
124
+ $request->validate([
125
+ 'latitude' => ['required', 'numeric', 'between:-90,90'],
126
+ 'longitude' => ['required', 'numeric', 'between:-180,180'],
127
+ 'observed_at' => ['nullable', 'date'],
128
+ 'speed' => ['nullable', 'numeric', 'min:0'],
129
+ 'heading' => ['nullable', 'numeric', 'between:0,360'],
130
+ 'altitude' => ['nullable', 'numeric'],
131
+ 'odometer' => ['nullable', 'numeric', 'min:0'],
132
+ ]);
133
+
134
+ try {
135
+ $resolved = $this->resolveModel(Trailer::class, $id);
136
+ } catch (ModelNotFoundException) {
137
+ return $this->notFound();
138
+ }
139
+
140
+ if ((float) $request->latitude === 0.0 && (float) $request->longitude === 0.0) {
141
+ return new TrailerResource($this->load($resolved, $request));
142
+ }
143
+
144
+ [$trailer, $changed] = DB::transaction(function () use ($resolved, $request) {
145
+ $trailer = Trailer::where('company_uuid', session('company'))->where('uuid', $resolved->uuid)->lockForUpdate()->firstOrFail();
146
+ $observedAt = $request->date('observed_at') ?? now();
147
+ $currentAt = data_get($trailer->telematics, 'last_event_at');
148
+
149
+ if ($currentAt && $observedAt->lt(Carbon::parse($currentAt))) {
150
+ return [$trailer, false];
151
+ }
152
+
153
+ $snapshot = ['location' => new Point((float) $request->latitude, (float) $request->longitude), 'online' => true, 'last_online_at' => $observedAt];
154
+ foreach (['speed', 'heading', 'altitude', 'odometer'] as $field) {
155
+ if ($request->exists($field)) {
156
+ $snapshot[$field] = $request->input($field);
157
+ }
158
+ }
159
+
160
+ $snapshot['telematics'] = array_merge($trailer->telematics ?? [], [
161
+ 'last_event_at' => $observedAt->toISOString(),
162
+ 'last_provider' => 'public_api',
163
+ 'last_telemetry_data' => $request->only(['speed', 'heading', 'altitude', 'odometer']),
164
+ ]);
165
+
166
+ $trailer->update($snapshot);
167
+ $trailer->createPosition($request->only(['latitude', 'longitude', 'speed', 'heading', 'altitude']));
168
+
169
+ return [$trailer, true];
170
+ }, 3);
171
+
172
+ if ($changed) {
173
+ broadcast(new TrailerLocationChanged($trailer, ['source' => 'public_api']));
174
+ }
175
+
176
+ return new TrailerResource($this->load($trailer, $request));
177
+ }
178
+
179
+ /**
180
+ * Attach the Trailer to a Vehicle. Re-attaching to the same Vehicle is idempotent
181
+ * and returns the existing active connection.
182
+ */
183
+ public function attach(string $id, Request $request)
184
+ {
185
+ $this->rejectUuidIdentifiers($request);
186
+ $request->validate([
187
+ 'vehicle' => ['required', 'string'],
188
+ 'connected_at' => ['nullable', 'date'],
189
+ 'source' => ['nullable', 'string', 'max:255'],
190
+ 'position' => ['nullable', 'integer', 'min:1'],
191
+ ]);
192
+
193
+ try {
194
+ $trailer = $this->resolveModel(Trailer::class, $id);
195
+ $vehicle = $this->resolveModel(Vehicle::class, $request->input('vehicle'));
196
+ } catch (ModelNotFoundException) {
197
+ return response()->json(['error' => 'Trailer or vehicle resource not found.'], 404);
198
+ }
199
+
200
+ $result = DB::transaction(function () use ($trailer, $vehicle, $request) {
201
+ $active = $this->activeConnectionQuery($trailer)->lockForUpdate()->first();
202
+
203
+ if ($active) {
204
+ if ($active->connector_uuid === $vehicle->uuid) {
205
+ return $active;
206
+ }
207
+
208
+ return 'Trailer is already attached to another vehicle. Detach it before moving.';
209
+ }
210
+
211
+ $position = $request->integer('position', 1);
212
+ $positionKey = $vehicle->uuid . ':' . $position;
213
+
214
+ if (AssetConnection::where('company_uuid', session('company'))->where('active_connector_position', $positionKey)->lockForUpdate()->exists()) {
215
+ return 'Another trailer already occupies this towing position.';
216
+ }
217
+
218
+ return AssetConnection::create([
219
+ 'company_uuid' => session('company'),
220
+ 'connector_type' => Vehicle::class,
221
+ 'connector_uuid' => $vehicle->uuid,
222
+ 'connected_type' => Trailer::class,
223
+ 'connected_uuid' => $trailer->uuid,
224
+ 'active_connected_uuid' => $trailer->uuid,
225
+ 'active_connector_position' => $positionKey,
226
+ 'relationship_type' => 'towing',
227
+ 'position' => $position,
228
+ 'connected_at' => $request->date('connected_at') ?? now(),
229
+ 'source' => $request->input('source', 'manual'),
230
+ 'created_by_uuid' => session('user'),
231
+ 'updated_by_uuid' => session('user'),
232
+ ]);
233
+ }, 3);
234
+
235
+ if (is_string($result)) {
236
+ return response()->json(['error' => $result], 409);
237
+ }
238
+
239
+ return new AssetConnectionResource($result->load(['vehicle', 'trailer']));
240
+ }
241
+
242
+ /**
243
+ * End the active towing connection while preserving it as history. Detaching an
244
+ * unattached Trailer is a no-op.
245
+ */
246
+ public function detach(string $id, Request $request)
247
+ {
248
+ $request->validate([
249
+ 'disconnected_at' => ['nullable', 'date'],
250
+ 'notes' => ['nullable', 'string'],
251
+ ]);
252
+
253
+ try {
254
+ $trailer = $this->resolveModel(Trailer::class, $id);
255
+ } catch (ModelNotFoundException) {
256
+ return $this->notFound();
257
+ }
258
+
259
+ $connection = DB::transaction(function () use ($trailer, $request) {
260
+ $active = $this->activeConnectionQuery($trailer)->lockForUpdate()->first();
261
+
262
+ if (!$active) {
263
+ return null;
264
+ }
265
+
266
+ $active->update([
267
+ 'disconnected_at' => $request->date('disconnected_at') ?? now(),
268
+ 'active_connected_uuid' => null,
269
+ 'active_connector_position' => null,
270
+ 'notes' => $request->input('notes', $active->notes),
271
+ 'updated_by_uuid' => session('user'),
272
+ ]);
273
+
274
+ return $active->load(['vehicle', 'trailer']);
275
+ }, 3);
276
+
277
+ return response()->json([
278
+ 'status' => 'ok',
279
+ 'connection' => $connection ? new AssetConnectionResource($connection) : null,
280
+ ]);
281
+ }
282
+
283
+ public function connections(string $id)
284
+ {
285
+ try {
286
+ $trailer = $this->resolveModel(Trailer::class, $id);
287
+ } catch (ModelNotFoundException) {
288
+ return $this->notFound();
289
+ }
290
+
291
+ return AssetConnectionResource::collection($trailer->connections()->with(['vehicle', 'trailer'])->get());
292
+ }
293
+
294
+ public function vehicleTrailers(string $id)
295
+ {
296
+ try {
297
+ $vehicle = $this->resolveModel(Vehicle::class, $id);
298
+ } catch (ModelNotFoundException) {
299
+ return response()->json(['error' => 'Vehicle resource not found.'], 404);
300
+ }
301
+
302
+ $trailers = Trailer::whereHas('currentConnection', fn ($q) => $q->where('connector_uuid', $vehicle->uuid))
303
+ ->with(self::RELATIONS)
304
+ ->withCount(['devices', 'equipments'])
305
+ ->get();
306
+
307
+ return TrailerResource::collection($trailers);
308
+ }
309
+
310
+ protected function input(Request $request): array
311
+ {
312
+ $input = $request->only([
313
+ 'name', 'description', 'code', 'type', 'body_type', 'status', 'vin', 'plate_number', 'serial_number', 'make', 'model', 'year', 'color',
314
+ 'usage_type', 'measurement_system', 'odometer', 'odometer_unit', 'ownership_type', 'purchased_at', 'lease_expires_at', 'financing_status',
315
+ 'currency', 'acquisition_cost', 'current_value', 'insurance_value', 'depreciation_rate', 'length', 'width', 'height', 'tare_weight', 'gvwr',
316
+ 'payload_capacity', 'cargo_volume', 'axle_count', 'tire_count', 'door_count', 'coupling_type', 'brake_type', 'abs_equipped', 'ebs_equipped',
317
+ 'refrigerated', 'temperature_min', 'temperature_max', 'reefer_engine_hours', 'capacity', 'specs', 'attributes', 'notes',
318
+ ]);
319
+
320
+ if ($request->filled(['latitude', 'longitude'])) {
321
+ $input['location'] = new Point((float) $request->latitude, (float) $request->longitude);
322
+ } elseif ($request->filled('location')) {
323
+ $input['location'] = Utils::getPointFromCoordinates($request->location);
324
+ }
325
+
326
+ $this->applyPublicIdRelation($input, 'category', 'category_uuid', Category::class, $request);
327
+ $this->applyPublicIdRelation($input, 'vendor', 'vendor_uuid', Vendor::class, $request);
328
+ $this->applyPublicIdRelation($input, 'warranty', 'warranty_uuid', Warranty::class, $request);
329
+ $this->applyPublicIdRelation($input, 'photo', 'photo_uuid', File::class, $request);
330
+
331
+ return $input;
332
+ }
333
+
334
+ protected function queryTrailers(Request $request, callable $callback)
335
+ {
336
+ // @codeCoverageIgnoreStart
337
+ return Trailer::queryWithRequest($request, $callback);
338
+ // @codeCoverageIgnoreEnd
339
+ }
340
+
341
+ protected function activeConnectionQuery(Trailer $trailer)
342
+ {
343
+ return AssetConnection::where('company_uuid', session('company'))->where('active_connected_uuid', $trailer->uuid);
344
+ }
345
+
346
+ protected function hasActiveConnection(Trailer $trailer): bool
347
+ {
348
+ return $this->activeConnectionQuery($trailer)->exists();
349
+ }
350
+
351
+ protected function notFound()
352
+ {
353
+ return response()->json(['error' => 'Trailer resource not found.'], 404);
354
+ }
355
+
356
+ private function load(Trailer $trailer, ?Request $request = null): Trailer
357
+ {
358
+ $expansions = $request ? $this->resolvePublicExpansions($request, static::EXPANDABLE) : [];
359
+
360
+ return $trailer->load(array_merge(self::RELATIONS, $expansions))->loadCount(['devices', 'equipments']);
361
+ }
362
+ }
@@ -52,6 +52,7 @@ class VehicleController extends Controller
52
52
  'warranty' => 'warranty',
53
53
  'photo' => 'photo',
54
54
  'devices' => 'devices',
55
+ 'trailers' => 'currentTrailers',
55
56
  ];
56
57
 
57
58
  /**
@@ -2,6 +2,7 @@
2
2
 
3
3
  namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
4
4
 
5
+ use Fleetbase\FleetOps\Exceptions\DeviceAlreadyAttachedException;
5
6
  use Fleetbase\FleetOps\Exports\DeviceExport;
6
7
  use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
7
8
  use Fleetbase\FleetOps\Models\Device;
@@ -134,6 +135,8 @@ class DeviceController extends FleetOpsController
134
135
  try {
135
136
  $device->attachTo($vehicle);
136
137
  $device->load(['telematic', 'warranty', 'attachable']);
138
+ } catch (DeviceAlreadyAttachedException $e) {
139
+ return response()->error($e->getMessage(), 409);
137
140
  } catch (\Throwable $e) {
138
141
  $this->logDeviceAttachmentFailure('attach', $device, $vehicle, $e);
139
142
 
@@ -176,7 +176,7 @@ class LiveController extends Controller
176
176
  return LiveCacheService::remember('vehicles', $cacheParams, function () use ($bounds, $limit) {
177
177
  // Fetch vehicles that are online
178
178
  $query = Vehicle::where(['company_uuid' => session('company')])
179
- ->with(['devices', 'driver'])
179
+ ->with(['devices', 'driver', 'currentTrailers'])
180
180
  ->applyDirectivesForPermissions('fleet-ops list vehicle');
181
181
 
182
182
  $this->applyLiveLocationGuards($query);
@@ -21,6 +21,7 @@ use Fleetbase\FleetOps\Models\Place;
21
21
  use Fleetbase\FleetOps\Models\Sensor;
22
22
  use Fleetbase\FleetOps\Models\ServiceRate;
23
23
  use Fleetbase\FleetOps\Models\Telematic;
24
+ use Fleetbase\FleetOps\Models\Trailer;
24
25
  use Fleetbase\FleetOps\Models\Vehicle;
25
26
  use Fleetbase\FleetOps\Models\Vendor;
26
27
  use Fleetbase\FleetOps\Models\WorkOrder;
@@ -38,6 +39,7 @@ class SearchController extends Controller
38
39
  'orders',
39
40
  'drivers',
40
41
  'vehicles',
42
+ 'trailers',
41
43
  'fleets',
42
44
  'vendors',
43
45
  'contacts',
@@ -108,6 +110,7 @@ class SearchController extends Controller
108
110
  'orders' => 'fleet-ops see order',
109
111
  'drivers' => 'fleet-ops see driver',
110
112
  'vehicles' => 'fleet-ops see vehicle',
113
+ 'trailers' => 'fleet-ops see trailer',
111
114
  'fleets' => 'fleet-ops see fleet',
112
115
  'vendors' => 'fleet-ops see vendor',
113
116
  'contacts' => 'fleet-ops see contact',
@@ -144,6 +147,7 @@ class SearchController extends Controller
144
147
  'orders' => $this->searchOrders($query, $limit),
145
148
  'drivers' => $this->searchDrivers($query, $limit),
146
149
  'vehicles' => $this->searchVehicles($query, $limit),
150
+ 'trailers' => $this->searchTrailers($query, $limit),
147
151
  'fleets' => $this->searchFleets($query, $limit),
148
152
  'vendors' => $this->searchVendors($query, $limit),
149
153
  'contacts' => $this->searchContacts($query, $limit),
@@ -226,6 +230,18 @@ class SearchController extends Controller
226
230
  ]);
227
231
  }
228
232
 
233
+ private function searchTrailers(string $query, int $limit): Collection
234
+ {
235
+ return $this->searchGeneric(Trailer::class, ['name', 'description', 'code', 'make', 'model', 'year', 'plate_number', 'vin', 'serial_number', 'public_id', 'uuid'], $query, $limit, fn (Trailer $trailer) => [
236
+ 'label' => $trailer->display_name ?: $trailer->name ?: $trailer->public_id,
237
+ 'description' => $this->description($trailer->type, $trailer->plate_number, $trailer->status),
238
+ 'icon' => 'trailer', 'type' => 'Trailer',
239
+ 'route' => 'console.fleet-ops.management.trailers.index.details',
240
+ 'models' => [$this->routeModel($trailer)],
241
+ 'breadcrumb' => 'Fleet-Ops > Resources > Trailers',
242
+ ]);
243
+ }
244
+
229
245
  private function searchFleets(string $query, int $limit): Collection
230
246
  {
231
247
  return $this->searchGeneric(Fleet::class, ['name', 'public_id', 'uuid'], $query, $limit, fn (Fleet $fleet) => [