@fleetbase/fleetops-engine 0.6.17 → 0.6.18

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 (44) hide show
  1. package/addon/components/layout/fleet-ops-sidebar.hbs +25 -0
  2. package/addon/templates/virtual.hbs +3 -3
  3. package/composer.json +3 -2
  4. package/extension.json +1 -1
  5. package/package.json +1 -1
  6. package/server/migrations/2025_08_28_054920_create_warranties_table.php +56 -0
  7. package/server/migrations/2025_08_28_054921_create_telematics_table.php +60 -0
  8. package/server/migrations/2025_08_28_054922_create_assets_table.php +94 -0
  9. package/server/migrations/2025_08_28_054925_create_devices_table.php +190 -0
  10. package/server/migrations/2025_08_28_054926_create_device_events_table.php +99 -0
  11. package/server/migrations/2025_08_28_054926_create_sensors_table.php +62 -0
  12. package/server/migrations/2025_08_28_054927_create_parts_table.php +73 -0
  13. package/server/migrations/2025_08_28_054929_create_equipments_table.php +58 -0
  14. package/server/migrations/2025_08_28_054930_create_work_orders_table.php +85 -0
  15. package/server/migrations/2025_08_28_054931_create_maintenances_table.php +79 -0
  16. package/server/migrations/2025_08_28_082002_update_vehicles_table_telematics.php +60 -0
  17. package/server/src/Http/Controllers/Api/v1/OrderController.php +6 -1
  18. package/server/src/Http/Resources/v1/Order.php +111 -60
  19. package/server/src/Models/Asset.php +548 -0
  20. package/server/src/Models/Contact.php +2 -0
  21. package/server/src/Models/Device.php +435 -0
  22. package/server/src/Models/DeviceEvent.php +501 -0
  23. package/server/src/Models/Driver.php +2 -0
  24. package/server/src/Models/Entity.php +2 -0
  25. package/server/src/Models/Equipment.php +483 -0
  26. package/server/src/Models/Fleet.php +2 -0
  27. package/server/src/Models/FuelReport.php +2 -0
  28. package/server/src/Models/Issue.php +2 -0
  29. package/server/src/Models/Maintenance.php +549 -0
  30. package/server/src/Models/Order.php +32 -112
  31. package/server/src/Models/OrderConfig.php +8 -0
  32. package/server/src/Models/Part.php +502 -0
  33. package/server/src/Models/Payload.php +101 -20
  34. package/server/src/Models/Place.php +10 -4
  35. package/server/src/Models/Sensor.php +510 -0
  36. package/server/src/Models/ServiceArea.php +1 -1
  37. package/server/src/Models/Telematic.php +336 -0
  38. package/server/src/Models/Vehicle.php +45 -1
  39. package/server/src/Models/VehicleDevice.php +1 -1
  40. package/server/src/Models/Vendor.php +2 -0
  41. package/server/src/Models/Warranty.php +413 -0
  42. package/server/src/Models/WorkOrder.php +532 -0
  43. package/server/src/Support/Utils.php +5 -0
  44. package/server/src/Traits/Maintainable.php +307 -0
@@ -308,6 +308,34 @@ class Payload extends Model
308
308
  return $this;
309
309
  }
310
310
 
311
+ /**
312
+ * Set waypoints for the current entity by resolving or creating Places and
313
+ * upserting Waypoint records in order.
314
+ *
315
+ * Input supports multiple shapes per item:
316
+ * - Top-level fields (preferred): 'type', 'place_uuid', 'id' (public_id), 'uuid' (temp search UUID),
317
+ * 'customer_uuid' (preferred), 'customer_id' (customer public_id), 'customer_type'
318
+ * - Or wrapped place payload: { type, place: {...} }
319
+ *
320
+ * Place resolution order:
321
+ * 1) attributes.place_uuid (must exist in DB)
322
+ * 2) attributes.id (public_id) -> resolve to uuid
323
+ * 3) Place::createFromMixed($attributes)
324
+ * - If attributes.uuid is present and differs from created uuid, stores it as meta: search_uuid
325
+ *
326
+ * Customer resolution (optional):
327
+ * - Uses 'customer_type' (default: 'fleetops:contact') to determine the model via Utils::getMutationType()
328
+ * - Tries 'customer_uuid' first (must exist)
329
+ * - If not found, tries 'customer_id' (public_id) and resolves to uuid
330
+ * - Only sets customer_uuid/customer_type if a valid record is found
331
+ *
332
+ * Uniqueness for each waypoint is defined by: (payload_uuid, place_uuid, order).
333
+ * Updatable fields include: type, customer_uuid, customer_type.
334
+ *
335
+ * @param array<int, array<string,mixed>> $waypoints list of waypoint attribute maps
336
+ *
337
+ * @return static
338
+ */
311
339
  public function setWaypoints($waypoints = [])
