@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,360 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
4
+
5
+ use Fleetbase\FleetOps\Exceptions\DeviceAlreadyAttachedException;
6
+ use Fleetbase\FleetOps\Exports\TrailerExport;
7
+ use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
8
+ use Fleetbase\FleetOps\Http\Resources\v1\AssetConnection as AssetConnectionResource;
9
+ use Fleetbase\FleetOps\Http\Resources\v1\Trailer as TrailerResource;
10
+ use Fleetbase\FleetOps\Imports\TrailerImport;
11
+ use Fleetbase\FleetOps\Models\AssetConnection;
12
+ use Fleetbase\FleetOps\Models\Device;
13
+ use Fleetbase\FleetOps\Models\Equipment;
14
+ use Fleetbase\FleetOps\Models\Trailer;
15
+ use Fleetbase\FleetOps\Models\Vehicle;
16
+ use Fleetbase\Http\Requests\ExportRequest;
17
+ use Fleetbase\Http\Requests\ImportRequest;
18
+ use Fleetbase\Support\Resolve;
19
+ use Illuminate\Http\Request;
20
+ use Illuminate\Support\Facades\DB;
21
+ use Illuminate\Support\Str;
22
+ use Maatwebsite\Excel\Facades\Excel;
23
+
24
+ class TrailerController extends FleetOpsController
25
+ {
26
+ /**
27
+ * The resource to query.
28
+ *
29
+ * @var string
30
+ */
31
+ public $resource = 'trailer';
32
+
33
+ /**
34
+ * Handle post save transactions.
35
+ */
36
+ public function afterSave(Request $request, Trailer $trailer): void
37
+ {
38
+ $values = $request->array('trailer.custom_field_values');
39
+
40
+ if ($values) {
41
+ $trailer->syncCustomFieldValues($values);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Deleting a trailer that is still coupled to a vehicle would orphan the active
47
+ * towing connection, so it must be detached first.
48
+ */
49
+ public function deleteRecord($id, Request $request)
50
+ {
51
+ $trailer = $this->resolveTrailer($id);
52
+
53
+ if ($trailer && $this->activeConnectionQuery($trailer)->exists()) {
54
+ return response()->error('Detach the trailer from its vehicle before deleting it.', 409);
55
+ }
56
+
57
+ return $this->deleteTrailerRecord($id, $request);
58
+ }
59
+
60
+ // @codeCoverageIgnoreStart
61
+ // Thin seam over the framework delete flow; the guard above is what this controller owns.
62
+ protected function deleteTrailerRecord($id, Request $request)
63
+ {
64
+ return parent::deleteRecord($id, $request);
65
+ }
66
+
67
+ // @codeCoverageIgnoreEnd
68
+
69
+ /**
70
+ * Attach the trailer to a vehicle. Re-attaching to the same vehicle is idempotent.
71
+ */
72
+ public function attach(Request $request, string $id)
73
+ {
74
+ $request->validate(['vehicle' => ['required', 'string'], 'position' => ['nullable', 'integer', 'min:1']]);
75
+
76
+ $trailer = $this->resolveTrailer($id);
77
+ $vehicle = $this->resolveVehicle($request->input('vehicle'));
78
+
79
+ if (!$trailer) {
80
+ return response()->error('Trailer not found or not available for this organization.', 404);
81
+ }
82
+
83
+ if (!$vehicle) {
84
+ return response()->error('Vehicle not found or not available for this organization.', 404);
85
+ }
86
+
87
+ $result = DB::transaction(function () use ($trailer, $vehicle, $request) {
88
+ $active = $this->activeConnectionQuery($trailer)->lockForUpdate()->first();
89
+
90
+ if ($active) {
91
+ if ($active->connector_uuid !== $vehicle->uuid) {
92
+ return 'Trailer is already attached to another vehicle. Detach it before moving.';
93
+ }
94
+
95
+ return $active;
96
+ }
97
+
98
+ $position = $request->integer('position', 1);
99
+ $positionKey = $vehicle->uuid . ':' . $position;
100
+
101
+ if (AssetConnection::where('company_uuid', session('company'))->where('active_connector_position', $positionKey)->lockForUpdate()->exists()) {
102
+ return 'Another trailer already occupies this towing position.';
103
+ }
104
+
105
+ return AssetConnection::create([
106
+ 'company_uuid' => session('company'),
107
+ 'connector_type' => Vehicle::class,
108
+ 'connector_uuid' => $vehicle->uuid,
109
+ 'connected_type' => Trailer::class,
110
+ 'connected_uuid' => $trailer->uuid,
111
+ 'active_connected_uuid' => $trailer->uuid,
112
+ 'active_connector_position' => $positionKey,
113
+ 'relationship_type' => 'towing',
114
+ 'position' => $position,
115
+ 'connected_at' => now(),
116
+ 'source' => 'manual',
117
+ 'created_by_uuid' => session('user'),
118
+ 'updated_by_uuid' => session('user'),
119
+ ]);
120
+ }, 3);
121
+
122
+ if (is_string($result)) {
123
+ return response()->error($result, 409);
124
+ }
125
+
126
+ return response()->json([
127
+ 'status' => 'ok',
128
+ 'message' => 'Trailer attached to vehicle.',
129
+ 'trailer' => new TrailerResource($trailer->fresh(['currentConnection.vehicle'])),
130
+ 'connection' => new AssetConnectionResource($result->load(['vehicle', 'trailer'])),
131
+ ]);
132
+ }
133
+
134
+ /**
135
+ * End the active towing connection. Detaching an unattached trailer is a no-op.
136
+ */
137
+ public function detach(string $id)
138
+ {
139
+ $trailer = $this->resolveTrailer($id);
140
+
141
+ if (!$trailer) {
142
+ return response()->error('Trailer not found or not available for this organization.', 404);
143
+ }
144
+
145
+ DB::transaction(fn () => $this->activeConnectionQuery($trailer)->lockForUpdate()->update([
146
+ 'active_connected_uuid' => null,
147
+ 'active_connector_position' => null,
148
+ 'disconnected_at' => now(),
149
+ 'updated_by_uuid' => session('user'),
150
+ 'updated_at' => now(),
151
+ ]));
152
+
153
+ return response()->json([
154
+ 'status' => 'ok',
155
+ 'message' => 'Trailer detached from vehicle.',
156
+ 'trailer' => new TrailerResource($trailer->fresh(['currentConnection.vehicle'])),
157
+ ]);
158
+ }
159
+
160
+ public function attachDevice(Request $request, string $id)
161
+ {
162
+ $request->validate(['device' => ['required', 'string']]);
163
+
164
+ $trailer = $this->resolveTrailer($id);
165
+ $device = $this->resolveDevice($request->input('device'));
166
+
167
+ if (!$trailer) {
168
+ return response()->error('Trailer not found or not available for this organization.', 404);
169
+ }
170
+
171
+ if (!$device) {
172
+ return response()->error('Device not found or not available for this organization.', 404);
173
+ }
174
+
175
+ try {
176
+ $device->attachTo($trailer);
177
+ } catch (DeviceAlreadyAttachedException $e) {
178
+ return response()->error($e->getMessage(), 409);
179
+ }
180
+
181
+ return response()->json([
182
+ 'status' => 'ok',
183
+ 'message' => 'Device attached to trailer.',
184
+ 'trailer' => new TrailerResource($trailer->fresh(['devices'])),
185
+ 'device' => Resolve::httpResourceForModel($device->fresh(['attachable'])),
186
+ ]);
187
+ }
188
+
189
+ public function detachDevice(Request $request, string $id)
190
+ {
191
+ $request->validate(['device' => ['required', 'string']]);
192
+
193
+ $trailer = $this->resolveTrailer($id);
194
+ $device = $this->resolveDevice($request->input('device'));
195
+
196
+ if (!$trailer) {
197
+ return response()->error('Trailer not found or not available for this organization.', 404);
198
+ }
199
+
200
+ if (!$device) {
201
+ return response()->error('Device not found or not available for this organization.', 404);
202
+ }
203
+
204
+ if ($device->attachable_uuid !== $trailer->uuid || $device->attachable_type !== Trailer::class) {
205
+ return response()->error('This device is not attached to the selected trailer.', 422);
206
+ }
207
+
208
+ $device->detach();
209
+
210
+ return response()->json([
211
+ 'status' => 'ok',
212
+ 'message' => 'Device detached from trailer.',
213
+ 'trailer' => new TrailerResource($trailer->fresh(['devices'])),
214
+ 'device' => Resolve::httpResourceForModel($device->fresh(['attachable'])),
215
+ ]);
216
+ }
217
+
218
+ public function attachEquipment(Request $request, string $id)
219
+ {
220
+ $request->validate(['equipment' => ['required', 'string']]);
221
+
222
+ $trailer = $this->resolveTrailer($id);
223
+ $equipment = $this->resolveEquipment($request->input('equipment'));
224
+
225
+ if (!$trailer) {
226
+ return response()->error('Trailer not found or not available for this organization.', 404);
227
+ }
228
+
229
+ if (!$equipment) {
230
+ return response()->error('Equipment not found or not available for this organization.', 404);
231
+ }
232
+
233
+ DB::transaction(function () use ($equipment, $trailer) {
234
+ Equipment::where('uuid', $equipment->uuid)->lockForUpdate()->firstOrFail()->update(['equipable_type' => Trailer::class, 'equipable_uuid' => $trailer->uuid]);
235
+ });
236
+
237
+ return response()->json([
238
+ 'status' => 'ok',
239
+ 'message' => 'Equipment attached to trailer.',
240
+ 'trailer' => new TrailerResource($trailer->fresh(['equipments'])),
241
+ 'equipment' => Resolve::httpResourceForModel($equipment->fresh(['equipable'])),
242
+ ]);
243
+ }
244
+
245
+ public function detachEquipment(Request $request, string $id)
246
+ {
247
+ $request->validate(['equipment' => ['required', 'string']]);
248
+
249
+ $trailer = $this->resolveTrailer($id);
250
+ $equipment = $this->resolveEquipment($request->input('equipment'));
251
+
252
+ if (!$trailer) {
253
+ return response()->error('Trailer not found or not available for this organization.', 404);
254
+ }
255
+
256
+ if (!$equipment) {
257
+ return response()->error('Equipment not found or not available for this organization.', 404);
258
+ }
259
+
260
+ if ($equipment->equipable_uuid !== $trailer->uuid || $equipment->equipable_type !== Trailer::class) {
261
+ return response()->error('This equipment is not attached to the selected trailer.', 422);
262
+ }
263
+
264
+ DB::transaction(fn () => Equipment::where('uuid', $equipment->uuid)->lockForUpdate()->update(['equipable_type' => null, 'equipable_uuid' => null]));
265
+
266
+ return response()->json([
267
+ 'status' => 'ok',
268
+ 'message' => 'Equipment detached from trailer.',
269
+ 'trailer' => new TrailerResource($trailer->fresh(['equipments'])),
270
+ 'equipment' => Resolve::httpResourceForModel($equipment->fresh(['equipable'])),
271
+ ]);
272
+ }
273
+
274
+ /**
275
+ * Lifecycle statuses available to trailers, used by the console status filter.
276
+ */
277
+ public function statuses()
278
+ {
279
+ return response()->json(Trailer::STATUSES);
280
+ }
281
+
282
+ /**
283
+ * Trailer classification types, used by the console type filter.
284
+ */
285
+ public function types()
286
+ {
287
+ return response()->json(Trailer::TYPES);
288
+ }
289
+
290
+ public function export(ExportRequest $request)
291
+ {
292
+ $format = $request->input('format', 'xlsx');
293
+
294
+ return $this->downloadExport(new TrailerExport($request->array('selections')), Str::slug('trailers-' . date('Y-m-d-H:i')) . '.' . $format);
295
+ }
296
+
297
+ public function import(ImportRequest $request)
298
+ {
299
+ $count = 0;
300
+
301
+ foreach ($request->resolveFilesFromIds() as $file) {
302
+ $import = $this->createImport();
303
+ $this->importFile($import, $file->path, $request->input('disk', config('filesystems.default')));
304
+ $count += $import->imported;
305
+ }
306
+
307
+ return response()->json(['status' => 'ok', 'message' => 'Import completed', 'imported' => $count]);
308
+ }
309
+
310
+ protected function resolveTrailer(string $id): ?Trailer
311
+ {
312
+ return Trailer::where('company_uuid', session('company'))
313
+ ->where(fn ($query) => $query->where('uuid', $id)->orWhere('public_id', $id))
314
+ ->first();
315
+ }
316
+
317
+ protected function resolveVehicle(string $id): ?Vehicle
318
+ {
319
+ return Vehicle::where('company_uuid', session('company'))
320
+ ->where(fn ($query) => $query->where('uuid', $id)->orWhere('public_id', $id)->orWhere('internal_id', $id))
321
+ ->first();
322
+ }
323
+
324
+ protected function resolveDevice(string $id): ?Device
325
+ {
326
+ return Device::where('company_uuid', session('company'))
327
+ ->where(fn ($query) => $query->where('uuid', $id)->orWhere('public_id', $id))
328
+ ->first();
329
+ }
330
+
331
+ protected function resolveEquipment(string $id): ?Equipment
332
+ {
333
+ return Equipment::where('company_uuid', session('company'))
334
+ ->where(fn ($query) => $query->where('uuid', $id)->orWhere('public_id', $id))
335
+ ->first();
336
+ }
337
+
338
+ protected function activeConnectionQuery(Trailer $trailer)
339
+ {
340
+ return AssetConnection::where('company_uuid', session('company'))->where('active_connected_uuid', $trailer->uuid);
341
+ }
342
+
343
+ // @codeCoverageIgnoreStart
344
+ // Thin spreadsheet adapter seams are exercised through export/import caller behavior.
345
+ protected function downloadExport(TrailerExport $export, string $fileName)
346
+ {
347
+ return Excel::download($export, $fileName);
348
+ }
349
+
350
+ protected function createImport(): TrailerImport
351
+ {
352
+ return new TrailerImport();
353
+ }
354
+
355
+ protected function importFile(TrailerImport $import, string $path, string $disk): void
356
+ {
357
+ Excel::import($import, $path, $disk);
358
+ }
359
+ // @codeCoverageIgnoreEnd
360
+ }
@@ -2,16 +2,19 @@
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\VehicleExport;
6
7
  use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
7
8
  use Fleetbase\FleetOps\Http\Resources\v1\Index\Order as IndexOrderResource;
8
9
  use Fleetbase\FleetOps\Imports\VehicleImport;
9
10
  use Fleetbase\FleetOps\Models\Device;
10
11
  use Fleetbase\FleetOps\Models\Driver;
12
+ use Fleetbase\FleetOps\Models\Equipment;
11
13
  use Fleetbase\FleetOps\Models\Order;
12
14
  use Fleetbase\FleetOps\Models\Vehicle;
13
15
  use Fleetbase\Http\Requests\ExportRequest;
14
16
  use Fleetbase\Http\Requests\ImportRequest;
17
+ use Fleetbase\Support\Resolve;
15
18
  use Illuminate\Http\JsonResponse;
16
19
  use Illuminate\Http\Request;
17
20
  use Illuminate\Support\Arr;
@@ -200,6 +203,8 @@ class VehicleController extends FleetOpsController
200
203
  $device->attachTo($vehicle);
201
204
  $device->load(['telematic', 'warranty', 'attachable']);
202
205
  $vehicle->load(['driver', 'devices']);
206
+ } catch (DeviceAlreadyAttachedException $e) {
207
+ return response()->error($e->getMessage(), 409);
203
208
  } catch (\Throwable $e) {
204
209
  $this->logDeviceAttachmentFailure('attach-device', $vehicle, $device, $e);
205
210
 
@@ -256,6 +261,66 @@ class VehicleController extends FleetOpsController
256
261
  ]);
