Skip to content

Commit a6df418

Browse files
author
Ronald A Richardson
committed
feat: Intelligent Order Allocation Engine — engine-agnostic, core FleetOps integration
## Backend ### Allocation Engine Architecture (server/src/Allocation/) - AllocationEngineInterface: defines the allocate()/getName()/getIdentifier() contract - AllocationEngineRegistry: singleton service-locator; engines register via resolving() hook - AllocationPayloadBuilder: engine-agnostic normalizer — builds jobs/vehicles arrays from Order/Vehicle models, reads custom fields for skill codes, injects driver shift time_windows from Driver::activeShiftFor() (prerequisite: PR #216 driver scheduling integration) - VroomAllocationEngine: default VROOM implementation; maps normalized payload to VROOM VRP wire format, handles integer ID mapping, parses routes/unassigned back to public_ids ### AllocationController (server/src/Http/Controllers/Internal/v1/) - POST fleet-ops/allocation/run — run engine against unassigned orders + online vehicles - POST fleet-ops/allocation/commit — commit assignments via Order::firstDispatchWithActivity() - GET fleet-ops/allocation/preview — preview without side effects - GET fleet-ops/allocation/engines — list registered engines (for settings dropdown) - GET fleet-ops/allocation/settings — get allocation settings - PATCH fleet-ops/allocation/settings — save allocation settings ### ProcessAllocationJob (server/src/Jobs/) - Queueable, idempotent background job for auto-allocation on order creation or re-allocation - Reads active engine from Setting::lookup('fleetops.allocation_engine', 'vroom') ### HandleDeliveryCompletion (server/src/Listeners/) - Listens on OrderCompleted; dispatches ProcessAllocationJob when auto_reallocate_on_complete is enabled — closes the re-allocation loop ### Provider/Route wiring - FleetOpsServiceProvider: registers AllocationEngineRegistry singleton + VroomAllocationEngine - EventServiceProvider: adds HandleDeliveryCompletion to OrderCompleted listeners - routes.php: adds /allocation group with 6 endpoints under internal v1 fleet-ops prefix ## Frontend ### Engine Registry Pattern (addon/services/) - allocation-engine-interface.js: abstract base class with allocate() contract - allocation-engine.js: registry service — register()/resolve()/has()/availableEngines - vroom-allocation-engine.js: VROOM adapter — delegates to backend AllocationController - order-allocation.js: orchestration service — run/commit/loadSettings/saveSettings tasks ### Instance Initializer (addon/instance-initializers/) - register-vroom-allocation.js: registers VroomAllocationEngine into the allocation-engine registry at app boot — identical pattern to register-osrm.js for route optimization ### Dispatcher Workbench (addon/components/) - order-allocation-workbench.js: three-panel workbench with Order Bucket, Proposed Plan view, Vehicle Bucket; runAllocation/commitPlan/discardPlan tasks; handleDrop for drag-and-drop override; planByVehicle computed groups assignments by vehicle for the plan view - order-allocation-workbench.hbs: full Handlebars template with toolbar, three panels, per-vehicle route cards, unassigned warning banner, override badges, empty states ### Settings UI (addon/controllers/settings/ + addon/templates/settings/) - order-allocation.js controller: loadSettings/saveSettings tasks, engineOptions from registry - order-allocation.hbs template: engine selector (PowerSelect from registry), auto-allocate toggles, max travel time input, balance workload toggle ### Route/Navigation wiring - routes.js: adds operations.allocation and settings.order-allocation routes - routes/operations/allocation.js: ability-guarded route - routes/settings/order-allocation.js: ability-guarded route with setupController hook - templates/operations/allocation.hbs: renders OrderAllocationWorkbench - extension.js: adds Allocation shortcut tile + fleet-ops:template:settings:order-allocation registry - layout/fleet-ops-sidebar.js: adds Allocation to operations nav, Order Allocation to settings nav Closes #214
1 parent 4c46b3b commit a6df418

26 files changed

Lines changed: 1864 additions & 1 deletion

addon/components/layout/fleet-ops-sidebar.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ export default class LayoutFleetOpsSidebarComponent extends Component {
6666
permission: 'fleet-ops list order-config',
6767
visible: this.abilities.can('fleet-ops see order-config'),
6868
},
69+
{
70+
intl: 'menu.allocation',
71+
title: this.intl.t('menu.allocation'),
72+
icon: 'circle-nodes',
73+
route: 'operations.allocation',
74+
permission: 'fleet-ops list order',
75+
visible: this.abilities.can('fleet-ops see order'),
76+
},
6977
];
7078

7179
const resourcesItems = [
@@ -269,6 +277,14 @@ export default class LayoutFleetOpsSidebarComponent extends Component {
269277
permission: 'fleet-ops view avatar',
270278
visible: this.abilities.can('fleet-ops see avatar'),
271279
},
280+
{
281+
intl: 'menu.order-allocation',
282+
title: this.intl.t('menu.order-allocation'),
283+
icon: 'circle-nodes',
284+
route: 'settings.order-allocation',
285+
permission: 'fleet-ops view routing-settings',
286+
visible: this.abilities.can('fleet-ops see routing-settings'),
287+
},
272288
];
273289

