@fleetbase/fleetops-engine 0.6.49 → 0.6.50
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.
- package/addon/components/customer/create-order-form.hbs +2 -2
- package/addon/components/customer/form.hbs +2 -2
- package/addon/components/customer/order-form.hbs +2 -2
- package/addon/components/customer/orders.hbs +2 -2
- package/addon/components/display-place.hbs +2 -2
- package/addon/components/map/google-live-map.js +6 -2
- package/addon/components/map/leaflet-live-map.hbs +48 -14
- package/addon/components/map/leaflet-live-map.js +184 -20
- package/addon/components/modals/order-change-destination.hbs +46 -0
- package/addon/components/modals/order-change-destination.js +106 -0
- package/addon/components/modals/stop-activity.hbs +28 -0
- package/addon/components/modals/stop-activity.js +52 -0
- package/addon/components/modals/update-order-activity.hbs +45 -3
- package/addon/components/modals/update-order-activity.js +143 -5
- package/addon/components/orchestrator/resource-panel.hbs +0 -10
- package/addon/components/orchestrator/resource-panel.js +2 -4
- package/addon/components/order/activity-list.hbs +2 -2
- package/addon/components/order/activity-timeline.hbs +31 -29
- package/addon/components/order/details/activity.hbs +3 -3
- package/addon/components/order/details/activity.js +11 -0
- package/addon/components/order/details/documents.js +2 -0
- package/addon/components/order/details/purchase-rate.hbs +2 -2
- package/addon/components/order/details/route.hbs +11 -3
- package/addon/components/order/details/route.js +91 -14
- package/addon/components/order/details/tracking.hbs +101 -72
- package/addon/components/order/details/tracking.js +149 -2
- package/addon/components/order/form/details.hbs +3 -3
- package/addon/components/order/form/documents.js +11 -4
- package/addon/components/order/form/route.hbs +108 -104
- package/addon/components/order/form/route.js +37 -1
- package/addon/components/order/form/service-rate.hbs +2 -2
- package/addon/components/order-config-manager/activity-flow.hbs +4 -1
- package/addon/components/order-config-manager/activity-flow.js +178 -12
- package/addon/components/place/address.js +1 -1
- package/addon/components/route-list.hbs +17 -17
- package/addon/components/route-list.js +42 -10
- package/addon/components/service-rate/details.hbs +41 -0
- package/addon/components/service-rate/form.hbs +118 -0
- package/addon/components/service-rate/form.js +55 -0
- package/addon/components/vehicle/form.js +1 -1
- package/addon/components/vehicle/pill.hbs +1 -1
- package/addon/controllers/management/vehicles/index/new.js +1 -1
- package/addon/controllers/operations/orders/index/details.js +4 -4
- package/addon/controllers/operations/orders/index.js +14 -0
- package/addon/helpers/place-address-html.js +2 -2
- package/addon/routes/operations/service-rates/index.js +1 -1
- package/addon/services/geofence.js +163 -19
- package/addon/services/leaflet-contextmenu-manager.js +27 -0
- package/addon/services/vehicle-actions.js +1 -1
- package/addon/styles/fleetops-engine.css +91 -1
- package/addon/utils/fleet-ops-options.js +2 -1
- package/addon/utils/normalize-order-config-flow.js +41 -0
- package/addon/utils/place-address-html.js +3 -2
- package/addon/utils/route-visualization.js +54 -7
- package/app/components/modals/order-change-destination.js +1 -0
- package/app/components/modals/stop-activity.js +1 -0
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/package.json +3 -3
- package/server/migrations/2026_05_21_000001_add_geographic_rules_to_service_rate_fees_table.php +37 -0
- package/server/migrations/2026_05_25_000001_add_endpoint_tracking_refs_to_payloads_table.php +50 -0
- package/server/migrations/2026_05_27_000001_default_driver_vehicle_statuses_to_available.php +48 -0
- package/server/resources/views/labels/default.php +57 -49
- package/server/resources/views/labels/waypoint-label.php +27 -20
- package/server/seeders/Testing/ConnectivitySeeder.php +1 -0
- package/server/seeders/Testing/FleetSeeder.php +22 -22
- package/server/seeders/Testing/OrdersSeeder.php +6 -22
- package/server/seeders/Testing/VroomOrchestrationSeeder.php +21 -21
- package/server/src/Console/Commands/AuditCustomerUserConflicts.php +75 -0
- package/server/src/Exceptions/CustomerUserConflictException.php +13 -0
- package/server/src/Http/Controllers/Api/v1/ContactController.php +17 -2
- package/server/src/Http/Controllers/Api/v1/CustomerController.php +876 -0
- package/server/src/Http/Controllers/Api/v1/DriverController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/OrderConfigController.php +54 -0
- package/server/src/Http/Controllers/Api/v1/OrderController.php +98 -70
- package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -2
- package/server/src/Http/Controllers/Api/v1/VehicleController.php +20 -3
- package/server/src/Http/Controllers/Internal/v1/ContactController.php +19 -0
- package/server/src/Http/Controllers/Internal/v1/LiveController.php +101 -66
- package/server/src/Http/Controllers/Internal/v1/OrderController.php +429 -26
- package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +1 -1
- package/server/src/Http/Controllers/Internal/v1/SettingController.php +2 -2
- package/server/src/Http/Controllers/Internal/v1/VehicleController.php +54 -1
- package/server/src/Http/Middleware/AuthenticateCustomerToken.php +34 -0
- package/server/src/Http/Requests/CreateCustomerOrderRequest.php +63 -0
- package/server/src/Http/Requests/CreateCustomerRequest.php +48 -0
- package/server/src/Http/Requests/CreateDriverRequest.php +1 -1
- package/server/src/Http/Requests/CreateVehicleRequest.php +25 -1
- package/server/src/Http/Requests/Internal/CreateDriverRequest.php +1 -1
- package/server/src/Http/Requests/VerifyCreateCustomerRequest.php +29 -0
- package/server/src/Http/Resources/Internal/v1/OrderConfig.php +41 -0
- package/server/src/Http/Resources/v1/Customer.php +79 -0
- package/server/src/Http/Resources/v1/OrderConfig.php +74 -0
- package/server/src/Http/Resources/v1/Payload.php +23 -20
- package/server/src/Http/Resources/v1/ServiceArea.php +33 -19
- package/server/src/Http/Resources/v1/ServiceRateFee.php +7 -0
- package/server/src/Listeners/HandleOrderDispatched.php +2 -2
- package/server/src/Models/Contact.php +92 -15
- package/server/src/Models/Customer.php +64 -0
- package/server/src/Models/Driver.php +12 -3
- package/server/src/Models/Entity.php +2 -2
- package/server/src/Models/Order.php +29 -4
- package/server/src/Models/Payload.php +17 -1
- package/server/src/Models/ServiceRate.php +273 -1
- package/server/src/Models/ServiceRateFee.php +39 -9
- package/server/src/Models/TrackingNumber.php +8 -2
- package/server/src/Models/Vehicle.php +18 -2
- package/server/src/Models/Waypoint.php +2 -2
- package/server/src/Notifications/LateDeparture.php +2 -2
- package/server/src/Notifications/ProlongedStoppage.php +2 -2
- package/server/src/Notifications/RouteDeviation.php +2 -2
- package/server/src/Observers/ContactObserver.php +2 -0
- package/server/src/Observers/ServiceRateObserver.php +6 -6
- package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +1 -1
- package/server/src/Providers/FleetOpsServiceProvider.php +1 -0
- package/server/src/Support/Analytics/AbstractAnalytics.php +6 -6
- package/server/src/Support/Analytics/FuelEfficiency.php +2 -2
- package/server/src/Support/Analytics/OrdersByStatus.php +1 -1
- package/server/src/Support/Analytics/RevenueTrend.php +0 -1
- package/server/src/Support/Analytics/TopDrivers.php +1 -1
- package/server/src/Support/CustomerAuth.php +98 -0
- package/server/src/Support/LabelPdf.php +52 -0
- package/server/src/Support/Metrics/AbstractMetric.php +4 -4
- package/server/src/Support/ResolvesOrderServiceStops.php +512 -0
- package/server/src/Tracking/TrackingContextBuilder.php +41 -38
- package/server/src/Tracking/TrackingIntelligenceService.php +109 -9
- package/server/src/Tracking/TrackingStop.php +13 -11
- package/server/src/routes.php +43 -9
- package/server/tests/CapacityAllocationEngineTest.php +10 -10
- package/server/tests/CustomerContactIdentitySafetyTest.php +54 -0
- package/server/tests/CustomerEndpointTest.php +199 -0
- package/server/tests/DriverVehicleStatusDefaultTest.php +39 -0
- package/server/tests/LabelPdfRenderingTest.php +23 -0
- package/server/tests/LiveControllerViewportTest.php +60 -0
- package/server/tests/MetricsRegistryTest.php +4 -8
- package/server/tests/OrchestrationPayloadBuilderTest.php +2 -2
- package/server/tests/OrderConfigEndpointTest.php +86 -0
- package/server/tests/OrderFileAttachmentTest.php +20 -0
- package/server/tests/ServiceAreaZoneApiShapeTest.php +34 -0
- package/server/tests/VehicleDriverAssignmentTest.php +18 -0
- package/translations/en-us.yaml +1 -0
- package/server/migrations/2026_05_13_000001_repair_customer_contact_user_types.php +0 -86
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
namespace Fleetbase\FleetOps\Models;
|
|
4
4
|
|
|
5
5
|
use Fleetbase\Casts\Json;
|
|
6
|
+
use Fleetbase\FleetOps\Exceptions\CustomerUserConflictException;
|
|
6
7
|
use Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException;
|
|
7
8
|
use Fleetbase\FleetOps\Support\Utils;
|
|
8
9
|
use Fleetbase\Models\CompanyUser;
|
|
@@ -319,7 +320,12 @@ class Contact extends Model
|
|
|
319
320
|
// Check if user already exist with email or phone number
|
|
320
321
|
$existingUser = null;
|
|
321
322
|
if ($contact->email || $contact->phone) {
|
|
322
|
-
$existingUser = User::where(
|
|
323
|
+
$existingUser = User::where(function ($query) use ($contact) {
|
|
324
|
+
$query->where('company_uuid', $contact->company_uuid)
|
|
325
|
+
->orWhereHas('companyUsers', function ($query) use ($contact) {
|
|
326
|
+
$query->where('company_uuid', $contact->company_uuid);
|
|
327
|
+
});
|
|
328
|
+
})
|
|
323
329
|
->where(function ($query) use ($contact) {
|
|
324
330
|
if ($contact->email) {
|
|
325
331
|
$query->where('email', $contact->email);
|
|
@@ -335,15 +341,15 @@ class Contact extends Model
|
|
|
335
341
|
}
|
|
336
342
|
|
|
337
343
|
if ($existingUser) {
|
|
344
|
+
if ($contact->isCustomer()) {
|
|
345
|
+
$contact->assertCustomerUserCanBeAssigned($existingUser);
|
|
346
|
+
}
|
|
347
|
+
|
|
338
348
|
// Check if existing user belongs to another contact
|
|
339
349
|
$existingUserContact = Contact::where(['user_uuid' => $existingUser->uuid, 'company_uuid' => $contact->company_uuid])->whereHas('user')->first();
|
|
340
350
|
if ($existingUserContact) {
|
|
341
351
|
throw new UserAlreadyExistsException('User already exists, try to assigning the user to this contact.', $existingUser);
|
|
342
352
|
} else {
|
|
343
|
-
if ($contact->isCustomer()) {
|
|
344
|
-
$contact->normalizeCustomerUser($existingUser);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
353
|
// Assign the user to this contact instead
|
|
348
354
|
$contact->setAttribute('user_uuid', $existingUser->uuid);
|
|
349
355
|
if ($update) {
|
|
@@ -423,13 +429,7 @@ class Contact extends Model
|
|
|
423
429
|
return null;
|
|
424
430
|
}
|
|
425
431
|
|
|
426
|
-
|
|
427
|
-
if ($quiet) {
|
|
428
|
-
$user->forceFill(['type' => 'customer'])->saveQuietly();
|
|
429
|
-
} else {
|
|
430
|
-
$user->setType('customer');
|
|
431
|
-
}
|
|
432
|
-
}
|
|
432
|
+
$this->assertCustomerUserCanBeAssigned($user);
|
|
433
433
|
|
|
434
434
|
$this->loadMissing('company');
|
|
435
435
|
if ($this->company) {
|
|
@@ -463,7 +463,7 @@ class Contact extends Model
|
|
|
463
463
|
public function repairCustomerTypeInvariant(bool $quiet = false): ?User
|
|
464
464
|
{
|
|
465
465
|
if (!$this->isCustomer()) {
|
|
466
|
-
$this->
|
|
466
|
+
return $this->getUser();
|
|
467
467
|
}
|
|
468
468
|
|
|
469
469
|
$user = $this->getUser();
|
|
@@ -512,8 +512,17 @@ class Contact extends Model
|
|
|
512
512
|
// Load company
|
|
513
513
|
$this->loadMissing('company');
|
|
514
514
|
|
|
515
|
-
if ($this->isCustomer()
|
|
516
|
-
$
|
|
515
|
+
if ($this->isCustomer()) {
|
|
516
|
+
$this->assertCustomerUserCanBeAssigned($user);
|
|
517
|
+
|
|
518
|
+
$existingUserContact = static::where(['user_uuid' => $user->uuid, 'company_uuid' => $this->company_uuid])
|
|
519
|
+
->whereNot('uuid', $this->uuid)
|
|
520
|
+
->whereHas('user')
|
|
521
|
+
->first();
|
|
522
|
+
|
|
523
|
+
if ($existingUserContact) {
|
|
524
|
+
throw new UserAlreadyExistsException('User already exists, try to assigning the user to this contact.', $user);
|
|
525
|
+
}
|
|
517
526
|
}
|
|
518
527
|
|
|
519
528
|
// Assing to company
|
|
@@ -551,6 +560,74 @@ class Contact extends Model
|
|
|
551
560
|
return $this;
|
|
552
561
|
}
|
|
553
562
|
|
|
563
|
+
public function assertCustomerIdentityIsAvailable(): void
|
|
564
|
+
{
|
|
565
|
+
if (!$this->isCustomer()) {
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
$assignedUser = $this->getAssignedUserWithoutTypeScope();
|
|
570
|
+
if ($assignedUser) {
|
|
571
|
+
$this->assertCustomerUserCanBeAssigned($assignedUser);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
$matchedUser = $this->findExistingUserByIdentity();
|
|
575
|
+
if ($matchedUser && $matchedUser->uuid !== $this->user_uuid) {
|
|
576
|
+
$this->assertCustomerUserCanBeAssigned($matchedUser);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
public function assertCustomerUserCanBeAssigned(User $user): void
|
|
581
|
+
{
|
|
582
|
+
if ($user->type === 'customer') {
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
throw new CustomerUserConflictException(static::customerUserConflictMessage($user), $user);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
public static function customerUserConflictMessage(?User $user = null): string
|
|
590
|
+
{
|
|
591
|
+
$field = $user?->email ? 'email' : ($user?->phone ? 'phone number' : 'user account');
|
|
592
|
+
|
|
593
|
+
return 'This ' . $field . ' belongs to an existing staff user and cannot be used for a customer account.';
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private function findExistingUserByIdentity(): ?User
|
|
597
|
+
{
|
|
598
|
+
if (!$this->email && !$this->phone) {
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return User::where(function ($query) {
|
|
603
|
+
$query->where('company_uuid', $this->company_uuid)
|
|
604
|
+
->orWhereHas('companyUsers', function ($query) {
|
|
605
|
+
$query->where('company_uuid', $this->company_uuid);
|
|
606
|
+
});
|
|
607
|
+
})
|
|
608
|
+
->where(function ($query) {
|
|
609
|
+
if ($this->email) {
|
|
610
|
+
$query->where('email', $this->email);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if ($this->phone) {
|
|
614
|
+
$method = $this->email ? 'orWhere' : 'where';
|
|
615
|
+
$query->{$method}('phone', Utils::formatPhoneNumber($this->phone));
|
|
616
|
+
}
|
|
617
|
+
})
|
|
618
|
+
->whereNull('deleted_at')
|
|
619
|
+
->first();
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
private function getAssignedUserWithoutTypeScope(): ?User
|
|
623
|
+
{
|
|
624
|
+
if (!Str::isUuid($this->user_uuid)) {
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return User::where('uuid', $this->user_uuid)->first();
|
|
629
|
+
}
|
|
630
|
+
|
|
554
631
|
public function syncWithUser(): bool
|
|
555
632
|
{
|
|
556
633
|
$updates = [];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Models;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Database\Eloquent\Builder;
|
|
6
|
+
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
7
|
+
use Illuminate\Support\Str;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Fleet-Ops customer.
|
|
11
|
+
*
|
|
12
|
+
* A thin specialization of {@see Contact} that is scoped to rows with
|
|
13
|
+
* `type='customer'` and forces that type on create. This is what the public
|
|
14
|
+
* customer API surface (`/v1/customers/...`) operates on.
|
|
15
|
+
*
|
|
16
|
+
* Mirrors {@see \Fleetbase\Storefront\Models\Customer} but lives directly in
|
|
17
|
+
* FleetOps so that customer auth + customer-scoped orders work without the
|
|
18
|
+
* Storefront publishable-key + Store/Network gating.
|
|
19
|
+
*/
|
|
20
|
+
class Customer extends Contact
|
|
21
|
+
{
|
|
22
|
+
/**
|
|
23
|
+
* The key to use in the payload responses.
|
|
24
|
+
*/
|
|
25
|
+
protected string $payloadKey = 'customer';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Boot the model.
|
|
29
|
+
*/
|
|
30
|
+
protected static function boot()
|
|
31
|
+
{
|
|
32
|
+
parent::boot();
|
|
33
|
+
|
|
34
|
+
static::creating(function ($model) {
|
|
35
|
+
$model->type = 'customer';
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
static::addGlobalScope('type', function (Builder $builder) {
|
|
39
|
+
$builder->where('type', 'customer');
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Orders owned by this customer.
|
|
45
|
+
*/
|
|
46
|
+
public function orders(): HasMany
|
|
47
|
+
{
|
|
48
|
+
return $this->hasMany(Order::class, 'customer_uuid')
|
|
49
|
+
->whereNull('deleted_at')
|
|
50
|
+
->withoutGlobalScopes();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Find a customer by either a `customer_xxx` or `contact_xxx` public id.
|
|
55
|
+
*/
|
|
56
|
+
public static function findFromCustomerId(string $publicId): ?self
|
|
57
|
+
{
|
|
58
|
+
if (Str::startsWith($publicId, 'customer')) {
|
|
59
|
+
$publicId = Str::replaceFirst('customer', 'contact', $publicId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return static::where('public_id', $publicId)->first();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -119,6 +119,15 @@ class Driver extends Model
|
|
|
119
119
|
'time_window_end',
|
|
120
120
|
];
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Set attributes and defaults.
|
|
124
|
+
*
|
|
125
|
+
* @var array
|
|
126
|
+
*/
|
|
127
|
+
protected $attributes = [
|
|
128
|
+
'status' => 'available',
|
|
129
|
+
];
|
|
130
|
+
|
|
122
131
|
/**
|
|
123
132
|
* The attributes that are guarded and not mass assignable.
|
|
124
133
|
*
|
|
@@ -595,9 +604,9 @@ class Driver extends Model
|
|
|
595
604
|
/**
|
|
596
605
|
* Set the driver status attribute.
|
|
597
606
|
*/
|
|
598
|
-
public function setStatusAttribute(?string $status = '
|
|
607
|
+
public function setStatusAttribute(?string $status = 'available'): void
|
|
599
608
|
{
|
|
600
|
-
$this->attributes['status'] = $status;
|
|
609
|
+
$this->attributes['status'] = $status === null || $status === 'active' ? 'available' : $status;
|
|
601
610
|
}
|
|
602
611
|
|
|
603
612
|
/**
|
|
@@ -854,7 +863,7 @@ class Driver extends Model
|
|
|
854
863
|
'user_uuid' => $user->uuid,
|
|
855
864
|
'drivers_license_number' => $driversLicenseNumber,
|
|
856
865
|
'country' => $country,
|
|
857
|
-
'status' => '
|
|
866
|
+
'status' => 'available',
|
|
858
867
|
'location' => Utils::parsePointToWkt(new Point(0, 0)),
|
|
859
868
|
]);
|
|
860
869
|
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
namespace Fleetbase\FleetOps\Models;
|
|
4
4
|
|
|
5
|
-
use Barryvdh\DomPDF\Facade\Pdf;
|
|
6
5
|
use Fleetbase\Casts\Json;
|
|
7
6
|
use Fleetbase\Casts\PolymorphicType;
|
|
7
|
+
use Fleetbase\FleetOps\Support\LabelPdf;
|
|
8
8
|
use Fleetbase\FleetOps\Support\Utils;
|
|
9
9
|
use Fleetbase\FleetOps\Traits\HasTrackingNumber;
|
|
10
10
|
use Fleetbase\FleetOps\Traits\PayloadAccessors;
|
|
@@ -142,7 +142,7 @@ class Entity extends Model
|
|
|
142
142
|
*/
|
|
143
143
|
public function pdfLabel()
|
|
144
144
|
{
|
|
145
|
-
return
|
|
145
|
+
return LabelPdf::fromHtml($this->label());
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
/**
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
namespace Fleetbase\FleetOps\Models;
|
|
4
4
|
|
|
5
|
-
use Barryvdh\DomPDF\Facade\Pdf;
|
|
6
5
|
use Fleetbase\Casts\Json;
|
|
7
6
|
use Fleetbase\Casts\PolymorphicType;
|
|
8
7
|
use Fleetbase\FleetOps\Events\OrderCanceled;
|
|
@@ -10,6 +9,7 @@ use Fleetbase\FleetOps\Events\OrderCompleted;
|
|
|
10
9
|
use Fleetbase\FleetOps\Events\OrderDispatched;
|
|
11
10
|
use Fleetbase\FleetOps\Events\OrderDriverAssigned;
|
|
12
11
|
use Fleetbase\FleetOps\Flow\Activity;
|
|
12
|
+
use Fleetbase\FleetOps\Support\LabelPdf;
|
|
13
13
|
use Fleetbase\FleetOps\Support\OrderTracker;
|
|
14
14
|
use Fleetbase\FleetOps\Support\Utils;
|
|
15
15
|
use Fleetbase\FleetOps\Traits\HasTrackingNumber;
|
|
@@ -275,7 +275,32 @@ class Order extends Model
|
|
|
275
275
|
*/
|
|
276
276
|
public function pdfLabel()
|
|
277
277
|
{
|
|
278
|
-
return
|
|
278
|
+
return LabelPdf::fromHtml($this->label());
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
public function attachFiles($uploads): self
|
|
282
|
+
{
|
|
283
|
+
$ids = collect($uploads)
|
|
284
|
+
->map(function ($upload) {
|
|
285
|
+
if (is_string($upload)) {
|
|
286
|
+
return $upload;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return data_get($upload, 'uuid') ?? data_get($upload, 'id');
|
|
290
|
+
})
|
|
291
|
+
->filter()
|
|
292
|
+
->unique()
|
|
293
|
+
->values();
|
|
294
|
+
|
|
295
|
+
if ($ids->isEmpty()) {
|
|
296
|
+
return $this;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
\Fleetbase\Models\File::whereIn('uuid', $ids)->get()->each(function ($file) {
|
|
300
|
+
$file->setKey($this);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
return $this;
|
|
279
304
|
}
|
|
280
305
|
|
|
281
306
|
/**
|
|
@@ -1991,11 +2016,11 @@ class Order extends Model
|
|
|
1991
2016
|
return collect();
|
|
1992
2017
|
}
|
|
1993
2018
|
|
|
1994
|
-
$drivers = Driver::where(['status' => '
|
|
2019
|
+
$drivers = Driver::where(['status' => 'available', 'online' => 1])
|
|
1995
2020
|
->whereHas('company', function ($q) {
|
|
1996
2021
|
$q->whereHas('users', function ($q) {
|
|
1997
2022
|
$q->whereHas('driver', function ($q) {
|
|
1998
|
-
$q->where(['status' => '
|
|
2023
|
+
$q->where(['status' => 'available', 'online' => 1]);
|
|
1999
2024
|
$q->whereNull('deleted_at');
|
|
2000
2025
|
});
|
|
2001
2026
|
});
|
|
@@ -61,7 +61,23 @@ class Payload extends Model
|
|
|
61
61
|
*
|
|
62
62
|
* @var array
|
|
63
63
|
*/
|
|
64
|
-
protected $fillable = [
|
|
64
|
+
protected $fillable = [
|
|
65
|
+
'_key',
|
|
66
|
+
'company_uuid',
|
|
67
|
+
'pickup_uuid',
|
|
68
|
+
'pickup_tracking_number_uuid',
|
|
69
|
+
'dropoff_uuid',
|
|
70
|
+
'dropoff_tracking_number_uuid',
|
|
71
|
+
'return_uuid',
|
|
72
|
+
'return_tracking_number_uuid',
|
|
73
|
+
'current_waypoint_uuid',
|
|
74
|
+
'meta',
|
|
75
|
+
'payment_method',
|
|
76
|
+
'cod_amount',
|
|
77
|
+
'cod_currency',
|
|
78
|
+
'cod_payment_method',
|
|
79
|
+
'type',
|
|
80
|
+
];
|
|
65
81
|
|
|
66
82
|
/**
|
|
67
83
|
* The attributes that should be cast to native types.
|
|
@@ -216,6 +216,14 @@ class ServiceRate extends Model
|
|
|
216
216
|
return $this->rate_calculation_method === 'per_meter';
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Check if the rate calculation method is "multi_zone_distance".
|
|
221
|
+
*/
|
|
222
|
+
public function isMultiZoneDistance(): bool
|
|
223
|
+
{
|
|
224
|
+
return $this->rate_calculation_method === 'multi_zone_distance';
|
|
225
|
+
}
|
|
226
|
+
|
|
219
227
|
/**
|
|
220
228
|
* Check if the rate calculation method is "per_drop".
|
|
221
229
|
*/
|
|
@@ -329,6 +337,16 @@ class ServiceRate extends Model
|
|
|
329
337
|
return $this;
|
|
330
338
|
}
|
|
331
339
|
|
|
340
|
+
$serviceRateFees = collect($serviceRateFees)
|
|
341
|
+
->map(fn ($fee) => $this->normalizeServiceRateFeePayload($fee))
|
|
342
|
+
->filter()
|
|
343
|
+
->values()
|
|
344
|
+
->toArray();
|
|
345
|
+
|
|
346
|
+
if (!$serviceRateFees) {
|
|
347
|
+
return $this;
|
|
348
|
+
}
|
|
349
|
+
|
|
332
350
|
$iterate = count($serviceRateFees);
|
|
333
351
|
|
|
334
352
|
for ($i = 0; $i < $iterate; $i++) {
|
|
@@ -356,6 +374,43 @@ class ServiceRate extends Model
|
|
|
356
374
|
return $this;
|
|
357
375
|
}
|
|
358
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Normalize embedded service-rate fee payloads before persistence.
|
|
379
|
+
*/
|
|
380
|
+
public function normalizeServiceRateFeePayload($fee): ?array
|
|
381
|
+
{
|
|
382
|
+
if (!is_array($fee)) {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
$fee['service_area_uuid'] ??= data_get($fee, 'service_area.uuid');
|
|
387
|
+
$fee['zone_uuid'] ??= data_get($fee, 'zone.uuid');
|
|
388
|
+
|
|
389
|
+
if (Utils::castBoolean($fee['is_fallback'] ?? false)) {
|
|
390
|
+
$fee['service_area_uuid'] = null;
|
|
391
|
+
$fee['zone_uuid'] = null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return collect($fee)
|
|
395
|
+
->only([
|
|
396
|
+
'uuid',
|
|
397
|
+
'service_rate_uuid',
|
|
398
|
+
'service_area_uuid',
|
|
399
|
+
'zone_uuid',
|
|
400
|
+
'label',
|
|
401
|
+
'priority',
|
|
402
|
+
'is_fallback',
|
|
403
|
+
'distance',
|
|
404
|
+
'distance_unit',
|
|
405
|
+
'min',
|
|
406
|
+
'max',
|
|
407
|
+
'unit',
|
|
408
|
+
'fee',
|
|
409
|
+
'currency',
|
|
410
|
+
])
|
|
411
|
+
->toArray();
|
|
412
|
+
}
|
|
413
|
+
|
|
359
414
|
/**
|
|
360
415
|
* Set the service rate parcel fees for the current object.
|
|
361
416
|
*
|
|
@@ -506,7 +561,7 @@ class ServiceRate extends Model
|
|
|
506
561
|
public static function getServicableForPlaces($places = [], $service = null, $currency = null, ?\Closure $queryCallback = null): array
|
|
507
562
|
{
|
|
508
563
|
$reader = new GeoJSONReader();
|
|
509
|
-
$serviceRatesQuery = static::with(['zone', 'serviceArea', 'rateFees', 'parcelFees']);
|
|
564
|
+
$serviceRatesQuery = static::with(['zone', 'serviceArea', 'rateFees.zone', 'rateFees.serviceArea', 'parcelFees']);
|
|
510
565
|
|
|
511
566
|
if ($currency) {
|
|
512
567
|
$serviceRatesQuery->whereRaw('lower(currency) = ?', [strtolower($currency)]);
|
|
@@ -738,6 +793,12 @@ class ServiceRate extends Model
|
|
|
738
793
|
]);
|
|
739
794
|
}
|
|
740
795
|
|
|
796
|
+
if ($this->isMultiZoneDistance()) {
|
|
797
|
+
[$multiZoneFee, $multiZoneLines] = $this->quoteMultiZoneDistance($waypoints, $totalDistance);
|
|
798
|
+
$subTotal += $multiZoneFee;
|
|
799
|
+
$lines = $lines->merge($multiZoneLines);
|
|
800
|
+
}
|
|
801
|
+
|
|
741
802
|
if ($this->isAlgorithm()) {
|
|
742
803
|
$resolvedEndpointCount = $endpointCount ?? $this->inferEndpointCountFromStops($waypoints);
|
|
743
804
|
$rateFee = $this->normalizeCalculatedMoney(Algo::exec(
|
|
@@ -927,6 +988,12 @@ class ServiceRate extends Model
|
|
|
927
988
|
]);
|
|
928
989
|
}
|
|
929
990
|
|
|
991
|
+
if ($this->isMultiZoneDistance()) {
|
|
992
|
+
[$multiZoneFee, $multiZoneLines] = $this->quoteMultiZoneDistance($waypoints, $totalDistance);
|
|
993
|
+
$subTotal += $multiZoneFee;
|
|
994
|
+
$lines = $lines->merge($multiZoneLines);
|
|
995
|
+
}
|
|
996
|
+
|
|
930
997
|
if ($this->isAlgorithm()) {
|
|
931
998
|
$rateFee = $this->normalizeCalculatedMoney(Algo::exec(
|
|
932
999
|
$this->algorithm,
|
|
@@ -1086,6 +1153,211 @@ class ServiceRate extends Model
|
|
|
1086
1153
|
return 2;
|
|
1087
1154
|
}
|
|
1088
1155
|
|
|
1156
|
+
protected function quoteMultiZoneDistance($waypoints = [], ?int $totalDistance = 0): array
|
|
1157
|
+
{
|
|
1158
|
+
$this->loadMissing(['rateFees.zone', 'rateFees.serviceArea']);
|
|
1159
|
+
|
|
1160
|
+
$rules = $this->rateFees
|
|
1161
|
+
->filter(fn ($rule) => !$rule->is_fallback && ($rule->zone || $rule->serviceArea))
|
|
1162
|
+
->sortByDesc(fn ($rule) => (int) ($rule->priority ?? 0))
|
|
1163
|
+
->values();
|
|
1164
|
+
|
|
1165
|
+
$fallbackRule = $this->rateFees
|
|
1166
|
+
->filter(fn ($rule) => (bool) $rule->is_fallback)
|
|
1167
|
+
->sortByDesc(fn ($rule) => (int) ($rule->priority ?? 0))
|
|
1168
|
+
->first();
|
|
1169
|
+
|
|
1170
|
+
if ($rules->isEmpty() && !$fallbackRule) {
|
|
1171
|
+
return [0, collect()];
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
$pricedDistances = $this->calculateMultiZoneDistances($waypoints, $rules, $fallbackRule, $totalDistance);
|
|
1175
|
+
$lines = collect();
|
|
1176
|
+
$subTotal = 0;
|
|
1177
|
+
|
|
1178
|
+
foreach ($pricedDistances as $entry) {
|
|
1179
|
+
$rule = $entry['rule'];
|
|
1180
|
+
if (!$rule instanceof ServiceRateFee) {
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
$distanceInMeters = (float) ($entry['distance_m'] ?? 0);
|
|
1185
|
+
if ($distanceInMeters <= 0) {
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
$unit = strtolower($rule->distance_unit ?: 'km');
|
|
1190
|
+
$distance = $this->normalizeDistanceForUnit($distanceInMeters, $unit);
|
|
1191
|
+
$fee = $this->normalizeCalculatedMoney($distance * Utils::numbersOnly($rule->fee ?? 0));
|
|
1192
|
+
$subTotal += $fee;
|
|
1193
|
+
|
|
1194
|
+
if ($rule->is_fallback) {
|
|
1195
|
+
$label = 'Out-of-zone distance charge';
|
|
1196
|
+
} else {
|
|
1197
|
+
$label = data_get($rule, 'zone.name') ?: data_get($rule, 'serviceArea.name') ?: $rule->label ?: 'Geographic';
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
if (!$rule->is_fallback && !str_ends_with(strtolower($label), 'distance charge')) {
|
|
1201
|
+
$label .= ' distance charge';
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
$lines->push([
|
|
1205
|
+
'details' => sprintf('%s (%s %s x %s)', $label, round($distance, 2), $unit, Utils::moneyFormat($rule->fee, $this->currency)),
|
|
1206
|
+
'amount' => $fee,
|
|
1207
|
+
'formatted_amount' => Utils::moneyFormat($fee, $this->currency),
|
|
1208
|
+
'currency' => $this->currency,
|
|
1209
|
+
'code' => 'MULTI_ZONE_DISTANCE_FEE',
|
|
1210
|
+
]);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
return [$subTotal, $lines];
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
protected function calculateMultiZoneDistances($waypoints, $rules, ?ServiceRateFee $fallbackRule = null, ?int $totalDistance = 0): array
|
|
1217
|
+
{
|
|
1218
|
+
$places = collect($waypoints)->map(fn ($place) => Place::createFromMixed($place))->filter()->values();
|
|
1219
|
+
|
|
1220
|
+
if ($places->count() < 2 || (int) $totalDistance <= 0) {
|
|
1221
|
+
return $fallbackRule ? [['rule' => $fallbackRule, 'distance_m' => (float) ($totalDistance ?? 0)]] : [];
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
$reader = new GeoJSONReader();
|
|
1225
|
+
$ruleGeometries = $rules->map(function ($rule) use ($reader) {
|
|
1226
|
+
return [
|
|
1227
|
+
'rule' => $rule,
|
|
1228
|
+
'geometry' => $this->readRateRuleGeometry($rule, $reader),
|
|
1229
|
+
];
|
|
1230
|
+
})->filter(fn ($entry) => $entry['geometry'])->values();
|
|
1231
|
+
|
|
1232
|
+
if ($ruleGeometries->isEmpty()) {
|
|
1233
|
+
return $fallbackRule ? [['rule' => $fallbackRule, 'distance_m' => (float) $totalDistance]] : [];
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
$distances = [];
|
|
1237
|
+
$sampledTotal = 0;
|
|
1238
|
+
|
|
1239
|
+
for ($i = 0; $i < $places->count() - 1; $i++) {
|
|
1240
|
+
$start = $this->getLngLatFromPlace($places[$i]);
|
|
1241
|
+
$end = $this->getLngLatFromPlace($places[$i + 1]);
|
|
1242
|
+
|
|
1243
|
+
if (!$start || !$end) {
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
$legDistance = $this->haversineDistanceInMeters($start['lat'], $start['lng'], $end['lat'], $end['lng']);
|
|
1248
|
+
$steps = max(1, min(500, (int) ceil($legDistance / 1000)));
|
|
1249
|
+
|
|
1250
|
+
for ($step = 0; $step < $steps; $step++) {
|
|
1251
|
+
$fromRatio = $step / $steps;
|
|
1252
|
+
$toRatio = ($step + 1) / $steps;
|
|
1253
|
+
$midRatio = ($fromRatio + $toRatio) / 2;
|
|
1254
|
+
|
|
1255
|
+
$from = $this->interpolateLngLat($start, $end, $fromRatio);
|
|
1256
|
+
$to = $this->interpolateLngLat($start, $end, $toRatio);
|
|
1257
|
+
$mid = $this->interpolateLngLat($start, $end, $midRatio);
|
|
1258
|
+
|
|
1259
|
+
$sampleDistance = $this->haversineDistanceInMeters($from['lat'], $from['lng'], $to['lat'], $to['lng']);
|
|
1260
|
+
$sampledTotal += $sampleDistance;
|
|
1261
|
+
|
|
1262
|
+
$matchedRule = $this->matchMultiZoneRule($mid, $ruleGeometries) ?: $fallbackRule;
|
|
1263
|
+
if (!$matchedRule instanceof ServiceRateFee) {
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
$key = $matchedRule->uuid ?? spl_object_hash($matchedRule);
|
|
1268
|
+
if (!isset($distances[$key])) {
|
|
1269
|
+
$distances[$key] = ['rule' => $matchedRule, 'distance_m' => 0];
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
$distances[$key]['distance_m'] += $sampleDistance;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
if ($sampledTotal > 0 && $totalDistance > 0) {
|
|
1277
|
+
$scale = $totalDistance / $sampledTotal;
|
|
1278
|
+
foreach ($distances as &$entry) {
|
|
1279
|
+
$entry['distance_m'] *= $scale;
|
|
1280
|
+
}
|
|
1281
|
+
unset($entry);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
return array_values($distances);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
protected function readRateRuleGeometry(ServiceRateFee $rule, GeoJSONReader $reader)
|
|
1288
|
+
{
|
|
1289
|
+
$border = data_get($rule, 'zone.border') ?: data_get($rule, 'serviceArea.border');
|
|
1290
|
+
|
|
1291
|
+
if (!$border) {
|
|
1292
|
+
return null;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
try {
|
|
1296
|
+
if (is_object($border) && method_exists($border, 'toJson')) {
|
|
1297
|
+
return $reader->read($border->toJson());
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
if (is_array($border) || is_object($border)) {
|
|
1301
|
+
return $reader->read(json_encode($border, JSON_UNESCAPED_UNICODE));
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
if (is_string($border)) {
|
|
1305
|
+
return $reader->read($border);
|
|
1306
|
+
}
|
|
1307
|
+
} catch (\Throwable $e) {
|
|
1308
|
+
return null;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
return null;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
protected function matchMultiZoneRule(array $point, $ruleGeometries): ?ServiceRateFee
|
|
1315
|
+
{
|
|
1316
|
+
$brickPoint = \Brick\Geo\Point::fromText(sprintf('POINT (%F %F)', $point['lng'], $point['lat']), 4326);
|
|
1317
|
+
|
|
1318
|
+
foreach ($ruleGeometries as $entry) {
|
|
1319
|
+
if ($entry['geometry']->contains($brickPoint)) {
|
|
1320
|
+
return $entry['rule'];
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
return null;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
protected function getLngLatFromPlace(?Place $place): ?array
|
|
1328
|
+
{
|
|
1329
|
+
if (!$place instanceof Place) {
|
|
1330
|
+
return null;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
$point = $place->getLocationAsPoint();
|
|
1334
|
+
|
|
1335
|
+
if (!$point || !method_exists($point, 'getLat') || !method_exists($point, 'getLng')) {
|
|
1336
|
+
return null;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
return ['lat' => (float) $point->getLat(), 'lng' => (float) $point->getLng()];
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
protected function interpolateLngLat(array $start, array $end, float $ratio): array
|
|
1343
|
+
{
|
|
1344
|
+
return [
|
|
1345
|
+
'lat' => $start['lat'] + (($end['lat'] - $start['lat']) * $ratio),
|
|
1346
|
+
'lng' => $start['lng'] + (($end['lng'] - $start['lng']) * $ratio),
|
|
1347
|
+
];
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
protected function haversineDistanceInMeters(float $lat1, float $lng1, float $lat2, float $lng2): float
|
|
1351
|
+
{
|
|
1352
|
+
$earthRadius = 6371000;
|
|
1353
|
+
$latDelta = deg2rad($lat2 - $lat1);
|
|
1354
|
+
$lngDelta = deg2rad($lng2 - $lng1);
|
|
1355
|
+
|
|
1356
|
+
$a = sin($latDelta / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($lngDelta / 2) ** 2;
|
|
1357
|
+
|
|
1358
|
+
return $earthRadius * 2 * atan2(sqrt($a), sqrt(1 - $a));
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1089
1361
|
/**
|
|
1090
1362
|
* Find the ServiceRateFee based on the total distance.
|
|
1091
1363
|
*
|