@fleetbase/fleetops-engine 0.6.31 → 0.6.33

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.
@@ -42,7 +42,7 @@ export default class CustomerOrdersComponent extends Component {
42
42
 
43
43
  get modalsManager() {
44
44
  const owner = getOwner(this);
45
- const application = typeof this.universe.getApplicationInstance === 'function' ? this.universe.getApplicationInstance() : window.Fleetbase;
45
+ const application = this.universe.getApplicationInstance();
46
46
  const modalsManager = application ? application.lookup('service:modals-manager') : owner.lookup('service:modals-manager');
47
47
  return modalsManager;
48
48
  }
@@ -67,7 +67,7 @@ export default class LeafletRoutingControlService extends Service {
67
67
 
68
68
  #initializeRegistry() {
69
69
  const registry = 'registry:routing-controls';
70
- const application = typeof this.universe?.getApplicationInstance === 'function' ? this.universe.getApplicationInstance() : window.Fleetbase;
70
+ const application = this.universe.getApplicationInstance();
71
71
  if (!application.hasRegistration(registry)) {
72
72
  application.register(registry, new RoutingControlRegistry(), { instantiate: false });
73
73
  }
@@ -122,6 +122,7 @@ export class EventBuffer {
122
122
 
123
123
  export default class MovementTrackerService extends Service {
124
124
  @service socket;
125
+ @service universe;
125
126
  @tracked channels = [];
126
127
  @tracked buffers = new Map();
127
128
 
@@ -131,7 +132,7 @@ export default class MovementTrackerService extends Service {
131
132
  }
132
133
 
133
134
  #getOwner(owner = null) {
134
- return owner ?? window.Fleetbase ?? getOwner(this);
135
+ return owner ?? this.universe.getApplicationInstance() ?? getOwner(this);
135
136
  }
136
137
 
137
138
  #getBuffer(key, model, opts = {}) {
@@ -57,7 +57,7 @@ export default class RouteOptimizationService extends Service {
57
57
 
58
58
  #initializeRegistry() {
59
59
  const registry = 'registry:route-optimization-engines';
60
- const application = typeof this.universe?.getApplicationInstance === 'function' ? this.universe.getApplicationInstance() : window.Fleetbase;
60
+ const application = this.universe.getApplicationInstance();
61
61
  if (!application.hasRegistration(registry)) {
62
62
  application.register(registry, new RouteOptimizationRegistry(), { instantiate: false });
63
63
  }
package/composer.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fleetbase/fleetops-api",
3
- "version": "0.6.31",
3
+ "version": "0.6.33",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "keywords": [
6
6
  "fleetbase-extension",
package/extension.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "Fleet-Ops",
3
- "version": "0.6.31",
3
+ "version": "0.6.33",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "repository": "https://github.com/fleetbase/fleetops",
6
6
  "license": "AGPL-3.0-or-later",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fleetbase/fleetops-engine",
3
- "version": "0.6.31",
3
+ "version": "0.6.33",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "fleetbase": {
6
6
  "route": "fleet-ops"
@@ -42,9 +42,9 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "@babel/core": "^7.23.2",
45
- "@fleetbase/ember-core": "^0.3.9",
46
- "@fleetbase/ember-ui": "^0.3.15",
47
- "@fleetbase/fleetops-data": "^0.1.24",
45
+ "@fleetbase/ember-core": "^0.3.10",
46
+ "@fleetbase/ember-ui": "^0.3.17",
47
+ "@fleetbase/fleetops-data": "^0.1.25",
48
48
  "@fleetbase/leaflet-routing-machine": "^3.2.17",
49
49
  "@fortawesome/ember-fontawesome": "^2.0.0",
50
50
  "@fortawesome/fontawesome-svg-core": "6.4.0",
@@ -0,0 +1,98 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Console\Commands;
4
+
5
+ use Fleetbase\FleetOps\Mail\CustomerCredentialsMail;
6
+ use Fleetbase\FleetOps\Models\Contact;
7
+ use Fleetbase\Models\Company;
8
+ use Fleetbase\Models\User;
9
+ use Illuminate\Console\Command;
10
+ use Illuminate\Support\Facades\Mail;
11
+
12
+ class TestEmail extends Command
13
+ {
14
+ /**
15
+ * The name and signature of the console command.
16
+ *
17
+ * @var string
18
+ */
19
+ protected $signature = 'fleetops:test-email {email} {--type=customer_credentials : The type of email to test}';
20
+
21
+ /**
22
+ * The console command description.
23
+ *
24
+ * @var string
25
+ */
26
+ protected $description = 'Test FleetOps email templates';
27
+
28
+ /**
29
+ * Execute the console command.
30
+ *
31
+ * @return int
32
+ */
33
+ public function handle()
34
+ {
35
+ $email = $this->argument('email');
36
+ $type = $this->option('type');
37
+
38
+ $this->info('Sending test email...');
39
+ $this->info("Type: {$type}");
40
+ $this->info("To: {$email}");
41
+
42
+ try {
43
+ switch ($type) {
44
+ case 'customer_credentials':
45
+ $this->sendCustomerCredentialsEmail($email);
46
+ break;
47
+
48
+ default:
49
+ $this->error("Unknown email type: {$type}");
50
+ return Command::FAILURE;
51
+ }
52
+
53
+ $this->info('✓ Test email sent successfully!');
54
+ return Command::SUCCESS;
55
+ } catch (\Exception $e) {
56
+ $this->error('Failed to send test email: ' . $e->getMessage());
57
+ return Command::FAILURE;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Send a test customer credentials email.
63
+ *
64
+ * @param string $email
65
+ * @return void
66
+ */
67
+ private function sendCustomerCredentialsEmail(string $email): void
68
+ {
69
+ // Create a mock user
70
+ $user = new User([
71
+ 'name' => 'Test Customer',
72
+ 'email' => $email,
73
+ ]);
74
+
75
+ // Create a mock company
76
+ $company = new Company([
77
+ 'name' => 'Test Company',
78
+ 'public_id' => 'test_company_123',
79
+ ]);
80
+
81
+ // Create a mock customer
82
+ $customer = new Contact([
83
+ 'name' => 'Test Customer',
84
+ 'email' => $email,
85
+ 'phone' => '+1234567890',
86
+ ]);
87
+
88
+ // Set relations
89
+ $customer->setRelation('company', $company);
90
+ $customer->setRelation('user', $user);
91
+
92
+ // Mock password
93
+ $plaintextPassword = 'TestPassword123!';
94
+
95
+ // Send the email
96
+ Mail::to($email)->send(new CustomerCredentialsMail($plaintextPassword, $customer));
97
+ }
98
+ }
@@ -62,6 +62,9 @@ class DriverController extends Controller
62
62
  // Apply user infos
63
63
  $userDetails = User::applyUserInfoFromRequest($request, $userDetails);
64
64
 
65
+ // Set company_uuid before creating user
66
+ $userDetails['company_uuid'] = $company->uuid;
67
+
65
68
  // create user account for driver
66
69
  $user = User::create($userDetails);
67
70
 
@@ -27,12 +27,10 @@ class VehicleController extends Controller
27
27
  {
28
28
  // get request input
29
29
  $input = $request->only(['status', 'make', 'model', 'year', 'trim', 'type', 'plate_number', 'vin', 'meta', 'online', 'location', 'altitude', 'heading', 'speed']);
30
+
30
31
  // make sure company is set
31
32
  $input['company_uuid'] = session('company');
32
33
 
33
- // create instance of vehicle model
34
- $vehicle = new Vehicle();
35
-
36
34
  // set default online
37
35
  if (!isset($input['online'])) {
38
36
  $input['online'] = 0;
@@ -51,11 +49,8 @@ class VehicleController extends Controller
51
49
  $input['location'] = Utils::getPointFromCoordinates($request->only(['latitude', 'longitude']));
52
50
  }
53
51
 
54
- // apply user input to vehicle
55
- $vehicle = $vehicle->fill($input);
56
-
57
- // save the vehicle
58
- $vehicle->save();
52
+ // create the vehicle (fires 'created' event for billing resource tracking)
53
+ $vehicle = Vehicle::create($input);
59
54
 
60
55
  // driver assignment
61
56
  if ($request->has('driver')) {
@@ -136,7 +136,37 @@ class DriverController extends FleetOpsController
136
136
 
137
137
  if ($input->has('user_uuid')) {
138
138
  $user = User::where('uuid', $input->get('user_uuid'))->first();
139
- if ($user && $input->has('photo_uuid')) {
139
+
140
+ // If user doesn't exist with provided UUID, create new user
141
+ if (!$user) {
142
+ $userInput = $input
143
+ ->only(['name', 'password', 'email', 'phone', 'status', 'avatar_uuid'])
144
+ ->filter()
145
+ ->toArray();
146
+
147
+ // handle `photo_uuid`
148
+ if (isset($input['photo_uuid']) && Str::isUuid($input['photo_uuid'])) {
149
+ $userInput['avatar_uuid'] = $input['photo_uuid'];
150
+ }
151
+
152
+ // Make sure password is set
153
+ if (empty($userInput['password'])) {
154
+ $userInput['password'] = Str::random(14);
155
+ }
156
+
157
+ // Set user company
158
+ $userInput['company_uuid'] = session('company', $company->uuid);
159
+
160
+ // Apply user infos
161
+ $userInput = User::applyUserInfoFromRequest($request, $userInput);
162
+
163
+ // Create user account
164
+ $user = User::create($userInput);
165
+
166
+ // Set the user type to driver
167
+ $user->setType('driver');
168
+ } elseif ($input->has('photo_uuid')) {
169
+ // Update existing user's avatar if photo provided
140
170
  $user->update(['avatar_uuid' => $input->get('photo_uuid')]);
141
171
  }
142
172
  } else {
@@ -139,6 +139,7 @@ class LiveController extends Controller
139
139
 
140
140
  if ($active) {
141
141
  $query->whereHas('driverAssigned');
142
+ $query->whereNotIn('status', ['created', 'completed', 'expired', 'order_canceled', 'canceled', 'pending']);
142
143
  }
143
144
 
144
145
  if ($unassigned) {
@@ -166,7 +167,7 @@ class LiveController extends Controller
166
167
 
167
168
  return LiveCacheService::remember('drivers', $cacheParams, function () use ($bounds) {
168
169
  $query = Driver::where(['company_uuid' => session('company')])
169
- ->with(['user', 'vehicle', 'currentJob'])
170
+ ->with(['user', 'vehicle'])
170
171
  ->applyDirectivesForPermissions('fleet-ops list driver');
171
172
 
172
173
  // Filter out drivers with invalid coordinates
@@ -29,14 +29,12 @@ use Fleetbase\FleetOps\Models\Waypoint;
29
29
  use Fleetbase\FleetOps\Support\Utils;
30
30
  use Fleetbase\Http\Requests\ExportRequest;
31
31
  use Fleetbase\Http\Requests\Internal\BulkActionRequest;
32
- use Fleetbase\Http\Requests\Internal\BulkDeleteRequest;
33
32
  use Fleetbase\Models\File;
34
33
  use Fleetbase\Models\Type;
35
34
  use Fleetbase\Support\TemplateString;
36
35
  use Illuminate\Database\Eloquent\ModelNotFoundException;
37
36
  use Illuminate\Database\QueryException;
38
37
  use Illuminate\Http\Request;
39
- use Illuminate\Support\Collection;
40
38
  use Illuminate\Support\Facades\Cache;
41
39
  use Illuminate\Support\Facades\DB;
42
40
  use Illuminate\Support\Facades\Validator;
@@ -330,36 +328,6 @@ class OrderController extends FleetOpsController
330
328
  );
331
329
  }
332
330
 
333
- /**
334
- * Updates a order to canceled and updates order activity.
335
- *
336
- * @return \Illuminate\Http\Response
337
- */
338
- public function bulkDelete(BulkDeleteRequest $request)
339
- {
340
- $ids = $request->input('ids', []);
341
-
342
- if (!$ids) {
343
- return response()->error('Nothing to delete.');
344
- }
345
-
346
- /** @var Order */
347
- $count = Order::whereIn('uuid', $ids)->count();
348
- $deleted = Order::whereIn('uuid', $ids)->delete();
349
-
350
- if (!$deleted) {
351
- return response()->error('Failed to bulk delete orders.');
352
- }
353
-
354
- return response()->json(
355
- [
356
- 'status' => 'OK',
357
- 'message' => 'Deleted ' . $count . ' orders',
358
- 'count' => $count,
359
- ]
360
- );
361
- }
362
-
363
331
  /**
364
332
  * Updates a order to canceled and updates order activity.
365
333
  *
@@ -10,7 +10,6 @@ use Fleetbase\FleetOps\Models\Place;
10
10
  use Fleetbase\FleetOps\Support\Geocoding;
11
11
  use Fleetbase\Http\Requests\ExportRequest;
12
12
  use Fleetbase\Http\Requests\ImportRequest;
13
- use Fleetbase\Http\Requests\Internal\BulkDeleteRequest;
14
13
  use Fleetbase\LaravelMysqlSpatial\Types\Point;
15
14
  use Illuminate\Http\Request;
16
15
  use Illuminate\Support\Str;
@@ -150,38 +149,6 @@ class PlaceController extends FleetOpsController
150
149
  return Excel::download(new PlaceExport($selections), $fileName);
151
150
  }
152
151
 
153
- /**
154
- * Bulk deletes resources.
155
- *
156
- * @return \Illuminate\Http\Response
157
- */
158
- public function bulkDelete(BulkDeleteRequest $request)
159
- {
160
- $ids = $request->input('ids', []);
161
-
162
- if (!$ids) {
163
- return response()->error('Nothing to delete.');
164
- }
165
-
166
- /**
167
- * @var \Fleetbase\Models\Place
168
- */
169
- $count = Place::whereIn('uuid', $ids)->applyDirectivesForPermissions('fleet-ops list place')->count();
170
- $deleted = Place::whereIn('uuid', $ids)->applyDirectivesForPermissions('fleet-ops list place')->delete();
171
-
172
- if (!$deleted) {
173
- return response()->error('Failed to bulk delete places.');
174
- }
175
-
176
- return response()->json(
177
- [
178
- 'status' => 'OK',
179
- 'message' => 'Deleted ' . $count . ' places',
180
- ],
181
- 200
182
- );
183
- }
184
-
185
152
  /**
186
153
  * Get all avatar options for an vehicle.
187
154
  *
@@ -9,7 +9,6 @@ use Fleetbase\FleetOps\Models\Driver;
9
9
  use Fleetbase\FleetOps\Models\Vendor;
10
10
  use Fleetbase\Http\Requests\ExportRequest;
11
11
  use Fleetbase\Http\Requests\ImportRequest;
12
- use Fleetbase\Http\Requests\Internal\BulkDeleteRequest;
13
12
  use Illuminate\Http\Request;
14
13
  use Illuminate\Support\Facades\DB;
15
14
  use Illuminate\Support\Str;
@@ -85,36 +84,6 @@ class VendorController extends FleetOpsController
85
84
  return Excel::download(new VendorExport($selections), $fileName);
86
85
  }
87
86
 
88
- /**
89
- * Bulk delete resources.
90
- *
91
- * @return \Illuminate\Http\Response
92
- */
93
- public function bulkDelete(BulkDeleteRequest $request)
94
- {
95
- $ids = $request->input('ids', []);
96
-
97
- if (!$ids) {
98
- return response()->error('Nothing to delete.');
99
- }
100
-
101
- /** @var \Fleetbase\Models\Vendor */
102
- $count = Vendor::whereIn('uuid', $ids)->count();
103
- $deleted = Vendor::whereIn('uuid', $ids)->delete();
104
-
105
- if (!$deleted) {
106
- return response()->error('Failed to bulk delete vendors.');
107
- }
108
-
109
- return response()->json(
110
- [
111
- 'status' => 'OK',
112
- 'message' => 'Deleted ' . $count . ' vendors',
113
- ],
114
- 200
115
- );
116
- }
117
-
118
87
  /**
119
88
  * Get all status options for an vehicle.
120
89
  *
@@ -116,7 +116,7 @@ class OrderFilter extends Filter
116
116
  $this->builder->where(
117
117
  function ($q) {
118
118
  $q->whereHas('driverAssigned');
119
- $q->whereNotIn('status', ['created', 'canceled', 'order_canceled', 'completed']);
119
+ $q->whereNotIn('status', ['created', 'completed', 'expired', 'order_canceled', 'canceled', 'pending']);
120
120
  }
121
121
  );
122
122
  }
@@ -36,8 +36,8 @@ class CreateServiceRateRequest extends FleetbaseRequest
36
36
  'base_fee' => ['numeric'],
37
37
  'per_meter_unit' => ['required_if:rate_calculation_method,per_meter', 'string', 'in:km,m'],
38
38
  'per_meter_flat_rate_fee' => ['required_if:rate_calculation_method,per_meter', 'numeric'],
39
- 'meter_fees' => [Rule::requiredIf(function ($input) {
40
- return in_array($input->rate_calculation_method, ['fixed_meter', 'fixed_rate']);
39
+ 'meter_fees' => [Rule::requiredIf(function () {
40
+ return in_array($this->input('rate_calculation_method'), ['fixed_meter', 'fixed_rate']);
41
41
  }), 'array'],
42
42
  'meter_fees.*.distance' => ['numeric'],
43
43
  'meter_fees.*.fee' => ['numeric'],
@@ -3,7 +3,9 @@
3
3
  namespace Fleetbase\FleetOps\Http\Requests\Internal;
4
4
 
5
5
  use Fleetbase\FleetOps\Http\Requests\CreateDriverRequest as CreateDriverApiRequest;
6
+ use Fleetbase\FleetOps\Rules\ResolvablePoint;
6
7
  use Fleetbase\Support\Auth;
8
+ use Illuminate\Validation\Rule;
7
9
 
8
10
  class CreateDriverRequest extends CreateDriverApiRequest
9
11
  {
@@ -25,13 +27,75 @@ class CreateDriverRequest extends CreateDriverApiRequest
25
27
  public function rules()
26
28
  {
27
29
  $isCreating = $this->isMethod('POST');
30
+ $isCreatingWithUser = $this->filled('driver.user_uuid');
31
+ $shouldValidateUserAttributes = $isCreating && !$isCreatingWithUser;
28
32
 
29
33
  return [
30
- 'password' => 'nullable|string',
31
- 'country' => 'nullable|size:2',
32
- 'city' => 'nullable|string',
33
- 'status' => 'nullable|string|in:active,inactive',
34
- 'job' => 'nullable|exists:orders,public_id',
34
+ // Required fields for driver creation
35
+ 'name' => [Rule::requiredIf($shouldValidateUserAttributes), 'nullable', 'string', 'max:255'],
36
+ 'email' => [
37
+ Rule::requiredIf($shouldValidateUserAttributes),
38
+ Rule::when($this->filled('email'), ['email']),
39
+ Rule::when($shouldValidateUserAttributes, [Rule::unique('users')->whereNull('deleted_at')])
40
+ ],
41
+ 'phone' => [
42
+ Rule::requiredIf($shouldValidateUserAttributes),
43
+ Rule::when($shouldValidateUserAttributes, [Rule::unique('users')->whereNull('deleted_at')])
44
+ ],
45
+
46
+ // Optional fields
47
+ 'password' => 'nullable|string|min:8',
48
+ 'drivers_license_number' => 'nullable|string|max:255',
49
+ 'internal_id' => 'nullable|string|max:255',
50
+ 'country' => 'nullable|string|size:2',
51
+ 'city' => 'nullable|string|max:255',
52
+ 'vehicle' => 'nullable|string|starts_with:vehicle_|exists:vehicles,public_id',
53
+ 'status' => 'nullable|string|in:active,inactive',
54
+ 'vendor' => 'nullable|exists:vendors,public_id',
55
+ 'job' => 'nullable|exists:orders,public_id',
56
+ 'location' => ['nullable', new ResolvablePoint()],
57
+ 'latitude' => ['nullable', 'required_with:longitude', 'numeric'],
58
+ 'longitude' => ['nullable', 'required_with:latitude', 'numeric'],
59
+
60
+ // Photo/avatar
61
+ 'photo_uuid' => 'nullable|exists:files,uuid',
62
+ 'avatar_uuid' => 'nullable|exists:files,uuid',
63
+ ];
64
+ }
65
+
66
+ /**
67
+ * Get custom attributes for validator errors.
68
+ *
69
+ * @return array
70
+ */
71
+ public function attributes()
72
+ {
73
+ return [
74
+ 'name' => 'driver name',
75
+ 'email' => 'email address',
76
+ 'phone' => 'phone number',
77
+ 'drivers_license_number' => 'driver\'s license number',
78
+ 'internal_id' => 'internal ID',
79
+ 'photo_uuid' => 'photo',
80
+ 'avatar_uuid' => 'avatar',
81
+ ];
82
+ }
83
+
84
+ /**
85
+ * Get custom messages for validator errors.
86
+ *
87
+ * @return array
88
+ */
89
+ public function messages()
90
+ {
91
+ return [
92
+ 'name.required' => 'Driver name is required.',
93
+ 'email.required' => 'Email address is required.',
94
+ 'email.email' => 'Please provide a valid email address.',
95
+ 'email.unique' => 'This email address is already registered.',
96
+ 'phone.required' => 'Phone number is required.',
97
+ 'phone.unique' => 'This phone number is already registered.',
98
+ 'password.min' => 'Password must be at least 8 characters.',
35
99
  ];
36
100
  }
37
101
  }
@@ -84,6 +84,7 @@ class Order extends FleetbaseResource
84
84
  'pod_method' => $this->pod_method,
85
85
  'pod_required' => (bool) data_get($this, 'pod_required', false),
86
86
  'dispatched' => (bool) data_get($this, 'dispatched', false),
87
+ 'started' => (bool) data_get($this, 'started', false),
87
88
  'adhoc' => (bool) data_get($this, 'adhoc', false),
88
89
  'adhoc_distance' => (int) $this->getAdhocDistance(),
89
90
  'distance' => (int) $this->distance,
@@ -262,12 +262,12 @@ class Driver extends Model
262
262
 
263
263
  public function currentJob(): BelongsTo|Builder
264
264
  {
265
- return $this->belongsTo(Order::class)->select(['id', 'uuid', 'public_id', 'payload_uuid', 'driver_assigned_uuid'])->without(['driver']);
265
+ return $this->belongsTo(Order::class)->without(['driver']);
266
266
  }
267
267
 
268
268
  public function currentOrder(): BelongsTo|Builder
269
269
  {
270
- return $this->belongsTo(Order::class, 'current_job_uuid')->select(['id', 'uuid', 'public_id', 'payload_uuid', 'driver_assigned_uuid'])->without(['driver']);
270
+ return $this->belongsTo(Order::class, 'current_job_uuid')->without(['driver']);
271
271
  }
272
272
 
273
273
  public function jobs(): HasMany|Builder
@@ -333,6 +333,28 @@ class Place extends Model
333
333
  {
334
334
  $instance = (new static())->fillWithGoogleAddress($address);
335
335
 
336
+ // Before saving or returning this instance check the database for a duplicate address
337
+ // it cannot have any owner, and must belong to this session
338
+ if ($companyUuid = session('company')) {
339
+ $duplicate = static::where([
340
+ 'company_uuid' => $companyUuid,
341
+ 'street1' => $instance->street1,
342
+ 'city' => $instance->city,
343
+ 'country' => $instance->country,
344
+ ])
345
+ ->when(
346
+ $instance->postal_code !== null,
347
+ fn ($q) => $q->where('postal_code', $instance->postal_code),
348
+ fn ($q) => $q->whereNull('postal_code')
349
+ )
350
+ ->whereNull('owner_uuid')
351
+ ->first();
352
+
353
+ if ($duplicate) {
354
+ return $duplicate;
355
+ }
356
+ }
357
+
336
358
  if ($saveInstance) {
337
359
  $instance->save();
338
360
  }
@@ -18,6 +18,20 @@ class OrderObserver
18
18
  $this->invalidateCache($order);
19
19
  }
20
20
 
21
+ /**
22
+ * Handle the Order "updating" event.
23
+ *
24
+ * This event is fired before the order is persisted to the database.
25
+ * It is used to mutate attributes as part of the same update operation
26
+ * without triggering additional save cycles.
27
+ *
28
+ * @param Order $order The order being updated
29
+ */
30
+ public function updating(Order $order): void
31
+ {
32
+ $this->ensureOrderStarted($order);
33
+ }
34
+
21
35
  /**
22
36
  * Handle the Order "updated" event.
23
37
  *
@@ -62,4 +76,40 @@ class OrderObserver
62
76
  Cache::forget("order:{$order->uuid}:tracker");
63
77
  }
64
78
  }
79
+
80
+ /**
81
+ * Detects when an order has just transitioned to the "started" status
82
+ * and initializes start-related fields.
83
+ *
84
+ * This method should be called during the "updating" lifecycle event
85
+ * to ensure that the changes are persisted as part of the same database
86
+ * update and do not trigger additional observer events.
87
+ *
88
+ * An order is considered "started" when:
89
+ * - The "status" attribute is being changed in the current update
90
+ * - The previous status was not "started"
91
+ * - The new status is "started"
92
+ *
93
+ * When these conditions are met, the order's start timestamp and
94
+ * started flag are set if they have not already been initialized.
95
+ *
96
+ * @param Order $order The order being evaluated for a start transition
97
+ */
98
+ protected function ensureOrderStarted(Order $order): void
99
+ {
100
+ if (
101
+ $order->isDirty('status')
102
+ && $order->getOriginal('status') === 'dispatched'
103
+ && $order->status === 'started'
104
+ ) {
105
+ // Only set defaults if not explicitly provided
106
+ if (is_null($order->started_at)) {
107
+ $order->started_at = now();
108
+ }
109
+
110
+ if (!$order->started) {
111
+ $order->started = true;
112
+ }
113
+ }
114
+ }
65
115
  }
@@ -60,6 +60,7 @@ class FleetOpsServiceProvider extends CoreServiceProvider
60
60
  \Fleetbase\FleetOps\Console\Commands\PurgeUnpurchasedServiceQuotes::class,
61
61
  \Fleetbase\FleetOps\Console\Commands\SendDriverNotification::class,
62
62
  \Fleetbase\FleetOps\Console\Commands\ReplayVehicleLocations::class,
63
+ \Fleetbase\FleetOps\Console\Commands\TestEmail::class,
63
64
  ];
64
65
 
65
66
  /**