@fleetbase/fleetops-engine
Version:
Fleet & Transport Management Extension for Fleetbase
317 lines (288 loc) • 11.5 kB
JavaScript
import Service from '@ember/service';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { isNone } from '@ember/utils';
import { addMinutes, areIntervalsOverlapping } from 'date-fns';
/**
* SchedulingService
*
* Central domain-logic service for the unified order scheduler.
* Encapsulates conflict detection, best-fit insertion, single/bulk order
* assignment, and a client-side undo/redo history stack.
*
* Usage in a controller:
* @service scheduling;
*
* const result = await this.scheduling.assignOrder(order, driverId, scheduledAt);
* if (result.hasConflict) { ... }
*/
export default class SchedulingService extends Service {
store;
fetch;
notifications;
// -------------------------------------------------------------------------
// Undo / Redo History Stack
// -------------------------------------------------------------------------
/** @type {Array<{orderId, previousDate, newDate, previousDriverId, newDriverId}>} */
_history = [];
/** @type {Array<{orderId, previousDate, newDate, previousDriverId, newDriverId}>} */
_future = [];
/** True when there is at least one action to undo. */
get canUndo() {
return this._history.length > 0;
}
/** True when there is at least one action to redo. */
get canRedo() {
return this._future.length > 0;
}
/**
* Push a new action onto the history stack.
* Clears the redo stack — a new action invalidates any future states.
* Capped at 20 entries to prevent unbounded memory growth.
*
* @param {Object} entry
*/
_pushHistory(entry) {
const capped = [...this._history, entry].slice(-20);
this._history = capped;
this._future = [];
}
/**
* Revert the most recent scheduling action.
*/
async undo() {
if (!this.canUndo) return;
const history = [...this._history];
const entry = history.pop();
this._history = history;
this._future = [entry, ...this._future];
await this._applyHistoryEntry(entry, true);
}
/**
* Re-apply the most recently undone action.
*/
async redo() {
if (!this.canRedo) return;
const [entry, ...rest] = this._future;
this._future = rest;
this._history = [...this._history, entry];
await this._applyHistoryEntry(entry, false);
}
/**
* Apply a history entry in either the undo (reverse) or redo (forward) direction.
*
* @param {Object} entry
* @param {boolean} isUndo true = restore previous state, false = re-apply new state
*/
async _applyHistoryEntry(entry, isUndo) {
const order = this.store.peekRecord('order', entry.orderId);
if (!order) return;
const targetDate = isUndo ? entry.previousDate : entry.newDate;
const targetDriver = isUndo ? entry.previousDriverId : entry.newDriverId;
const payload = {
order: order.id,
scheduled_at: targetDate instanceof Date ? targetDate.toISOString() : targetDate,
};
if (targetDriver !== undefined) {
payload.driver_id = targetDriver;
}
try {
await this.fetch.patch('orders/schedule', payload);
order.set('scheduled_at', targetDate);
if (targetDriver !== undefined) {
order.set('driver_assigned_uuid', targetDriver);
}
} catch (error) {
this.notifications.serverError(error);
}
}
// -------------------------------------------------------------------------
// Conflict Detection
// -------------------------------------------------------------------------
/**
* Returns all orders assigned to `driverId` whose time window overlaps
* with the proposed [start, end] interval.
*
* The `excludeOrderId` parameter allows the caller to exclude the order
* being rescheduled from its own conflict check.
*
* @param {string} driverId
* @param {Date} start
* @param {Date} end
* @param {string} [excludeOrderId]
* @returns {Array<Model>} Conflicting order records
*/
checkConflicts(driverId, start, end, excludeOrderId = null) {
return this.store.peekAll('order').filter((o) => {
if (o.id === excludeOrderId) return false;
if (o.driver_assigned_uuid !== driverId) return false;
if (isNone(o.scheduled_at)) return false;
const oStart = new Date(o.scheduled_at);
const oEnd = addMinutes(oStart, o.estimated_duration ?? 60);
return areIntervalsOverlapping({ start, end }, { start: oStart, end: oEnd }, { inclusive: false });
});
}
// -------------------------------------------------------------------------
// Best-Fit Insertion
// -------------------------------------------------------------------------
/**
* Calculates the optimal `scheduled_at` time for `order` when dropped
* onto a driver's row without a specific time target.
*
* Primary strategy: POST to the Fleetbase routing API for a travel-time-
* aware recommendation.
* Fallback: Append the order after the last existing order for that driver.
*
* @param {string} driverId
* @param {Model} order
* @returns {Promise<Date>}
*/
async findBestFit(driverId, order) {
// Try the server-side routing API first
try {
const response = await this.fetch.post('orders/best-fit', {
driver_id: driverId,
order_id: order.id,
});
if (response?.recommended_scheduled_at) {
return new Date(response.recommended_scheduled_at);
}
} catch {
// Non-critical — fall through to heuristic
}
// Heuristic fallback: find the end time of the last order for this driver
const driverOrders = this.store
.peekAll('order')
.filter((o) => o.driver_assigned_uuid === driverId && !isNone(o.scheduled_at))
.sortBy('scheduled_at');
if (driverOrders.length === 0) {
// No existing orders — use now as the starting point
return new Date();
}
const lastOrder = driverOrders[driverOrders.length - 1];
return addMinutes(new Date(lastOrder.scheduled_at), lastOrder.estimated_duration ?? 60);
}
// -------------------------------------------------------------------------
// Single Order Assignment
// -------------------------------------------------------------------------
/**
* Assigns a single order to a driver at a given datetime.
*
* Flow:
* 1. Conflict check (unless `options.skipConflictCheck` is true)
* 2. Persist via a targeted PATCH to the schedule endpoint
* (does NOT trigger dispatch or change the order status)
* 3. Reflect the change in the local Ember Data store
* 4. On failure: notify and return error
*
* @param {Model} order
* @param {string} driverId
* @param {Date} scheduledAt
* @param {Object} [options]
* @param {boolean} [options.skipConflictCheck=false]
* @returns {Promise<{hasConflict: boolean, conflicts?: Array, error?: Error}>}
*/
async assignOrder(order, driverId, scheduledAt, options = {}) {
const duration = order.estimated_duration ?? 60;
const end = addMinutes(scheduledAt, duration);
const previousDate = order.scheduled_at;
const previousDriverId = order.driver_assigned_uuid;
if (!options.skipConflictCheck && driverId) {
const conflicts = this.checkConflicts(driverId, scheduledAt, end, order.id);
if (conflicts.length > 0) {
return { hasConflict: true, conflicts };
}
}
const payload = {
order: order.id,
scheduled_at: scheduledAt instanceof Date ? scheduledAt.toISOString() : scheduledAt,
};
if (driverId) {
payload.driver_id = driverId;
}
try {
// Use the dedicated schedule endpoint so the backend only sets
// scheduled_at / driver_assigned_uuid without triggering dispatch.
await this.fetch.patch('orders/schedule', payload);
// Reflect the change locally without triggering another save
order.set('scheduled_at', scheduledAt);
if (driverId) {
order.set('driver_assigned_uuid', driverId);
}
this._pushHistory({
orderId: order.id,
previousDate,
newDate: scheduledAt,
previousDriverId,
newDriverId: driverId,
});
return { hasConflict: false };
} catch (error) {
this.notifications.serverError(error);
return { hasConflict: false, error };
}
}
/**
* Removes the schedule assignment from an order (sets `scheduled_at` to null).
*
* @param {Model} order
*/
async unscheduleOrder(order) {
const previousDate = order.scheduled_at;
const previousDriverId = order.driver_assigned_uuid;
try {
await this.fetch.patch('orders/schedule', { order: order.id, scheduled_at: null });
order.set('scheduled_at', null);
this._pushHistory({
orderId: order.id,
previousDate,
newDate: null,
previousDriverId,
newDriverId: null,
});
} catch (error) {
this.notifications.serverError(error);
}
}
// -------------------------------------------------------------------------
// Bulk Assignment
// -------------------------------------------------------------------------
/**
* Assigns multiple orders to a driver on a given date in a single operation.
*
* Attempts a batch API call first. On failure, falls back to sequential saves.
*
* @param {Array<Model>} orders
* @param {string} driverId
* @param {Date} date
*/
async bulkAssign(orders, driverId, date) {
const orderIds = orders.map((o) => o.id);
try {
await this.fetch.post('orders/bulk-schedule', {
order_ids: orderIds,
driver_id: driverId,
scheduled_at: date instanceof Date ? date.toISOString() : date,
});
} catch {
// Fallback: sequential saves
for (const order of orders) {
try {
order.set('scheduled_at', date);
order.set('driver_assigned_uuid', driverId);
await order.save();
} catch (error) {
order.rollbackAttributes();
this.notifications.serverError(error);
}
}
return;
}
// Reflect the bulk change in the local store
orders.forEach((order) => {
order.set('scheduled_at', date);
order.set('driver_assigned_uuid', driverId);
});
}
}