diff --git a/addon/components/layout/fleet-ops-sidebar.js b/addon/components/layout/fleet-ops-sidebar.js index f075267d0..94bf69c7c 100644 --- a/addon/components/layout/fleet-ops-sidebar.js +++ b/addon/components/layout/fleet-ops-sidebar.js @@ -66,6 +66,14 @@ export default class LayoutFleetOpsSidebarComponent extends Component { permission: 'fleet-ops list order-config', visible: this.abilities.can('fleet-ops see order-config'), }, + { + intl: 'menu.allocation', + title: this.intl.t('menu.allocation'), + icon: 'circle-nodes', + route: 'operations.allocation', + permission: 'fleet-ops list order', + visible: this.abilities.can('fleet-ops see order'), + }, ]; const resourcesItems = [ @@ -269,6 +277,14 @@ export default class LayoutFleetOpsSidebarComponent extends Component { permission: 'fleet-ops view avatar', visible: this.abilities.can('fleet-ops see avatar'), }, + { + intl: 'menu.order-allocation', + title: this.intl.t('menu.order-allocation'), + icon: 'circle-nodes', + route: 'settings.order-allocation', + permission: 'fleet-ops view routing-settings', + visible: this.abilities.can('fleet-ops see routing-settings'), + }, ]; const createPanel = (intl, routePrefix, items = [], options = {}) => ({ diff --git a/addon/components/order-allocation-workbench.hbs b/addon/components/order-allocation-workbench.hbs new file mode 100644 index 000000000..66d51bbfc --- /dev/null +++ b/addon/components/order-allocation-workbench.hbs @@ -0,0 +1,200 @@ +{{! Dispatcher Workbench — Intelligent Order Allocation Engine }} +
+ + {{! ── Toolbar ── }} +
+
+

{{t "allocation.workbench-title"}}

+ {{#if this.hasProposedPlan}} + + {{/if}} +
+
+ {{#if this.hasProposedPlan}} +
+
+ + {{! ── Main Content ── }} +
+ + {{! ── Left Panel: Order Bucket ── }} +
+
+ + {{t "allocation.unassigned-orders"}} ({{this.unassignedOrders.length}}) + +
+ {{#if this.loadUnassignedOrders.isRunning}} +
+ {{else if this.unassignedOrders.length}} + {{#each this.unassignedOrders as |order|}} +
+
{{order.public_id}}
+
{{order.payload.dropoff.address}}
+ {{#if order.scheduled_at}} +
+ {{format-date order.scheduled_at "HH:mm"}} +
+ {{/if}} +
+ {{/each}} + {{else}} +
+ + {{t "allocation.no-unassigned-orders"}} +
+ {{/if}} +
+ + {{! ── Centre Panel: Proposed Plan / Vehicle Bucket ── }} +
+ {{#if this.runAllocation.isRunning}} +
+ + {{t "allocation.running"}} +
+ {{else if this.hasProposedPlan}} + + {{! Unassigned warning banner }} + {{#if this.hasUnassigned}} +
+ + {{t "allocation.unassigned-warning" count=this.unassignedAfterRun.length}} +
+ {{/if}} + + {{! Per-vehicle route cards }} + {{#each this.planByVehicle as |group|}} +
+ {{! Vehicle header }} +
+ + + {{group.vehicle.display_name}} + + {{#if group.driver}} + + {{group.driver.name}} + + {{/if}} + {{#if group.driver.activeShift}} + + {{/if}} +
+ + {{! Assigned orders list (drag target) }} +
+ {{#each group.orders as |item|}} +
+ {{item.sequence}} + {{item.order.public_id}} + {{#if item._overridden}} + + {{/if}} +
+ {{/each}} +
+
+ {{/each}} + + {{else}} + + {{! Empty state — no plan yet }} +
+ +

{{t "allocation.empty-state-title"}}

+

{{t "allocation.empty-state-body"}}

+
+ + {{/if}} +
+ + {{! ── Right Panel: Vehicle Bucket ── }} +
+
+ + {{t "allocation.available-vehicles"}} ({{this.availableVehicles.length}}) + +
+ {{#if this.loadAvailableVehicles.isRunning}} +
+ {{else if this.availableVehicles.length}} + {{#each this.availableVehicles as |vehicle|}} +
+
+ {{vehicle.display_name}} + {{#if vehicle.driver.online}} + + {{/if}} +
+ {{#if vehicle.driver}} +
+ {{vehicle.driver.name}} +
+ {{/if}} +
+ {{/each}} + {{else}} +
+ + {{t "allocation.no-available-vehicles"}} +
+ {{/if}} +
+ +
+
diff --git a/addon/components/order-allocation-workbench.js b/addon/components/order-allocation-workbench.js new file mode 100644 index 000000000..e5cd06e97 --- /dev/null +++ b/addon/components/order-allocation-workbench.js @@ -0,0 +1,222 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; + +/** + * OrderAllocationWorkbenchComponent + * + * The Dispatcher Workbench — the primary UI for the Intelligent Order + * Allocation Engine. Provides: + * + * - Order Bucket: list of unassigned orders (drag source) + * - Vehicle/Driver Bucket: list of available vehicles with driver info + * - Proposed Plan View: the allocation result with per-vehicle route lists + * - Drag-and-drop override: dispatcher can reassign any order to any vehicle + * - Commit / Discard actions + * + * The component is rendered at /operations/allocation and is also accessible + * as an overlay from the live map via the order-list-overlay. + */ +export default class OrderAllocationWorkbenchComponent extends Component { + @service store; + @service fetch; + @service notifications; + @service intl; + @service modalsManager; + @service('order-allocation') allocationService; + + /** Unassigned orders loaded from the store. */ + @tracked unassignedOrders = []; + + /** Available vehicles (with online drivers). */ + @tracked availableVehicles = []; + + /** The proposed allocation plan — null until a run completes. */ + @tracked proposedPlan = null; + + /** Whether the workbench is in "committed" state (plan applied). */ + @tracked isCommitted = false; + + /** Orders that the engine could not assign. */ + @tracked unassignedAfterRun = []; + + /** Tracks manual drag-and-drop overrides made by the dispatcher. */ + @tracked manualOverrides = {}; + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + constructor() { + super(...arguments); + this.loadData.perform(); + } + + // ------------------------------------------------------------------------- + // Data loading + // ------------------------------------------------------------------------- + + @task *loadData() { + yield this.loadUnassignedOrders.perform(); + yield this.loadAvailableVehicles.perform(); + } + + @task *loadUnassignedOrders() { + try { + const orders = yield this.store.query('order', { + unassigned: true, + status: 'created', + limit: 200, + }); + this.unassignedOrders = orders.toArray(); + } catch (error) { + this.notifications.serverError(error); + } + } + + @task *loadAvailableVehicles() { + try { + const vehicles = yield this.store.query('vehicle', { + with_online_driver: true, + limit: 200, + }); + this.availableVehicles = vehicles.toArray(); + } catch (error) { + this.notifications.serverError(error); + } + } + + // ------------------------------------------------------------------------- + // Allocation actions + // ------------------------------------------------------------------------- + + /** + * Run the allocation engine and populate the proposed plan view. + */ + @task *runAllocation() { + this.proposedPlan = null; + this.isCommitted = false; + this.manualOverrides = {}; + + const orderIds = this.unassignedOrders.map((o) => o.public_id); + const vehicleIds = this.availableVehicles.map((v) => v.public_id); + + try { + const result = yield this.allocationService.run.perform(orderIds, vehicleIds, { + balance_workload: false, + }); + + this.proposedPlan = result.assignments ?? []; + this.unassignedAfterRun = result.unassigned ?? []; + } catch (error) { + // Error notification already handled by the service + } + } + + /** + * Commit the (possibly modified) proposed plan. + */ + @task *commitPlan() { + if (!this.proposedPlan?.length) { + return; + } + + // Apply any manual overrides before committing + const finalAssignments = this.proposedPlan.map((assignment) => { + const override = this.manualOverrides[assignment.order_id]; + return override ? { ...assignment, ...override } : assignment; + }); + + try { + yield this.allocationService.commit.perform(finalAssignments); + this.isCommitted = true; + this.proposedPlan = null; + yield this.loadData.perform(); + } catch (error) { + // Error notification already handled by the service + } + } + + /** + * Discard the proposed plan without committing. + */ + @action discardPlan() { + this.proposedPlan = null; + this.unassignedAfterRun = []; + this.manualOverrides = {}; + this.isCommitted = false; + } + + // ------------------------------------------------------------------------- + // Drag-and-drop override + // ------------------------------------------------------------------------- + + /** + * Handle a drag-and-drop reassignment. + * Called when the dispatcher drops an order onto a different vehicle row. + * + * @param {string} orderId The public_id of the dragged order. + * @param {string} vehicleId The public_id of the target vehicle. + * @param {string} driverId The public_id of the target vehicle's driver. + */ + @action handleDrop(orderId, vehicleId, driverId) { + this.manualOverrides = { + ...this.manualOverrides, + [orderId]: { vehicle_id: vehicleId, driver_id: driverId }, + }; + + // Update the proposedPlan array so the UI reflects the override immediately + this.proposedPlan = this.proposedPlan.map((assignment) => { + if (assignment.order_id === orderId) { + return { ...assignment, vehicle_id: vehicleId, driver_id: driverId, _overridden: true }; + } + return assignment; + }); + } + + // ------------------------------------------------------------------------- + // Computed helpers + // ------------------------------------------------------------------------- + + /** + * Group proposed assignments by vehicle_id for the plan view. + * Returns an array of { vehicle, driver, orders } objects. + */ + get planByVehicle() { + if (!this.proposedPlan?.length) { + return []; + } + + const groups = {}; + for (const assignment of this.proposedPlan) { + if (!groups[assignment.vehicle_id]) { + const vehicle = this.availableVehicles.find((v) => v.public_id === assignment.vehicle_id); + groups[assignment.vehicle_id] = { + vehicle, + driver: vehicle?.driver, + orders: [], + }; + } + const order = this.unassignedOrders.find((o) => o.public_id === assignment.order_id); + if (order) { + groups[assignment.vehicle_id].orders.push({ + order, + sequence: assignment.sequence, + _overridden: assignment._overridden ?? false, + }); + } + } + + return Object.values(groups).sort((a, b) => (a.vehicle?.name ?? '').localeCompare(b.vehicle?.name ?? '')); + } + + get hasProposedPlan() { + return Array.isArray(this.proposedPlan) && this.proposedPlan.length > 0; + } + + get hasUnassigned() { + return this.unassignedAfterRun.length > 0; + } +} diff --git a/addon/controllers/operations/allocation.js b/addon/controllers/operations/allocation.js new file mode 100644 index 000000000..4c9421fc7 --- /dev/null +++ b/addon/controllers/operations/allocation.js @@ -0,0 +1,18 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; + +/** + * Operations::AllocationController + * + * Thin controller for the /operations/allocation route. + * All allocation logic lives in the OrderAllocationWorkbench component + * and the order-allocation service. This controller exists to satisfy + * the Ember route/controller convention and to provide the page title. + */ +export default class OperationsAllocationController extends Controller { + @service intl; + + get pageTitle() { + return this.intl.t('allocation.page-title'); + } +} diff --git a/addon/controllers/settings/order-allocation.js b/addon/controllers/settings/order-allocation.js new file mode 100644 index 000000000..d0b25aecc --- /dev/null +++ b/addon/controllers/settings/order-allocation.js @@ -0,0 +1,71 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; + +/** + * Settings::OrderAllocationController + * + * Manages the allocation settings page at /settings/order-allocation. + * Loads current settings, presents the engine selector dropdown (populated + * from the allocation-engine registry), and saves changes. + */ +export default class SettingsOrderAllocationController extends Controller { + @service fetch; + @service notifications; + @service intl; + @service('allocation-engine') engineRegistry; + @service('order-allocation') allocationService; + + @tracked allocationEngine = 'vroom'; + @tracked autoAllocateOnCreate = false; + @tracked autoReallocateOnComplete = false; + @tracked maxTravelTimeSeconds = 3600; + @tracked balanceWorkload = false; + @tracked isLoading = false; + + /** + * Engine options for the selector dropdown. + * Populated from the allocation-engine registry so new engines appear + * automatically without modifying this controller. + */ + get engineOptions() { + return this.engineRegistry.availableEngines; + } + + @task *loadSettings() { + this.isLoading = true; + try { + const settings = yield this.fetch.get('fleet-ops/allocation/settings'); + this.allocationEngine = settings.allocation_engine ?? 'vroom'; + this.autoAllocateOnCreate = settings.auto_allocate_on_create ?? false; + this.autoReallocateOnComplete = settings.auto_reallocate_on_complete ?? false; + this.maxTravelTimeSeconds = settings.max_travel_time_seconds ?? 3600; + this.balanceWorkload = settings.balance_workload ?? false; + } catch (error) { + this.notifications.serverError(error); + } finally { + this.isLoading = false; + } + } + + @task *saveSettings() { + try { + yield this.fetch.patch('fleet-ops/allocation/settings', { + allocation_engine: this.allocationEngine, + auto_allocate_on_create: this.autoAllocateOnCreate, + auto_reallocate_on_complete: this.autoReallocateOnComplete, + max_travel_time_seconds: this.maxTravelTimeSeconds, + balance_workload: this.balanceWorkload, + }); + this.notifications.success(this.intl.t('allocation.settings-saved')); + } catch (error) { + this.notifications.serverError(error); + } + } + + @action selectEngine(engine) { + this.allocationEngine = engine.id; + } +} diff --git a/addon/extension.js b/addon/extension.js index 77da0f770..2a1e69182 100644 --- a/addon/extension.js +++ b/addon/extension.js @@ -60,6 +60,12 @@ export default { icon: 'chart-bar', route: 'console.fleet-ops.analytics.reports', }, + { + title: 'Allocation', + description: 'Intelligently allocate and dispatch orders to available drivers.', + icon: 'diagram-project', + route: 'console.fleet-ops.operations.allocation', + }, ], }); @@ -160,6 +166,7 @@ export default { 'fleet-ops:component:order:form:payload:entity', 'fleet-ops:component:order:form:payload:entity:form', 'fleet-ops:template:settings:routing', + 'fleet-ops:template:settings:order-allocation', ]); }, }; diff --git a/addon/instance-initializers/register-vroom-allocation.js b/addon/instance-initializers/register-vroom-allocation.js new file mode 100644 index 000000000..8fbe06156 --- /dev/null +++ b/addon/instance-initializers/register-vroom-allocation.js @@ -0,0 +1,27 @@ +/** + * register-vroom-allocation + * + * Instance initializer that registers the VROOM allocation engine adapter + * into the allocation-engine registry service. + * + * This pattern is identical to how register-osrm.js registers the OSRM + * routing engine into the route-optimization registry. + * + * Third-party allocation engines follow the same pattern: + * 1. Create a service extending AllocationEngineInterfaceService + * 2. Create an instance initializer that calls allocationEngine.register() + * 3. The engine will appear in the FleetOps allocation settings dropdown + */ +export function initialize(appInstance) { + const allocationEngine = appInstance.lookup('service:allocation-engine'); + const vroomEngine = appInstance.lookup('service:vroom-allocation-engine'); + + if (allocationEngine && vroomEngine && !allocationEngine.has('vroom')) { + allocationEngine.register('vroom', vroomEngine); + } +} + +export default { + name: 'register-vroom-allocation', + initialize, +}; diff --git a/addon/routes.js b/addon/routes.js index ebeb8e18c..d57627d6b 100644 --- a/addon/routes.js +++ b/addon/routes.js @@ -15,6 +15,7 @@ export default buildRoutes(function () { }); }); this.route('scheduler', function () {}); + this.route('allocation', function () {}); this.route('orders', { path: '/' }, function () { this.route('index', { path: '/' }, function () { this.route('new'); @@ -229,6 +230,7 @@ export default buildRoutes(function () { this.route('custom-fields'); this.route('avatars'); this.route('routing'); + this.route('order-allocation'); this.route('payments', function () { this.route('index', { path: '/' }); this.route('onboard'); diff --git a/addon/routes/operations/allocation.js b/addon/routes/operations/allocation.js new file mode 100644 index 000000000..640075a44 --- /dev/null +++ b/addon/routes/operations/allocation.js @@ -0,0 +1,23 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +/** + * Operations::AllocationRoute + * + * Entry point for the Dispatcher Workbench at /operations/allocation. + * The route performs an ability check and then hands off to the + * OrderAllocationWorkbench component via the controller. + */ +export default class OperationsAllocationRoute extends Route { + @service notifications; + @service hostRouter; + @service abilities; + @service intl; + + beforeModel() { + if (this.abilities.cannot('fleet-ops list order')) { + this.notifications.warning(this.intl.t('common.unauthorized-access')); + return this.hostRouter.transitionTo('console.fleet-ops'); + } + } +} diff --git a/addon/routes/settings/order-allocation.js b/addon/routes/settings/order-allocation.js new file mode 100644 index 000000000..9b4bf016d --- /dev/null +++ b/addon/routes/settings/order-allocation.js @@ -0,0 +1,27 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +/** + * Settings::OrderAllocationRoute + * + * Entry point for the allocation settings page at /settings/order-allocation. + * Loads current settings into the controller on entry. + */ +export default class SettingsOrderAllocationRoute extends Route { + @service notifications; + @service abilities; + @service intl; + @service hostRouter; + + beforeModel() { + if (this.abilities.cannot('fleet-ops list order')) { + this.notifications.warning(this.intl.t('common.unauthorized-access')); + return this.hostRouter.transitionTo('console.fleet-ops'); + } + } + + setupController(controller) { + super.setupController(...arguments); + controller.loadSettings.perform(); + } +} diff --git a/addon/services/allocation-engine-interface.js b/addon/services/allocation-engine-interface.js new file mode 100644 index 000000000..e7a33658f --- /dev/null +++ b/addon/services/allocation-engine-interface.js @@ -0,0 +1,49 @@ +import Service from '@ember/service'; + +/** + * AllocationEngineInterfaceService + * + * Base class that all allocation engine adapters must extend. + * The interface mirrors the backend AllocationEngineInterface contract. + * + * Third-party engines extend this class, implement allocate(), and register + * themselves via an instance initializer: + * + * // addon/instance-initializers/register-my-engine.js + * export function initialize(appInstance) { + * const registry = appInstance.lookup('service:allocation-engine'); + * const engine = appInstance.lookup('service:my-allocation-engine'); + * registry.register('my-engine', engine); + * } + * export default { name: 'register-my-engine', initialize }; + * + * @abstract + */ +export default class AllocationEngineInterfaceService extends Service { + /** + * Human-readable display name shown in the settings dropdown. + * @type {string} + */ + name = 'Unknown Engine'; + + /** + * Machine-readable identifier. Must be unique across all registered engines. + * @type {string} + */ + identifier = 'unknown'; + + /** + * Run the allocation algorithm. + * + * @param {Array} orders Array of order records to allocate. + * @param {Array} vehicles Array of vehicle records (with loaded driver). + * @param {Object} options Engine-specific options. + * @returns {Promise<{assignments: Array, unassigned: Array, summary: Object}>} + * + * @abstract + */ + // eslint-disable-next-line no-unused-vars + async allocate(orders, vehicles, options = {}) { + throw new Error(`AllocationEngineInterfaceService: allocate() must be implemented by ${this.constructor.name}`); + } +} diff --git a/addon/services/allocation-engine.js b/addon/services/allocation-engine.js new file mode 100644 index 000000000..834b697e7 --- /dev/null +++ b/addon/services/allocation-engine.js @@ -0,0 +1,74 @@ +import Service from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +/** + * AllocationEngineService + * + * Registry service for allocation engine adapters. Mirrors the backend + * AllocationEngineRegistry pattern. Engines register themselves from + * instance initializers and the settings UI reads availableEngines to + * populate the engine selector dropdown. + * + * This service is intentionally engine-agnostic — it has no knowledge of + * VROOM or any other specific solver. + */ +export default class AllocationEngineService extends Service { + /** + * Map of registered engines keyed by identifier. + * @type {Map} + */ + @tracked _engines = new Map(); + + /** + * Register an allocation engine adapter. + * + * @param {string} identifier Unique engine key. + * @param {AllocationEngineInterfaceService} engine Engine service instance. + * @throws {Error} if an engine with the same identifier is already registered. + */ + register(identifier, engine) { + if (this._engines.has(identifier)) { + throw new Error(`AllocationEngineService: engine '${identifier}' is already registered.`); + } + this._engines.set(identifier, engine); + } + + /** + * Resolve an engine by identifier. + * + * @param {string} identifier + * @returns {AllocationEngineInterfaceService} + * @throws {Error} if no engine with the given identifier is registered. + */ + resolve(identifier) { + const engine = this._engines.get(identifier); + if (!engine) { + const available = [...this._engines.keys()].join(', '); + throw new Error(`AllocationEngineService: no engine registered for '${identifier}'. Available: ${available}`); + } + return engine; + } + + /** + * Return all registered engines as an array of {id, name} objects. + * Used by the settings UI to populate the engine selector dropdown. + * + * @returns {Array<{id: string, name: string}>} + */ + get availableEngines() { + return [...this._engines.values()].map((engine) => ({ + id: engine.identifier, + name: engine.name, + })); + } + + /** + * Check whether an engine with the given identifier is registered. + * + * @param {string} identifier + * @returns {boolean} + */ + has(identifier) { + return this._engines.has(identifier); + } +} diff --git a/addon/services/order-allocation.js b/addon/services/order-allocation.js new file mode 100644 index 000000000..65a7a408a --- /dev/null +++ b/addon/services/order-allocation.js @@ -0,0 +1,119 @@ +import Service, { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; + +/** + * OrderAllocationService + * + * Orchestrates the full allocation workflow: + * 1. Fetch unassigned orders and available vehicles + * 2. Run the active allocation engine (via the allocation-engine registry) + * 3. Present the proposed plan to the dispatcher + * 4. Commit confirmed assignments to the backend + * + * This service is injected into the Dispatcher Workbench component and the + * allocation settings controller. + */ +export default class OrderAllocationService extends Service { + @service fetch; + @service store; + @service notifications; + @service intl; + @service('allocation-engine') engineRegistry; + + /** The proposed allocation plan returned by the last run. */ + @tracked currentPlan = null; + + /** Whether an allocation run is in progress. */ + @tracked isRunning = false; + + /** The identifier of the currently active engine (from settings). */ + @tracked activeEngineId = 'vroom'; + + /** + * Load allocation settings from the backend. + */ + @task *loadSettings() { + try { + const settings = yield this.fetch.get('fleet-ops/allocation/settings'); + this.activeEngineId = settings.allocation_engine ?? 'vroom'; + return settings; + } catch (error) { + this.notifications.serverError(error); + } + } + + /** + * Save allocation settings to the backend. + * + * @param {Object} settings + */ + @task *saveSettings(settings) { + try { + yield this.fetch.patch('fleet-ops/allocation/settings', settings); + this.notifications.success(this.intl.t('allocation.settings-saved')); + } catch (error) { + this.notifications.serverError(error); + throw error; + } + } + + /** + * Run the allocation engine and store the proposed plan. + * + * @param {Array} orderIds Optional list of order public_ids to allocate. + * @param {Array} vehicleIds Optional list of vehicle public_ids to use. + * @param {Object} options Engine-specific options. + */ + @task *run(orderIds = [], vehicleIds = [], options = {}) { + this.isRunning = true; + try { + const result = yield this.fetch.post('fleet-ops/allocation/run', { + order_ids: orderIds, + vehicle_ids: vehicleIds, + options, + }); + this.currentPlan = result; + return result; + } catch (error) { + this.notifications.serverError(error); + throw error; + } finally { + this.isRunning = false; + } + } + + /** + * Commit a (possibly modified) allocation plan. + * The dispatcher may have adjusted assignments via drag-and-drop before + * calling commit. + * + * @param {Array} assignments Array of {order_id, vehicle_id, driver_id, sequence} + */ + @task *commit(assignments) { + try { + const result = yield this.fetch.post('fleet-ops/allocation/commit', { assignments }); + this.notifications.success( + this.intl.t('allocation.committed', { count: result.committed?.length ?? 0 }) + ); + this.currentPlan = null; + return result; + } catch (error) { + this.notifications.serverError(error); + throw error; + } + } + + /** + * Fetch the list of available allocation engines from the backend. + * Used to cross-validate that the backend and frontend registries are in sync. + */ + @task *fetchAvailableEngines() { + try { + const response = yield this.fetch.get('fleet-ops/allocation/engines'); + return response.engines ?? []; + } catch (error) { + return []; + } + } +} diff --git a/addon/services/vroom-allocation-engine.js b/addon/services/vroom-allocation-engine.js new file mode 100644 index 000000000..cc5b24728 --- /dev/null +++ b/addon/services/vroom-allocation-engine.js @@ -0,0 +1,45 @@ +import { inject as service } from '@ember/service'; +import AllocationEngineInterfaceService from './allocation-engine-interface'; + +/** + * VroomAllocationEngineService + * + * Frontend adapter for the VROOM allocation engine. Delegates all computation + * to the backend AllocationController — the frontend adapter's role is to + * call the correct API endpoint and return the normalized result. + * + * This service is registered into the allocation-engine registry via the + * register-vroom-allocation instance initializer. + */ +export default class VroomAllocationEngineService extends AllocationEngineInterfaceService { + @service fetch; + @service notifications; + + name = 'VROOM'; + identifier = 'vroom'; + + /** + * Run the VROOM allocation via the backend API. + * + * @param {Array} orders Array of order records (or order public_ids). + * @param {Array} vehicles Array of vehicle records (or vehicle public_ids). + * @param {Object} options Options forwarded to the backend engine. + * @returns {Promise<{assignments: Array, unassigned: Array, summary: Object}>} + */ + async allocate(orders = [], vehicles = [], options = {}) { + const orderIds = orders.map((o) => (typeof o === 'string' ? o : o.public_id)); + const vehicleIds = vehicles.map((v) => (typeof v === 'string' ? v : v.public_id)); + + try { + const result = await this.fetch.post('fleet-ops/allocation/run', { + order_ids: orderIds, + vehicle_ids: vehicleIds, + options, + }); + return result; + } catch (error) { + this.notifications.serverError(error); + throw error; + } + } +} diff --git a/addon/templates/operations/allocation.hbs b/addon/templates/operations/allocation.hbs new file mode 100644 index 000000000..6ce48343e --- /dev/null +++ b/addon/templates/operations/allocation.hbs @@ -0,0 +1,2 @@ +{{page-title this.pageTitle}} + diff --git a/addon/templates/settings/order-allocation.hbs b/addon/templates/settings/order-allocation.hbs new file mode 100644 index 000000000..9a427c610 --- /dev/null +++ b/addon/templates/settings/order-allocation.hbs @@ -0,0 +1,86 @@ +{{page-title (t "allocation.settings-title")}} + + +