257
262
  }
258
263
 
264
+ /**
265
+ * Attach equipment to the vehicle. Equipment can only be equipped to one asset at a
266
+ * time, so attaching moves it from any previous vehicle or trailer.
267
+ */
268
+ public function attachEquipment(Request $request, string $id): JsonResponse
269
+ {
270
+ $request->validate(['equipment' => 'required|string']);
271
+
272
+ $vehicle = $this->resolveVehicle($id);
273
+ $equipment = $this->resolveEquipment($request->input('equipment'));
274
+
275
+ if (!$vehicle) {
276
+ return response()->error('Vehicle not found or not available for this organization.', 404);
277
+ }
278
+
279
+ if (!$equipment) {
280
+ return response()->error('Equipment not found or not available for this organization.', 404);
281
+ }
282
+
283
+ DB::transaction(function () use ($equipment, $vehicle) {
284
+ Equipment::where('uuid', $equipment->uuid)->lockForUpdate()->firstOrFail()->update(['equipable_type' => Vehicle::class, 'equipable_uuid' => $vehicle->uuid]);
285
+ });
286
+
287
+ return response()->json([
288
+ 'status' => 'ok',
289
+ 'message' => 'Equipment attached to vehicle.',
290
+ 'vehicle' => Resolve::httpResourceForModel($vehicle->fresh(['equipments'])),
291
+ 'equipment' => Resolve::httpResourceForModel($equipment->fresh(['equipable'])),
292
+ ]);
293
+ }
294
+
295
+ public function detachEquipment(Request $request, string $id): JsonResponse
296
+ {
297
+ $request->validate(['equipment' => 'required|string']);
298
+
299
+ $vehicle = $this->resolveVehicle($id);
300
+ $equipment = $this->resolveEquipment($request->input('equipment'));
301
+
302
+ if (!$vehicle) {
303
+ return response()->error('Vehicle not found or not available for this organization.', 404);
304
+ }
305
+
306
+ if (!$equipment) {
307
+ return response()->error('Equipment not found or not available for this organization.', 404);
308
+ }
309
+
310
+ if ($equipment->equipable_uuid !== $vehicle->uuid || $equipment->equipable_type !== Vehicle::class) {
311
+ return response()->error('This equipment is not attached to the selected vehicle.', 422);
312
+ }
313
+
314
+ DB::transaction(fn () => Equipment::where('uuid', $equipment->uuid)->lockForUpdate()->update(['equipable_type' => null, 'equipable_uuid' => null]));
315
+
316
+ return response()->json([
317
+ 'status' => 'ok',
318
+ 'message' => 'Equipment detached from vehicle.',
319
+ 'vehicle' => Resolve::httpResourceForModel($vehicle->fresh(['equipments'])),
320
+ 'equipment' => Resolve::httpResourceForModel($equipment->fresh(['equipable'])),
321
+ ]);
322
+ }
323
+
259
324
  protected function findVehicle(string $id): Vehicle