312
340
  {
313
341
  if (!is_array($waypoints)) {
@@ -315,45 +343,98 @@ class Payload extends Model
315
343
  }
316
344
 
317
345
  foreach ($waypoints as $index => $attributes) {
318
- $waypoint = ['payload_uuid' => $this->payload_uuid, 'type' => data_get($attributes, 'type', 'dropoff')];
346
+ // Keep a copy to safely read top-level fields regardless of { place: {...} } normalization.
347
+ $raw = $attributes;
319
348
 
349
+ // Read top-level fields BEFORE normalizing the place shape.
350
+ $type = data_get($raw, 'type', 'dropoff');
351
+ $customerUuidIn = data_get($raw, 'customer_uuid'); // preferred
352
+ $customerPubIdIn = data_get($raw, 'customer_id'); // public_id fallback
353
+ $customerType = data_get($raw, 'customer_type', 'fleetops:contact');
354
+
355
+ // Normalize { place: {...} } shape if present.
320
356
  if (Utils::isset($attributes, 'place') && is_array(Utils::get($attributes, 'place'))) {
321
357
  $attributes = Utils::get($attributes, 'place');
322
358
  }
323
359
 
324
- if (is_array($attributes) && array_key_exists('place_uuid', $attributes) && Place::where('uuid', $attributes['place_uuid'])->exists()) {
325
- $waypoint = [
326
- 'place_uuid' => $attributes['place_uuid'],
327
- 'payload_uuid' => $attributes['payload_uuid'] ?? null,
328
- 'order' => $index,
329
- ];
360
+ // -------- Resolve Place (uuid) --------
361
+ $placeUuid = null;
362
+
363
+ // Path 1: explicit place_uuid, ensure it exists
364
+ if (
365
+ is_array($attributes)
366
+ && isset($attributes['place_uuid'])
367
+ && Place::where('uuid', $attributes['place_uuid'])->exists()
368
+ ) {
369
+ $placeUuid = $attributes['place_uuid'];
370
+
371
+ // Path 2: public_id under "id" -> resolve to uuid
372
+ } elseif (
373
+ is_array($attributes)
374
+ && isset($attributes['id'])
375
+ && ($resolvedUuid = Place::where('public_id', $attributes['id'])->value('uuid'))
376
+ ) {
377
+ $placeUuid = $resolvedUuid;
378
+
379
+ // Path 3: create from mixed payload
330
380
  } else {
331
381
  $place = Place::createFromMixed($attributes);
332
382
 
333
- // if has a temporary uuid from search create meta attr for search_uuid
383
+ // Store temp search UUID for traceability if present and different
334
384
  if ($place instanceof Place && isset($attributes['uuid']) && $place->uuid !== $attributes['uuid']) {
335
385
  $place->updateMeta('search_uuid', $attributes['uuid']);
336
386
  }
337
387
 
338
- $waypoint['place_uuid'] = $place->uuid;
388
+ $placeUuid = $place->uuid;
339
389
  }
340
390
 
341
- // Handle customer assosciation for waypoint
342
- $customerId = data_get($attributes, 'customer_uuid');
343
- $customerType = data_get($attributes, 'customer_type', 'fleetops:contact');
344
- if ($customerId && $customerType) {
391
+ // -------- Resolve Customer (uuid) --------
392
+ $customerUuid = null;
393
+ $customerTypeNamespace = null;
394
+
395
+ if ($customerType) {
345
396
  $customerTypeNamespace = Utils::getMutationType($customerType);
346
- $customerExists = app($customerTypeNamespace)->where('uuid', $customerId)->exists();
347
- if ($customerExists) {
348
- $waypoint['customer_uuid'] = $customerId;
349
- $waypoint['customer_type'] = $customerTypeNamespace;
397
+ $customerModel = app($customerTypeNamespace);
398
+
399
+ // Try by UUID first (preferred)
400
+ if ($customerUuidIn && $customerModel->where('uuid', $customerUuidIn)->exists()) {
401
+ $customerUuid = $customerUuidIn;
402
+ }
403
+ // If not found by UUID, try by public_id
404
+ if (!$customerUuid && $customerPubIdIn) {
405
+ $maybeUuid = $customerModel->where('public_id', $customerPubIdIn)->value('uuid');
406
+ if ($maybeUuid) {
407
+ $customerUuid = $maybeUuid;
408
+ } else {
409
+ // If neither lookup succeeds, drop the type namespace to avoid FK/type mismatch
410
+ $customerTypeNamespace = null;
411
+ }
412
+ }
413
+
414
+ // If no valid uuid after both attempts, clear the type
415
+ if (!$customerUuid) {
416
+ $customerTypeNamespace = null;
350
417
  }
351
418
  }
352
419
 
353
- // set payload
354
- $waypoint['payload_uuid'] = $this->uuid;
355
- $waypointRecord = Waypoint::updateOrCreate($waypoint);
420
+ // -------- Upsert Waypoint --------
421
+ // Uniqueness: payload + place + order for deterministic row per position.
422
+ $unique = [
423
+ 'payload_uuid' => $this->payload_uuid,
424
+ 'place_uuid' => $placeUuid,
425
+ 'order' => $index,
426
+ ];
427
+
428
+ // Only mutable/updatable fields go here.
429
+ $values = array_filter([
430
+ 'type' => $type,
431
+ 'customer_uuid' => $customerUuid,
432
+ 'customer_type' => $customerTypeNamespace,
433
+ ], fn ($v) => !is_null($v));
434
+
435
+ $waypointRecord = Waypoint::updateOrCreate($unique, $values);
356
436
 
437
+ // Track it (assumes this is a Collection)
357
438
  $this->waypointMarkers->push($waypointRecord);
358
439
  }
359
440
 
@@ -431,12 +431,10 @@ class Place extends Model
431
431
 
432
432
  $results = \Geocoder\Laravel\Facades\Geocoder::reverse($latitude, $longitude)->get();
433
433
 
434
- if ($results->isEmpty()) {
435
- return null;
434
+ if (!$results->isEmpty()) {
435
+ $instance->fillWithGoogleAddress($results->first());
436
436
  }
437
437
 
438
- $instance->fillWithGoogleAddress($results->first());
439
-
440
438
  if ($saveInstance) {
441
439
  $instance->save();
442
440
  }
@@ -541,6 +539,14 @@ class Place extends Model
541
539
  return $existingPlace;
542
540
  }
543
541
 
542
+ // Get public_id if supplied if set
543
+ $id = data_get($place, 'id') || data_get($place, 'public_id');
544
+
545
+ // If $place has a valid uuid and a matching Place object exists, return the uuid
546
+ if (Utils::isPublicId($id) && $existingPlace = static::where('public_id', $id)->first()) {
547
+ return $existingPlace;
548
+ }
549
+
544
550
  // If has $attributes['address']
545
551
  if (!empty($place['address'])) {
546
552
  return static::createFromGeocodingLookup($place['address'], $saveInstance);
@@ -0,0 +1,510 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Models;
4
+
5
+ use Fleetbase\Casts\Json;
6
+ use Fleetbase\Models\Alert;
7
+ use Fleetbase\Models\Model;
8
+ use Fleetbase\Models\User;
9
+ use Fleetbase\Traits\HasApiModelBehavior;
10
+ use Fleetbase\Traits\HasCustomFields;
11
+ use Fleetbase\Traits\HasMetaAttributes;
12
+ use Fleetbase\Traits\HasPublicId;
13
+ use Fleetbase\Traits\HasUuid;
14
+ use Fleetbase\Traits\Searchable;
15
+ use Fleetbase\Traits\TracksApiCredential;
16
+ use Illuminate\Database\Eloquent\Relations\BelongsTo;
17
+ use Illuminate\Database\Eloquent\Relations\HasMany;
18
+ use Illuminate\Database\Eloquent\Relations\MorphTo;
19
+ use Spatie\Activitylog\LogOptions;
20
+ use Spatie\Activitylog\Traits\LogsActivity;
21
+ use Spatie\Sluggable\HasSlug;
22
+ use Spatie\Sluggable\SlugOptions;
23
+
24
+ /**
25
+ * Class Sensor.
26
+ *
27
+ * Represents a sensor that can measure and report various metrics like temperature,
28
+ * door status, fuel level, tire pressure, etc. Sensors can be attached to devices,
29
+ * assets, or other entities in the fleet management system.
30
+ */
31
+ class Sensor extends Model
32
+ {
33
+ use HasUuid;
34
+ use HasPublicId;
35
+ use TracksApiCredential;
36
+ use HasApiModelBehavior;
37
+ use HasSlug;
38
+ use LogsActivity;
39
+ use HasMetaAttributes;
40
+ use Searchable;
41
+ use HasCustomFields;
42
+
43
+ /**
44
+ * The database table used by the model.
45
+ *
46
+ * @var string
47
+ */
48
+ protected $table = 'sensors';
49
+
50
+ /**
51
+ * The type of public Id to generate.
52
+ *
53
+ * @var string
54
+ */
55
+ protected $publicIdType = 'sensor';
56
+
57
+ /**
58
+ * The attributes that can be queried.
59
+ *
60
+ * @var array
61
+ */
62
+ protected $searchableColumns = ['name', 'sensor_type', 'unit', 'public_id'];
63
+
64
+ /**
65
+ * The attributes that can be used for filtering.
66
+ *
67
+ * @var array
68
+ */
69
+ protected $filterParams = ['sensor_type', 'status', 'device_uuid', 'warranty_uuid', 'sensorable_type'];
70
+
71
+ /**
72
+ * The attributes that are mass assignable.
73
+ *
74
+ * @var array
75
+ */
76
+ protected $fillable = [
77
+ 'company_uuid',
78
+ 'device_uuid',
79
+ 'warranty_uuid',
80
+ 'name',
81
+ 'sensor_type',
82
+ 'unit',
83
+ 'min_threshold',
84
+ 'max_threshold',
85
+ 'threshold_inclusive',
86
+ 'last_reading_at',
87
+ 'last_value',
88
+ 'calibration',
89
+ 'report_frequency_sec',
90
+ 'sensorable_type',
91
+ 'sensorable_uuid',
92
+ 'status',
93
+ 'meta',
94
+ 'slug',
95
+ ];
96
+
97
+ /**
98
+ * Dynamic attributes that are appended to object.
99
+ *
100
+ * @var array
101
+ */
102
+ protected $appends = [
103
+ 'device_name',
104
+ 'warranty_name',
105
+ 'attached_to_name',
106
+ 'is_active',
107
+ 'threshold_status',
108
+ 'last_reading_formatted',
109
+ ];
110
+
111
+ /**
112
+ * The attributes excluded from the model's JSON form.
113
+ *
114
+ * @var array
115
+ */
116
+ protected $hidden = ['device', 'warranty', 'sensorable'];
117
+
118
+ /**
119
+ * The attributes that should be cast to native types.
120
+ *
121
+ * @var array
122
+ */
123
+ protected $casts = [
124
+ 'min_threshold' => 'float',
125
+ 'max_threshold' => 'float',
126
+ 'threshold_inclusive' => 'boolean',
127
+ 'last_reading_at' => 'datetime',
128
+ 'report_frequency_sec' => 'integer',
129
+ 'calibration' => Json::class,
130
+ 'meta' => Json::class,
131
+ ];
132
+
133
+ /**
134
+ * Properties which activity needs to be logged.
135
+ *
136
+ * @var array
137
+ */
138
+ protected static $logAttributes = '*';
139
+
140
+ /**
141
+ * Do not log empty changed.
142
+ *
143
+ * @var bool
144
+ */
145
+ protected static $submitEmptyLogs = false;
146
+
147
+ /**
148
+ * The name of the subject to log.
149
+ *
150
+ * @var string
151
+ */
152
+ protected static $logName = 'sensor';
153
+
154
+ /**
155
+ * Get the options for generating the slug.
156
+ */
157
+ public function getSlugOptions(): SlugOptions
158
+ {
159
+ return SlugOptions::create()
160
+ ->generateSlugsFrom(['name', 'sensor_type'])
161
+ ->saveSlugsTo('slug');
162
+ }
163
+
164
+ /**
165
+ * Get the activity log options for the model.
166
+ */
167
+ public function getActivitylogOptions(): LogOptions
168
+ {
169
+ return LogOptions::defaults()->logAll();
170
+ }
171
+
172
+ public function device(): BelongsTo
173
+ {
174
+ return $this->belongsTo(Device::class, 'device_uuid', 'uuid');
175
+ }
176
+
177
+ public function warranty(): BelongsTo
178
+ {
179
+ return $this->belongsTo(Warranty::class, 'warranty_uuid', 'uuid');
180
+ }
181
+
182
+ public function createdBy(): BelongsTo
183
+ {
184
+ return $this->belongsTo(User::class, 'created_by_uuid', 'uuid');
185
+ }
186
+
187
+ public function updatedBy(): BelongsTo
188
+ {
189
+ return $this->belongsTo(User::class, 'updated_by_uuid', 'uuid');
190
+ }
191
+
192
+ public function sensorable(): MorphTo
193
+ {
194
+ return $this->morphTo();
195
+ }
196
+
197
+ public function alerts(): HasMany
198
+ {
199
+ return $this->hasMany(Alert::class, 'subject_uuid', 'uuid')
200
+ ->where('subject_type', static::class);
201
+ }
202
+
203
+ /**
204
+ * Get the device name.
205
+ */
206
+ public function getDeviceNameAttribute(): ?string
207
+ {
208
+ return $this->device?->name;
209
+ }
210
+
211
+ /**
212
+ * Get the warranty name.
213
+ */
214
+ public function getWarrantyNameAttribute(): ?string
215
+ {
216
+ return $this->warranty?->name;
217
+ }
218
+
219
+ /**
220
+ * Get the name of what the sensor is attached to.
221
+ */
222
+ public function getAttachedToNameAttribute(): ?string
223
+ {
224
+ if ($this->sensorable) {
225
+ return $this->sensorable->name ?? $this->sensorable->display_name ?? null;
226
+ }
227
+
228
+ return null;
229
+ }
230
+
231
+ /**
232
+ * Check if the sensor is currently active.
233
+ */
234
+ public function getIsActiveAttribute(): bool
235
+ {
236
+ if ($this->status !== 'active') {
237
+ return false;
238
+ }
239
+
240
+ // Consider active if we've received a reading within the expected frequency
241
+ if ($this->last_reading_at && $this->report_frequency_sec) {
242
+ $expectedNextReading = $this->last_reading_at->addSeconds($this->report_frequency_sec * 2); // Allow 2x frequency
243
+
244
+ return now()->lte($expectedNextReading);
245
+ }
246
+
247
+ return $this->status === 'active';
248
+ }
249
+
250
+ /**
251
+ * Get the threshold status of the last reading.
252
+ */
253
+ public function getThresholdStatusAttribute(): string
254
+ {
255
+ if (!$this->last_value || (!$this->min_threshold && !$this->max_threshold)) {
256
+ return 'normal';
257
+ }
258
+
259
+ $value = (float) $this->last_value;
260
+
261
+ if ($this->min_threshold && $this->max_threshold) {
262
+ if ($this->threshold_inclusive) {
263
+ if ($value < $this->min_threshold || $value > $this->max_threshold) {
264
+ return 'out_of_range';
265
+ }
266
+ } else {
267
+ if ($value <= $this->min_threshold || $value >= $this->max_threshold) {
268
+ return 'out_of_range';
269
+ }
270
+ }
271
+ } elseif ($this->min_threshold) {
272
+ if ($this->threshold_inclusive ? $value < $this->min_threshold : $value <= $this->min_threshold) {
273
+ return 'below_minimum';
274
+ }
275
+ } elseif ($this->max_threshold) {
276
+ if ($this->threshold_inclusive ? $value > $this->max_threshold : $value >= $this->max_threshold) {
277
+ return 'above_maximum';
278
+ }
279
+ }
280
+
281
+ return 'normal';
282
+ }
283
+
284
+ /**
285
+ * Get the last reading with unit formatting.
286
+ */
287
+ public function getLastReadingFormattedAttribute(): ?string
288
+ {
289
+ if (!$this->last_value) {
290
+ return null;
291
+ }
292
+
293
+ return $this->last_value . ($this->unit ? ' ' . $this->unit : '');
294
+ }
295
+
296
+ /**
297
+ * Scope to get sensors by type.
298
+ *
299
+ * @param \Illuminate\Database\Eloquent\Builder $query
300
+ *
301
+ * @return \Illuminate\Database\Eloquent\Builder
302
+ */
303
+ public function scopeByType($query, string $type)
304
+ {
305
+ return $query->where('sensor_type', $type);
306
+ }
307
+
308
+ /**
309
+ * Scope to get active sensors.
310
+ *
311
+ * @param \Illuminate\Database\Eloquent\Builder $query
312
+ *
313
+ * @return \Illuminate\Database\Eloquent\Builder
314
+ */
315
+ public function scopeActive($query)
316
+ {
317
+ return $query->where('status', 'active');
318
+ }
319
+
320
+ /**
321
+ * Scope to get sensors with recent readings.
322
+ *
323
+ * @param \Illuminate\Database\Eloquent\Builder $query
324
+ *
325
+ * @return \Illuminate\Database\Eloquent\Builder
326
+ */
327
+ public function scopeWithRecentReadings($query, int $minutes = 60)
328
+ {
329
+ return $query->where('last_reading_at', '>=', now()->subMinutes($minutes));
330
+ }
331
+
332
+ /**
333
+ * Scope to get sensors that are out of threshold.
334
+ *
335
+ * @param \Illuminate\Database\Eloquent\Builder $query
336
+ *
337
+ * @return \Illuminate\Database\Eloquent\Builder
338
+ */
339
+ public function scopeOutOfThreshold($query)
340
+ {
341
+ return $query->where(function ($q) {
342
+ $q->whereRaw('CAST(last_value AS DECIMAL) < min_threshold')
343
+ ->orWhereRaw('CAST(last_value AS DECIMAL) > max_threshold');
344
+ })->whereNotNull('last_value');
345
+ }
346
+
347
+ /**
348
+ * Record a new sensor reading.
349
+ */
350
+ public function recordReading($value, ?\DateTime $timestamp = null): bool
351
+ {
352
+ $timestamp = $timestamp ?? now();
353
+
354
+ $updated = $this->update([
355
+ 'last_value' => $value,
356
+ 'last_reading_at' => $timestamp,
357
+ ]);
358
+
359
+ if ($updated) {
360
+ // Check if the reading is out of threshold and create alert if needed
361
+ $this->checkThresholdAndCreateAlert($value);
362
+
363
+ activity('sensor_reading')
364
+ ->performedOn($this)
365
+ ->withProperties([
366
+ 'value' => $value,
367
+ 'unit' => $this->unit,
368
+ 'threshold_status' => $this->threshold_status,
369
+ 'timestamp' => $timestamp,
370
+ ])
371
+ ->log('Sensor reading recorded');
372
+ }
373
+
374
+ return $updated;
375
+ }
376
+
377
+ /**
378
+ * Check threshold and create alert if needed.
379
+ */
380
+ protected function checkThresholdAndCreateAlert($value): void
381
+ {
382
+ $thresholdStatus = $this->threshold_status;
383
+
384
+ if ($thresholdStatus !== 'normal') {
385
+ // Check if there's already an open alert for this sensor
386
+ $existingAlert = $this->alerts()
387
+ ->where('status', 'open')
388
+ ->where('type', 'sensor_threshold')
389
+ ->first();
390
+
391
+ if (!$existingAlert) {
392
+ Alert::create([
393
+ 'company_uuid' => $this->company_uuid,
394
+ 'type' => 'sensor_threshold',
395
+ 'severity' => $this->getSeverityForThresholdStatus($thresholdStatus),
396
+ 'status' => 'open',
397
+ 'subject_type' => static::class,
398
+ 'subject_uuid' => $this->uuid,
399
+ 'message' => $this->generateThresholdAlertMessage($value, $thresholdStatus),
400
+ 'context' => [
401
+ 'sensor_name' => $this->name,
402
+ 'sensor_type' => $this->sensor_type,
403
+ 'value' => $value,
404
+ 'unit' => $this->unit,
405
+ 'threshold_status' => $thresholdStatus,
406
+ 'min_threshold' => $this->min_threshold,
407
+ 'max_threshold' => $this->max_threshold,
408
+ ],
409
+ 'triggered_at' => now(),
410
+ ]);
411
+ }
412
+ } else {
413
+ // Resolve any open threshold alerts for this sensor
414
+ $this->alerts()
415
+ ->where('status', 'open')
416
+ ->where('type', 'sensor_threshold')
417
+ ->update([
418
+ 'status' => 'resolved',
419
+ 'resolved_at' => now(),
420
+ ]);
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Get severity level for threshold status.
426
+ */
427
+ protected function getSeverityForThresholdStatus(string $thresholdStatus): string
428
+ {
429
+ switch ($thresholdStatus) {
430
+ case 'out_of_range':
431
+ return 'high';
432
+ case 'above_maximum':
433
+ case 'below_minimum':
434
+ return 'medium';
435
+ default:
436
+ return 'low';
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Generate alert message for threshold violation.
442
+ */
443
+ protected function generateThresholdAlertMessage($value, string $thresholdStatus): string
444
+ {
445
+ $formattedValue = $value . ($this->unit ? ' ' . $this->unit : '');
446
+
447
+ switch ($thresholdStatus) {
448
+ case 'out_of_range':
449
+ return "Sensor '{$this->name}' reading ({$formattedValue}) is out of acceptable range ({$this->min_threshold}-{$this->max_threshold} {$this->unit})";
450
+ case 'above_maximum':
451
+ return "Sensor '{$this->name}' reading ({$formattedValue}) exceeds maximum threshold ({$this->max_threshold} {$this->unit})";
452
+ case 'below_minimum':
453
+ return "Sensor '{$this->name}' reading ({$formattedValue}) is below minimum threshold ({$this->min_threshold} {$this->unit})";
454
+ default:
455
+ return "Sensor '{$this->name}' threshold violation detected";
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Calibrate the sensor with offset and scale factors.
461
+ */
462
+ public function calibrate(float $offset = 0, float $scale = 1): bool
463
+ {
464
+ $calibration = [
465
+ 'offset' => $offset,
466
+ 'scale' => $scale,
467
+ 'calibrated_at' => now(),
468
+ 'calibrated_by' => auth()->id(),
469
+ ];
470
+
471
+ return $this->update(['calibration' => $calibration]);
472
+ }
473
+
474
+ /**
475
+ * Apply calibration to a raw sensor value.
476
+ */
477
+ public function applyCalibratedValue(float $rawValue): float
478
+ {
479
+ $calibration = $this->calibration ?? [];
480
+ $offset = $calibration['offset'] ?? 0;
481
+ $scale = $calibration['scale'] ?? 1;
482
+
483
+ return ($rawValue * $scale) + $offset;
484
+ }
485
+
486
+ /**
487
+ * Get sensor reading history.
488
+ */
489
+ public function getReadingHistory(int $limit = 100, int $hours = 24): array
490
+ {
491
+ // This would typically query a separate sensor_readings table
492
+ // For now, return a placeholder structure
493
+ return [
494
+ 'sensor_uuid' => $this->uuid,
495
+ 'sensor_name' => $this->name,
496
+ 'period' => [
497
+ 'start' => now()->subHours($hours),
498
+ 'end' => now(),
499
+ ],
500
+ 'readings' => [], // Would contain actual reading data
501
+ 'summary' => [
502
+ 'count' => 0,
503
+ 'min' => null,
504
+ 'max' => null,
505
+ 'avg' => null,
506
+ 'last' => $this->last_value,
507
+ ],
508
+ ];
509
+ }
510
+ }