@fleetbase/fleetops-engine 0.6.47 → 0.6.49
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/admin/routing-settings.hbs +71 -0
- package/addon/components/admin/routing-settings.js +124 -0
- package/addon/components/customer/orders.js +1 -1
- package/addon/components/display-place.hbs +1 -1
- package/addon/components/driver/details.hbs +5 -1
- package/addon/components/driver/form.hbs +11 -1
- package/addon/components/fleet-listing-panel.hbs +2 -2
- package/addon/components/fleet-listing-panel.js +10 -0
- package/addon/components/map/google-live-map.js +12 -73
- package/addon/components/map/leaflet-live-map.hbs +149 -33
- package/addon/components/map/order-list-overlay/order.hbs +18 -3
- package/addon/components/map/toolbar.hbs +2 -2
- package/addon/components/map/toolbar.js +10 -2
- package/addon/components/orchestrator/phase-builder.hbs +49 -5
- package/addon/components/orchestrator/phase-builder.js +34 -1
- package/addon/components/orchestrator/resource-panel.hbs +19 -1
- package/addon/components/orchestrator/resource-panel.js +4 -0
- package/addon/components/orchestrator-workbench.js +4 -1
- package/addon/components/order/details/purchase-rate.hbs +19 -22
- package/addon/components/order/details/route.hbs +26 -1
- package/addon/components/order/details/route.js +50 -0
- package/addon/components/order/details/tracking.hbs +171 -27
- package/addon/components/order/details/tracking.js +457 -0
- package/addon/components/order/form.hbs +2 -2
- package/addon/components/order/kanban-card.hbs +18 -3
- package/addon/components/order-list-overlay/order.hbs +18 -3
- package/addon/components/order-progress-card.hbs +21 -6
- package/addon/components/order-tracking-lookup.hbs +7 -7
- package/addon/components/order-tracking-lookup.js +1 -1
- package/addon/components/route-list.hbs +78 -8
- package/addon/components/route-list.js +48 -6
- package/addon/components/tracking-stop-progress.hbs +45 -0
- package/addon/components/tracking-stop-progress.js +79 -0
- package/addon/components/widget/fleet-ops-key-metrics.hbs +19 -17
- package/addon/components/widget/fleet-ops-key-metrics.js +26 -0
- package/addon/components/widget/fuel-efficiency.hbs +26 -0
- package/addon/components/widget/fuel-efficiency.js +43 -0
- package/addon/components/widget/geofence-violations.hbs +44 -0
- package/addon/components/widget/geofence-violations.js +55 -0
- package/addon/components/widget/issues-insights.hbs +43 -0
- package/addon/components/widget/issues-insights.js +62 -0
- package/addon/components/widget/kpi-active-orders.hbs +10 -0
- package/addon/components/widget/kpi-active-orders.js +5 -0
- package/addon/components/widget/kpi-aov.hbs +10 -0
- package/addon/components/widget/kpi-aov.js +5 -0
- package/addon/components/widget/kpi-distance.hbs +10 -0
- package/addon/components/widget/kpi-distance.js +5 -0
- package/addon/components/widget/kpi-drivers-online.hbs +10 -0
- package/addon/components/widget/kpi-drivers-online.js +5 -0
- package/addon/components/widget/kpi-earnings.hbs +10 -0
- package/addon/components/widget/kpi-earnings.js +5 -0
- package/addon/components/widget/kpi-open-issues.hbs +11 -0
- package/addon/components/widget/kpi-open-issues.js +5 -0
- package/addon/components/widget/kpi-tile.hbs +59 -0
- package/addon/components/widget/kpi-tile.js +168 -0
- package/addon/components/widget/live-fleet.hbs +122 -0
- package/addon/components/widget/live-fleet.js +365 -0
- package/addon/components/widget/maintenance-overview.hbs +45 -0
- package/addon/components/widget/maintenance-overview.js +40 -0
- package/addon/components/widget/on-time-delivery.hbs +27 -0
- package/addon/components/widget/on-time-delivery.js +73 -0
- package/addon/components/widget/operations-pulse.hbs +51 -0
- package/addon/components/widget/operations-pulse.js +173 -0
- package/addon/components/widget/orders-by-status.hbs +26 -0
- package/addon/components/widget/orders-by-status.js +54 -0
- package/addon/components/widget/revenue-trend.hbs +34 -0
- package/addon/components/widget/revenue-trend.js +67 -0
- package/addon/components/widget/top-drivers.hbs +44 -0
- package/addon/components/widget/top-drivers.js +54 -0
- package/addon/controllers/settings/routing.js +124 -1
- package/addon/extension.js +172 -3
- package/addon/helpers/format-duration.js +38 -0
- package/addon/routes/application.js +6 -0
- package/addon/services/map-adapter/google.js +13 -2
- package/addon/services/order-list-overlay.js +2 -2
- package/addon/styles/fleetops-engine.css +806 -46
- package/addon/templates/maintenance/equipment/index.hbs +1 -1
- package/addon/templates/maintenance/parts/index.hbs +1 -1
- package/addon/templates/maintenance/schedules/index.hbs +1 -1
- package/addon/templates/management/vehicles/index.hbs +1 -1
- package/addon/templates/settings/avatars.hbs +1 -1
- package/addon/templates/settings/custom-fields.hbs +1 -1
- package/addon/templates/settings/map.hbs +1 -1
- package/addon/templates/settings/navigator-app.hbs +1 -1
- package/addon/templates/settings/notifications.hbs +1 -1
- package/addon/templates/settings/orchestrator.hbs +1 -1
- package/addon/templates/settings/payments/index.hbs +1 -1
- package/addon/templates/settings/payments/onboard.hbs +1 -1
- package/addon/templates/settings/routing.hbs +82 -4
- package/addon/templates/settings/scheduling.hbs +1 -1
- package/addon/utils/live-map-card-content.js +171 -0
- package/app/components/tracking-stop-progress.js +1 -0
- package/app/components/widget/fuel-efficiency.js +1 -0
- package/app/components/widget/geofence-violations.js +1 -0
- package/app/components/widget/issues-insights.js +1 -0
- package/app/components/widget/kpi-active-orders.js +1 -0
- package/app/components/widget/kpi-aov.js +1 -0
- package/app/components/widget/kpi-distance.js +1 -0
- package/app/components/widget/kpi-drivers-online.js +1 -0
- package/app/components/widget/kpi-earnings.js +1 -0
- package/app/components/widget/kpi-open-issues.js +1 -0
- package/app/components/widget/kpi-tile.js +1 -0
- package/app/components/widget/live-fleet.js +1 -0
- package/app/components/widget/maintenance-overview.js +1 -0
- package/app/components/widget/on-time-delivery.js +1 -0
- package/app/components/widget/operations-pulse.js +1 -0
- package/app/components/widget/orders-by-status.js +1 -0
- package/app/components/widget/revenue-trend.js +1 -0
- package/app/components/widget/top-drivers.js +1 -0
- package/app/helpers/format-duration.js +1 -0
- package/app/utils/live-map-card-content.js +1 -0
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/package.json +3 -3
- package/pnpm-workspace.yaml +1 -0
- package/server/config/fleetops.php +32 -11
- package/server/config/telematics.php +111 -1
- package/server/migrations/2026_05_13_000001_repair_customer_contact_user_types.php +86 -0
- package/server/migrations/2026_05_18_000001_add_license_expiry_to_drivers_table.php +25 -0
- package/server/seeders/Testing/Concerns/SeedsTestingData.php +183 -0
- package/server/seeders/Testing/ConnectivitySeeder.php +212 -0
- package/server/seeders/Testing/FleetSeeder.php +341 -0
- package/server/seeders/Testing/MaintenanceSeeder.php +238 -0
- package/server/seeders/Testing/NetworkSeeder.php +250 -0
- package/server/seeders/Testing/OrdersSeeder.php +214 -0
- package/server/seeders/Testing/TestingSeeder.php +40 -0
- package/server/seeders/Testing/VroomOrchestrationSeeder.php +409 -0
- package/server/src/Console/Commands/ProcessOperationalAlerts.php +279 -0
- package/server/src/Http/Controllers/Api/v1/OrchestrationController.php +64 -0
- package/server/src/Http/Controllers/Api/v1/OrderController.php +26 -26
- package/server/src/Http/Controllers/Api/v1/PayloadController.php +10 -10
- package/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +1 -1
- package/server/src/Http/Controllers/Internal/v1/AnalyticsController.php +119 -0
- package/server/src/Http/Controllers/Internal/v1/ContactController.php +41 -0
- package/server/src/Http/Controllers/Internal/v1/LiveController.php +6 -42
- package/server/src/Http/Controllers/Internal/v1/MetricsController.php +82 -3
- package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +1 -1
- package/server/src/Http/Controllers/Internal/v1/OrderController.php +52 -21
- package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +1 -1
- package/server/src/Http/Controllers/Internal/v1/SettingController.php +164 -12
- package/server/src/Http/Resources/v1/Driver.php +2 -0
- package/server/src/Http/Resources/v1/Fleet.php +4 -0
- package/server/src/Http/Resources/v1/Index/Driver.php +48 -0
- package/server/src/Http/Resources/v1/Index/Payload.php +2 -2
- package/server/src/Http/Resources/v1/Index/Vehicle.php +74 -1
- package/server/src/Models/Contact.php +122 -13
- package/server/src/Models/Driver.php +2 -0
- package/server/src/Models/Order.php +1 -1
- package/server/src/Models/OrderConfig.php +14 -11
- package/server/src/Models/Payload.php +8 -8
- package/server/src/Models/Place.php +0 -3
- package/server/src/Models/ServiceRate.php +1 -1
- package/server/src/Notifications/LateDeparture.php +48 -0
- package/server/src/Notifications/ProlongedStoppage.php +48 -0
- package/server/src/Notifications/RouteDeviation.php +48 -0
- package/server/src/Observers/ContactObserver.php +8 -0
- package/server/src/Orchestration/Engines/CapacityAllocationEngine.php +220 -0
- package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +357 -38
- package/server/src/Orchestration/Support/OrchestrationPayloadBuilder.php +259 -51
- package/server/src/Providers/FleetOpsServiceProvider.php +32 -0
- package/server/src/Providers/NotificationServiceProvider.php +3 -0
- package/server/src/Support/Analytics/AbstractAnalytics.php +77 -0
- package/server/src/Support/Analytics/FuelEfficiency.php +83 -0
- package/server/src/Support/Analytics/GeofenceViolations.php +63 -0
- package/server/src/Support/Analytics/IssuesInsights.php +67 -0
- package/server/src/Support/Analytics/LiveFleet.php +127 -0
- package/server/src/Support/Analytics/MaintenanceOverview.php +70 -0
- package/server/src/Support/Analytics/OnTimeDelivery.php +75 -0
- package/server/src/Support/Analytics/OperationsPulse.php +86 -0
- package/server/src/Support/Analytics/OrdersByStatus.php +64 -0
- package/server/src/Support/Analytics/RevenueTrend.php +79 -0
- package/server/src/Support/Analytics/TopDrivers.php +92 -0
- package/server/src/Support/LiveOrderQuery.php +75 -0
- package/server/src/Support/Metrics/AbstractMetric.php +170 -0
- package/server/src/Support/Metrics/ActiveLiveOrdersMetric.php +28 -0
- package/server/src/Support/Metrics/AvgOrderValueMetric.php +53 -0
- package/server/src/Support/Metrics/DriversOnlineMetric.php +30 -0
- package/server/src/Support/Metrics/EarningsMetric.php +30 -0
- package/server/src/Support/Metrics/FuelCostsMetric.php +30 -0
- package/server/src/Support/Metrics/MoneyMetric.php +30 -0
- package/server/src/Support/Metrics/OpenIssuesMetric.php +35 -0
- package/server/src/Support/Metrics/OrdersCanceledMetric.php +35 -0
- package/server/src/Support/Metrics/OrdersCompletedMetric.php +35 -0
- package/server/src/Support/Metrics/OrdersInProgressMetric.php +51 -0
- package/server/src/Support/Metrics/OrdersScheduledMetric.php +37 -0
- package/server/src/Support/Metrics/Registry.php +46 -0
- package/server/src/Support/Metrics/ResolvedIssuesMetric.php +35 -0
- package/server/src/Support/Metrics/TotalCustomersMetric.php +29 -0
- package/server/src/Support/Metrics/TotalDistanceTraveledMetric.php +35 -0
- package/server/src/Support/Metrics/TotalDriversMetric.php +28 -0
- package/server/src/Support/Metrics/TotalTimeTraveledMetric.php +35 -0
- package/server/src/Support/Metrics.php +46 -234
- package/server/src/Support/OSRM.php +11 -6
- package/server/src/Support/OrderTracker.php +7 -582
- package/server/src/Support/Telematics/Providers/AfaqyProvider.php +290 -0
- package/server/src/Support/Telematics/Providers/SafeeProvider.php +333 -0
- package/server/src/Support/Utils.php +1 -1
- package/server/src/Tracking/Contracts/TrackingProviderInterface.php +19 -0
- package/server/src/Tracking/Providers/CalculatedTrackingProvider.php +65 -0
- package/server/src/Tracking/Providers/GoogleRoutesTrackingProvider.php +118 -0
- package/server/src/Tracking/Providers/OsrmTrackingProvider.php +71 -0
- package/server/src/Tracking/README.md +25 -0
- package/server/src/Tracking/Support/FakeTrackingProvider.php +42 -0
- package/server/src/Tracking/TrackingContext.php +63 -0
- package/server/src/Tracking/TrackingContextBuilder.php +204 -0
- package/server/src/Tracking/TrackingIntelligenceService.php +180 -0
- package/server/src/Tracking/TrackingOptions.php +49 -0
- package/server/src/Tracking/TrackingProviderCapabilities.php +30 -0
- package/server/src/Tracking/TrackingProviderManager.php +89 -0
- package/server/src/Tracking/TrackingProviderRegistry.php +35 -0
- package/server/src/Tracking/TrackingProviderResult.php +20 -0
- package/server/src/Tracking/TrackingStop.php +59 -0
- package/server/src/routes.php +27 -0
- package/server/tests/AnalyticsRoutesTest.php +41 -0
- package/server/tests/CapacityAllocationEngineTest.php +108 -0
- package/server/tests/LiveOrderQueryTest.php +34 -0
- package/server/tests/MetricsRegistryTest.php +88 -0
- package/server/tests/OrchestrationPayloadBuilderTest.php +132 -0
- package/server/tests/OrchestratorConsumableApiTest.php +74 -0
- package/server/tests/PingDriverEndpointTest.php +19 -0
- package/server/tests/TrackingIntelligenceTest.php +176 -0
- package/server/tests/VroomOrchestrationEngineTest.php +250 -0
- package/translations/en-us.yaml +41 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Tracking;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\FleetOps\Models\Order;
|
|
6
|
+
use Illuminate\Support\Carbon;
|
|
7
|
+
use Illuminate\Support\Facades\Cache;
|
|
8
|
+
|
|
9
|
+
class TrackingIntelligenceService
|
|
10
|
+
{
|
|
11
|
+
public function __construct(
|
|
12
|
+
protected TrackingContextBuilder $contextBuilder,
|
|
13
|
+
protected TrackingProviderManager $providerManager,
|
|
14
|
+
) {
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
public function track(Order $order, array|TrackingOptions $options = []): array
|
|
18
|
+
{
|
|
19
|
+
$options = $options instanceof TrackingOptions ? $options : TrackingOptions::fromArray($options);
|
|
20
|
+
$context = $this->contextBuilder->build($order, $options);
|
|
21
|
+
$cacheKey = $this->cacheKey($context, $options);
|
|
22
|
+
|
|
23
|
+
return Cache::remember($cacheKey, $options->cacheTtlSeconds, function () use ($context, $options) {
|
|
24
|
+
return $this->buildResult($context, $this->providerManager->track($context, $options), $options);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public function eta(Order $order, array|TrackingOptions $options = []): array
|
|
29
|
+
{
|
|
30
|
+
$tracker = $this->track($order, $options);
|
|
31
|
+
|
|
32
|
+
return [
|
|
33
|
+
'active_stop_seconds' => data_get($tracker, 'eta.active_stop_seconds'),
|
|
34
|
+
'completion_seconds' => data_get($tracker, 'eta.completion_seconds'),
|
|
35
|
+
'active_stop_at' => data_get($tracker, 'eta.active_stop_at'),
|
|
36
|
+
'completion_at' => data_get($tracker, 'eta.completion_at'),
|
|
37
|
+
'provider' => data_get($tracker, 'provider'),
|
|
38
|
+
'confidence' => data_get($tracker, 'confidence'),
|
|
39
|
+
'warnings' => data_get($tracker, 'warnings', []),
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
protected function buildResult(TrackingContext $context, TrackingProviderResult $providerResult, TrackingOptions $options): array
|
|
44
|
+
{
|
|
45
|
+
$now = now();
|
|
46
|
+
$completionSeconds = $providerResult->durationInTrafficSeconds ?? $providerResult->durationSeconds;
|
|
47
|
+
$activeStopSeconds = data_get($providerResult->legs, '0.duration_in_traffic_s', data_get($providerResult->legs, '0.duration_s', $completionSeconds));
|
|
48
|
+
$progress = $this->progress($context, $providerResult);
|
|
49
|
+
$warnings = array_values(array_unique([...$context->warnings, ...$providerResult->warnings]));
|
|
50
|
+
|
|
51
|
+
if ($providerResult->confidence === 'low') {
|
|
52
|
+
$warnings[] = 'low_confidence_eta';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return [
|
|
56
|
+
'provider' => $providerResult->provider,
|
|
57
|
+
'fallback_provider' => in_array('fallback_used', $warnings) ? $providerResult->provider : null,
|
|
58
|
+
'generated_at' => $now->toISOString(),
|
|
59
|
+
'confidence' => $providerResult->confidence,
|
|
60
|
+
'warnings' => array_values(array_unique($warnings)),
|
|
61
|
+
'driver' => [
|
|
62
|
+
'location' => $this->pointToGeoJson($context->driverLocation),
|
|
63
|
+
'location_age_seconds' => $context->driverLocationAgeSeconds,
|
|
64
|
+
'online' => (bool) data_get($context->driver, 'online', false),
|
|
65
|
+
],
|
|
66
|
+
'progress' => $progress,
|
|
67
|
+
'stops' => $context->stops->map(fn (TrackingStop $stop) => $stop->toArray())->values()->all(),
|
|
68
|
+
'active_stop' => $context->activeStop?->toArray(),
|
|
69
|
+
'next_stop' => $context->nextStop?->toArray(),
|
|
70
|
+
'route' => [
|
|
71
|
+
'distance_m' => $providerResult->distanceMeters,
|
|
72
|
+
'duration_s' => $providerResult->durationSeconds,
|
|
73
|
+
'duration_in_traffic_s' => $providerResult->durationInTrafficSeconds,
|
|
74
|
+
'polyline' => $providerResult->polyline,
|
|
75
|
+
'coordinates' => $providerResult->coordinates,
|
|
76
|
+
'legs' => $this->legs($context, $providerResult, $now),
|
|
77
|
+
],
|
|
78
|
+
'eta' => [
|
|
79
|
+
'active_stop_seconds' => $activeStopSeconds,
|
|
80
|
+
'completion_seconds' => $completionSeconds,
|
|
81
|
+
'active_stop_at' => $this->addSeconds($now, $activeStopSeconds),
|
|
82
|
+
'completion_at' => $this->addSeconds($now, $completionSeconds),
|
|
83
|
+
],
|
|
84
|
+
'insights' => [
|
|
85
|
+
'is_delayed' => false,
|
|
86
|
+
'delay_seconds' => 0,
|
|
87
|
+
'is_location_stale' => in_array('stale_driver_location', $warnings),
|
|
88
|
+
'is_off_route' => in_array('off_route', $warnings),
|
|
89
|
+
],
|
|
90
|
+
'capabilities' => $this->capabilitiesFor($providerResult->provider),
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
protected function progress(TrackingContext $context, TrackingProviderResult $providerResult): array
|
|
95
|
+
{
|
|
96
|
+
$totalStops = max($context->stops->count(), 1);
|
|
97
|
+
$completedStops = $context->completedStops->count();
|
|
98
|
+
$percentage = $context->order->status === 'completed' ? 100 : round(($completedStops / $totalStops) * 100, 2);
|
|
99
|
+
|
|
100
|
+
return [
|
|
101
|
+
'percentage' => $percentage,
|
|
102
|
+
'completed_stops' => $completedStops,
|
|
103
|
+
'remaining_stops' => $context->remainingStops->count(),
|
|
104
|
+
'total_stops' => $context->stops->count(),
|
|
105
|
+
'completed_distance_m' => null,
|
|
106
|
+
'remaining_distance_m' => $providerResult->distanceMeters,
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
protected function legs(TrackingContext $context, TrackingProviderResult $providerResult, Carbon $now): array
|
|
111
|
+
{
|
|
112
|
+
$elapsedSeconds = 0;
|
|
113
|
+
|
|
114
|
+
return collect($providerResult->legs)->map(function ($leg, $index) use ($context, $now, &$elapsedSeconds) {
|
|
115
|
+
$stop = $context->remainingStops->values()->get($index);
|
|
116
|
+
$legSeconds = data_get($leg, 'duration_in_traffic_s', data_get($leg, 'duration_s'));
|
|
117
|
+
$etaSeconds = null;
|
|
118
|
+
$etaAt = null;
|
|
119
|
+
|
|
120
|
+
if ($legSeconds !== null) {
|
|
121
|
+
$elapsedSeconds += (float) $legSeconds;
|
|
122
|
+
$etaSeconds = $elapsedSeconds;
|
|
123
|
+
$etaAt = $this->addSeconds($now, $etaSeconds);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return array_merge($leg, [
|
|
127
|
+
'stop' => $stop instanceof TrackingStop ? $stop->toArray() : null,
|
|
128
|
+
'eta_seconds' => $etaSeconds,
|
|
129
|
+
'eta_at' => $etaAt,
|
|
130
|
+
]);
|
|
131
|
+
})->all();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
protected function capabilitiesFor(string $provider): array
|
|
135
|
+
{
|
|
136
|
+
$registry = app(TrackingProviderRegistry::class);
|
|
137
|
+
$registered = $registry->get($provider);
|
|
138
|
+
|
|
139
|
+
return $registered ? $registered->capabilities()->toArray() : (new TrackingProviderCapabilities())->toArray();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
protected function cacheKey(TrackingContext $context, TrackingOptions $options): string
|
|
143
|
+
{
|
|
144
|
+
return 'order_tracking_intelligence:' . md5(json_encode([
|
|
145
|
+
'order' => $context->order->uuid,
|
|
146
|
+
'order_ts' => optional($context->order->updated_at)->timestamp,
|
|
147
|
+
'payload' => optional($context->payload)->uuid,
|
|
148
|
+
'payload_ts' => optional($context->payload?->updated_at)->timestamp,
|
|
149
|
+
'driver' => optional($context->driver)->uuid,
|
|
150
|
+
'driver_ts' => optional($context->driver?->updated_at)->timestamp,
|
|
151
|
+
'stops' => $context->stateSignature(),
|
|
152
|
+
'provider' => $options->provider,
|
|
153
|
+
'fallbacks' => $options->fallbacks,
|
|
154
|
+
'traffic' => $options->trafficEnabled,
|
|
155
|
+
'settings' => [
|
|
156
|
+
'summary_ttl' => $options->cacheTtlSeconds,
|
|
157
|
+
'route_ttl' => $options->routeCacheTtlSeconds,
|
|
158
|
+
'stale_threshold' => $options->staleLocationThresholdSeconds,
|
|
159
|
+
'fallback_speed' => $options->defaultVehicleSpeedKph,
|
|
160
|
+
],
|
|
161
|
+
]));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
protected function pointToGeoJson($point): ?array
|
|
165
|
+
{
|
|
166
|
+
if (!$point) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return [
|
|
171
|
+
'type' => 'Point',
|
|
172
|
+
'coordinates' => [$point->getLng(), $point->getLat()],
|
|
173
|
+
];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
protected function addSeconds(Carbon $now, ?float $seconds): ?string
|
|
177
|
+
{
|
|
178
|
+
return $seconds === null ? null : $now->copy()->addSeconds((int) round($seconds))->toISOString();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Tracking;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\Models\Setting;
|
|
6
|
+
use Illuminate\Support\Arr;
|
|
7
|
+
|
|
8
|
+
class TrackingOptions
|
|
9
|
+
{
|
|
10
|
+
public function __construct(
|
|
11
|
+
public ?string $provider = null,
|
|
12
|
+
public array $fallbacks = [],
|
|
13
|
+
public bool $trafficEnabled = true,
|
|
14
|
+
public int $cacheTtlSeconds = 60,
|
|
15
|
+
public int $routeCacheTtlSeconds = 600,
|
|
16
|
+
public int $staleLocationThresholdSeconds = 300,
|
|
17
|
+
public float $defaultVehicleSpeedKph = 35,
|
|
18
|
+
public array $raw = [],
|
|
19
|
+
) {
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public static function fromArray(array $options = []): self
|
|
23
|
+
{
|
|
24
|
+
$systemSettings = Setting::lookup('fleet-ops.tracking-settings', []);
|
|
25
|
+
$config = array_merge(config('fleetops.tracking', []), is_array($systemSettings) ? $systemSettings : []);
|
|
26
|
+
$companySettings = [];
|
|
27
|
+
try {
|
|
28
|
+
$companySettings = Setting::lookupCompany('tracking', []);
|
|
29
|
+
} catch (\Throwable) {
|
|
30
|
+
$companySettings = [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
$fallbacks = Arr::get($options, 'fallbacks', Arr::get($companySettings, 'fallbacks', Arr::get($config, 'fallbacks', [])));
|
|
34
|
+
if (is_string($fallbacks)) {
|
|
35
|
+
$fallbacks = array_values(array_filter(array_map('trim', explode(',', $fallbacks))));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return new self(
|
|
39
|
+
provider: Arr::get($options, 'provider', Arr::get($companySettings, 'provider', Arr::get($config, 'provider'))),
|
|
40
|
+
fallbacks: Arr::wrap($fallbacks),
|
|
41
|
+
trafficEnabled: (bool) Arr::get($options, 'traffic_enabled', Arr::get($companySettings, 'traffic_enabled', Arr::get($config, 'traffic_enabled', true))),
|
|
42
|
+
cacheTtlSeconds: (int) Arr::get($options, 'cache_ttl_seconds', Arr::get($companySettings, 'cache_ttl_seconds', Arr::get($config, 'cache_ttl_seconds', 60))),
|
|
43
|
+
routeCacheTtlSeconds: (int) Arr::get($options, 'route_cache_ttl_seconds', Arr::get($companySettings, 'route_cache_ttl_seconds', Arr::get($config, 'route_cache_ttl_seconds', 600))),
|
|
44
|
+
staleLocationThresholdSeconds: (int) Arr::get($options, 'stale_location_threshold_seconds', Arr::get($companySettings, 'stale_location_threshold_seconds', Arr::get($config, 'stale_location_threshold_seconds', 300))),
|
|
45
|
+
defaultVehicleSpeedKph: (float) Arr::get($options, 'default_vehicle_speed_kph', Arr::get($companySettings, 'default_vehicle_speed_kph', Arr::get($config, 'default_vehicle_speed_kph', 35))),
|
|
46
|
+
raw: $options
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Tracking;
|
|
4
|
+
|
|
5
|
+
class TrackingProviderCapabilities implements \JsonSerializable
|
|
6
|
+
{
|
|
7
|
+
public function __construct(
|
|
8
|
+
public bool $traffic = false,
|
|
9
|
+
public bool $perLegEta = false,
|
|
10
|
+
public bool $mapMatching = false,
|
|
11
|
+
public bool $routeGeometry = false,
|
|
12
|
+
public array $extras = [],
|
|
13
|
+
) {
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public function toArray(): array
|
|
17
|
+
{
|
|
18
|
+
return array_merge([
|
|
19
|
+
'traffic' => $this->traffic,
|
|
20
|
+
'per_leg_eta' => $this->perLegEta,
|
|
21
|
+
'map_matching' => $this->mapMatching,
|
|
22
|
+
'route_geometry' => $this->routeGeometry,
|
|
23
|
+
], $this->extras);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public function jsonSerialize(): mixed
|
|
27
|
+
{
|
|
28
|
+
return $this->toArray();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Tracking;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\FleetOps\Tracking\Contracts\TrackingProviderInterface;
|
|
6
|
+
use Illuminate\Support\Arr;
|
|
7
|
+
use Illuminate\Support\Facades\Cache;
|
|
8
|
+
use Illuminate\Support\Facades\Log;
|
|
9
|
+
use Illuminate\Support\Str;
|
|
10
|
+
|
|
11
|
+
class TrackingProviderManager
|
|
12
|
+
{
|
|
13
|
+
public function __construct(protected TrackingProviderRegistry $registry)
|
|
14
|
+
{
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
public function track(TrackingContext $context, TrackingOptions $options): TrackingProviderResult
|
|
18
|
+
{
|
|
19
|
+
$warnings = [];
|
|
20
|
+
foreach ($this->providerOrder($options) as $providerKey) {
|
|
21
|
+
$provider = $this->registry->get($providerKey);
|
|
22
|
+
if (!$provider instanceof TrackingProviderInterface) {
|
|
23
|
+
$warnings[] = 'provider_not_registered:' . $providerKey;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!$provider->canTrack($context)) {
|
|
28
|
+
$warnings[] = 'provider_unavailable:' . $providerKey;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
$result = $this->trackWithProviderCache($provider, $context, $options);
|
|
34
|
+
$result->warnings = array_values(array_unique([...$warnings, ...$result->warnings]));
|
|
35
|
+
|
|
36
|
+
if (Str::snake($result->provider) !== Str::snake((string) $options->provider)) {
|
|
37
|
+
$result->warnings[] = 'fallback_used';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return $result;
|
|
41
|
+
} catch (\Throwable $e) {
|
|
42
|
+
$warnings[] = 'provider_failed:' . $providerKey;
|
|
43
|
+
Log::warning('Tracking provider failed.', [
|
|
44
|
+
'provider' => $providerKey,
|
|
45
|
+
'order' => $context->order->public_id ?? $context->order->uuid,
|
|
46
|
+
'error' => $e->getMessage(),
|
|
47
|
+
]);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return new TrackingProviderResult(
|
|
52
|
+
provider: 'none',
|
|
53
|
+
warnings: array_values(array_unique([...$warnings, 'no_tracking_provider_available'])),
|
|
54
|
+
confidence: 'none'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
protected function providerOrder(TrackingOptions $options): array
|
|
59
|
+
{
|
|
60
|
+
$provider = $options->provider ?: Arr::get(config('fleetops.tracking', []), 'provider', 'google_routes');
|
|
61
|
+
$fallbacks = $options->fallbacks ?: Arr::get(config('fleetops.tracking', []), 'fallbacks', ['osrm', 'calculated']);
|
|
62
|
+
|
|
63
|
+
return collect([$provider, ...$fallbacks])
|
|
64
|
+
->filter()
|
|
65
|
+
->map(fn ($key) => Str::snake($key))
|
|
66
|
+
->unique()
|
|
67
|
+
->values()
|
|
68
|
+
->all();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
protected function trackWithProviderCache(TrackingProviderInterface $provider, TrackingContext $context, TrackingOptions $options): TrackingProviderResult
|
|
72
|
+
{
|
|
73
|
+
return Cache::remember($this->providerCacheKey($provider, $context, $options), $options->routeCacheTtlSeconds, function () use ($provider, $context, $options) {
|
|
74
|
+
return $provider->track($context, $options);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
protected function providerCacheKey(TrackingProviderInterface $provider, TrackingContext $context, TrackingOptions $options): string
|
|
79
|
+
{
|
|
80
|
+
return 'order_tracking_provider_route:' . md5(json_encode([
|
|
81
|
+
'provider' => $provider->key(),
|
|
82
|
+
'points' => array_map(function ($point) {
|
|
83
|
+
return [$point->getLat(), $point->getLng()];
|
|
84
|
+
}, $context->routePoints()),
|
|
85
|
+
'traffic' => $options->trafficEnabled,
|
|
86
|
+
'speed' => $options->defaultVehicleSpeedKph,
|
|
87
|
+
]));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Tracking;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\FleetOps\Tracking\Contracts\TrackingProviderInterface;
|
|
6
|
+
use Illuminate\Support\Str;
|
|
7
|
+
|
|
8
|
+
class TrackingProviderRegistry
|
|
9
|
+
{
|
|
10
|
+
protected array $providers = [];
|
|
11
|
+
|
|
12
|
+
public function register(TrackingProviderInterface|string $provider, ?string $key = null): self
|
|
13
|
+
{
|
|
14
|
+
$instance = is_string($provider) ? app($provider) : $provider;
|
|
15
|
+
$providerKey = Str::snake($key ?? $instance->key());
|
|
16
|
+
$this->providers[$providerKey] = $instance;
|
|
17
|
+
|
|
18
|
+
return $this;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public function has(string $key): bool
|
|
22
|
+
{
|
|
23
|
+
return isset($this->providers[Str::snake($key)]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public function get(string $key): ?TrackingProviderInterface
|
|
27
|
+
{
|
|
28
|
+
return $this->providers[Str::snake($key)] ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public function all(): array
|
|
32
|
+
{
|
|
33
|
+
return $this->providers;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Tracking;
|
|
4
|
+
|
|
5
|
+
class TrackingProviderResult
|
|
6
|
+
{
|
|
7
|
+
public function __construct(
|
|
8
|
+
public string $provider,
|
|
9
|
+
public ?float $distanceMeters = null,
|
|
10
|
+
public ?float $durationSeconds = null,
|
|
11
|
+
public ?float $durationInTrafficSeconds = null,
|
|
12
|
+
public ?string $polyline = null,
|
|
13
|
+
public array $coordinates = [],
|
|
14
|
+
public array $legs = [],
|
|
15
|
+
public array $warnings = [],
|
|
16
|
+
public string $confidence = 'medium',
|
|
17
|
+
public ?array $raw = null,
|
|
18
|
+
) {
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Tracking;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\FleetOps\Models\Place;
|
|
6
|
+
use Fleetbase\FleetOps\Models\Waypoint;
|
|
7
|
+
use Fleetbase\FleetOps\Support\Utils;
|
|
8
|
+
use Fleetbase\LaravelMysqlSpatial\Types\Point;
|
|
9
|
+
|
|
10
|
+
class TrackingStop implements \JsonSerializable
|
|
11
|
+
{
|
|
12
|
+
public function __construct(
|
|
13
|
+
public ?string $uuid,
|
|
14
|
+
public ?string $publicId,
|
|
15
|
+
public string $type,
|
|
16
|
+
public ?string $status,
|
|
17
|
+
public ?Place $place,
|
|
18
|
+
public ?Waypoint $waypoint = null,
|
|
19
|
+
public bool $completed = false,
|
|
20
|
+
public ?int $sequence = null,
|
|
21
|
+
) {
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public function point(): ?Point
|
|
25
|
+
{
|
|
26
|
+
try {
|
|
27
|
+
return Utils::getPointFromMixed($this->place);
|
|
28
|
+
} catch (\Throwable) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public function toArray(): array
|
|
34
|
+
{
|
|
35
|
+
$point = $this->point();
|
|
36
|
+
|
|
37
|
+
return [
|
|
38
|
+
'uuid' => $this->uuid,
|
|
39
|
+
'public_id' => $this->publicId,
|
|
40
|
+
'type' => $this->type,
|
|
41
|
+
'status' => $this->status,
|
|
42
|
+
'completed' => $this->completed,
|
|
43
|
+
'sequence' => $this->sequence,
|
|
44
|
+
'address' => data_get($this->place, 'address'),
|
|
45
|
+
'name' => data_get($this->place, 'name'),
|
|
46
|
+
'location' => $point ? [
|
|
47
|
+
'type' => 'Point',
|
|
48
|
+
'coordinates' => [$point->getLng(), $point->getLat()],
|
|
49
|
+
] : null,
|
|
50
|
+
'latitude' => $point ? $point->getLat() : null,
|
|
51
|
+
'longitude' => $point ? $point->getLng() : null,
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public function jsonSerialize(): mixed
|
|
56
|
+
{
|
|
57
|
+
return $this->toArray();
|
|
58
|
+
}
|
|
59
|
+
}
|
package/server/src/routes.php
CHANGED
|
@@ -203,6 +203,12 @@ Route::prefix(config('fleetops.api.routing.prefix', null))->namespace('Fleetbase
|
|
|
203
203
|
$router->get('{id}', 'LabelController@getLabel');
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
+
// orchestrator routes
|
|
207
|
+
$router->group(['prefix' => 'orchestrator'], function () use ($router) {
|
|
208
|
+
$router->post('run', 'OrchestrationController@run');
|
|
209
|
+
$router->post('commit', 'OrchestrationController@commit');
|
|
210
|
+
});
|
|
211
|
+
|
|
206
212
|
// navigator routes
|
|
207
213
|
$router->group(['prefix' => 'onboard'], function () use ($router) {
|
|
208
214
|
$router->get('driver-onboard-settings/{companyId}', 'NavigatorController@getDriverOnboardSettings');
|
|
@@ -339,6 +345,7 @@ Route::prefix(config('fleetops.api.routing.prefix', null))->namespace('Fleetbase
|
|
|
339
345
|
$router->get('next-activity/{id}', $controller('nextActivity'));
|
|
340
346
|
$router->get('{id}/tracker', 'OrderController@trackerInfo');
|
|
341
347
|
$router->get('{id}/eta', 'OrderController@waypointEtas');
|
|
348
|
+
$router->post('{id}/ping-driver', $controller('pingDriver'));
|
|
342
349
|
$router->post('process-imports', $controller('importFromFiles'));
|
|
343
350
|
$router->patch('route/{id}', $controller('editOrderRoute'));
|
|
344
351
|
$router->patch('update-activity/{id}', $controller('updateActivity'));
|
|
@@ -546,6 +553,10 @@ Route::prefix(config('fleetops.api.routing.prefix', null))->namespace('Fleetbase
|
|
|
546
553
|
$router->post('notification-settings', 'SettingController@saveNotificationSettings');
|
|
547
554
|
$router->get('routing-settings', 'SettingController@getRoutingSettings');
|
|
548
555
|
$router->post('routing-settings', 'SettingController@saveRoutingSettings');
|
|
556
|
+
$router->get('tracking-settings', 'SettingController@getTrackingSettings');
|
|
557
|
+
$router->post('tracking-settings', 'SettingController@saveTrackingSettings');
|
|
558
|
+
$router->get('admin-tracking-settings', 'SettingController@getAdminTrackingSettings');
|
|
559
|
+
$router->post('admin-tracking-settings', 'SettingController@saveAdminTrackingSettings');
|
|
549
560
|
$router->get('map', 'SettingController@getMapSettings');
|
|
550
561
|
$router->post('map', 'SettingController@saveMapSettings');
|
|
551
562
|
$router->get('admin-map', 'SettingController@getAdminMapSettings');
|
|
@@ -562,6 +573,22 @@ Route::prefix(config('fleetops.api.routing.prefix', null))->namespace('Fleetbase
|
|
|
562
573
|
['prefix' => 'metrics'],
|
|
563
574
|
function ($router) {
|
|
564
575
|
$router->get('/', 'MetricsController@all');
|
|
576
|
+
$router->get('{slug}', 'MetricsController@show');
|
|
577
|
+
}
|
|
578
|
+
);
|
|
579
|
+
$router->group(
|
|
580
|
+
['prefix' => 'analytics'],
|
|
581
|
+
function ($router) {
|
|
582
|
+
$router->get('operations-pulse', 'AnalyticsController@operationsPulse');
|
|
583
|
+
$router->get('revenue-trend', 'AnalyticsController@revenueTrend');
|
|
584
|
+
$router->get('orders-by-status', 'AnalyticsController@ordersByStatus');
|
|
585
|
+
$router->get('on-time-delivery', 'AnalyticsController@onTimeDelivery');
|
|
586
|
+
$router->get('top-drivers', 'AnalyticsController@topDrivers');
|
|
587
|
+
$router->get('fuel-efficiency', 'AnalyticsController@fuelEfficiency');
|
|
588
|
+
$router->get('issues-insights', 'AnalyticsController@issuesInsights');
|
|
589
|
+
$router->get('maintenance-overview', 'AnalyticsController@maintenanceOverview');
|
|
590
|
+
$router->get('geofence-violations', 'AnalyticsController@geofenceViolations');
|
|
591
|
+
$router->get('live-fleet', 'AnalyticsController@liveFleet');
|
|
565
592
|
}
|
|
566
593
|
);
|
|
567
594
|
$router->group(
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
test('analytics routes are registered alongside metrics routes', function () {
|
|
4
|
+
$routes = file_get_contents(dirname(__DIR__) . '/src/routes.php');
|
|
5
|
+
|
|
6
|
+
expect($routes)->toContain("['prefix' => 'analytics']");
|
|
7
|
+
expect($routes)->toContain("'AnalyticsController@operationsPulse'");
|
|
8
|
+
expect($routes)->toContain("'AnalyticsController@revenueTrend'");
|
|
9
|
+
expect($routes)->toContain("'AnalyticsController@ordersByStatus'");
|
|
10
|
+
expect($routes)->toContain("'AnalyticsController@onTimeDelivery'");
|
|
11
|
+
expect($routes)->toContain("'AnalyticsController@topDrivers'");
|
|
12
|
+
expect($routes)->toContain("'AnalyticsController@fuelEfficiency'");
|
|
13
|
+
expect($routes)->toContain("'AnalyticsController@issuesInsights'");
|
|
14
|
+
expect($routes)->toContain("'AnalyticsController@maintenanceOverview'");
|
|
15
|
+
expect($routes)->toContain("'AnalyticsController@geofenceViolations'");
|
|
16
|
+
expect($routes)->toContain("'AnalyticsController@liveFleet'");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('per-slug metrics endpoint is registered', function () {
|
|
20
|
+
$routes = file_get_contents(dirname(__DIR__) . '/src/routes.php');
|
|
21
|
+
|
|
22
|
+
expect($routes)->toContain("\$router->get('{slug}', 'MetricsController@show');");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('legacy metrics endpoint is preserved for backward compat', function () {
|
|
26
|
+
$routes = file_get_contents(dirname(__DIR__) . '/src/routes.php');
|
|
27
|
+
|
|
28
|
+
expect($routes)->toContain("\$router->get('/', 'MetricsController@all');");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('analytics controller has one method per widget', function () {
|
|
32
|
+
$controller = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Internal/v1/AnalyticsController.php');
|
|
33
|
+
|
|
34
|
+
foreach ([
|
|
35
|
+
'operationsPulse', 'revenueTrend', 'ordersByStatus', 'onTimeDelivery',
|
|
36
|
+
'topDrivers', 'fuelEfficiency', 'issuesInsights', 'maintenanceOverview',
|
|
37
|
+
'geofenceViolations', 'liveFleet',
|
|
38
|
+
] as $method) {
|
|
39
|
+
expect($controller)->toContain("public function {$method}(");
|
|
40
|
+
}
|
|
41
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
use Fleetbase\FleetOps\Models\Order;
|
|
4
|
+
use Fleetbase\FleetOps\Models\Vehicle;
|
|
5
|
+
use Fleetbase\FleetOps\Orchestration\Engines\CapacityAllocationEngine;
|
|
6
|
+
|
|
7
|
+
function capacityTestOrder(string $publicId, float $weightKg, float $volumeLitres = 0, array $skills = []): Order
|
|
8
|
+
{
|
|
9
|
+
$payload = new class($weightKg, $volumeLitres) {
|
|
10
|
+
public $entities;
|
|
11
|
+
|
|
12
|
+
public function __construct(float $weightKg, float $volumeLitres)
|
|
13
|
+
{
|
|
14
|
+
$this->entities = collect([
|
|
15
|
+
new class($weightKg, $volumeLitres) {
|
|
16
|
+
public float $weight;
|
|
17
|
+
public string $weight_unit = 'kg';
|
|
18
|
+
public float $length;
|
|
19
|
+
public float $width = 1;
|
|
20
|
+
public float $height = 1;
|
|
21
|
+
public string $dimensions_unit = 'm';
|
|
22
|
+
|
|
23
|
+
public function __construct(float $weightKg, float $volumeLitres)
|
|
24
|
+
{
|
|
25
|
+
$this->weight = $weightKg;
|
|
26
|
+
$this->length = $volumeLitres / 1000;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
]);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
$order = new Order();
|
|
34
|
+
$order->public_id = $publicId;
|
|
35
|
+
$order->required_skills = $skills;
|
|
36
|
+
$order->setRelation('payload', $payload);
|
|
37
|
+
|
|
38
|
+
return $order;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function capacityTestVehicle(string $publicId, float $weightKg, float $volumeM3 = 0, int $maxTasks = 0, array $skills = []): Vehicle
|
|
42
|
+
{
|
|
43
|
+
$vehicle = new Vehicle();
|
|
44
|
+
$vehicle->public_id = $publicId;
|
|
45
|
+
$vehicle->payload_capacity = $weightKg;
|
|
46
|
+
$vehicle->payload_capacity_volume = $volumeM3;
|
|
47
|
+
$vehicle->payload_capacity_pallets = 0;
|
|
48
|
+
$vehicle->payload_capacity_parcels = 100;
|
|
49
|
+
$vehicle->max_tasks = $maxTasks;
|
|
50
|
+
$vehicle->skills = $skills;
|
|
51
|
+
$vehicle->setRelation('driver', null);
|
|
52
|
+
|
|
53
|
+
return $vehicle;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test('capacity engine assigns orders without vehicle locations', function () {
|
|
57
|
+
$engine = new CapacityAllocationEngine();
|
|
58
|
+
|
|
59
|
+
$result = $engine->allocate(collect([
|
|
60
|
+
capacityTestOrder('order_light', 10, 250),
|
|
61
|
+
capacityTestOrder('order_medium', 20, 500),
|
|
62
|
+
]), collect([
|
|
63
|
+
capacityTestVehicle('vehicle_van', 100, 2),
|
|
64
|
+
]), [
|
|
65
|
+
'balance_workload' => false,
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
expect($result['assignments'])->toHaveCount(2);
|
|
69
|
+
expect(array_column($result['assignments'], 'vehicle_id'))->toBe(['vehicle_van', 'vehicle_van']);
|
|
70
|
+
expect($result['unassigned'])->toBe([]);
|
|
71
|
+
expect($result['summary']['allocation_strategy'])->toBe('capacity_only');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('capacity engine rejects over-capacity orders with useful reasons', function () {
|
|
75
|
+
$engine = new CapacityAllocationEngine();
|
|
76
|
+
|
|
77
|
+
$result = $engine->allocate(collect([
|
|
78
|
+
capacityTestOrder('order_heavy', 250),
|
|
79
|
+
]), collect([
|
|
80
|
+
capacityTestVehicle('vehicle_small', 100),
|
|
81
|
+
]));
|
|
82
|
+
|
|
83
|
+
expect($result['assignments'])->toBe([]);
|
|
84
|
+
expect($result['unassigned'])->toBe(['order_heavy']);
|
|
85
|
+
expect($result['summary']['unassigned_reasons'][0])->toMatchArray([
|
|
86
|
+
'order_id' => 'order_heavy',
|
|
87
|
+
'reason' => 'capacity_exceeded',
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('capacity engine respects skills max tasks and balanced workload', function () {
|
|
92
|
+
$engine = new CapacityAllocationEngine();
|
|
93
|
+
|
|
94
|
+
$result = $engine->allocate(collect([
|
|
95
|
+
capacityTestOrder('order_cold_1', 10, 0, ['cold_chain']),
|
|
96
|
+
capacityTestOrder('order_cold_2', 10, 0, ['cold_chain']),
|
|
97
|
+
capacityTestOrder('order_fragile', 10, 0, ['fragile']),
|
|
98
|
+
]), collect([
|
|
99
|
+
capacityTestVehicle('vehicle_cold_a', 100, 0, 1, ['cold_chain']),
|
|
100
|
+
capacityTestVehicle('vehicle_cold_b', 100, 0, 1, ['cold_chain']),
|
|
101
|
+
]), [
|
|
102
|
+
'balance_workload' => true,
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
expect(array_column($result['assignments'], 'vehicle_id'))->toBe(['vehicle_cold_a', 'vehicle_cold_b']);
|
|
106
|
+
expect($result['unassigned'])->toBe(['order_fragile']);
|
|
107
|
+
expect($result['summary']['unassigned_reasons'][0]['reason'])->toBe('missing_required_skills');
|
|
108
|
+
});
|