260
325
  {
261
326
  return Vehicle::where(function ($query) use ($id) {
@@ -283,6 +348,15 @@ class VehicleController extends FleetOpsController
283
348
  ->firstOrFail();
284
349
  }
285
350
 
351
+ protected function resolveEquipment(string $id): ?Equipment
352
+ {
353
+ return Equipment::where(function ($query) use ($id) {
354
+ $query->where('uuid', $id)->orWhere('public_id', $id);
355
+ })
356
+ ->where('company_uuid', session('company'))
357
+ ->first();
358
+ }
359
+
286
360
  protected function resolveDevice(string $id): ?Device
287
361
  {
288
362
  return Device::where(function ($query) use ($id) {
@@ -2,7 +2,11 @@
2
2
 
3
3
  namespace Fleetbase\FleetOps\Http\Filter;
4
4
 
5
+ use Fleetbase\FleetOps\Models\Driver;
6
+ use Fleetbase\FleetOps\Models\Trailer;
7
+ use Fleetbase\FleetOps\Models\Vehicle;
5
8
  use Fleetbase\Http\Filter\Filter;
9
+ use Fleetbase\Support\Http;
6
10
 
7
11
  class EquipmentFilter extends Filter
8
12
  {
@@ -20,4 +24,51 @@ class EquipmentFilter extends Filter
20
24
  {
21
25
  $this->builder->search($searchQuery);
22
26
  }
27
+
28
+ public function attachmentState(?string $state)
29
+ {
30
+ $state === 'attached' ? $this->builder->whereNotNull('equipable_uuid') : $this->builder->whereNull('equipable_uuid');
31
+ }
32
+
33
+ public function equipableType(?string $type)
34
+ {
35
+ $class = match ($type) {
36
+ 'vehicle', 'fleet-ops:vehicle' => Vehicle::class, 'trailer', 'fleet-ops:trailer' => Trailer::class, default => null,
37
+ };
38
+ $class ? $this->builder->where('equipable_type', $class) : $this->builder->whereRaw('0 = 1');
39
+ }
40
+
41
+ /**
42
+ * Filter by the asset the equipment is equipped to. `equipable` is a morphTo relation,
43
+ * which `whereHas` cannot traverse, so the identifier is resolved to the asset UUID
44
+ * across every equipable model first. The public API filters by public id; the
45
+ * console may also pass the asset UUID.
46
+ */
47
+ public function equipable($id)
48
+ {
49
+ $identifiers = array_values(array_filter(array_map('strval', (array) $id), 'strlen'));
50
+
51
+ if (empty($identifiers)) {
52
+ return;
53
+ }
54
+
55
+ $allowUuid = Http::isInternalRequest($this->request);
56
+ $uuids = [];
57
+
58
+ foreach ([Vehicle::class, Trailer::class, Driver::class] as $modelClass) {
59
+ $uuids = array_merge($uuids, $modelClass::query()
60
+ ->where('company_uuid', $this->session->get('company'))
61
+ ->where(function ($query) use ($identifiers, $allowUuid) {
62
+ $query->whereIn('public_id', $identifiers);
63
+
64
+ if ($allowUuid) {
65
+ $query->orWhereIn('uuid', $identifiers);
66
+ }
67
+ })
68
+ ->pluck('uuid')
69
+ ->all());
70
+ }
71
+
72
+ $this->builder->whereIn('equipable_uuid', $uuids);
73
+ }
23
74
  }
@@ -366,6 +366,21 @@ class OrderFilter extends Filter
366
366
  }
367
367
  }
368
368
 
369
+ /**
370
+ * Exact match on one or many operator-side ids. The console's tag-input filter
371
+ * joins scanned or pasted ids with commas so a batch of labels resolves in one query.
372
+ */
373
+ public function internalId($internalId)
374
+ {
375
+ $ids = array_values(array_filter(array_map('trim', explode(',', (string) $internalId)), 'strlen'));
376
+
377
+ if ($ids === []) {
378
+ return;
379
+ }
380
+
381
+ $this->builder->whereIn('internal_id', $ids);
382
+ }
383
+
369
384
  public function bulkQuery($ids)
370
385
  {
371
386
  $ids = Utils::arrayFrom($ids);