@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
@@ -14,11 +14,10 @@ use Fleetbase\FleetOps\Support\OrderTracker;
14
14
  use Fleetbase\FleetOps\Support\Utils;
15
15
  use Fleetbase\FleetOps\Traits\HasTrackingNumber;
16
16
  use Fleetbase\LaravelMysqlSpatial\Types\Point;
17
- use Fleetbase\Models\CustomField;
18
- use Fleetbase\Models\CustomFieldValue;
19
17
  use Fleetbase\Models\Model;
20
18
  use Fleetbase\Models\Transaction;
21
19
  use Fleetbase\Traits\HasApiModelBehavior;
20
+ use Fleetbase\Traits\HasCustomFields;
22
21
  use Fleetbase\Traits\HasInternalId;
23
22
  use Fleetbase\Traits\HasMetaAttributes;
24
23
  use Fleetbase\Traits\HasOptionsAttributes;
@@ -34,6 +33,7 @@ use Illuminate\Database\Eloquent\Relations\HasManyThrough;
34
33
  use Illuminate\Database\Eloquent\Relations\MorphTo;
35
34
  use Illuminate\Database\Eloquent\Relations\Relation;
36
35
  use Illuminate\Support\Carbon;
36
+ use Illuminate\Support\Collection;
37
37
  use Illuminate\Support\Str;
38
38
  use Spatie\Activitylog\LogOptions;
39
39
  use Spatie\Activitylog\Traits\LogsActivity;
@@ -51,6 +51,7 @@ class Order extends Model
51
51
  use Searchable;
52
52
  use LogsActivity;
53
53
  use HasTrackingNumber;
54
+ use HasCustomFields;
54
55
 
55
56
  /**
56
57
  * The database table used by the model.
@@ -348,16 +349,6 @@ class Order extends Model
348
349
  return $this->hasMany(\Fleetbase\Models\File::class, 'subject_uuid')->latest();
349
350
  }
350
351
 
351
- public function customFields(): HasMany
352
- {
353
- return $this->hasMany(CustomField::class, 'subject_uuid')->orderBy('order');
354
- }
355
-
356
- public function customFieldValues(): HasMany
357
- {
358
- return $this->hasMany(CustomFieldValue::class, 'subject_uuid');
359
- }
360
-
361
352
  public function drivers(): HasManyThrough
362
353
  {
363
354
  return $this->hasManyThrough(Driver::class, Entity::class, 'tracking_number_uuid', 'tracking_number_uuid');
@@ -1141,7 +1132,7 @@ class Order extends Model
1141
1132
  */
1142
1133
  public function firstDispatch(): Order