274290
const createPanel = (intl, routePrefix, items = [], options = {}) => ({
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
{{! Dispatcher Workbench — Intelligent Order Allocation Engine }}
2+
<div class="order-allocation-workbench flex flex-col h-full">
3+
4+
{{! ── Toolbar ── }}
5+
<div class="workbench-toolbar flex items-center justify-between px-4 py-3 border-b dark:border-gray-700">
6+
<div class="flex items-center space-x-2">
7+
<h2 class="text-sm font-semibold dark:text-white">{{t "allocation.workbench-title"}}</h2>
8+
{{#if this.hasProposedPlan}}
9+
<Badge @status="info" @label={{t "allocation.plan-ready"}} />
10+
{{/if}}
11+
</div>
12+
<div class="flex items-center space-x-2">
13+
{{#if this.hasProposedPlan}}
14+
<Button
15+
@icon="check"
16+
@text={{t "allocation.commit-plan"}}
17+
@type="success"
18+
@size="sm"
19+
@isLoading={{this.commitPlan.isRunning}}
20+
{{on "click" (perform this.commitPlan)}}
21+
/>
22+
<Button
23+
@icon="times"
24+
@text={{t "allocation.discard-plan"}}
25+
@type="default"
26+
@size="sm"
27+
{{on "click" this.discardPlan}}
28+
/>
29+
{{else}}
30+
<Button
31+
@icon="bolt"
32+
@text={{t "allocation.run-allocation"}}
33+
@type="primary"
34+
@size="sm"
35+
@isLoading={{this.runAllocation.isRunning}}
36+
{{on "click" (perform this.runAllocation)}}
37+
/>
38+
{{/if}}
39+
<Button
40+
@icon="sync"
41+
@text={{t "common.refresh"}}
42+
@type="default"
43+
@size="sm"
44+
@isLoading={{this.loadData.isRunning}}
45+
{{on "click" (perform this.loadData)}}
46+
/>
47+
</div>
48+
</div>
49+
50+
{{! ── Main Content ── }}
51+
<div class="workbench-body flex flex-1 overflow-hidden">
52+
53+
{{! ── Left Panel: Order Bucket ── }}
54+
<div class="order-bucket w-72 flex-shrink-0 border-r dark:border-gray-700 overflow-y-auto">
55+
<div class="px-3 py-2 border-b dark:border-gray-700">
56+
<span class="text-xs font-medium uppercase tracking-wide dark:text-gray-400">
57+
{{t "allocation.unassigned-orders"}} ({{this.unassignedOrders.length}})
58+
</span>
59+
</div>
60+
{{#if this.loadUnassignedOrders.isRunning}}
61+
<div class="flex justify-center py-8"><Spinner /></div>
62+
{{else if this.unassignedOrders.length}}
63+
{{#each this.unassignedOrders as |order|}}
64+
<div
65+
class="order-card mx-2 my-1 px-3 py-2 rounded border dark:border-gray-600 dark:bg-gray-800 cursor-grab text-xs"
66+
draggable="true"
67+
data-order-id={{order.public_id}}
68+
>
69+
<div class="font-medium dark:text-white truncate">{{order.public_id}}</div>
70+
<div class="text-gray-400 truncate">{{order.payload.dropoff.address}}</div>
71+
{{#if order.scheduled_at}}
72+
<div class="text-blue-400 mt-0.5">
73+
<FaIcon @icon="clock" @size="xs" /> {{format-date order.scheduled_at "HH:mm"}}
74+
</div>
75+
{{/if}}
76+
</div>
77+
{{/each}}
78+
{{else}}
79+
<div class="flex flex-col items-center justify-center py-12 text-gray-400 text-xs">
80+
<FaIcon @icon="check-circle" @size="2x" class="mb-2 text-green-400" />
81+
{{t "allocation.no-unassigned-orders"}}
82+
</div>
83+
{{/if}}
84+
</div>
85+
86+
{{! ── Centre Panel: Proposed Plan / Vehicle Bucket ── }}
87+
<div class="plan-panel flex-1 overflow-y-auto">
88+
{{#if this.runAllocation.isRunning}}
89+
<div class="flex flex-col items-center justify-center h-full text-gray-400">
90+
<Spinner @size="lg" class="mb-3" />
91+
<span class="text-sm">{{t "allocation.running"}}</span>
92+
</div>
93+
{{else if this.hasProposedPlan}}
94+
95+
{{! Unassigned warning banner }}
96+
{{#if this.hasUnassigned}}
97+
<div class="mx-4 mt-3 px-3 py-2 rounded bg-yellow-900/30 border border-yellow-600 text-yellow-400 text-xs">
98+
<FaIcon @icon="exclamation-triangle" />
99+
{{t "allocation.unassigned-warning" count=this.unassignedAfterRun.length}}
100+
</div>
101+
{{/if}}
102+
103+
{{! Per-vehicle route cards }}
104+
{{#each this.planByVehicle as |group|}}
105+
<div
106+
class="vehicle-route-card mx-4 my-3 rounded border dark:border-gray-600 dark:bg-gray-800"
107+
data-vehicle-id={{group.vehicle.public_id}}
108+
data-driver-id={{group.driver.public_id}}
109+
>
110+
{{! Vehicle header }}
111+
<div class="flex items-center px-3 py-2 border-b dark:border-gray-700">
112+
<FaIcon @icon="truck" class="text-blue-400 mr-2" />
113+
<span class="text-xs font-semibold dark:text-white flex-1">
114+
{{group.vehicle.display_name}}
115+
</span>
116+
{{#if group.driver}}
117+
<span class="text-xs text-gray-400">
118+
<FaIcon @icon="user" class="mr-1" />{{group.driver.name}}
119+
</span>
120+
{{/if}}
121+
{{#if group.driver.activeShift}}
122+
<Badge
123+
@status="success"
124+
@label={{t "allocation.on-shift"}}
125+
class="ml-2"
126+
/>
127+
{{/if}}
128+
</div>
129+
130+
{{! Assigned orders list (drag target) }}
131+
<div
132+
class="vehicle-drop-zone px-2 py-1 min-h-12"
133+
data-vehicle-id={{group.vehicle.public_id}}
134+
data-driver-id={{group.driver.public_id}}
135+
>
136+
{{#each group.orders as |item|}}
137+
<div
138+
class="assigned-order flex items-center px-2 py-1.5 my-0.5 rounded text-xs
139+
{{if item._overridden "border border-dashed border-orange-400 bg-orange-900/20" "dark:bg-gray-700"}}"
140+
draggable="true"
141+
data-order-id={{item.order.public_id}}
142+
>
143+
<span class="text-gray-400 w-5 text-center mr-2">{{item.sequence}}</span>
144+
<span class="flex-1 dark:text-white truncate">{{item.order.public_id}}</span>
145+
{{#if item._overridden}}
146+
<Badge @status="warning" @label={{t "allocation.overridden"}} />
147+
{{/if}}
148+
</div>
149+
{{/each}}
150+
</div>
151+
</div>
152+
{{/each}}
153+
154+
{{else}}
155+
156+
{{! Empty state — no plan yet }}
157+
<div class="flex flex-col items-center justify-center h-full text-gray-400">
158+
<FaIcon @icon="route" @size="3x" class="mb-4 opacity-30" />
159+
<p class="text-sm font-medium mb-1">{{t "allocation.empty-state-title"}}</p>
160+
<p class="text-xs text-center max-w-xs">{{t "allocation.empty-state-body"}}</p>
161+
</div>
162+
163+
{{/if}}
164+
</div>
165+
166+
{{! ── Right Panel: Vehicle Bucket ── }}
167+
<div class="vehicle-bucket w-72 flex-shrink-0 border-l dark:border-gray-700 overflow-y-auto">
168+
<div class="px-3 py-2 border-b dark:border-gray-700">
169+
<span class="text-xs font-medium uppercase tracking-wide dark:text-gray-400">
170+
{{t "allocation.available-vehicles"}} ({{this.availableVehicles.length}})
171+
</span>
172+
</div>
173+
{{#if this.loadAvailableVehicles.isRunning}}
174+
<div class="flex justify-center py-8"><Spinner /></div>
175+
{{else if this.availableVehicles.length}}
176+
{{#each this.availableVehicles as |vehicle|}}
177+
<div class="vehicle-card mx-2 my-1 px-3 py-2 rounded border dark:border-gray-600 dark:bg-gray-800 text-xs">
178+
<div class="flex items-center justify-between">
179+
<span class="font-medium dark:text-white truncate">{{vehicle.display_name}}</span>
180+
{{#if vehicle.driver.online}}
181+
<Badge @status="success" @label={{t "common.online"}} />
182+
{{/if}}
183+
</div>
184+
{{#if vehicle.driver}}
185+
<div class="text-gray-400 mt-0.5 truncate">
186+
<FaIcon @icon="user" class="mr-1" />{{vehicle.driver.name}}
187+
</div>
188+
{{/if}}
189+
</div>
190+
{{/each}}
191+
{{else}}
192+
<div class="flex flex-col items-center justify-center py-12 text-gray-400 text-xs">
193+
<FaIcon @icon="truck" @size="2x" class="mb-2 opacity-40" />
194+
{{t "allocation.no-available-vehicles"}}
195+
</div>
196+
{{/if}}
197+
</div>
198+
199+
</div>
200+
</div>

0 commit comments

Comments
 (0)