1143
1134
  {
1144
- if ($this->dispatched) {
1135
+ if (!$this->dispatched) {
1145
1136
  $this->dispatch();
1146
1137
  }
1147
1138
 
@@ -1156,7 +1147,7 @@ class Order extends Model
1156
1147
  */
1157
1148
  public function firstDispatchWithActivity(): Order
1158
1149
  {
1159
- if ($this->dispatched) {
1150
+ if (!$this->dispatched) {
1160
1151
  $this->dispatchWithActivity();
1161
1152
  }
1162
1153
 
@@ -1603,104 +1594,6 @@ class Order extends Model
1603
1594
  return (int) Utils::get($this, 'adhoc_distance', Utils::get($this, 'company.options.fleetops.adhoc_distance', 6000));
1604
1595
  }
1605
1596
 
1606
- /**
1607
- * Retrieves a custom field by its key.
1608
- *
1609
- * This method searches for a custom field where the name or label matches the given key.
1610
- *
1611
- * @param string $key the key used to search for the custom field
1612
- *
1613
- * @return CustomField|null the found CustomField object or null if not found
1614
- */
1615
- public function getCustomField(string $key): ?CustomField
1616
- {
1617
- $name = Str::slug($key);
1618
- $label = Str::title($key);
1619
-
1620
- return $this->customFields()->where('name', $name)->orWhere('label', $label)->first();
1621
- }
1622
-
1623
- /**
1624
- * Retrieves the custom field value for the specified custom field.
1625
- *
1626
- * @param CustomField $customField the custom field to retrieve the value for
1627
- *
1628
- * @return CustomFieldValue|null the custom field value, or null if not found
1629
- */
1630
- public function getCustomFieldValue(CustomField $customField): ?CustomFieldValue
1631
- {
1632
- $customFieldValue = $this->customFieldValues()->where('custom_field_uuid', $customField->uuid)->first();
1633
- if ($customFieldValue) {
1634
- return $customFieldValue;
1635
- }
1636
-
1637
- return null;
1638
- }
1639
-
1640
- /**
1641
- * Retrieves the value of a custom field by its key.
1642
- *
1643
- * @param string $key the key of the custom field
1644
- *
1645
- * @return mixed|null the value of the custom field, or null if not found
1646
- */
1647
- public function getCustomFieldValueByKey(string $key)
1648
- {
1649
- $customField = $this->getCustomField($key);
1650
- if ($customField) {
1651
- $customFieldValue = $this->getCustomFieldValue($customField);
1652
- if ($customFieldValue) {
1653
- return $customFieldValue->value;
1654
- }
1655
- }
1656
-
1657
- return null;
1658
- }
1659
-
1660
- /**
1661
- * Checks if a custom field exists.
1662
- *
1663
- * @param string $key the key of the custom field
1664
- */
1665
- public function isCustomField(string $key): bool
1666
- {
1667
- $name = Str::slug($key);
1668
- $label = Str::title($key);
1669
-
1670
- return $this->customFields()->where('name', $name)->orWhere('label', $label)->exists();
1671
- }
1672
-
1673
- /**
1674
- * Retrieves all custom field values associated with the order.
1675
- *
1676
- * @return array an array of custom field values
1677
- */
1678
- public function getCustomFieldValues(): array
1679
- {
1680
- $customFields = [];
1681
- foreach ($this->customFieldValues as $customFieldValue) {
1682
- $key = Str::snake(strtolower($customFieldValue->custom_field_label));
1683
- if ($key) {
1684
- $customFields[$key] = $customFieldValue->value;
1685
- }
1686
- }
1687
-
1688
- return $customFields;
1689
- }
1690
-
1691
- public function getCustomFieldKeys(): array
1692
- {
1693
- $keys = [];
1694
- foreach ($this->customFieldValues as $customFieldValue) {
1695
- $key = Str::snake(strtolower($customFieldValue->custom_field_label));
1696
- if ($key) {
1697
- $keys[] = $key;
1698
- }
1699
- }
1700
-
1701
- return $keys;
1702
- }
1703
-
1704
1597
  /**
1705
1598
  * Retrieves the OrderConfig associated with this order.
1706
1599
  *
@@ -1925,4 +1818,31 @@ class Order extends Model
1925
1818
 
1926
1819
  return $this->{$property};
1927
1820
  }
1821
+
1822
+ public function findClosestDrivers(int $distance = 6000): Collection
1823
+ {
1824
+ $pickup = $this->getPickupLocation();
1825
+ $distance = $distance;
1826
+
1827
+ if (!Utils::isPoint($pickup)) {
1828
+ return collect();
1829
+ }
1830
+
1831
+ $drivers = Driver::where(['status' => 'active', 'online' => 1])
1832
+ ->whereHas('company', function ($q) {
1833
+ $q->whereHas('users', function ($q) {
1834
+ $q->whereHas('driver', function ($q) {
1835
+ $q->where(['status' => 'active', 'online' => 1]);
1836
+ $q->whereNull('deleted_at');
1837
+ });
1838
+ });
1839
+ })
1840
+ ->whereNull('deleted_at')
1841
+ ->distanceSphere('location', $pickup, $distance)
1842
+ ->distanceSphereValue('location', $pickup)
1843
+ ->withoutGlobalScopes()
1844
+ ->get();
1845
+
1846
+ return $drivers;
1847
+ }
1928
1848
  }
@@ -368,6 +368,14 @@ class OrderConfig extends Model
368
368
  return collect();
369
369
  }
370
370
 
371
+ /**
372
+ * Gets an activity from the order config by it's code.
373
+ */
374
+ public function getActivityByCode(string $code): ?Activity
375
+ {
376
+ return $this->activities()->firstWhere('code', $code);
377
+ }
378
+
371
379
  /**
372
380
  * Creates an Activity instance representing a canceled order.
373
381
  *
@@ -0,0 +1,502 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Models;
4
+
5
+ use Fleetbase\Casts\Json;
6
+ use Fleetbase\FleetOps\Traits\Maintainable;
7
+ use Fleetbase\Models\Alert;
8
+ use Fleetbase\Models\File;
9
+ use Fleetbase\Models\Model;
10
+ use Fleetbase\Models\User;
11
+ use Fleetbase\Traits\HasApiModelBehavior;
12
+ use Fleetbase\Traits\HasMetaAttributes;
13
+ use Fleetbase\Traits\HasPublicId;
14
+ use Fleetbase\Traits\HasUuid;
15
+ use Fleetbase\Traits\Searchable;
16
+ use Fleetbase\Traits\TracksApiCredential;
17
+ use Illuminate\Database\Eloquent\Relations\BelongsTo;
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 Part.
26
+ *
27
+ * Represents a stocked, replaceable component in the fleet management system.
28
+ * Parts can include filters, tires, belts, and other components with quantity and cost tracking.
29
+ */
30
+ class Part extends Model
31
+ {
32
+ use HasUuid;
33
+ use HasPublicId;
34
+ use TracksApiCredential;
35
+ use Maintainable;
36
+ use HasApiModelBehavior;
37
+ use HasSlug;
38
+ use LogsActivity;
39
+ use HasMetaAttributes;
40
+ use Searchable;
41
+
42
+ /**
43
+ * The database table used by the model.
44
+ *
45
+ * @var string
46
+ */
47
+ protected $table = 'parts';
48
+
49
+ /**
50
+ * The type of public Id to generate.
51
+ *
52
+ * @var string
53
+ */
54
+ protected $publicIdType = 'part';
55
+
56
+ /**
57
+ * The attributes that can be queried.
58
+ *
59
+ * @var array
60
+ */
61
+ protected $searchableColumns = ['sku', 'name', 'manufacturer', 'model', 'serial_number', 'barcode', 'public_id'];
62
+
63
+ /**
64
+ * The attributes that can be used for filtering.
65
+ *
66
+ * @var array
67
+ */
68
+ protected $filterParams = ['vendor_uuid', 'manufacturer', 'status', 'asset_type', 'warranty_uuid'];
69
+
70
+ /**
71
+ * The attributes that are mass assignable.
72
+ *
73
+ * @var array
74
+ */
75
+ protected $fillable = [
76
+ 'company_uuid',
77
+ 'vendor_uuid',
78
+ 'warranty_uuid',
79
+ 'photo_uuid',
80
+ 'sku',
81
+ 'name',
82
+ 'manufacturer',
83
+ 'model',
84
+ 'serial_number',
85
+ 'barcode',
86
+ 'description',
87
+ 'quantity_on_hand',
88
+ 'unit_cost',
89
+ 'msrp',
90
+ 'asset_type',
91
+ 'asset_uuid',
92
+ 'type',
93
+ 'status',
94
+ 'specs',
95
+ 'meta',
96
+ 'slug',
97
+ ];
98
+
99
+ /**
100
+ * Dynamic attributes that are appended to object.
101
+ *
102
+ * @var array
103
+ */
104
+ protected $appends = [
105
+ 'vendor_name',
106
+ 'warranty_name',
107
+ 'photo_url',
108
+ 'total_value',
109
+ 'is_in_stock',
110
+ 'is_low_stock',
111
+ 'asset_name',
112
+ ];
113
+
114
+ /**
115
+ * The attributes excluded from the model's JSON form.
116
+ *
117
+ * @var array
118
+ */
119
+ protected $hidden = ['vendor', 'warranty', 'photo', 'asset'];
120
+
121
+ /**
122
+ * The attributes that should be cast to native types.
123
+ *
124
+ * @var array
125
+ */
126
+ protected $casts = [
127
+ 'quantity_on_hand' => 'integer',
128
+ 'unit_cost' => 'decimal:2',
129
+ 'msrp' => 'decimal:2',
130
+ 'specs' => Json::class,
131
+ 'meta' => Json::class,
132
+ ];
133
+
134
+ /**
135
+ * Properties which activity needs to be logged.
136
+ *
137
+ * @var array
138
+ */
139
+ protected static $logAttributes = '*';
140
+
141
+ /**
142
+ * Do not log empty changed.
143
+ *
144
+ * @var bool
145
+ */
146
+ protected static $submitEmptyLogs = false;
147
+
148
+ /**
149
+ * The name of the subject to log.
150
+ *
151
+ * @var string
152
+ */
153
+ protected static $logName = 'part';
154
+
155
+ /**
156
+ * Get the options for generating the slug.
157
+ */
158
+ public function getSlugOptions(): SlugOptions
159
+ {
160
+ return SlugOptions::create()
161
+ ->generateSlugsFrom(['name', 'sku'])
162
+ ->saveSlugsTo('slug');
163
+ }
164
+
165
+ /**
166
+ * Get the activity log options for the model.
167
+ */
168
+ public function getActivitylogOptions(): LogOptions
169
+ {
170
+ return LogOptions::defaults()->logAll();
171
+ }
172
+
173
+ public function vendor(): BelongsTo
174
+ {
175
+ return $this->belongsTo(Vendor::class, 'vendor_uuid', 'uuid');
176
+ }
177
+
178
+ public function warranty(): BelongsTo
179
+ {
180
+ return $this->belongsTo(Warranty::class, 'warranty_uuid', 'uuid');
181
+ }
182
+
183
+ public function photo(): BelongsTo
184
+ {
185
+ return $this->belongsTo(File::class, 'photo_uuid', 'uuid');
186
+ }
187
+
188
+ public function createdBy(): BelongsTo
189
+ {
190
+ return $this->belongsTo(User::class, 'created_by_uuid', 'uuid');
191
+ }
192
+
193
+ public function updatedBy(): BelongsTo
194
+ {
195
+ return $this->belongsTo(User::class, 'updated_by_uuid', 'uuid');
196
+ }
197
+
198
+ public function asset(): MorphTo
199
+ {
200
+ return $this->morphTo();
201
+ }
202
+
203
+ /**
204
+ * Get the vendor name.
205
+ */
206
+ public function getVendorNameAttribute(): ?string
207
+ {
208
+ return $this->vendor?->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 photo URL.
221
+ */
222
+ public function getPhotoUrlAttribute(): ?string
223
+ {
224
+ return $this->photo?->url;
225
+ }
226
+
227
+ /**
228
+ * Get the total value of parts in stock.
229
+ */
230
+ public function getTotalValueAttribute(): float
231
+ {
232
+ return $this->quantity_on_hand * ($this->unit_cost ?? 0);
233
+ }
234
+
235
+ /**
236
+ * Check if the part is in stock.
237
+ */
238
+ public function getIsInStockAttribute(): bool
239
+ {
240
+ return $this->quantity_on_hand > 0;
241
+ }
242
+
243
+ /**
244
+ * Check if the part is low on stock.
245
+ */
246
+ public function getIsLowStockAttribute(): bool
247
+ {
248
+ $specs = $this->specs ?? [];
249
+ $lowStockThreshold = $specs['low_stock_threshold'] ?? 5;
250
+
251
+ return $this->quantity_on_hand <= $lowStockThreshold;
252
+ }
253
+
254
+ /**
255
+ * Get the asset name if associated.
256
+ */
257
+ public function getAssetNameAttribute(): ?string
258
+ {
259
+ if ($this->asset) {
260
+ return $this->asset->name ?? $this->asset->display_name ?? null;
261
+ }
262
+
263
+ return null;
264
+ }
265
+
266
+ /**
267
+ * Scope to get parts in stock.
268
+ *
269
+ * @param \Illuminate\Database\Eloquent\Builder $query
270
+ *
271
+ * @return \Illuminate\Database\Eloquent\Builder
272
+ */
273
+ public function scopeInStock($query)
274
+ {
275
+ return $query->where('quantity_on_hand', '>', 0);
276
+ }
277
+
278
+ /**
279
+ * Scope to get parts that are out of stock.
280
+ *
281
+ * @param \Illuminate\Database\Eloquent\Builder $query
282
+ *
283
+ * @return \Illuminate\Database\Eloquent\Builder
284
+ */
285
+ public function scopeOutOfStock($query)
286
+ {
287
+ return $query->where('quantity_on_hand', '<=', 0);
288
+ }
289
+
290
+ /**
291
+ * Scope to get parts with low stock.
292
+ *
293
+ * @param \Illuminate\Database\Eloquent\Builder $query
294
+ *
295
+ * @return \Illuminate\Database\Eloquent\Builder
296
+ */
297
+ public function scopeLowStock($query, ?int $threshold = null)
298
+ {
299
+ $threshold = $threshold ?? 5;
300
+
301
+ return $query->where('quantity_on_hand', '<=', $threshold)
302
+ ->where('quantity_on_hand', '>', 0);
303
+ }
304
+
305
+ /**
306
+ * Scope to get parts by manufacturer.
307
+ *
308
+ * @param \Illuminate\Database\Eloquent\Builder $query
309
+ *
310
+ * @return \Illuminate\Database\Eloquent\Builder
311
+ */
312
+ public function scopeByManufacturer($query, string $manufacturer)
313
+ {
314
+ return $query->where('manufacturer', $manufacturer);
315
+ }
316
+
317
+ /**
318
+ * Add stock to the part.
319
+ */
320
+ public function addStock(int $quantity, ?string $reason = null): bool
321
+ {
322
+ if ($quantity <= 0) {
323
+ return false;
324
+ }
325
+
326
+ $oldQuantity = $this->quantity_on_hand;
327
+ $newQuantity = $oldQuantity + $quantity;
328
+
329
+ $updated = $this->update(['quantity_on_hand' => $newQuantity]);
330
+
331
+ if ($updated) {
332
+ activity('stock_added')
333
+ ->performedOn($this)
334
+ ->withProperties([
335
+ 'old_quantity' => $oldQuantity,
336
+ 'added_quantity' => $quantity,
337
+ 'new_quantity' => $newQuantity,
338
+ 'reason' => $reason,
339
+ ])
340
+ ->log("Added {$quantity} units to stock");
341
+ }
342
+
343
+ return $updated;
344
+ }
345
+
346
+ /**
347
+ * Remove stock from the part.
348
+ */
349
+ public function removeStock(int $quantity, ?string $reason = null): bool
350
+ {
351
+ if ($quantity <= 0 || $quantity > $this->quantity_on_hand) {
352
+ return false;
353
+ }
354
+
355
+ $oldQuantity = $this->quantity_on_hand;
356
+ $newQuantity = $oldQuantity - $quantity;
357
+
358
+ $updated = $this->update(['quantity_on_hand' => $newQuantity]);
359
+
360
+ if ($updated) {
361
+ activity('stock_removed')
362
+ ->performedOn($this)
363
+ ->withProperties([
364
+ 'old_quantity' => $oldQuantity,
365
+ 'removed_quantity' => $quantity,
366
+ 'new_quantity' => $newQuantity,
367
+ 'reason' => $reason,
368
+ ])
369
+ ->log("Removed {$quantity} units from stock");
370
+
371
+ // Check if stock is now low and create alert if needed
372
+ if ($this->is_low_stock) {
373
+ $this->createLowStockAlert();
374
+ }
375
+ }
376
+
377
+ return $updated;
378
+ }
379
+
380
+ /**
381
+ * Set the stock quantity.
382
+ */
383
+ public function setStock(int $quantity, ?string $reason = null): bool
384
+ {
385
+ if ($quantity < 0) {
386
+ return false;
387
+ }
388
+
389
+ $oldQuantity = $this->quantity_on_hand;
390
+ $updated = $this->update(['quantity_on_hand' => $quantity]);
391
+
392
+ if ($updated) {
393
+ activity('stock_adjusted')
394
+ ->performedOn($this)
395
+ ->withProperties([
396
+ 'old_quantity' => $oldQuantity,
397
+ 'new_quantity' => $quantity,
398
+ 'adjustment' => $quantity - $oldQuantity,
399
+ 'reason' => $reason,
400
+ ])
401
+ ->log("Stock adjusted from {$oldQuantity} to {$quantity}");
402
+ }
403
+
404
+ return $updated;
405
+ }
406
+
407
+ /**
408
+ * Create a low stock alert.
409
+ */
410
+ protected function createLowStockAlert(): void
411
+ {
412
+ // Check if there's already an open low stock alert
413
+ $existingAlert = Alert::where('subject_type', static::class)
414
+ ->where('subject_uuid', $this->uuid)
415
+ ->where('type', 'low_stock')
416
+ ->where('status', 'open')
417
+ ->first();
418
+
419
+ if (!$existingAlert) {
420
+ Alert::create([
421
+ 'company_uuid' => $this->company_uuid,
422
+ 'type' => 'low_stock',
423
+ 'severity' => 'medium',
424
+ 'status' => 'open',
425
+ 'subject_type' => static::class,
426
+ 'subject_uuid' => $this->uuid,
427
+ 'message' => "Part '{$this->name}' (SKU: {$this->sku}) is low on stock ({$this->quantity_on_hand} remaining)",
428
+ 'context' => [
429
+ 'part_name' => $this->name,
430
+ 'sku' => $this->sku,
431
+ 'current_quantity' => $this->quantity_on_hand,
432
+ 'low_stock_threshold' => $this->specs['low_stock_threshold'] ?? 5,
433
+ ],
434
+ 'triggered_at' => now(),
435
+ ]);
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Check if the part is compatible with an asset.
441
+ */
442
+ public function isCompatibleWith(Asset $asset): bool
443
+ {
444
+ $specs = $this->specs ?? [];
445
+ $compatibleAssets = $specs['compatible_assets'] ?? [];
446
+
447
+ if (empty($compatibleAssets)) {
448
+ return true; // No restrictions
449
+ }
450
+
451
+ // Check by asset type
452
+ if (in_array($asset->type, $compatibleAssets)) {
453
+ return true;
454
+ }
455
+
456
+ // Check by make/model
457
+ $assetIdentifier = $asset->make . ' ' . $asset->model;
458
+ if (in_array($assetIdentifier, $compatibleAssets)) {
459
+ return true;
460
+ }
461
+
462
+ return false;
463
+ }
464
+
465
+ /**
466
+ * Get the reorder point for this part.
467
+ */
468
+ public function getReorderPoint(): int
469
+ {
470
+ $specs = $this->specs ?? [];
471
+
472
+ return $specs['reorder_point'] ?? $specs['low_stock_threshold'] ?? 5;
473
+ }
474
+
475
+ /**
476
+ * Get the reorder quantity for this part.
477
+ */
478
+ public function getReorderQuantity(): int
479
+ {
480
+ $specs = $this->specs ?? [];
481
+
482
+ return $specs['reorder_quantity'] ?? 10;
483
+ }
484
+
485
+ /**
486
+ * Check if the part needs to be reordered.
487
+ */
488
+ public function needsReorder(): bool
489
+ {
490
+ return $this->quantity_on_hand <= $this->getReorderPoint();
491
+ }
492
+
493
+ /**
494
+ * Get the estimated cost for a quantity of this part.
495
+ */
496
+ public function getEstimatedCost(int $quantity = 1, bool $useRetailPrice = false): float
497
+ {
498
+ $price = $useRetailPrice ? ($this->msrp ?? $this->unit_cost) : $this->unit_cost;
499
+
500
+ return $quantity * ($price ?? 0);
501
+ }
502
+ }