+ {{#if @resource.meta.is_recurring_generated}}
+
Recurring
+ {{/if}}
{{#if @resource.dispatched_at}}
{{concat "Dispatched at " @resource.dispatchedAt}}
{{/if}}
diff --git a/addon/components/recurring-order-schedule/details.hbs b/addon/components/recurring-order-schedule/details.hbs
new file mode 100644
index 000000000..347c5a755
--- /dev/null
+++ b/addon/components/recurring-order-schedule/details.hbs
@@ -0,0 +1,138 @@
+
+
+
+
+
ID
+
{{n-a @resource.public_id}}
+
+
+
Status
+
{{smart-humanize @resource.status}}
+
+
+
Timezone
+
{{n-a @resource.timezone}}
+
+
+
Next Occurrence
+
+ {{#if @resource.next_occurrence_at}}
+ {{format-date-fns @resource.next_occurrence_at "dd MMM yyyy, HH:mm"}}
+ {{else}}
+ --
+ {{/if}}
+
+
+
+
Customer
+
{{n-a @resource.customer_name}}
+
+
+
Order Type
+
{{n-a @resource.order_config_name}}
+
+
+
Service Rate
+
{{n-a @resource.service_rate_name "No default service rate"}}
+
+
+
Pattern
+
{{n-a @resource.rrule}}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{#if this.isUpcomingTab}}
+
+ {{#if this.upcomingOccurrences.length}}
+
+ {{#each this.upcomingOccurrences as |occurrence|}}
+
+
+
{{n-a occurrence.occurrence_at_local occurrence.occurrence_at}}
+
Status: {{smart-humanize occurrence.status}}
+ {{#if occurrence.order}}
+
Order: {{occurrence.order.public_id}}
+ {{/if}}
+
+
+ {{#if occurrence.order}}
+
+ {{/if}}
+ {{#if (eq occurrence.status "scheduled")}}
+
+ {{/if}}
+
+
+ {{/each}}
+
+ {{else}}
+ No upcoming occurrences found for this recurring series.
+ {{/if}}
+
+ {{/if}}
+
+ {{#if this.isHistoryTab}}
+
+ {{#if this.historyOccurrences.length}}
+
+ {{#each this.historyOccurrences as |occurrence|}}
+
+
{{n-a occurrence.occurrence_at_local occurrence.occurrence_at}}
+
Status: {{smart-humanize occurrence.status}}
+
+ {{/each}}
+
+ {{else}}
+ No historical occurrences found for this recurring series.
+ {{/if}}
+
+ {{/if}}
+
+ {{#if this.isSettingsTab}}
+
+
+
+
Starts At
+
+ {{#if @resource.starts_at}}
+ {{format-date-fns @resource.starts_at "dd MMM yyyy, HH:mm"}}
+ {{else}}
+ --
+ {{/if}}
+
+
+
+
Ends At
+
+ {{#if @resource.ends_at}}
+ {{format-date-fns @resource.ends_at "dd MMM yyyy, HH:mm"}}
+ {{else}}
+ --
+ {{/if}}
+
+
+
+
Rule
+
{{n-a @resource.rrule}}
+
+
+
+ {{/if}}
+
+ {{#if @resource.description}}
+
+ {{@resource.description}}
+
+ {{/if}}
+
+
+
diff --git a/addon/components/recurring-order-schedule/details.js b/addon/components/recurring-order-schedule/details.js
new file mode 100644
index 000000000..e1430a867
--- /dev/null
+++ b/addon/components/recurring-order-schedule/details.js
@@ -0,0 +1,52 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+export default class RecurringOrderScheduleDetailsComponent extends Component {
+ @service recurringOrderScheduleActions;
+ @service hostRouter;
+
+ get upcomingOccurrences() {
+ return this.args.resource?.upcoming_occurrences ?? this.args.resource?.meta?.upcoming_occurrences ?? [];
+ }
+
+ get activeTab() {
+ return this.args.activeTab ?? 'upcoming';
+ }
+
+ get isUpcomingTab() {
+ return this.activeTab === 'upcoming';
+ }
+
+ get isHistoryTab() {
+ return this.activeTab === 'history';
+ }
+
+ get isSettingsTab() {
+ return this.activeTab === 'settings';
+ }
+
+ get historyOccurrences() {
+ return this.args.resource?.history_occurrences ?? this.args.resource?.meta?.history_occurrences ?? [];
+ }
+
+ @action selectTab(tab) {
+ this.args.onSelectTab?.(tab);
+ }
+
+ @action skipOccurrence(occurrence) {
+ return this.recurringOrderScheduleActions.skipOccurrence(this.args.resource, occurrence.occurrence_at).then(async () => {
+ if (typeof this.args.resource?.reload === 'function') {
+ await this.args.resource.reload();
+ }
+ });
+ }
+
+ @action viewOrder(order) {
+ if (!order?.public_id) {
+ return;
+ }
+
+ return this.hostRouter.transitionTo('console.fleet-ops.operations.orders.index.details', order.public_id);
+ }
+}
diff --git a/addon/components/recurring-order-schedule/form.hbs b/addon/components/recurring-order-schedule/form.hbs
new file mode 100644
index 000000000..87231bbdf
--- /dev/null
+++ b/addon/components/recurring-order-schedule/form.hbs
@@ -0,0 +1,136 @@
+
\ No newline at end of file
diff --git a/addon/components/recurring-order-schedule/form.js b/addon/components/recurring-order-schedule/form.js
new file mode 100644
index 000000000..5635878af
--- /dev/null
+++ b/addon/components/recurring-order-schedule/form.js
@@ -0,0 +1,163 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { task } from 'ember-concurrency';
+import { buildRrule, parseRrule, WEEKDAY_OPTIONS } from '../../utils/recurring-rrule';
+import { createRecurringDraftOrder } from '../../utils/recurring-order-blueprint';
+
+const FREQUENCY_OPTIONS = [
+ { value: 'daily', label: 'Daily' },
+ { value: 'weekly', label: 'Weekly' },
+ { value: 'monthly', label: 'Monthly' },
+ { value: 'yearly', label: 'Yearly' },
+];
+
+const STATUS_OPTIONS = ['active', 'paused', 'canceled'];
+
+export default class RecurringOrderScheduleFormComponent extends Component {
+ @service store;
+ @service recurringOrderScheduleActions;
+ @service serviceRateActions;
+
+ @tracked draftOrder;
+ @tracked frequency = 'weekly';
+ @tracked interval = 1;
+ @tracked selectedWeekdays = ['MO'];
+ @tracked monthday = null;
+ @tracked previewOccurrences = [];
+ @tracked serviceRates = [];
+ @tracked selectedServiceRate = null;
+
+ weekdayOptions = WEEKDAY_OPTIONS;
+ frequencyOptions = FREQUENCY_OPTIONS;
+ statusOptions = STATUS_OPTIONS;
+
+ constructor() {
+ super(...arguments);
+
+ const { resource, sourceOrder } = this.args;
+ const parsedRule = parseRrule(resource.rrule);
+
+ this.frequency = parsedRule.frequency;
+ this.interval = parsedRule.interval;
+ this.selectedWeekdays = parsedRule.weekdays.length > 0 ? parsedRule.weekdays : ['MO'];
+ this.monthday = parsedRule.monthday ?? resource.starts_at?.getDate?.() ?? new Date().getDate();
+
+ this.draftOrder = resource.draftOrder ?? createRecurringDraftOrder(this.store, sourceOrder ?? resource);
+ resource.draftOrder = this.draftOrder;
+
+ if (!resource.name && sourceOrder) {
+ resource.name = `Recurring ${sourceOrder.tracking ?? sourceOrder.public_id ?? 'Order'}`;
+ }
+
+ if (resource.starts_at && !this.draftOrder.scheduled_at) {
+ this.draftOrder.scheduled_at = resource.starts_at;
+ }
+
+ this.selectedServiceRate = resource.service_rate ?? null;
+ this.updatePreview.perform();
+ }
+
+ get isWeekly() {
+ return this.frequency === 'weekly';
+ }
+
+ get isMonthly() {
+ return this.frequency === 'monthly';
+ }
+
+ get canQueryServiceRates() {
+ return this.draftOrder?.payloadCoordinates?.length >= 2;
+ }
+
+ get currentRrule() {
+ return buildRrule({
+ frequency: this.frequency,
+ interval: this.interval,
+ weekdays: this.selectedWeekdays,
+ monthday: this.monthday,
+ until: this.args.resource.ends_at,
+ });
+ }
+
+ @task *updatePreview() {
+ if (!this.args.resource.starts_at) {
+ this.previewOccurrences = [];
+ return;
+ }
+
+ try {
+ const response = yield this.recurringOrderScheduleActions.preview(this.args.resource, 8, {
+ rrule: this.currentRrule,
+ });
+ this.previewOccurrences = response?.occurrences ?? [];
+ } catch {
+ this.previewOccurrences = [];
+ }
+ }
+
+ @task *loadServiceRates() {
+ if (!this.canQueryServiceRates) {
+ this.serviceRates = [];
+ return;
+ }
+
+ this.serviceRates = yield this.serviceRateActions.queryServiceRatesForOrder.perform(this.draftOrder);
+ }
+
+ @action updateStartsAt(value) {
+ this.args.resource.starts_at = value;
+ this.draftOrder.scheduled_at = value;
+ this.updatePreview.perform();
+ }
+
+ @action updateEndsAt(value) {
+ this.args.resource.ends_at = value;
+ this.args.resource.rrule = this.currentRrule;
+ this.updatePreview.perform();
+ }
+
+ @action updateFrequency(option) {
+ this.frequency = option.value;
+ this.args.resource.rrule = this.currentRrule;
+ this.updatePreview.perform();
+ }
+
+ @action updateInterval({ target }) {
+ this.interval = Number(target.value) || 1;
+ this.args.resource.rrule = this.currentRrule;
+ this.updatePreview.perform();
+ }
+
+ @action updateMonthday({ target }) {
+ this.monthday = Number(target.value) || 1;
+ this.args.resource.rrule = this.currentRrule;
+ this.updatePreview.perform();
+ }
+
+ @action toggleWeekday(code) {
+ if (this.selectedWeekdays.includes(code)) {
+ this.selectedWeekdays = this.selectedWeekdays.filter((value) => value !== code);
+ } else {
+ this.selectedWeekdays = [...this.selectedWeekdays, code];
+ }
+
+ if (this.selectedWeekdays.length === 0) {
+ this.selectedWeekdays = ['MO'];
+ }
+
+ this.args.resource.rrule = this.currentRrule;
+ this.updatePreview.perform();
+ }
+
+ @action isWeekdaySelected(code) {
+ return this.selectedWeekdays.includes(code);
+ }
+
+ @action selectServiceRate(serviceRate) {
+ this.selectedServiceRate = serviceRate;
+ this.args.resource.service_rate = serviceRate;
+ this.args.resource.service_rate_uuid = serviceRate?.id ?? null;
+ }
+}
diff --git a/addon/components/recurring-order-schedule/manager.hbs b/addon/components/recurring-order-schedule/manager.hbs
new file mode 100644
index 000000000..3247f550c
--- /dev/null
+++ b/addon/components/recurring-order-schedule/manager.hbs
@@ -0,0 +1,32 @@
+
+
+
+ {{#each this.actionButtons as |actionButton|}}
+
+ {{/each}}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/addon/components/recurring-order-schedule/manager.js b/addon/components/recurring-order-schedule/manager.js
new file mode 100644
index 000000000..7604c8db8
--- /dev/null
+++ b/addon/components/recurring-order-schedule/manager.js
@@ -0,0 +1,229 @@
+import Component from '@glimmer/component';
+import ObjectProxy from '@ember/object/proxy';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { task, timeout } from 'ember-concurrency';
+
+const STATUS_OPTIONS = [
+ { value: null, label: 'All statuses' },
+ { value: 'active', label: 'Active' },
+ { value: 'paused', label: 'Paused' },
+ { value: 'canceled', label: 'Canceled' },
+];
+
+export default class RecurringOrderScheduleManagerComponent extends Component {
+ @service store;
+ @service intl;
+ @service recurringOrderScheduleActions;
+
+ @tracked page = 1;
+ @tracked limit = 12;
+ @tracked sort = '-created_at';
+ @tracked query = null;
+ @tracked status = null;
+ @tracked schedules = ObjectProxy.create({ content: [], meta: { total: 0, per_page: 12, current_page: 1, last_page: 1, from: null, to: null, time: 32 } });
+
+ statusOptions = STATUS_OPTIONS;
+
+ constructor() {
+ super(...arguments);
+ this.loadSchedules.perform();
+ }
+
+ get selectedStatusOption() {
+ return this.statusOptions.find((option) => option.value === this.status) ?? this.statusOptions[0];
+ }
+
+ get actionButtons() {
+ return [
+ {
+ icon: 'refresh',
+ helpText: this.intl.t('common.refresh'),
+ onClick: () => this.loadSchedules.perform(),
+ },
+ {
+ text: 'New',
+ type: 'primary',
+ icon: 'plus',
+ onClick: () =>
+ this.recurringOrderScheduleActions.modal.create(
+ {},
+ {},
+ {
+ refresh: false,
+ onSave: () => this.loadSchedules.perform(),
+ }
+ ),
+ },
+ ];
+ }
+
+ get columns() {
+ return [
+ {
+ sticky: true,
+ label: this.intl.t('column.id'),
+ valuePath: 'public_id',
+ cellComponent: 'table/cell/base',
+ resizable: true,
+ sortable: true,
+ },
+ {
+ label: this.intl.t('column.name'),
+ valuePath: 'name',
+ resizable: true,
+ sortable: true,
+ },
+ {
+ label: this.intl.t('column.customer'),
+ valuePath: 'customer.name',
+ cellComponent: 'table/cell/base',
+ resizable: true,
+ },
+ {
+ label: this.intl.t('column.type'),
+ valuePath: 'order_config.name',
+ cellComponent: 'table/cell/base',
+ resizable: true,
+ },
+ {
+ label: this.intl.t('column.status'),
+ valuePath: 'status',
+ cellComponent: 'table/cell/status',
+ resizable: true,
+ sortable: true,
+ },
+ {
+ label: 'Next Occurrence',
+ valuePath: 'next_occurrence_at',
+ resizable: true,
+ sortable: true,
+ },
+ {
+ label: this.intl.t('column.created-at'),
+ valuePath: 'created_at',
+ sortParam: 'created_at',
+ resizable: true,
+ sortable: true,
+ },
+ {
+ label: '',
+ cellComponent: 'table/cell/dropdown',
+ ddButtonText: false,
+ ddButtonIcon: 'ellipsis-h',
+ ddButtonIconPrefix: 'fas',
+ cellClassNames: 'overflow-visible',
+ wrapperClass: 'flex items-center justify-end mx-2',
+ width: 60,
+ actions: [
+ {
+ label: 'View schedule',
+ icon: 'eye',
+ fn: this.viewSchedule,
+ },
+ {
+ label: 'Edit schedule',
+ icon: 'pencil',
+ fn: this.editSchedule,
+ },
+ {
+ label: 'Pause schedule',
+ icon: 'pause',
+ fn: this.pauseSchedule,
+ isVisible: (schedule) => schedule.status !== 'paused' && schedule.status !== 'canceled',
+ },
+ {
+ label: 'Resume schedule',
+ icon: 'play',
+ fn: this.resumeSchedule,
+ isVisible: (schedule) => schedule.status === 'paused',
+ },
+ {
+ label: 'Cancel future orders',
+ icon: 'ban',
+ fn: this.cancelFutureOrders,
+ isVisible: (schedule) => schedule.status !== 'canceled',
+ },
+ { separator: true },
+ {
+ label: 'Delete schedule',
+ icon: 'trash',
+ class: 'text-red-500',
+ fn: this.deleteSchedule,
+ },
+ ],
+ sortable: false,
+ filterable: false,
+ resizable: false,
+ searchable: false,
+ },
+ ];
+ }
+
+ @task({ restartable: true }) *loadSchedules() {
+ const params = {
+ page: this.page,
+ limit: this.limit,
+ sort: this.sort,
+ };
+
+ if (this.query) {
+ params.query = this.query;
+ }
+
+ if (this.status) {
+ params.status = this.status;
+ }
+
+ this.schedules = yield this.store.query('recurring-order-schedule', params);
+ }
+
+ @task({ restartable: true }) *searchSchedules(event) {
+ this.query = event.target.value || null;
+ this.page = 1;
+ yield timeout(250);
+ yield this.loadSchedules.perform();
+ }
+
+ @action changePage(page) {
+ this.page = page;
+ this.loadSchedules.perform();
+ }
+
+ @action changeStatus(option) {
+ this.status = option?.value ?? null;
+ this.page = 1;
+ this.loadSchedules.perform();
+ }
+
+ @action handleSort(sort) {
+ this.sort = sort || '-created_at';
+ this.page = 1;
+ this.loadSchedules.perform();
+ }
+
+ @action editSchedule(schedule) {
+ return this.recurringOrderScheduleActions.modal.edit(schedule, {}, { refresh: false, onSave: () => this.loadSchedules.perform() });
+ }
+
+ @action viewSchedule(schedule) {
+ return this.recurringOrderScheduleActions.modal.view(schedule);
+ }
+
+ @action pauseSchedule(schedule) {
+ return this.recurringOrderScheduleActions.pause(schedule).then(() => this.loadSchedules.perform());
+ }
+
+ @action resumeSchedule(schedule) {
+ return this.recurringOrderScheduleActions.resume(schedule).then(() => this.loadSchedules.perform());
+ }
+
+ @action cancelFutureOrders(schedule) {
+ return this.recurringOrderScheduleActions.cancelFuture(schedule, { cancelGeneratedOrders: false }).then(() => this.loadSchedules.perform());
+ }
+
+ @action deleteSchedule(schedule) {
+ return this.recurringOrderScheduleActions.delete(schedule, {}, { callback: () => this.loadSchedules.perform() });
+ }
+}
diff --git a/addon/controllers/maintenance/schedules/index/new.js b/addon/controllers/maintenance/schedules/index/new.js
index db17b5c87..5f2fb7641 100644
--- a/addon/controllers/maintenance/schedules/index/new.js
+++ b/addon/controllers/maintenance/schedules/index/new.js
@@ -23,6 +23,7 @@ export default class MaintenanceSchedulesIndexNewController extends Controller {
this.notifications.success(this.intl.t('common.resource-created-success', { resource: this.intl.t('resource.maintenance-schedule') }));
this.resetForm();
} catch (err) {
+ console.log(err, err.message);
this.notifications.serverError(err);
}
}
diff --git a/addon/controllers/operations/orders/index.js b/addon/controllers/operations/orders/index.js
index 26840465e..b1f00ff49 100644
--- a/addon/controllers/operations/orders/index.js
+++ b/addon/controllers/operations/orders/index.js
@@ -5,12 +5,15 @@ import { action } from '@ember/object';
export default class OperationsOrdersIndexController extends Controller {
@service orderActions;
+ @service recurringOrderScheduleActions;
@service orderSocketEvents;
@service leafletMapManager;
@service mapDrawer;
@service orderListOverlay;
@service fetch;
+ @service store;
@service intl;
+ @service hostRouter;
/** query params */
@tracked queryParams = [
@@ -67,6 +70,7 @@ export default class OperationsOrdersIndexController extends Controller {
@tracked bulkSearchValue = '';
@tracked bulk_query = '';
@tracked layout = 'map';
+ @tracked seriesCount = 0;
/** action buttons */
get actionButtons() {
@@ -77,10 +81,11 @@ export default class OperationsOrdersIndexController extends Controller {
helpText: this.intl.t('common.refresh'),
},
{
- text: this.intl.t('common.new'),
- type: 'primary',
- icon: 'plus',
- onClick: this.orderActions.transition.create,
+ component: 'order/new-split-button',
+ options: {
+ onNewOrder: this.orderActions.transition.create,
+ onNewSeries: this.recurringOrderScheduleActions.transition.create,
+ },
},
{
text: this.intl.t('common.export'),
@@ -133,7 +138,7 @@ export default class OperationsOrdersIndexController extends Controller {
sticky: true,
label: this.intl.t('column.id'),
valuePath: 'public_id',
- cellComponent: 'table/cell/link-to',
+ cellComponent: 'cell/order-id-with-series',
route: 'operations.orders.index.details',
onLinkClick: this.orderActions.transition.view,
permission: 'fleet-ops view order',
@@ -252,6 +257,13 @@ export default class OperationsOrdersIndexController extends Controller {
filterParam: 'facilitator',
model: 'vendor',
},
+ {
+ label: 'Series',
+ valuePath: 'recurring_order_schedule.name',
+ cellComponent: 'cell/recurring-series-badge',
+ cellClassNames: 'overflow-visible',
+ resizable: true,
+ },
{
label: this.intl.t('column.scheduled-at'),
valuePath: 'scheduledAt',
@@ -385,6 +397,21 @@ export default class OperationsOrdersIndexController extends Controller {
permission: 'fleet-ops dispatch order',
isVisible: (order) => order.canBeDispatched,
},
+ {
+ label: 'Make this recurring...',
+ icon: 'arrows-rotate',
+ fn: this.recurringOrderScheduleActions.transition.createFromOrder,
+ permission: 'fleet-ops create recurring-order-schedule',
+ },
+ {
+ label: 'Reschedule',
+ icon: 'calendar-days',
+ fn: this.orderActions.editOrderDetails,
+ permission: 'fleet-ops update order',
+ },
+ {
+ separator: true,
+ },
{
label: this.intl.t('common.cancel-resource', { resource: this.intl.t('resource.order') }),
icon: 'ban',
@@ -415,10 +442,40 @@ export default class OperationsOrdersIndexController extends Controller {
}
@action changeLayout(mode) {
+ if (mode === 'series') {
+ return this.recurringOrderScheduleActions.transition.manage();
+ }
+
this.layout = mode;
if (mode === 'table') {
this.isSearchVisible = false;
}
}
+
+ @action changeWorkspaceLayout(mode) {
+ if (mode === 'series') {
+ return this.recurringOrderScheduleActions.transition.manage();
+ }
+
+ this.layout = mode;
+ return this.hostRouter.transitionTo('console.fleet-ops.operations.orders.index', {
+ queryParams: {
+ layout: mode,
+ },
+ });
+ }
+
+ @action setSeriesCount(count) {
+ this.seriesCount = Number(count ?? 0);
+ }
+
+ async loadSeriesCount() {
+ try {
+ const series = await this.store.query('recurring-order-schedule', { limit: 1 });
+ this.setSeriesCount(series?.meta?.total ?? series?.length ?? 0);
+ } catch {
+ this.setSeriesCount(0);
+ }
+ }
}
diff --git a/addon/controllers/operations/orders/index/details.js b/addon/controllers/operations/orders/index/details.js
index b9a1255b1..422de20d1 100644
--- a/addon/controllers/operations/orders/index/details.js
+++ b/addon/controllers/operations/orders/index/details.js
@@ -10,6 +10,7 @@ export default class OperationsOrdersIndexDetailsController extends Controller {
@controller('operations.orders.index') index;
@service('universe/menu-service') menuService;
@service orderActions;
+ @service recurringOrderScheduleActions;
@service orderSocketEvents;
@service mapManager;
@service leafletLayerVisibilityManager;
@@ -126,6 +127,11 @@ export default class OperationsOrdersIndexDetailsController extends Controller {
icon: 'table',
fn: () => this.orderActions.viewMetadata(this.model),
},
+ {
+ text: 'Make this recurring...',
+ icon: 'arrows-rotate',
+ fn: () => this.recurringOrderScheduleActions.transition.createFromOrder(this.model),
+ },
{
separator: true,
},
diff --git a/addon/controllers/operations/orders/index/new.js b/addon/controllers/operations/orders/index/new.js
index 761490dbb..8d18cee45 100644
--- a/addon/controllers/operations/orders/index/new.js
+++ b/addon/controllers/operations/orders/index/new.js
@@ -4,6 +4,8 @@ import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { task } from 'ember-concurrency';
import { debug } from '@ember/debug';
+import isNotEmpty from '@fleetbase/ember-core/utils/is-not-empty';
+import { createRecurringDraftOrder } from '../../../../utils/recurring-order-blueprint';
export default class OperationsOrdersIndexNewController extends Controller {
@controller('operations.orders.index') index;
@@ -19,10 +21,21 @@ export default class OperationsOrdersIndexNewController extends Controller {
@service orderImport;
@service orderCreation;
@service orderValidation;
+ @service recurringOrderScheduleActions;
+ @service store;
@service events;
@service sidebar;
@tracked order = this.orderCreation.newOrder();
@tracked overlay;
+ @tracked repeat = false;
+ @tracked source_order = null;
+ @tracked source_series = null;
+ @tracked repeatEnabled = false;
+ @tracked scheduleFirst = false;
+ @tracked seriesDraft = null;
+ @tracked editingSeries = false;
+
+ queryParams = ['repeat', 'source_order', 'source_series'];
/** action buttons */
get actionButtons() {
@@ -57,7 +70,43 @@ export default class OperationsOrdersIndexNewController extends Controller {
];
}
+ get headerTitle() {
+ if (this.editingSeries) {
+ return 'Edit recurring series';
+ }
+
+ return this.repeatEnabled ? 'Create a recurring series' : this.intl.t('common.create-a-new-resource', { resource: this.intl.t('resource.order').toLowerCase() });
+ }
+
+ get saveDisabled() {
+ if (this.repeatEnabled) {
+ return this.recurringSeriesValidationFails(this.order);
+ }
+
+ return this.orderValidation.validationFails(this.order);
+ }
+
+ recurringSeriesValidationFails(order = this.order) {
+ const hasValidOrderTemplate = !this.orderValidation.validationFails(order);
+ const hasSeriesName = isNotEmpty(this.seriesDraft?.name);
+ const hasSeriesStart = isNotEmpty(order?.scheduled_at) || isNotEmpty(this.seriesDraft?.starts_at);
+ const hasRecurrenceRule = isNotEmpty(this.seriesDraft?.rrule);
+
+ return !(hasValidOrderTemplate && hasSeriesName && hasSeriesStart && hasRecurrenceRule);
+ }
+
@task *save(order) {
+ if (this.repeatEnabled) {
+ this.ensureSeriesDraft(order);
+
+ if (this.recurringSeriesValidationFails(order)) {
+ this.notifications.warning('Recurring series is missing required order or schedule details.');
+ return;
+ }
+
+ return yield this.saveRecurringSeries(order);
+ }
+
if (this.orderValidation.validationFails(order)) return;
// Display loader
@@ -92,11 +141,94 @@ export default class OperationsOrdersIndexNewController extends Controller {
}
}
+ async saveRecurringSeries(order) {
+ this.ensureSeriesDraft(order);
+ this.loader.showLoader('body', { loadingMessage: this.editingSeries ? 'Updating recurring series...' : 'Creating recurring series...' });
+
+ try {
+ this.seriesDraft.draftOrder = order;
+ this.seriesDraft.starts_at = order.scheduled_at ?? this.seriesDraft.starts_at ?? new Date();
+
+ const createdSeries = await this.recurringOrderScheduleActions.save(this.seriesDraft);
+ if (this.editingSeries) {
+ this.events.trackResourceUpdated?.(createdSeries);
+ } else {
+ this.events.trackResourceCreated?.(createdSeries);
+ }
+
+ await this.hostRouter.transitionTo('console.fleet-ops.operations.orders.index.series.details', createdSeries.public_id ?? createdSeries.id);
+ this.notifications.success(this.editingSeries ? 'Recurring series updated.' : 'Recurring series created.');
+ } catch (err) {
+ console.error(err);
+ debug('Error creating recurring series: ' + err.message);
+ this.notifications.serverError(err);
+ } finally {
+ this.loader.removeLoader();
+ }
+ }
+
@action setup() {
this.index.changeLayout('map');
}
+ @action onRepeatChange(enabled) {
+ this.repeatEnabled = Boolean(enabled);
+
+ if (this.repeatEnabled) {
+ this.ensureSeriesDraft(this.order);
+ }
+ }
+
+ configureRepeatMode({ repeat = false, sourceOrder = null, sourceOrderId = null, sourceSeries = null, sourceSeriesId = null } = {}) {
+ this.source_order = sourceOrderId ?? null;
+ this.source_series = sourceSeriesId ?? null;
+ this.editingSeries = Boolean(sourceSeries);
+
+ if (sourceOrder || sourceSeries) {
+ this.order = createRecurringDraftOrder(this.store, sourceOrder ?? sourceSeries);
+ this.orderCreation.addContext('order', this.order);
+ }
+
+ this.repeatEnabled = Boolean(repeat);
+ this.repeat = this.repeatEnabled;
+ this.scheduleFirst = this.repeatEnabled;
+
+ if (this.repeatEnabled) {
+ this.ensureSeriesDraft(this.order, sourceOrder, sourceSeries);
+ }
+ }
+
+ ensureSeriesDraft(order = this.order, sourceOrder = null, sourceSeries = null) {
+ if (sourceSeries) {
+ this.seriesDraft = sourceSeries;
+ }
+
+ if (!this.seriesDraft || this.seriesDraft.isDeleted || this.seriesDraft.isDestroying) {
+ this.seriesDraft = this.recurringOrderScheduleActions.createNewInstance({
+ name: sourceOrder ? `Recurring ${sourceOrder.tracking ?? sourceOrder.public_id ?? 'series'}` : null,
+ starts_at: order?.scheduled_at ?? new Date(),
+ });
+ }
+
+ this.seriesDraft.draftOrder = order;
+ this.seriesDraft.starts_at = order?.scheduled_at ?? this.seriesDraft.starts_at ?? new Date();
+
+ if (!this.seriesDraft.name && order?.customer?.name) {
+ this.seriesDraft.name = `${order.customer.name} recurring series`;
+ }
+
+ return this.seriesDraft;
+ }
+
reset() {
this.order = this.orderCreation.newOrder();
+ this.orderCreation.addContext('order', this.order);
+ this.seriesDraft = null;
+ this.repeat = false;
+ this.source_order = null;
+ this.source_series = null;
+ this.repeatEnabled = false;
+ this.scheduleFirst = false;
+ this.editingSeries = false;
}
}
diff --git a/addon/controllers/operations/orders/index/series.js b/addon/controllers/operations/orders/index/series.js
new file mode 100644
index 000000000..747d06526
--- /dev/null
+++ b/addon/controllers/operations/orders/index/series.js
@@ -0,0 +1,168 @@
+import Controller, { inject as controller } from '@ember/controller';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { task, timeout } from 'ember-concurrency';
+
+const STATUS_OPTIONS = [
+ { value: null, label: 'All statuses' },
+ { value: 'active', label: 'Active' },
+ { value: 'paused', label: 'Paused' },
+ { value: 'canceled', label: 'Ended' },
+];
+
+export default class OperationsOrdersIndexSeriesController extends Controller {
+ @controller('operations.orders.index') index;
+ @service intl;
+ @service recurringOrderScheduleActions;
+
+ queryParams = [
+ { page: { as: 'series_page' } },
+ { limit: { as: 'series_limit' } },
+ { sort: { as: 'series_sort' } },
+ { query: { as: 'series_query' } },
+ { status: { as: 'series_status' } },
+ ];
+
+ @tracked page = 1;
+ @tracked limit = 20;
+ @tracked sort = '-created_at';
+ @tracked query = null;
+ @tracked status = null;
+
+ statusOptions = STATUS_OPTIONS;
+
+ get layout() {
+ return this.index?.layout ?? null;
+ }
+
+ get selectedStatusOption() {
+ return this.statusOptions.find((option) => option.value === this.status) ?? this.statusOptions[0];
+ }
+
+ get actionButtons() {
+ return [
+ {
+ icon: 'refresh',
+ helpText: this.intl.t('common.refresh'),
+ onClick: this.recurringOrderScheduleActions.refresh,
+ },
+ {
+ text: 'New series',
+ icon: 'plus',
+ type: 'primary',
+ onClick: this.recurringOrderScheduleActions.transition.create,
+ },
+ ];
+ }
+
+ get columns() {
+ return [
+ {
+ sticky: true,
+ label: 'Series',
+ valuePath: 'name',
+ cellComponent: 'cell/recurring-series-name',
+ route: 'operations.orders.index.series.details',
+ onLinkClick: this.recurringOrderScheduleActions.transition.view,
+ resizable: true,
+ sortable: true,
+ },
+ {
+ label: 'Pattern',
+ valuePath: 'rrule',
+ cellComponent: 'cell/recurring-series-pattern',
+ resizable: true,
+ },
+ {
+ label: 'Next occurrence',
+ valuePath: 'next_occurrence_at',
+ cellComponent: 'cell/recurring-series-next-occurrence',
+ resizable: true,
+ sortable: true,
+ },
+ {
+ label: this.intl.t('column.customer'),
+ valuePath: 'customer_name',
+ cellComponent: 'table/cell/base',
+ resizable: true,
+ },
+ {
+ label: this.intl.t('column.status'),
+ valuePath: 'status',
+ cellComponent: 'table/cell/status',
+ resizable: true,
+ sortable: true,
+ filterable: true,
+ filterComponent: 'filter/multi-option',
+ options: ['active', 'paused', 'canceled'],
+ },
+ {
+ label: '',
+ cellComponent: 'table/cell/dropdown',
+ ddButtonText: false,
+ ddButtonIcon: 'ellipsis-h',
+ ddButtonIconPrefix: 'fas',
+ cellClassNames: 'overflow-visible',
+ wrapperClass: 'flex items-center justify-end mx-2',
+ sticky: 'right',
+ width: 60,
+ actions: [
+ {
+ label: 'Open series',
+ icon: 'eye',
+ fn: this.recurringOrderScheduleActions.transition.view,
+ },
+ {
+ label: 'Pause',
+ icon: 'pause',
+ fn: this.recurringOrderScheduleActions.pause,
+ isVisible: (series) => series.status !== 'paused' && series.status !== 'canceled',
+ },
+ {
+ label: 'Resume',
+ icon: 'play',
+ fn: this.recurringOrderScheduleActions.resume,
+ isVisible: (series) => series.status === 'paused',
+ },
+ {
+ label: 'Skip next',
+ icon: 'forward-step',
+ fn: this.recurringOrderScheduleActions.skipNextOccurrence,
+ isVisible: (series) => series.status !== 'canceled',
+ },
+ {
+ label: 'Edit template',
+ icon: 'pencil',
+ fn: this.recurringOrderScheduleActions.transition.editTemplate,
+ },
+ {
+ label: 'Cancel future',
+ icon: 'ban',
+ fn: this.recurringOrderScheduleActions.cancelFuture,
+ isVisible: (series) => series.status !== 'canceled',
+ },
+ ],
+ sortable: false,
+ filterable: false,
+ resizable: false,
+ searchable: false,
+ },
+ ];
+ }
+
+ @task({ restartable: true }) *search(event) {
+ this.query = event.target.value || null;
+ this.page = 1;
+ yield timeout(250);
+ }
+
+ @action changeStatus(option) {
+ this.status = option?.value ?? null;
+ this.page = 1;
+ }
+
+ @action openSeries(series) {
+ return this.recurringOrderScheduleActions.transition.view(series);
+ }
+}
diff --git a/addon/controllers/operations/orders/index/series/details.js b/addon/controllers/operations/orders/index/series/details.js
new file mode 100644
index 000000000..6912a6fe7
--- /dev/null
+++ b/addon/controllers/operations/orders/index/series/details.js
@@ -0,0 +1,32 @@
+import Controller from '@ember/controller';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+
+export default class OperationsOrdersIndexSeriesDetailsController extends Controller {
+ @service recurringOrderScheduleActions;
+
+ @tracked overlay;
+ @tracked activeTab = 'upcoming';
+
+ get actionButtons() {
+ const isPaused = this.model?.status === 'paused';
+
+ return [
+ {
+ text: isPaused ? 'Resume' : 'Pause',
+ icon: isPaused ? 'play' : 'pause',
+ onClick: () => (isPaused ? this.recurringOrderScheduleActions.resume(this.model) : this.recurringOrderScheduleActions.pause(this.model)),
+ },
+ {
+ text: 'Edit template',
+ icon: 'pencil',
+ onClick: () => this.recurringOrderScheduleActions.transition.editTemplate(this.model),
+ },
+ ];
+ }
+
+ @action selectTab(tab) {
+ this.activeTab = tab;
+ }
+}
diff --git a/addon/models/maintenance-schedule.js b/addon/models/maintenance-schedule.js
deleted file mode 100644
index 127bb5455..000000000
--- a/addon/models/maintenance-schedule.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import Model, { attr, belongsTo } from '@ember-data/model';
-
-/**
- * Local maintenance-schedule model used by the fleetops engine.
- * The canonical model with full computed properties lives in fleetops-data.
- * This local copy adds the polymorphic @belongsTo relationships so the
- * engine's form and details components can use relationship accessors
- * directly instead of raw _type / _uuid attrs.
- */
-export default class MaintenanceScheduleModel extends Model {
- // Identification
- @attr('string') public_id;
- @attr('string') name;
- @attr('string') type;
- @attr('string') status;
-
- // Polymorphic subject (the asset this schedule applies to)
- @belongsTo('maintenance-subject', { polymorphic: true, async: false }) subject;
-
- // Polymorphic default_assignee (who should be assigned to generated work orders)
- @belongsTo('facilitator', { polymorphic: true, async: false }) default_assignee;
-
- // Interval definition
- @attr('string') interval_method;
- @attr('string') interval_type;
- @attr('number') interval_value;
- @attr('string') interval_unit;
- @attr('number') interval_distance;
- @attr('number') interval_engine_hours;
-
- // Baseline readings
- @attr('number') last_service_odometer;
- @attr('number') last_service_engine_hours;
- @attr('date') last_service_date;
-
- // Next-due thresholds
- @attr('date') next_due_date;
- @attr('number') next_due_odometer;
- @attr('number') next_due_engine_hours;
-
- // Work order defaults
- @attr('string') default_priority;
- @attr('string') instructions;
- @attr() meta;
-
- @attr('date') created_at;
- @attr('date') updated_at;
-
- // Computed display helpers
- get nextDueDate() {
- return this.next_due_date;
- }
-
- get isActive() {
- return this.status === 'active';
- }
-
- get isPaused() {
- return this.status === 'paused';
- }
-}
diff --git a/addon/routes.js b/addon/routes.js
index 004e3cd08..1d90ada2a 100644
--- a/addon/routes.js
+++ b/addon/routes.js
@@ -22,6 +22,9 @@ export default buildRoutes(function () {
this.route('orders', { path: '/' }, function () {
this.route('index', { path: '/' }, function () {
this.route('new');
+ this.route('series', function () {
+ this.route('details', { path: '/:public_id' });
+ });
this.route('details', { path: '/:public_id' }, function () {
this.route('index', { path: '/' });
this.route('virtual', { path: '/:slug' });
diff --git a/addon/routes/operations/orders/index.js b/addon/routes/operations/orders/index.js
index 48fa8c343..490df8b73 100644
--- a/addon/routes/operations/orders/index.js
+++ b/addon/routes/operations/orders/index.js
@@ -34,4 +34,9 @@ export default class OperationsOrdersIndexRoute extends Route {
model(params) {
return this.store.query('order', params);
}
+
+ setupController(controller) {
+ super.setupController(...arguments);
+ controller.loadSeriesCount?.();
+ }
}
diff --git a/addon/routes/operations/orders/index/new.js b/addon/routes/operations/orders/index/new.js
index e8302b75e..905b1d4de 100644
--- a/addon/routes/operations/orders/index/new.js
+++ b/addon/routes/operations/orders/index/new.js
@@ -8,6 +8,13 @@ export default class OperationsOrdersIndexNewRoute extends Route {
@service abilities;
@service intl;
@service sidebar;
+ @service store;
+
+ queryParams = {
+ repeat: { refreshModel: true },
+ source_order: { refreshModel: true },
+ source_series: { refreshModel: true },
+ };
@action willTransition() {
if (this.controller) {
@@ -29,4 +36,41 @@ export default class OperationsOrdersIndexNewRoute extends Route {
return this.hostRouter.transitionTo('console.fleet-ops.operations.orders.index');
}
}
+
+ async model({ source_order, source_series }) {
+ if (source_series) {
+ return {
+ sourceSeries: await this.store.queryRecord('recurring-order-schedule', {
+ public_id: source_series,
+ single: true,
+ with: ['customer', 'facilitator', 'orderConfig', 'serviceRate', 'driverAssigned', 'vehicleAssigned'],
+ upcoming_limit: 25,
+ history_limit: 25,
+ }),
+ };
+ }
+
+ if (source_order) {
+ return {
+ sourceOrder: await this.store.queryRecord('order', {
+ public_id: source_order,
+ single: true,
+ with: ['payload', 'driverAssigned', 'vehicleAssigned', 'orderConfig', 'customer', 'facilitator'],
+ }),
+ };
+ }
+
+ return null;
+ }
+
+ setupController(controller, model, transition) {
+ super.setupController(...arguments);
+ controller.configureRepeatMode({
+ repeat: transition.to.queryParams.repeat === true || transition.to.queryParams.repeat === 'true',
+ sourceOrder: model?.sourceOrder,
+ sourceOrderId: transition.to.queryParams.source_order,
+ sourceSeries: model?.sourceSeries,
+ sourceSeriesId: transition.to.queryParams.source_series,
+ });
+ }
}
diff --git a/addon/routes/operations/orders/index/series.js b/addon/routes/operations/orders/index/series.js
new file mode 100644
index 000000000..8b96b7b72
--- /dev/null
+++ b/addon/routes/operations/orders/index/series.js
@@ -0,0 +1,38 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+export default class OperationsOrdersIndexSeriesRoute extends Route {
+ @service store;
+
+ queryParams = {
+ page: { refreshModel: true },
+ limit: { refreshModel: true },
+ sort: { refreshModel: true },
+ query: { refreshModel: true },
+ status: { refreshModel: true },
+ };
+
+ model(params) {
+ const query = {
+ page: params.page ?? 1,
+ limit: params.limit ?? 20,
+ sort: params.sort ?? '-created_at',
+ };
+
+ if (params.query) {
+ query.query = params.query;
+ }
+
+ if (params.status) {
+ query.status = params.status;
+ }
+
+ return this.store.query('recurring-order-schedule', query);
+ }
+
+ setupController(controller, model) {
+ super.setupController(...arguments);
+ controller.index?.setSeriesCount(model?.meta?.total ?? model?.length ?? 0);
+ controller.index.layout = 'series';
+ }
+}
diff --git a/addon/routes/operations/orders/index/series/details.js b/addon/routes/operations/orders/index/series/details.js
new file mode 100644
index 000000000..de187a3df
--- /dev/null
+++ b/addon/routes/operations/orders/index/series/details.js
@@ -0,0 +1,32 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+export default class OperationsOrdersIndexSeriesDetailsRoute extends Route {
+ @service store;
+ @service notifications;
+ @service hostRouter;
+ @service sidebar;
+
+ @action error(error) {
+ this.notifications.serverError(error);
+ return this.hostRouter.transitionTo('console.fleet-ops.operations.orders.index.series');
+ }
+
+ activate() {
+ this.sidebar.hide();
+ }
+
+ deactivate() {
+ this.sidebar.show();
+ }
+
+ model({ public_id }) {
+ return this.store.queryRecord('recurring-order-schedule', {
+ public_id,
+ single: true,
+ with: ['customer', 'facilitator', 'orderConfig', 'serviceRate', 'driverAssigned', 'vehicleAssigned'],
+ upcoming_limit: 25,
+ });
+ }
+}
diff --git a/addon/services/recurring-order-schedule-actions.js b/addon/services/recurring-order-schedule-actions.js
new file mode 100644
index 000000000..ac679d09d
--- /dev/null
+++ b/addon/services/recurring-order-schedule-actions.js
@@ -0,0 +1,252 @@
+import ResourceActionService, { service } from '@fleetbase/ember-core/services/resource-action';
+import { action } from '@ember/object';
+import { serializeRecurringDraftOrder } from '../utils/recurring-order-blueprint';
+import { buildRrule } from '../utils/recurring-rrule';
+
+export default class RecurringOrderScheduleActionsService extends ResourceActionService {
+ @service store;
+ @service fetch;
+ @service notifications;
+ @service intl;
+ @service events;
+
+ constructor() {
+ super(...arguments);
+ this.initialize('recurring-order-schedule');
+ }
+
+ createNewInstance(attributes = {}) {
+ return this.store.createRecord('recurring-order-schedule', {
+ status: 'active',
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC',
+ starts_at: new Date(),
+ rrule: buildRrule({
+ frequency: 'weekly',
+ interval: 1,
+ weekdays: ['MO'],
+ monthday: new Date().getDate(),
+ until: null,
+ }),
+ ...attributes,
+ });
+ }
+
+ modal = {
+ create: (attributes = {}, options = {}, saveOptions = {}) => {
+ const schedule = this.createNewInstance(attributes);
+ return this.openFormModal(schedule, options, saveOptions);
+ },
+ createFromOrder: (order, options = {}, saveOptions = {}) => {
+ return this.modal.create(
+ {},
+ {
+ ...options,
+ sourceOrder: order,
+ },
+ saveOptions
+ );
+ },
+ edit: (schedule, options = {}, saveOptions = {}) => {
+ return this.openFormModal(schedule, options, saveOptions);
+ },
+ view: (schedule, options = {}) => {
+ return this.modalsManager.show('modals/resource', {
+ resource: schedule,
+ component: 'recurring-order-schedule/details',
+ title: schedule.name ?? schedule.public_id ?? this.intl.t('resource.recurring-order-schedule'),
+ modalClass: 'modal-xl',
+ hideAcceptButton: true,
+ declineButtonText: this.intl.t('common.done'),
+ ...options,
+ });
+ },
+ manage: (options = {}) => {
+ return this.modalsManager.show('modals/recurring-order-schedules-manager', {
+ title: this.intl.t('resource.recurring-order-schedules'),
+ modalClass: 'modal-xl flb-resource-modal',
+ hideFooterActions: true,
+ ...options,
+ });
+ },
+ };
+
+ transition = {
+ create: () => {
+ return this.hostRouter.transitionTo('console.fleet-ops.operations.orders.index.new', {
+ queryParams: {
+ repeat: true,
+ source_order: null,
+ source_series: null,
+ },
+ });
+ },
+ createFromOrder: (order) => {
+ return this.hostRouter.transitionTo('console.fleet-ops.operations.orders.index.new', {
+ queryParams: {
+ repeat: true,
+ source_order: order?.public_id ?? order?.id ?? null,
+ source_series: null,
+ },
+ });
+ },
+ view: (series) => {
+ return this.transitionTo('operations.orders.index.series.details', series?.public_id ?? series?.id ?? series);
+ },
+ editTemplate: (series) => {
+ return this.hostRouter.transitionTo('console.fleet-ops.operations.orders.index.new', {
+ queryParams: {
+ repeat: true,
+ source_order: null,
+ source_series: series?.public_id ?? series?.id ?? series,
+ },
+ });
+ },
+ manage: () => {
+ return this.transitionTo('operations.orders.index.series');
+ },
+ };
+
+ buildPayload(schedule, overrides = {}) {
+ return {
+ recurring_order_schedule: {
+ name: schedule.name,
+ description: schedule.description,
+ status: schedule.status ?? 'active',
+ timezone: schedule.timezone ?? 'UTC',
+ starts_at: schedule.starts_at,
+ ends_at: schedule.ends_at,
+ rrule: overrides.rrule ?? schedule.rrule,
+ meta: schedule.meta ?? {},
+ service_rate_uuid: schedule.service_rate_uuid ?? null,
+ order: serializeRecurringDraftOrder(schedule.draftOrder, schedule.service_rate_uuid ?? schedule.service_rate?.id ?? null),
+ },
+ };
+ }
+
+ async save(schedule, overrides = {}) {
+ const payload = this.buildPayload(schedule, overrides);
+ const method = schedule.isNew ? 'post' : 'patch';
+ const path = schedule.isNew ? 'recurring-order-schedules' : `recurring-order-schedules/${schedule.id ?? schedule.public_id}`;
+
+ return this.fetch[method](path, payload, {
+ normalizeToEmberData: true,
+ normalizeModelType: 'recurring-order-schedule',
+ });
+ }
+
+ async preview(schedule, limit = 8, overrides = {}) {
+ const payload = {
+ ...this.buildPayload(schedule, overrides),
+ limit,
+ };
+
+ return this.fetch.post('recurring-order-schedules/preview', payload);
+ }
+
+ openFormModal(schedule, options = {}, saveOptions = {}) {
+ const isNew = schedule.isNew;
+ const title = options.title ?? (isNew ? 'Create recurring schedule' : `Edit ${schedule.name ?? schedule.public_id ?? 'recurring schedule'}`);
+ const acceptButtonText = options.acceptButtonText ?? (isNew ? 'Create recurring schedule' : this.intl.t('common.save-changes'));
+
+ return this.modalsManager.show('modals/recurring-order-schedule-form', {
+ resource: schedule,
+ sourceOrder: options.sourceOrder,
+ title,
+ modalClass: options.modalClass,
+ modalBodyClass: options.modalBodyClass ?? 'overflow-y-scroll',
+ acceptButtonText,
+ acceptButtonIcon: options.acceptButtonIcon ?? (isNew ? 'plus' : 'save'),
+ confirm: (modal) => this.confirmFormModal(modal, schedule, saveOptions),
+ ...options,
+ });
+ }
+
+ async confirmFormModal(modal, schedule, saveOptions = {}) {
+ modal.startLoading();
+
+ try {
+ const wasNew = schedule.isNew;
+ const persisted = await this.save(schedule);
+
+ if (wasNew) {
+ this.events.trackResourceCreated?.(persisted);
+ }
+
+ if (saveOptions.refresh !== false) {
+ await this.hostRouter.refresh();
+ }
+
+ if (typeof saveOptions.onSave === 'function') {
+ await saveOptions.onSave(persisted);
+ }
+
+ const message = wasNew
+ ? this.intl.t('common.resource-created-success', { resource: this.intl.t('resource.recurring-order-schedule') })
+ : this.intl.t('common.resource-updated-success', { resource: this.intl.t('resource.recurring-order-schedule') });
+
+ this.notifications.success(message);
+ modal.done();
+ return persisted;
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ throw error;
+ }
+ }
+
+ @action async pause(schedule) {
+ try {
+ await this.fetch.post(`recurring-order-schedules/${schedule.id ?? schedule.public_id}/pause`);
+ schedule.status = 'paused';
+ this.notifications.success('Recurring order schedule paused.');
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ @action async resume(schedule) {
+ try {
+ await this.fetch.post(`recurring-order-schedules/${schedule.id ?? schedule.public_id}/resume`);
+ schedule.status = 'active';
+ this.notifications.success('Recurring order schedule resumed.');
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ @action async cancelFuture(schedule, options = {}) {
+ try {
+ await this.fetch.post(`recurring-order-schedules/${schedule.id ?? schedule.public_id}/cancel-future`, {
+ cancel_generated_orders: Boolean(options.cancelGeneratedOrders),
+ });
+ schedule.status = 'canceled';
+ this.notifications.success('Recurring order schedule canceled.');
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ @action async skipOccurrence(schedule, occurrenceAt, reason = null) {
+ try {
+ await this.fetch.post(`recurring-order-schedules/${schedule.id ?? schedule.public_id}/skip-occurrence`, {
+ occurrence_at: occurrenceAt,
+ reason,
+ cancel_generated_order: true,
+ });
+ this.notifications.success('Upcoming recurring order canceled.');
+ await this.hostRouter.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ @action async skipNextOccurrence(schedule) {
+ const occurrenceAt = schedule?.next_occurrence_at ?? schedule?.upcoming_occurrences?.[0]?.occurrence_at;
+
+ if (!occurrenceAt) {
+ return this.notifications.warning('No upcoming occurrence is available to skip.');
+ }
+
+ return this.skipOccurrence(schedule, occurrenceAt);
+ }
+}
diff --git a/addon/styles/fleetops-engine.css b/addon/styles/fleetops-engine.css
index 622a3e4c4..0f565037c 100644
--- a/addon/styles/fleetops-engine.css
+++ b/addon/styles/fleetops-engine.css
@@ -160,6 +160,444 @@ body.fleetbase-console .next-content-overlay > .next-content-overlay-panel-conta
flex-shrink: 0;
}
+.next-map-container-topbar.next-topbar-series {
+ position: relative;
+ box-shadow: none;
+}
+
+/** orders new split button */
+.fleetops-new-order-split-button {
+ display: inline-flex;
+ align-items: stretch;
+ height: 1.75rem;
+ border-radius: 0.375rem;
+ box-shadow: 0 1px 2px 0 rgb(0 0 0 / 5%);
+ overflow: visible;
+}
+
+.fleetops-new-order-split-button .btn-wrapper {
+ height: 1.75rem;
+ box-shadow: none !important;
+}
+
+.fleetops-new-order-split-button__primary-wrapper {
+ display: inline-flex;
+ height: 1.75rem;
+ border-top-right-radius: 0 !important;
+ border-bottom-right-radius: 0 !important;
+}
+
+.fleetops-new-order-split-button__menu-wrapper {
+ display: inline-flex;
+ align-items: stretch;
+ height: 1.75rem;
+ margin-left: 0;
+}
+
+.fleetops-new-order-split-button__menu-trigger {
+ display: inline-flex;
+ align-items: stretch;
+ height: 1.75rem;
+}
+
+.fleetops-new-order-split-button__primary,
+.fleetops-new-order-split-button__menu {
+ height: 1.75rem;
+ min-height: 1.75rem;
+ line-height: 1;
+}
+
+.fleetops-new-order-split-button__primary {
+ border-top-right-radius: 0 !important;
+ border-bottom-right-radius: 0 !important;
+ padding-top: 0;
+ padding-bottom: 0;
+ padding-right: 0.875rem;
+}
+
+.fleetops-new-order-split-button__menu {
+ width: 2rem;
+ justify-content: center;
+ border-top-left-radius: 0 !important;
+ border-bottom-left-radius: 0 !important;
+ border-left: 0 !important;
+ box-shadow: inset 1px 0 0 rgb(255 255 255 / 30%);
+ padding: 0 0.625rem;
+}
+
+.fleetops-new-order-split-button__menu .btn-icon-wrapper svg {
+ margin-right: 0 !important;
+}
+
+/** order schedule and recurring series composer */
+.fleetops-order-schedule-form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.fleetops-repeat-toggle-row {
+ display: flex;
+ min-height: 3.25rem;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ border-radius: 0.375rem;
+ border: 1px solid rgb(229 231 235);
+ background-color: rgb(249 250 251);
+ padding: 0.625rem 0.75rem;
+}
+
+body[data-theme='dark'] .fleetops-repeat-toggle-row {
+ border-color: rgb(55 65 81);
+ background-color: rgb(31 41 55);
+}
+
+.fleetops-recurring-series-editor {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ border-radius: 0.375rem;
+ border: 1px solid rgb(229 231 235);
+ background-color: rgb(255 255 255);
+ box-shadow: 0 4px 10px rgb(0 0 0 / 8%);
+ padding: 0.75rem;
+}
+
+body[data-theme='dark'] .fleetops-recurring-series-editor {
+ border-color: rgb(55 65 81);
+ background-color: rgb(31 41 55);
+ box-shadow: 0 8px 20px rgb(0 0 0 / 24%);
+}
+
+.fleetops-recurring-series-field,
+.fleetops-recurring-series-preview {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 0.375rem;
+}
+
+.fleetops-recurring-series-field label,
+.fleetops-recurring-series-preview-header label {
+ display: block;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ line-height: 1rem;
+ color: rgb(107 114 128);
+ text-transform: uppercase;
+}
+
+.fleetops-recurring-series-field-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 0.75rem;
+}
+
+@media (width >= 640px) {
+ .fleetops-recurring-series-field-grid {
+ grid-template-columns: minmax(0, 1fr) minmax(7rem, 0.45fr);
+ }
+}
+
+.fleetops-recurring-series-days {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.fleetops-recurring-series-day-button {
+ border-radius: 0.375rem;
+ border: 1px solid rgb(209 213 219);
+ background-color: rgb(255 255 255);
+ padding: 0.375rem 0.75rem;
+ font-size: 0.75rem;
+ line-height: 1rem;
+ color: rgb(75 85 99);
+ transition:
+ background-color 0.15s ease,
+ border-color 0.15s ease,
+ color 0.15s ease;
+}
+
+.fleetops-recurring-series-day-button.is-selected {
+ border-color: rgb(37 99 235);
+ background-color: rgb(37 99 235);
+ color: rgb(255 255 255);
+}
+
+body[data-theme='dark'] .fleetops-recurring-series-day-button {
+ border-color: rgb(75 85 99);
+ background-color: rgb(17 24 39);
+ color: rgb(209 213 219);
+}
+
+body[data-theme='dark'] .fleetops-recurring-series-day-button.is-selected {
+ border-color: rgb(59 130 246);
+ background-color: rgb(37 99 235);
+ color: rgb(255 255 255);
+}
+
+.fleetops-recurring-series-preview {
+ width: 100%;
+}
+
+.fleetops-recurring-series-preview-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+.fleetops-recurring-series-preview-box {
+ min-height: 3.5rem;
+ border-radius: 0.375rem;
+ border: 1px solid rgb(229 231 235);
+ background-color: rgb(249 250 251);
+ padding: 0.625rem 0.75rem;
+}
+
+body[data-theme='dark'] .fleetops-recurring-series-preview-box {
+ border-color: rgb(55 65 81);
+ background-color: rgb(17 24 39);
+}
+
+.fleetops-recurring-series-preview-item {
+ font-size: 0.8125rem;
+ line-height: 1.25rem;
+ color: rgb(55 65 81);
+}
+
+body[data-theme='dark'] .fleetops-recurring-series-preview-item {
+ color: rgb(229 231 235);
+}
+
+.fleetops-recurring-order-indicator {
+ display: inline-flex;
+ position: relative;
+ width: 1rem;
+ height: 1rem;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: center;
+ border-radius: 9999px;
+ background-color: rgb(224 231 255);
+ color: rgb(79 70 229);
+ cursor: default;
+}
+
+body[data-theme='dark'] .fleetops-recurring-order-indicator {
+ background-color: rgb(49 46 129);
+ color: rgb(199 210 254);
+}
+
+.fleetops-recurring-order-indicator svg {
+ width: 0.625rem;
+ height: 0.625rem;
+}
+
+.fleetops-recurring-order-popover {
+ box-sizing: border-box;
+ width: 19rem;
+ max-width: calc(100vw - 2rem);
+ overflow: hidden;
+ border-radius: 0.5rem;
+ border: 1px solid rgb(55 65 81);
+ background-color: rgb(17 24 39);
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 35%);
+ padding: 0.625rem;
+ font-size: 0.75rem;
+ line-height: 1.15rem;
+ color: rgb(243 244 246);
+}
+
+.fleetops-recurring-order-popover * {
+ box-sizing: border-box;
+}
+
+.fleetops-recurring-order-eyebrow {
+ font-size: 0.625rem;
+ line-height: 0.875rem;
+ text-transform: uppercase;
+ color: rgb(156 163 175);
+}
+
+body[data-theme='dark'] .fleetops-recurring-order-eyebrow {
+ color: rgb(107 114 128);
+}
+
+.fleetops-recurring-order-title {
+ overflow: hidden;
+ font-size: 0.8125rem;
+ line-height: 1rem;
+ font-weight: 600;
+ color: rgb(31 41 55);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+body[data-theme='dark'] .fleetops-recurring-order-title {
+ color: rgb(243 244 246);
+}
+
+.fleetops-recurring-order-schedule-card {
+ border-radius: 0.375rem;
+ border: 1px solid rgb(229 231 235);
+ background-color: rgb(249 250 251);
+ padding: 0.5rem;
+}
+
+body[data-theme='dark'] .fleetops-recurring-order-schedule-card {
+ border-color: rgb(55 65 81);
+ background-color: rgb(31 41 55);
+}
+
+.fleetops-recurring-order-summary {
+ overflow: hidden;
+ font-size: 0.8125rem;
+ line-height: 1rem;
+ font-weight: 500;
+ color: rgb(31 41 55);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+body[data-theme='dark'] .fleetops-recurring-order-summary {
+ color: rgb(243 244 246);
+}
+
+.fleetops-recurring-order-days {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ margin-top: 0.375rem;
+}
+
+.fleetops-recurring-order-day {
+ display: inline-flex;
+ min-width: 1.625rem;
+ height: 1.25rem;
+ align-items: center;
+ justify-content: center;
+ border-radius: 9999px;
+ border: 1px solid rgb(229 231 235);
+ background-color: rgb(255 255 255);
+ padding: 0 0.375rem;
+ font-size: 0.625rem;
+ font-weight: 500;
+ color: rgb(156 163 175);
+}
+
+body[data-theme='dark'] .fleetops-recurring-order-day {
+ border-color: rgb(55 65 81);
+ background-color: rgb(17 24 39);
+ color: rgb(107 114 128);
+}
+
+.fleetops-recurring-order-day.is-scheduled {
+ border-color: rgb(191 219 254);
+ background-color: rgb(239 246 255);
+ color: rgb(29 78 216);
+}
+
+body[data-theme='dark'] .fleetops-recurring-order-day.is-scheduled {
+ border-color: rgb(30 64 175);
+ background-color: rgb(23 37 84);
+ color: rgb(191 219 254);
+}
+
+.fleetops-recurring-order-day.is-highlighted {
+ border-color: rgb(99 102 241);
+ background-color: rgb(79 70 229);
+ color: rgb(255 255 255);
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 15%);
+}
+
+body[data-theme='dark'] .fleetops-recurring-order-day.is-highlighted {
+ border-color: rgb(129 140 248);
+ background-color: rgb(99 102 241);
+}
+
+.fleetops-recurring-order-stat {
+ border-radius: 0.375rem;
+ border: 1px solid rgb(229 231 235);
+ background-color: rgb(255 255 255);
+ padding: 0.375rem 0.5rem;
+}
+
+body[data-theme='dark'] .fleetops-recurring-order-stat {
+ border-color: rgb(55 65 81);
+ background-color: rgb(17 24 39);
+}
+
+.fleetops-recurring-order-stat span {
+ display: block;
+ font-size: 0.5625rem;
+ line-height: 0.75rem;
+ letter-spacing: 0;
+ text-transform: uppercase;
+ color: rgb(156 163 175);
+}
+
+body[data-theme='dark'] .fleetops-recurring-order-stat span {
+ color: rgb(107 114 128);
+}
+
+.fleetops-recurring-order-stat strong {
+ display: block;
+ margin-top: 0.125rem;
+ overflow: hidden;
+ font-size: 0.75rem;
+ font-weight: 600;
+ line-height: 1rem;
+ color: rgb(31 41 55);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+body[data-theme='dark'] .fleetops-recurring-order-stat strong {
+ color: rgb(243 244 246);
+}
+
+.fleetops-recurring-order-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ margin-top: 0.5rem;
+}
+
+.fleetops-recurring-order-action {
+ display: inline-flex;
+ height: 1.5rem;
+ min-width: 0;
+ flex: 1 1 0;
+ align-items: center;
+ justify-content: center;
+ gap: 0.25rem;
+ border-radius: 0.3125rem;
+ background-color: rgb(75 85 99);
+ padding: 0 0.375rem;
+ font-size: 0.6875rem;
+ line-height: 1;
+ color: rgb(243 244 246);
+}
+
+.fleetops-recurring-order-action:hover {
+ background-color: rgb(55 65 81);
+}
+
+.fleetops-recurring-order-action svg {
+ width: 0.6875rem;
+ height: 0.6875rem;
+ flex-shrink: 0;
+}
+
+.fleetops-recurring-order-action span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
/** calendar event css mods */
#fleet-ops-scheduler-calendar .fc-h-event .fc-event-main .fc-event-title {
diff --git a/addon/templates/operations/orders/index.hbs b/addon/templates/operations/orders/index.hbs
index e8952f014..f47305328 100644
--- a/addon/templates/operations/orders/index.hbs
+++ b/addon/templates/operations/orders/index.hbs
@@ -1,8 +1,8 @@
-
-
@@ -88,4 +97,4 @@
{{/if}}
-{{outlet}}
\ No newline at end of file
+{{outlet}}
diff --git a/addon/templates/operations/orders/index/new.hbs b/addon/templates/operations/orders/index/new.hbs
index 8cdbd8295..46f07cede 100644
--- a/addon/templates/operations/orders/index/new.hbs
+++ b/addon/templates/operations/orders/index/new.hbs
@@ -2,15 +2,22 @@
@resource={{this.order}}
@controller={{this}}
@actionButtons={{this.actionButtons}}
- @headerTitle={{t "common.create-a-new-resource" resource=(lowercase (t "resource.order"))}}
+ @headerTitle={{this.headerTitle}}
@saveTask={{this.save}}
- @saveDisabled={{this.orderValidation.isNotValid}}
+ @saveDisabled={{this.saveDisabled}}
@onPressCancel={{transition-to "operations.orders.index"}}
@onOverlayReady={{fn (mut this.overlay)}}
@width="530px"
@onOpen={{this.setup}}
class="new-order-panel"
>
-
+
-
\ No newline at end of file
+
diff --git a/addon/templates/operations/orders/index/series.hbs b/addon/templates/operations/orders/index/series.hbs
new file mode 100644
index 000000000..986c114c6
--- /dev/null
+++ b/addon/templates/operations/orders/index/series.hbs
@@ -0,0 +1,19 @@
+{{#if (eq this.layout "series")}}
+
table > thead,.next-map-container-topbar"
+ />
+{{/if}}
+
+{{outlet}}
\ No newline at end of file
diff --git a/addon/templates/operations/orders/index/series/details.hbs b/addon/templates/operations/orders/index/series/details.hbs
new file mode 100644
index 000000000..953dac2d9
--- /dev/null
+++ b/addon/templates/operations/orders/index/series/details.hbs
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/addon/utils/recurring-order-blueprint.js b/addon/utils/recurring-order-blueprint.js
new file mode 100644
index 000000000..71919fec3
--- /dev/null
+++ b/addon/utils/recurring-order-blueprint.js
@@ -0,0 +1,281 @@
+import serializeModel from '@fleetbase/ember-core/utils/serialize-model';
+import serializeArray from '@fleetbase/ember-core/utils/serialize-model-array';
+
+const CHILD_RECORD_IDENTITY_KEYS = ['id', 'uuid', 'public_id', 'created_at', 'updated_at', 'deleted_at'];
+
+const ENTITY_LINKAGE_KEYS = [
+ ...CHILD_RECORD_IDENTITY_KEYS,
+ 'payload_uuid',
+ 'company_uuid',
+ 'customer_uuid',
+ 'supplier_uuid',
+ 'tracking_number_uuid',
+ 'driver_assigned_uuid',
+ 'photo_uuid',
+ 'tracking',
+ 'trackingNumber',
+ 'barcode',
+ 'qr_code',
+ 'slug',
+];
+
+const WAYPOINT_LINKAGE_KEYS = [...CHILD_RECORD_IDENTITY_KEYS, 'waypoint_uuid', 'waypoint_public_id', 'tracking_number_uuid', 'tracking', 'status', 'status_code', 'complete'];
+
+function toPlainObject(record) {
+ if (!record) {
+ return {};
+ }
+
+ if (typeof record.serialize === 'function') {
+ return record.serialize();
+ }
+
+ return { ...record };
+}
+
+function copyWithout(record, keys = []) {
+ const copy = toPlainObject(record);
+
+ keys.forEach((key) => {
+ delete copy[key];
+ });
+
+ return copy;
+}
+
+function relationshipValue(record, relationshipName) {
+ if (!record) {
+ return null;
+ }
+
+ if (typeof record.belongsTo === 'function') {
+ try {
+ return record.belongsTo(relationshipName).value();
+ } catch {
+ return null;
+ }
+ }
+
+ return record[relationshipName] ?? null;
+}
+
+function normalizeCustomerType(order) {
+ if (order.customer_type) {
+ return order.customer_type;
+ }
+
+ const customerType = relationshipValue(order, 'customer')?.customer_type;
+ return customerType ? `fleet-ops:${customerType}` : null;
+}
+
+function normalizeFacilitatorType(order) {
+ if (order.facilitator_type) {
+ return order.facilitator_type;
+ }
+
+ const facilitatorType = relationshipValue(order, 'facilitator')?.facilitator_type;
+ return facilitatorType ? `fleet-ops:${facilitatorType}` : null;
+}
+
+function createDraftPlace(store, place) {
+ if (!place) {
+ return null;
+ }
+
+ if (typeof place.get === 'function') {
+ return place;
+ }
+
+ const placeId = place.id ?? place.public_id;
+ const loadedPlace = placeId ? store.peekRecord('place', placeId) : null;
+
+ if (loadedPlace) {
+ return loadedPlace;
+ }
+
+ return store.createRecord('place', {
+ ...place,
+ id: placeId ?? undefined,
+ });
+}
+
+function createDraftWaypoint(store, waypoint) {
+ const waypointAttrs = copyWithout(waypoint, WAYPOINT_LINKAGE_KEYS);
+
+ return store.createRecord('waypoint', {
+ ...waypointAttrs,
+ place: createDraftPlace(store, waypoint.place),
+ });
+}
+
+function createDraftEntity(store, entity) {
+ return store.createRecord('entity', copyWithout(entity, ENTITY_LINKAGE_KEYS));
+}
+
+export function createRecurringDraftOrder(store, source = {}) {
+ const templatePayload = source.template_payload ?? source.payload ?? {};
+ const templateOrderMeta = source.template_order_meta ?? {};
+ const templateEntities = source.template_entities ?? templatePayload.entities ?? [];
+ const customer = relationshipValue(source, 'customer');
+ const facilitator = relationshipValue(source, 'facilitator');
+ const orderConfig = relationshipValue(source, 'order_config') ?? relationshipValue(source, 'orderConfig');
+ const driverAssigned = relationshipValue(source, 'driver_assigned') ?? relationshipValue(source, 'driverAssigned');
+ const vehicleAssigned = relationshipValue(source, 'vehicle_assigned') ?? relationshipValue(source, 'vehicleAssigned');
+
+ const order = store.createRecord('order', {
+ customer,
+ customer_uuid: source.customer_uuid ?? customer?.id ?? null,
+ customer_type: source.customer_type ?? normalizeCustomerType(source),
+ facilitator,
+ facilitator_uuid: source.facilitator_uuid ?? facilitator?.id ?? null,
+ facilitator_type: source.facilitator_type ?? normalizeFacilitatorType(source),
+ order_config: orderConfig,
+ order_config_uuid: source.order_config_uuid ?? orderConfig?.id ?? null,
+ driver_assigned: driverAssigned,
+ driver_assigned_uuid: source.driver_assigned_uuid ?? driverAssigned?.id ?? null,
+ vehicle_assigned: vehicleAssigned,
+ vehicle_assigned_uuid: source.vehicle_assigned_uuid ?? vehicleAssigned?.id ?? null,
+ internal_id: source.internal_id ?? templateOrderMeta.internal_id ?? null,
+ scheduled_at: source.scheduled_at ?? source.starts_at ?? new Date(),
+ pod_method: source.pod_method ?? templateOrderMeta.pod_method ?? null,
+ pod_required: source.pod_required ?? templateOrderMeta.pod_required ?? false,
+ adhoc: source.adhoc ?? templateOrderMeta.adhoc ?? false,
+ adhoc_distance: source.adhoc_distance ?? templateOrderMeta.adhoc_distance ?? null,
+ notes: source.notes ?? templateOrderMeta.notes ?? null,
+ type: source.type ?? templateOrderMeta.type ?? templatePayload.type ?? null,
+ meta: source.meta ?? templateOrderMeta.meta ?? {},
+ required_skills: source.required_skills ?? templateOrderMeta.required_skills ?? [],
+ orchestrator_priority: source.orchestrator_priority ?? templateOrderMeta.orchestrator_priority ?? 50,
+ time_window_start: source.time_window_start ?? templateOrderMeta.time_window_start ?? null,
+ time_window_end: source.time_window_end ?? templateOrderMeta.time_window_end ?? null,
+ payload: store.createRecord('payload', {
+ pickup: createDraftPlace(store, templatePayload.pickup),
+ dropoff: createDraftPlace(store, templatePayload.dropoff),
+ return: createDraftPlace(store, templatePayload.return),
+ type: templatePayload.type ?? source.type ?? templateOrderMeta.type ?? null,
+ payment_method: templatePayload.payment_method ?? null,
+ cod_amount: templatePayload.cod_amount ?? null,
+ cod_currency: templatePayload.cod_currency ?? null,
+ cod_payment_method: templatePayload.cod_payment_method ?? null,
+ meta: templatePayload.meta ?? {},
+ }),
+ });
+
+ (templatePayload.waypoints ?? []).forEach((waypoint) => {
+ order.payload.waypoints.pushObject(createDraftWaypoint(store, waypoint));
+ });
+
+ (templateEntities ?? []).forEach((entity) => {
+ order.payload.entities.pushObject(createDraftEntity(store, entity));
+ });
+
+ return order;
+}
+
+export function serializeRecurringDraftOrder(order, serviceRateUuid = null) {
+ const payload = order.payload;
+
+ return {
+ internal_id: order.internal_id ?? null,
+ customer_uuid: order.customer?.id ?? order.customer_uuid ?? null,
+ customer_type: normalizeCustomerType(order),
+ facilitator_uuid: order.facilitator?.id ?? order.facilitator_uuid ?? null,
+ facilitator_type: normalizeFacilitatorType(order),
+ order_config_uuid: order.order_config?.id ?? order.order_config_uuid ?? null,
+ driver_assigned_uuid: order.driver_assigned?.id ?? order.driver_assigned_uuid ?? null,
+ vehicle_assigned_uuid: order.vehicle_assigned?.id ?? order.vehicle_assigned_uuid ?? null,
+ service_rate_uuid: serviceRateUuid ?? null,
+ type: order.type ?? payload.type ?? null,
+ pod_method: order.pod_method ?? null,
+ pod_required: Boolean(order.pod_required),
+ adhoc: Boolean(order.adhoc),
+ adhoc_distance: order.adhoc_distance ?? null,
+ notes: order.notes ?? null,
+ meta: order.meta ?? {},
+ required_skills: order.required_skills ?? [],
+ orchestrator_priority: order.orchestrator_priority ?? 50,
+ time_window_start: order.time_window_start ?? null,
+ time_window_end: order.time_window_end ?? null,
+ payload: {
+ pickup: serializeTemplatePlace(payload.pickup),
+ dropoff: serializeTemplatePlace(payload.dropoff),
+ return: serializeTemplatePlace(payload.return),
+ waypoints: serializeArray(payload.waypoints).map(serializeTemplateWaypoint),
+ entities: serializeArray(payload.entities).map(serializeTemplateEntity),
+ type: payload.type ?? order.type ?? null,
+ payment_method: payload.payment_method ?? null,
+ cod_amount: payload.cod_amount ?? null,
+ cod_currency: payload.cod_currency ?? null,
+ cod_payment_method: payload.cod_payment_method ?? null,
+ meta: payload.meta ?? {},
+ },
+ };
+}
+
+function serializeTemplatePlace(place) {
+ const serialized = serializeModel(place);
+
+ if (!serialized) {
+ return null;
+ }
+
+ return {
+ uuid: serialized.uuid ?? serialized.id ?? null,
+ public_id: serialized.public_id ?? null,
+ name: serialized.name ?? null,
+ phone: serialized.phone ?? null,
+ type: serialized.type ?? 'place',
+ address: serialized.address ?? null,
+ street1: serialized.street1 ?? null,
+ street2: serialized.street2 ?? null,
+ city: serialized.city ?? null,
+ province: serialized.province ?? null,
+ postal_code: serialized.postal_code ?? null,
+ neighborhood: serialized.neighborhood ?? null,
+ district: serialized.district ?? null,
+ building: serialized.building ?? null,
+ security_access_code: serialized.security_access_code ?? null,
+ country: serialized.country ?? null,
+ location: serialized.location ?? null,
+ meta: serialized.meta ?? {},
+ };
+}
+
+function serializeTemplateWaypoint(waypoint) {
+ return {
+ place: serializeTemplatePlace(waypoint.place ?? waypoint),
+ type: waypoint.type ?? 'dropoff',
+ order: waypoint.order ?? null,
+ customer_uuid: waypoint.customer_uuid ?? null,
+ customer_type: waypoint.customer_type ?? null,
+ time_window_start: waypoint.time_window_start ?? null,
+ time_window_end: waypoint.time_window_end ?? null,
+ service_time: waypoint.service_time ?? null,
+ notes: waypoint.notes ?? null,
+ pod_method: waypoint.pod_method ?? null,
+ pod_required: Boolean(waypoint.pod_required),
+ };
+}
+
+function serializeTemplateEntity(entity) {
+ return {
+ internal_id: entity.internal_id ?? null,
+ destination_uuid: entity.destination_uuid ?? entity.destination?.id ?? null,
+ name: entity.name ?? null,
+ type: entity.type ?? 'entity',
+ description: entity.description ?? null,
+ photo_url: entity.photo_url ?? null,
+ currency: entity.currency ?? null,
+ weight: entity.weight ?? null,
+ weight_unit: entity.weight_unit ?? null,
+ length: entity.length ?? null,
+ width: entity.width ?? null,
+ height: entity.height ?? null,
+ dimensions_unit: entity.dimensions_unit ?? null,
+ declared_value: entity.declared_value ?? null,
+ sku: entity.sku ?? null,
+ price: entity.price ?? null,
+ sale_price: entity.sale_price ?? null,
+ meta: entity.meta ?? {},
+ };
+}
diff --git a/addon/utils/recurring-rrule.js b/addon/utils/recurring-rrule.js
new file mode 100644
index 000000000..daa8f3c60
--- /dev/null
+++ b/addon/utils/recurring-rrule.js
@@ -0,0 +1,57 @@
+export const WEEKDAY_OPTIONS = [
+ { code: 'MO', label: 'Mon' },
+ { code: 'TU', label: 'Tue' },
+ { code: 'WE', label: 'Wed' },
+ { code: 'TH', label: 'Thu' },
+ { code: 'FR', label: 'Fri' },
+ { code: 'SA', label: 'Sat' },
+ { code: 'SU', label: 'Sun' },
+];
+
+export function parseRrule(rrule = '') {
+ const normalized = String(rrule).replace(/^RRULE:/i, '');
+ const parts = Object.fromEntries(
+ normalized
+ .split(';')
+ .map((segment) => segment.trim())
+ .filter(Boolean)
+ .map((segment) => {
+ const [key, value] = segment.split('=');
+ return [key?.toUpperCase(), value];
+ })
+ );
+
+ return {
+ frequency: String(parts.FREQ ?? 'WEEKLY').toLowerCase(),
+ interval: Number(parts.INTERVAL ?? 1),
+ weekdays: String(parts.BYDAY ?? '')
+ .split(',')
+ .map((value) => value.trim())
+ .filter(Boolean),
+ monthday: parts.BYMONTHDAY ? Number(parts.BYMONTHDAY) : null,
+ until: parts.UNTIL ?? null,
+ };
+}
+
+export function buildRrule({ frequency = 'weekly', interval = 1, weekdays = [], monthday = null, until = null } = {}) {
+ const normalizedFrequency = String(frequency || 'weekly').toUpperCase();
+ const normalizedInterval = Math.max(1, Number(interval) || 1);
+ const parts = [`FREQ=${normalizedFrequency}`, `INTERVAL=${normalizedInterval}`];
+
+ if (normalizedFrequency === 'WEEKLY' && weekdays.length > 0) {
+ parts.push(`BYDAY=${weekdays.join(',')}`);
+ }
+
+ if (normalizedFrequency === 'MONTHLY' && monthday) {
+ parts.push(`BYMONTHDAY=${monthday}`);
+ }
+
+ if (until) {
+ const untilDate = until instanceof Date ? until : new Date(until);
+ if (!Number.isNaN(untilDate.getTime())) {
+ parts.push(`UNTIL=${untilDate.toISOString().replace(/[-:]/g, '').split('.')[0]}Z`);
+ }
+ }
+
+ return parts.join(';');
+}
diff --git a/app/components/cell/order-id-with-series.js b/app/components/cell/order-id-with-series.js
new file mode 100644
index 000000000..66fa6193d
--- /dev/null
+++ b/app/components/cell/order-id-with-series.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/cell/order-id-with-series';
diff --git a/app/components/cell/recurring-series-badge.js b/app/components/cell/recurring-series-badge.js
new file mode 100644
index 000000000..b5816cbb5
--- /dev/null
+++ b/app/components/cell/recurring-series-badge.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/cell/recurring-series-badge';
diff --git a/app/components/cell/recurring-series-name.js b/app/components/cell/recurring-series-name.js
new file mode 100644
index 000000000..9ff8845fc
--- /dev/null
+++ b/app/components/cell/recurring-series-name.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/cell/recurring-series-name';
diff --git a/app/components/cell/recurring-series-next-occurrence.js b/app/components/cell/recurring-series-next-occurrence.js
new file mode 100644
index 000000000..6721bdf84
--- /dev/null
+++ b/app/components/cell/recurring-series-next-occurrence.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/cell/recurring-series-next-occurrence';
diff --git a/app/components/cell/recurring-series-pattern.js b/app/components/cell/recurring-series-pattern.js
new file mode 100644
index 000000000..fe4552951
--- /dev/null
+++ b/app/components/cell/recurring-series-pattern.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/cell/recurring-series-pattern';
diff --git a/app/components/modals/recurring-order-schedule-form.js b/app/components/modals/recurring-order-schedule-form.js
new file mode 100644
index 000000000..19da7313e
--- /dev/null
+++ b/app/components/modals/recurring-order-schedule-form.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/modals/recurring-order-schedule-form';
diff --git a/app/components/modals/recurring-order-schedules-manager.js b/app/components/modals/recurring-order-schedules-manager.js
new file mode 100644
index 000000000..7d2955579
--- /dev/null
+++ b/app/components/modals/recurring-order-schedules-manager.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/modals/recurring-order-schedules-manager';
diff --git a/app/components/order/form/orchestrator-constraints.js b/app/components/order/form/orchestrator-constraints.js
new file mode 100644
index 000000000..e44152cf9
--- /dev/null
+++ b/app/components/order/form/orchestrator-constraints.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/order/form/orchestrator-constraints';
diff --git a/app/components/order/form/schedule.js b/app/components/order/form/schedule.js
new file mode 100644
index 000000000..3c5448fdc
--- /dev/null
+++ b/app/components/order/form/schedule.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/order/form/schedule';
diff --git a/app/components/order/new-split-button.js b/app/components/order/new-split-button.js
new file mode 100644
index 000000000..24c5ad82e
--- /dev/null
+++ b/app/components/order/new-split-button.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/order/new-split-button';
diff --git a/app/components/recurring-order-schedule/details.js b/app/components/recurring-order-schedule/details.js
new file mode 100644
index 000000000..b8af9de8a
--- /dev/null
+++ b/app/components/recurring-order-schedule/details.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/recurring-order-schedule/details';
diff --git a/app/components/recurring-order-schedule/form.js b/app/components/recurring-order-schedule/form.js
new file mode 100644
index 000000000..a3dd32ce7
--- /dev/null
+++ b/app/components/recurring-order-schedule/form.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/recurring-order-schedule/form';
diff --git a/app/components/recurring-order-schedule/manager.js b/app/components/recurring-order-schedule/manager.js
new file mode 100644
index 000000000..5c5e742c7
--- /dev/null
+++ b/app/components/recurring-order-schedule/manager.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/recurring-order-schedule/manager';
diff --git a/app/controllers/operations/orders/index/series.js b/app/controllers/operations/orders/index/series.js
new file mode 100644
index 000000000..7fe01a9fb
--- /dev/null
+++ b/app/controllers/operations/orders/index/series.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/controllers/operations/orders/index/series';
diff --git a/app/controllers/operations/orders/index/series/details.js b/app/controllers/operations/orders/index/series/details.js
new file mode 100644
index 000000000..4dba89fe7
--- /dev/null
+++ b/app/controllers/operations/orders/index/series/details.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/controllers/operations/orders/index/series/details';
diff --git a/app/routes/operations/orders/index/series.js b/app/routes/operations/orders/index/series.js
new file mode 100644
index 000000000..9061d99d7
--- /dev/null
+++ b/app/routes/operations/orders/index/series.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/operations/orders/index/series';
diff --git a/app/routes/operations/orders/index/series/details.js b/app/routes/operations/orders/index/series/details.js
new file mode 100644
index 000000000..73885f760
--- /dev/null
+++ b/app/routes/operations/orders/index/series/details.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/operations/orders/index/series/details';
diff --git a/app/services/recurring-order-schedule-actions.js b/app/services/recurring-order-schedule-actions.js
new file mode 100644
index 000000000..a2235c882
--- /dev/null
+++ b/app/services/recurring-order-schedule-actions.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/services/recurring-order-schedule-actions';
diff --git a/app/templates/operations/orders/index/series.js b/app/templates/operations/orders/index/series.js
new file mode 100644
index 000000000..bfecfc691
--- /dev/null
+++ b/app/templates/operations/orders/index/series.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/operations/orders/index/series';
diff --git a/app/templates/operations/orders/index/series/details.js b/app/templates/operations/orders/index/series/details.js
new file mode 100644
index 000000000..49407e2cf
--- /dev/null
+++ b/app/templates/operations/orders/index/series/details.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/operations/orders/index/series/details';
diff --git a/app/utils/recurring-order-blueprint.js b/app/utils/recurring-order-blueprint.js
new file mode 100644
index 000000000..448c2863d
--- /dev/null
+++ b/app/utils/recurring-order-blueprint.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/utils/recurring-order-blueprint';
diff --git a/app/utils/recurring-rrule.js b/app/utils/recurring-rrule.js
new file mode 100644
index 000000000..e1958d1ee
--- /dev/null
+++ b/app/utils/recurring-rrule.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/utils/recurring-rrule';
diff --git a/package.json b/package.json
index 7dddd6846..9af7caa0f 100644
--- a/package.json
+++ b/package.json
@@ -44,7 +44,7 @@
"@babel/core": "^7.23.2",
"@fleetbase/ember-core": "^0.3.19",
"@fleetbase/ember-ui": "^0.3.29",
- "@fleetbase/fleetops-data": "^0.1.32",
+ "@fleetbase/fleetops-data": "link:../fleetops-data",
"@fleetbase/leaflet-routing-machine": "^3.2.17",
"@fortawesome/ember-fontawesome": "^2.0.0",
"@fortawesome/fontawesome-svg-core": "6.4.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fe1a3a898..a7d6bfdda 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -18,8 +18,8 @@ importers:
specifier: ^0.3.29
version: 0.3.29(@ember/test-helpers@3.3.1(@babel/core@7.27.1)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(webpack@5.99.8))(@glimmer/component@1.1.2(@babel/core@7.27.1))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8)))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(postcss@8.5.14)(rollup@2.79.2)(tracked-built-ins@3.4.0(@babel/core@7.27.1))(webpack@5.99.8)
'@fleetbase/fleetops-data':
- specifier: ^0.1.32
- version: 0.1.32(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.27.1)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(webpack@5.99.8))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8)))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(eslint@8.57.1)(webpack@5.99.8)
+ specifier: link:../fleetops-data
+ version: link:../fleetops-data
'@fleetbase/leaflet-routing-machine':
specifier: ^3.2.17
version: 3.2.17
@@ -1675,10 +1675,6 @@ packages:
peerDependencies:
ember-source: '>= 4.0.0'
- '@fleetbase/ember-core@0.3.18':
- resolution: {integrity: sha512-XA/Ysn3NlM37qK/xJCY+Uo2sZ8JTwcDaGruPi8dSVyGfYHO55m96TepCChEU18GdwosbsBBfL8C2R+fUvPsqIg==}
- engines: {node: '>= 18'}
-
'@fleetbase/ember-core@0.3.19':
resolution: {integrity: sha512-5phquVcfcpRtoxBvyAYDS9bmdPx5c46mXLrcvjmTFSdGElw9CZv44dRkuE8UTgpapMFfPc7vkA9/KwKFAgQ9JA==}
engines: {node: '>= 18'}
@@ -1687,10 +1683,6 @@ packages:
resolution: {integrity: sha512-c59jeJm876GOHd0OpOX6jZCwrgBqOxssqzXvo3Uqqc0qmLpfxrPJmnZDe22Sb80QbVysPMhA6s3LL8ZwEzdXPw==}
engines: {node: '>= 18'}
- '@fleetbase/fleetops-data@0.1.32':
- resolution: {integrity: sha512-8Y4KQstMyi+BJk3Gt3hXT7DNfYl7XxQ2bsOvr18BOqfan1TELfn3puy5O4ryaQVd2E3tJ4hCYcGm3zlBfBpCzw==}
- engines: {node: '>= 18'}
-
'@fleetbase/intl-lint@0.0.1':
resolution: {integrity: sha512-LkjxJr15hSiGmqh3JwixcpjmkhXNieNAEgQUVv1Duo50jTr/D5WXEyOaeI8wuVnVFhT+FS/DMqm403DgSKlsEg==}
hasBin: true
@@ -12087,39 +12079,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@fleetbase/ember-core@0.3.18(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.27.1)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(webpack@5.99.8))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8)))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(eslint@8.57.1)(webpack@5.99.8)':
- dependencies:
- '@babel/core': 7.29.0
- compress-json: 3.4.0
- date-fns: 2.30.0
- ember-auto-import: 2.10.0(webpack@5.99.8)
- ember-can: 6.0.0(@babel/core@7.29.0)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8)))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))
- ember-cli-babel: 8.3.1(@babel/core@7.29.0)
- ember-cli-htmlbars: 6.3.0
- ember-cli-notifications: 9.1.0(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))
- ember-concurrency: 4.0.4(@babel/core@7.29.0)
- ember-decorators: 6.1.1
- ember-get-config: 2.1.1(@babel/core@7.29.0)
- ember-inflector: 4.0.3(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))
- ember-intl: 6.3.2(@babel/core@7.29.0)(webpack@5.99.8)
- ember-loading: 2.0.0(@babel/core@7.29.0)
- ember-local-storage: 2.0.7(@babel/core@7.29.0)
- ember-simple-auth: 6.1.0(@babel/core@7.29.0)(@ember/test-helpers@3.3.1(@babel/core@7.27.1)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(webpack@5.99.8))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(eslint@8.57.1)
- ember-wormhole: 0.6.0
- socketcluster-client: 17.2.2
- transitivePeerDependencies:
- - '@ember/string'
- - '@ember/test-helpers'
- - '@glint/template'
- - bufferutil
- - ember-resolver
- - ember-source
- - eslint
- - supports-color
- - typescript
- - utf-8-validate
- - webpack
-
'@fleetbase/ember-core@0.3.19(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.27.1)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(webpack@5.99.8))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8)))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(eslint@8.57.1)(webpack@5.99.8)':
dependencies:
'@babel/core': 7.29.0
@@ -12259,26 +12218,6 @@ snapshots:
- webpack-command
- yaml
- '@fleetbase/fleetops-data@0.1.32(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.27.1)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(webpack@5.99.8))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8)))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(eslint@8.57.1)(webpack@5.99.8)':
- dependencies:
- '@babel/core': 7.29.0
- '@fleetbase/ember-core': 0.3.18(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.27.1)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(webpack@5.99.8))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8)))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.8))(eslint@8.57.1)(webpack@5.99.8)
- date-fns: 2.30.0
- ember-cli-babel: 8.3.1(@babel/core@7.29.0)
- ember-cli-htmlbars: 6.3.0
- transitivePeerDependencies:
- - '@ember/string'
- - '@ember/test-helpers'
- - '@glint/template'
- - bufferutil
- - ember-resolver
- - ember-source
- - eslint
- - supports-color
- - typescript
- - utf-8-validate
- - webpack
-
'@fleetbase/intl-lint@0.0.1':
dependencies:
js-yaml: 4.1.0
diff --git a/server/config/fleetops.php b/server/config/fleetops.php
index ce87d7398..e0f049c94 100644
--- a/server/config/fleetops.php
+++ b/server/config/fleetops.php
@@ -79,6 +79,15 @@
'app_identifier' => env('NAVIGATOR_APP_IDENTIFIER', 'io.fleetbase.navigator')
],
+ /*
+ |--------------------------------------------------------------------------
+ | Recurring Orders
+ |--------------------------------------------------------------------------
+ */
+ 'recurring_orders' => [
+ 'horizon_days' => env('FLEETOPS_RECURRING_ORDER_HORIZON_DAYS', 60),
+ ],
+
/*
|--------------------------------------------------------------------------
| API Events
diff --git a/server/migrations/2026_05_01_000001_create_recurring_order_schedules_table.php b/server/migrations/2026_05_01_000001_create_recurring_order_schedules_table.php
new file mode 100644
index 000000000..e9f64c99e
--- /dev/null
+++ b/server/migrations/2026_05_01_000001_create_recurring_order_schedules_table.php
@@ -0,0 +1,57 @@
+increments('id');
+ $table->uuid('uuid')->index();
+ $table->string('_key')->nullable()->index();
+ $table->string('public_id', 191)->nullable()->unique()->index();
+ $table->foreignUuid('company_uuid')->constrained('companies', 'uuid')->cascadeOnDelete();
+
+ $table->string('name');
+ $table->text('description')->nullable();
+ $table->string('status')->default('active')->index();
+ $table->string('timezone', 100)->default('UTC');
+ $table->dateTime('starts_at')->nullable()->index();
+ $table->dateTime('ends_at')->nullable()->index();
+ $table->text('rrule');
+ $table->dateTime('last_materialized_at')->nullable();
+ $table->dateTime('materialization_horizon')->nullable()->index();
+
+ $table->uuid('customer_uuid')->nullable()->index();
+ $table->string('customer_type')->nullable();
+ $table->uuid('facilitator_uuid')->nullable()->index();
+ $table->string('facilitator_type')->nullable();
+ $table->uuid('order_config_uuid')->nullable()->index();
+ $table->uuid('driver_assigned_uuid')->nullable()->index();
+ $table->uuid('vehicle_assigned_uuid')->nullable()->index();
+ $table->uuid('service_rate_uuid')->nullable()->index();
+
+ $table->json('template_order_meta')->nullable();
+ $table->json('template_payload')->nullable();
+ $table->json('template_entities')->nullable();
+ $table->json('meta')->nullable();
+
+ $table->foreignUuid('created_by_uuid')->nullable()->constrained('users', 'uuid')->nullOnDelete();
+ $table->foreignUuid('updated_by_uuid')->nullable()->constrained('users', 'uuid')->nullOnDelete();
+ $table->softDeletes();
+ $table->timestamps();
+
+ $table->index(['company_uuid', 'status'], 'ros_company_status_idx');
+ $table->index(['company_uuid', 'starts_at'], 'ros_company_starts_idx');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::disableForeignKeyConstraints();
+ Schema::dropIfExists('recurring_order_schedules');
+ Schema::enableForeignKeyConstraints();
+ }
+};
diff --git a/server/migrations/2026_05_01_000002_create_recurring_order_schedule_occurrences_table.php b/server/migrations/2026_05_01_000002_create_recurring_order_schedule_occurrences_table.php
new file mode 100644
index 000000000..8584ec2fa
--- /dev/null
+++ b/server/migrations/2026_05_01_000002_create_recurring_order_schedule_occurrences_table.php
@@ -0,0 +1,39 @@
+increments('id');
+ $table->uuid('uuid')->index();
+ $table->string('_key')->nullable()->index();
+ $table->string('public_id', 191)->nullable()->unique()->index();
+ $table->foreignUuid('company_uuid')->constrained('companies', 'uuid')->cascadeOnDelete();
+ $table->uuid('recurring_order_schedule_uuid');
+ $table->uuid('order_uuid')->nullable()->index();
+ $table->dateTime('occurrence_at')->index();
+ $table->string('status')->default('generated')->index();
+ $table->string('reason')->nullable();
+ $table->json('meta')->nullable();
+ $table->foreignUuid('created_by_uuid')->nullable()->constrained('users', 'uuid')->nullOnDelete();
+ $table->foreignUuid('updated_by_uuid')->nullable()->constrained('users', 'uuid')->nullOnDelete();
+ $table->softDeletes();
+ $table->timestamps();
+
+ $table->unique(['recurring_order_schedule_uuid', 'occurrence_at'], 'roso_schedule_occurrence_unique');
+ $table->index(['company_uuid', 'occurrence_at'], 'roso_company_occurrence_idx');
+ $table->index('recurring_order_schedule_uuid', 'roso_schedule_uuid_idx');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::disableForeignKeyConstraints();
+ Schema::dropIfExists('recurring_order_schedule_occurrences');
+ Schema::enableForeignKeyConstraints();
+ }
+};
diff --git a/server/migrations/2026_05_01_000003_add_recurring_order_columns_to_orders_table.php b/server/migrations/2026_05_01_000003_add_recurring_order_columns_to_orders_table.php
new file mode 100644
index 000000000..e03309d1f
--- /dev/null
+++ b/server/migrations/2026_05_01_000003_add_recurring_order_columns_to_orders_table.php
@@ -0,0 +1,24 @@
+uuid('recurring_order_schedule_uuid')->nullable()->after('manifest_uuid')->index();
+ $table->dateTime('recurring_occurrence_at')->nullable()->after('recurring_order_schedule_uuid')->index();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('orders', function (Blueprint $table) {
+ $table->dropIndex(['recurring_order_schedule_uuid']);
+ $table->dropIndex(['recurring_occurrence_at']);
+ $table->dropColumn(['recurring_order_schedule_uuid', 'recurring_occurrence_at']);
+ });
+ }
+};
diff --git a/server/src/Console/Commands/MaterializeRecurringOrders.php b/server/src/Console/Commands/MaterializeRecurringOrders.php
new file mode 100644
index 000000000..7d59da563
--- /dev/null
+++ b/server/src/Console/Commands/MaterializeRecurringOrders.php
@@ -0,0 +1,26 @@
+option('horizon'));
+ $stats = $service->materializeAll($horizon);
+
+ $this->info(sprintf(
+ 'Recurring order materialization complete. materialized=%d skipped=%d errors=%d',
+ $stats['materialized'],
+ $stats['skipped'],
+ $stats['errors']
+ ));
+ }
+}
diff --git a/server/src/Http/Controllers/Api/v1/OrderController.php b/server/src/Http/Controllers/Api/v1/OrderController.php
index bb23364b1..54413d5bf 100644
--- a/server/src/Http/Controllers/Api/v1/OrderController.php
+++ b/server/src/Http/Controllers/Api/v1/OrderController.php
@@ -93,17 +93,17 @@ public function create(CreateOrderRequest $request)
// create payload
if ($request->has('payload') && $request->isArray('payload')) {
- $payload = new Payload();
- $payloadInput = $request->input('payload');
- $entities = data_get($payloadInput, 'entities', []);
- $waypoints = data_get($payloadInput, 'waypoints', []);
- $pickup = data_get($payloadInput, 'pickup');
- $dropoff = data_get($payloadInput, 'dropoff');
- $return = data_get($payloadInput, 'return');
- $hasPickupField = array_key_exists('pickup', $payloadInput);
- $hasDropoffField = array_key_exists('dropoff', $payloadInput);
- $hasReturnField = array_key_exists('return', $payloadInput);
- $hasWaypointsField = array_key_exists('waypoints', $payloadInput);
+ $payload = new Payload();
+ $payloadInput = $request->input('payload');
+ $entities = data_get($payloadInput, 'entities', []);
+ $waypoints = data_get($payloadInput, 'waypoints', []);
+ $pickup = data_get($payloadInput, 'pickup');
+ $dropoff = data_get($payloadInput, 'dropoff');
+ $return = data_get($payloadInput, 'return');
+ $hasPickupField = array_key_exists('pickup', $payloadInput);
+ $hasDropoffField = array_key_exists('dropoff', $payloadInput);
+ $hasReturnField = array_key_exists('return', $payloadInput);
+ $hasWaypointsField = array_key_exists('waypoints', $payloadInput);
$hasRouteEndpointFields = $hasPickupField || $hasDropoffField || $hasReturnField;
if ($pickup) {
@@ -445,17 +445,17 @@ public function update($id, UpdateOrderRequest $request)
// create a payload if missing payload[] but has pickup/dropoff/etc
if ($request->missing('payload')) {
- $payload = data_get($order, 'payload', new Payload());
- $payloadInput = $request->only(['pickup', 'dropoff', 'return', 'waypoints', 'entities']);
- $entities = data_get($payloadInput, 'entities', []);
- $waypoints = data_get($payloadInput, 'waypoints', []);
- $pickup = data_get($payloadInput, 'pickup');
- $dropoff = data_get($payloadInput, 'dropoff');
- $return = data_get($payloadInput, 'return');
- $hasPickupField = $request->exists('pickup');
- $hasDropoffField = $request->exists('dropoff');
- $hasReturnField = $request->exists('return');
- $hasWaypointsField = $request->exists('waypoints');
+ $payload = data_get($order, 'payload', new Payload());
+ $payloadInput = $request->only(['pickup', 'dropoff', 'return', 'waypoints', 'entities']);
+ $entities = data_get($payloadInput, 'entities', []);
+ $waypoints = data_get($payloadInput, 'waypoints', []);
+ $pickup = data_get($payloadInput, 'pickup');
+ $dropoff = data_get($payloadInput, 'dropoff');
+ $return = data_get($payloadInput, 'return');
+ $hasPickupField = $request->exists('pickup');
+ $hasDropoffField = $request->exists('dropoff');
+ $hasReturnField = $request->exists('return');
+ $hasWaypointsField = $request->exists('waypoints');
$hasRouteEndpointFields = $hasPickupField || $hasDropoffField || $hasReturnField;
// if no pickup and dropoff extract from waypoints
diff --git a/server/src/Http/Controllers/Api/v1/PayloadController.php b/server/src/Http/Controllers/Api/v1/PayloadController.php
index 6d6315488..d9732156b 100644
--- a/server/src/Http/Controllers/Api/v1/PayloadController.php
+++ b/server/src/Http/Controllers/Api/v1/PayloadController.php
@@ -25,16 +25,16 @@ class PayloadController extends Controller
*/
public function create(CreatePayloadRequest $request)
{
- $input = $request->all();
- $entities = data_get($input, 'entities', []);
- $waypoints = data_get($input, 'waypoints', []);
- $pickup = data_get($input, 'pickup');
- $dropoff = data_get($input, 'dropoff');
- $return = data_get($input, 'return');
- $hasPickupField = array_key_exists('pickup', $input);
- $hasDropoffField = array_key_exists('dropoff', $input);
- $hasReturnField = array_key_exists('return', $input);
- $hasWaypointsField = array_key_exists('waypoints', $input);
+ $input = $request->all();
+ $entities = data_get($input, 'entities', []);
+ $waypoints = data_get($input, 'waypoints', []);
+ $pickup = data_get($input, 'pickup');
+ $dropoff = data_get($input, 'dropoff');
+ $return = data_get($input, 'return');
+ $hasPickupField = array_key_exists('pickup', $input);
+ $hasDropoffField = array_key_exists('dropoff', $input);
+ $hasReturnField = array_key_exists('return', $input);
+ $hasWaypointsField = array_key_exists('waypoints', $input);
$hasRouteEndpointFields = $hasPickupField || $hasDropoffField || $hasReturnField;
// make sure company is set
diff --git a/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php b/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php
index 9a673b909..cdb43efac 100644
--- a/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php
+++ b/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php
@@ -231,7 +231,7 @@ public function queryFromPreliminary(QueryServiceQuotesRequest $request)
$entities = collect($entities)->mapInto(Entity::class);
// should all be Place like
- $waypoints = collect([$pickup, ...$waypoints, $dropoff])->filter();
+ $waypoints = collect([$pickup, ...$waypoints, $dropoff])->filter();
$endpointCount = (int) ($pickup instanceof Place) + (int) ($dropoff instanceof Place);
// if facilitator is an integrated partner resolve service quotes from bridge
diff --git a/server/src/Http/Controllers/Internal/v1/OrchestrationController.php b/server/src/Http/Controllers/Internal/v1/OrchestrationController.php
index 4220829ff..34278e310 100644
--- a/server/src/Http/Controllers/Internal/v1/OrchestrationController.php
+++ b/server/src/Http/Controllers/Internal/v1/OrchestrationController.php
@@ -573,7 +573,7 @@ public function importOrders(Request $request): JsonResponse
$isMulti = $orderType === 'multi_waypoint';
// ── Resolve OrderConfig ───────────────────────────────────────
- $orderConfigUuid = null;
+ $orderConfigUuid = null;
$resolvedOrderConfig = null;
if (!empty($firstRow['type'])) {
$resolvedOrderConfig = OrderConfig::resolveFromIdentifier($firstRow['type']);
diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php
index 71582b9ab..267aa5084 100644
--- a/server/src/Http/Controllers/Internal/v1/OrderController.php
+++ b/server/src/Http/Controllers/Internal/v1/OrderController.php
@@ -74,6 +74,13 @@ public function onAfterUpdate($request, $order)
}
}
+ public function onQueryRecord($builder, $request): void
+ {
+ $builder->with([
+ 'recurringOrderSchedule' => fn ($query) => $query->withCount('generatedOrders'),
+ ]);
+ }
+
/**
* Creates a record with request payload.
*
@@ -121,7 +128,7 @@ function ($request, &$input) {
if ($resolvedOrderConfig) {
$input['order_config_uuid'] = $resolvedOrderConfig->uuid;
- $input['type'] = $resolvedOrderConfig->key;
+ $input['type'] = $resolvedOrderConfig->key;
} elseif (!isset($input['type'])) {
$input['type'] = 'transport';
}
@@ -762,7 +769,7 @@ public function nextActivity(string $id, Request $request)
return response()->error('No order found.');
}
- $waypoint = $request->filled('waypoint') ? Waypoint::findByPlace($request->input('waypoint'), $order) : null;
+ $waypoint = $request->filled('waypoint') ? Waypoint::findByPlace($request->input('waypoint'), $order) : null;
$orderConfig = $order->ensureOrderConfig();
if (!$orderConfig) {
return response()->error('No order config found for order.');
diff --git a/server/src/Http/Controllers/Internal/v1/RecurringOrderScheduleController.php b/server/src/Http/Controllers/Internal/v1/RecurringOrderScheduleController.php
new file mode 100644
index 000000000..432908032
--- /dev/null
+++ b/server/src/Http/Controllers/Internal/v1/RecurringOrderScheduleController.php
@@ -0,0 +1,380 @@
+materializer = $materializer;
+ }
+
+ public function createRecord(Request $request)
+ {
+ $validationError = $this->validateRecurringSchedulePayload($request, true);
+ if ($validationError) {
+ return $validationError;
+ }
+
+ try {
+ $record = $this->model->createRecordFromRequest(
+ $request,
+ function (Request $request, array &$input) {
+ return $this->onBeforeCreate($request, $input);
+ },
+ function (Request $request, RecurringOrderSchedule $record, array $input) {
+ return $this->onAfterCreate($request, $record, $input);
+ }
+ );
+
+ return new $this->resource($record);
+ } catch (\Throwable $e) {
+ return response()->error(app()->hasDebugModeEnabled() ? $e->getMessage() : 'Error occurred while trying to create a recurring order schedule');
+ }
+ }
+
+ public function updateRecord(Request $request, string $id)
+ {
+ $validationError = $this->validateRecurringSchedulePayload($request, false);
+ if ($validationError) {
+ return $validationError;
+ }
+
+ try {
+ $record = $this->model->updateRecordFromRequest(
+ $request,
+ $id,
+ function (Request $request, RecurringOrderSchedule $record, array &$input) {
+ return $this->onBeforeUpdate($request, $record, $input);
+ },
+ function (Request $request, RecurringOrderSchedule $record, array $input) {
+ return $this->onAfterUpdate($request, $record, $input);
+ }
+ );
+
+ return new $this->resource($record);
+ } catch (\Throwable $e) {
+ return response()->error(app()->hasDebugModeEnabled() ? $e->getMessage() : 'Error occurred while trying to update a recurring order schedule');
+ }
+ }
+
+ public function onBeforeCreate(Request $request, array &$input): ?JsonResponse
+ {
+ $input = $this->normalizeRecurringSeriesInput($input);
+
+ return null;
+ }
+
+ public function onBeforeUpdate(Request $request, RecurringOrderSchedule $record, array &$input): ?JsonResponse
+ {
+ $input = $this->normalizeRecurringSeriesInput($input, $record);
+
+ return null;
+ }
+
+ public function onAfterCreate($request, RecurringOrderSchedule $record, array $input): void
+ {
+ $this->materializer->materializeSchedule($record, now()->addDays((int) config('fleetops.recurring_orders.horizon_days', 60)));
+ }
+
+ public function onAfterUpdate($request, RecurringOrderSchedule $record, array $input): void
+ {
+ $this->materializer->materializeSchedule($record, now()->addDays((int) config('fleetops.recurring_orders.horizon_days', 60)));
+ }
+
+ public function onQueryRecord($builder, $request): void
+ {
+ $builder->with(['customer', 'orderConfig', 'serviceRate']);
+ $builder->withCount('generatedOrders');
+ }
+
+ public function onFindRecord($builder, $request): void
+ {
+ $builder->with(['customer', 'facilitator', 'orderConfig', 'driverAssigned', 'vehicleAssigned', 'serviceRate']);
+ }
+
+ public function pause(string $id): JsonResponse
+ {
+ $schedule = RecurringOrderSchedule::where('uuid', $id)->orWhere('public_id', $id)->firstOrFail();
+ $schedule->pause();
+
+ return response()->json(['status' => 'ok', 'message' => 'Recurring order schedule paused.', 'data' => new RecurringOrderScheduleResource($schedule->fresh())]);
+ }
+
+ public function resume(string $id): JsonResponse
+ {
+ $schedule = RecurringOrderSchedule::where('uuid', $id)->orWhere('public_id', $id)->firstOrFail();
+ $schedule->resume();
+ $this->materializer->materializeSchedule($schedule->fresh(), now()->addDays((int) config('fleetops.recurring_orders.horizon_days', 60)));
+
+ return response()->json(['status' => 'ok', 'message' => 'Recurring order schedule resumed.', 'data' => new RecurringOrderScheduleResource($schedule->fresh())]);
+ }
+
+ public function cancelFuture(string $id, Request $request): JsonResponse
+ {
+ $schedule = RecurringOrderSchedule::where('uuid', $id)->orWhere('public_id', $id)->firstOrFail();
+ $cancelGenerated = $request->boolean('cancel_generated_orders', false);
+
+ if ($cancelGenerated) {
+ $schedule->generatedOrders()
+ ->where('scheduled_at', '>=', now())
+ ->whereNotIn('status', ['completed', 'canceled'])
+ ->get()
+ ->each(function (Order $order) {
+ $order->cancel();
+ $order->save();
+ });
+ }
+
+ $schedule->cancelSchedule();
+
+ return response()->json(['status' => 'ok', 'message' => 'Recurring order schedule canceled.', 'data' => new RecurringOrderScheduleResource($schedule->fresh())]);
+ }
+
+ public function skipOccurrence(string $id, Request $request): JsonResponse
+ {
+ $request->validate([
+ 'occurrence_at' => ['required', 'date'],
+ 'reason' => ['nullable', 'string'],
+ ]);
+
+ $schedule = RecurringOrderSchedule::where('uuid', $id)->orWhere('public_id', $id)->firstOrFail();
+ $occurrence = $this->materializer->skipOccurrence(
+ $schedule,
+ Carbon::parse($request->input('occurrence_at'), $schedule->timezone ?: 'UTC'),
+ $request->input('reason'),
+ $request->boolean('cancel_generated_order', true)
+ );
+
+ return response()->json([
+ 'status' => 'ok',
+ 'message' => 'Occurrence canceled.',
+ 'occurrence' => $occurrence,
+ ]);
+ }
+
+ public function preview(Request $request): JsonResponse
+ {
+ $input = $request->input('recurring_order_schedule', $request->all());
+ $schedule = new RecurringOrderSchedule([
+ 'rrule' => $input['rrule'] ?? null,
+ 'timezone' => $input['timezone'] ?? 'UTC',
+ 'starts_at' => isset($input['starts_at']) ? Carbon::parse($input['starts_at']) : now(),
+ 'ends_at' => !empty($input['ends_at']) ? Carbon::parse($input['ends_at']) : null,
+ ]);
+
+ $limit = max(1, min((int) $request->input('limit', 10), 50));
+ $occurrences = $schedule->previewOccurrences(now($schedule->timezone ?: 'UTC'), now($schedule->timezone ?: 'UTC')->addYears(1), $limit)
+ ->map(fn (Carbon $occurrence) => [
+ 'occurrence_at' => $occurrence->copy()->setTimezone('UTC')->toISOString(),
+ 'occurrence_at_local' => $occurrence->toISOString(),
+ ])
+ ->values();
+
+ return response()->json(['occurrences' => $occurrences]);
+ }
+
+ public function occurrences(string $id, Request $request): JsonResponse
+ {
+ $schedule = RecurringOrderSchedule::where('uuid', $id)->orWhere('public_id', $id)->firstOrFail();
+ $limit = max(1, min((int) $request->input('limit', 25), 100));
+ $scope = $request->input('scope', 'upcoming');
+
+ return response()->json([
+ 'occurrences' => $scope === 'history' ? $schedule->getOccurrenceHistory($limit) : $schedule->getUpcomingOccurrences($limit),
+ ]);
+ }
+
+ protected function validateRecurringSchedulePayload(Request $request, bool $creating): ?JsonResponse
+ {
+ $input = $request->input('recurring_order_schedule', $request->all());
+ $validator = Validator::make($input, [
+ 'name' => [$creating ? 'required' : 'sometimes', 'string'],
+ 'rrule' => [$creating ? 'required' : 'sometimes', 'string'],
+ 'timezone' => [$creating ? 'required' : 'sometimes', 'string'],
+ 'starts_at' => [$creating ? 'required' : 'sometimes', 'date'],
+ 'order' => [$creating ? 'required' : 'sometimes', 'array'],
+ 'order.payload.pickup' => [$creating ? 'required' : 'sometimes'],
+ 'order.payload.dropoff' => [$creating ? 'required' : 'sometimes'],
+ ]);
+
+ if ($validator->fails()) {
+ return response()->error($validator->errors()->all());
+ }
+
+ return null;
+ }
+
+ protected function normalizeRecurringSeriesInput(array $input, ?RecurringOrderSchedule $existing = null): array
+ {
+ $order = (array) ($input['order'] ?? []);
+ $payload = (array) data_get($order, 'payload', []);
+
+ if (!$existing && empty($order)) {
+ throw new \Exception('Recurring series requires an order template.');
+ }
+
+ $templatePayload = [
+ 'pickup' => $this->compactTemplatePlace(data_get($payload, 'pickup', data_get($existing?->template_payload, 'pickup'))),
+ 'dropoff' => $this->compactTemplatePlace(data_get($payload, 'dropoff', data_get($existing?->template_payload, 'dropoff'))),
+ 'return' => $this->compactTemplatePlace(data_get($payload, 'return', data_get($existing?->template_payload, 'return'))),
+ 'waypoints' => array_values(array_filter(array_map(fn ($waypoint) => $this->compactTemplateWaypoint($waypoint), (array) data_get($payload, 'waypoints', data_get($existing?->template_payload, 'waypoints', []))))),
+ 'type' => data_get($payload, 'type', data_get($existing?->template_payload, 'type')),
+ 'payment_method' => data_get($payload, 'payment_method', data_get($existing?->template_payload, 'payment_method')),
+ 'cod_amount' => data_get($payload, 'cod_amount', data_get($existing?->template_payload, 'cod_amount')),
+ 'cod_currency' => data_get($payload, 'cod_currency', data_get($existing?->template_payload, 'cod_currency')),
+ 'cod_payment_method' => data_get($payload, 'cod_payment_method', data_get($existing?->template_payload, 'cod_payment_method')),
+ 'meta' => $this->compactTemplateMeta(data_get($payload, 'meta', data_get($existing?->template_payload, 'meta', []))),
+ ];
+
+ if (empty($templatePayload['pickup']) || empty($templatePayload['dropoff'])) {
+ throw new \Exception('Recurring series requires pickup and dropoff template locations.');
+ }
+
+ return [
+ 'name' => data_get($input, 'name', $existing?->name),
+ 'description' => data_get($input, 'description', $existing?->description),
+ 'status' => data_get($input, 'status', $existing?->status ?? 'active'),
+ 'timezone' => data_get($input, 'timezone', $existing?->timezone ?? 'UTC'),
+ 'starts_at' => !empty($input['starts_at']) ? Carbon::parse($input['starts_at']) : $existing?->starts_at,
+ 'ends_at' => !empty($input['ends_at']) ? Carbon::parse($input['ends_at']) : $existing?->ends_at,
+ 'rrule' => data_get($input, 'rrule', $existing?->rrule),
+ 'company_uuid' => session('company', $existing?->company_uuid),
+ 'customer_uuid' => data_get($order, 'customer_uuid') ?: data_get($order, 'customer.id') ?: $existing?->customer_uuid,
+ 'customer_type' => data_get($order, 'customer_type', $existing?->customer_type),
+ 'facilitator_uuid' => data_get($order, 'facilitator_uuid') ?: data_get($order, 'facilitator.id') ?: $existing?->facilitator_uuid,
+ 'facilitator_type' => data_get($order, 'facilitator_type', $existing?->facilitator_type),
+ 'order_config_uuid' => data_get($order, 'order_config_uuid') ?: data_get($order, 'order_config.id') ?: $existing?->order_config_uuid,
+ 'driver_assigned_uuid' => data_get($order, 'driver_assigned_uuid') ?: data_get($order, 'driver_assigned.id') ?: $existing?->driver_assigned_uuid,
+ 'vehicle_assigned_uuid' => data_get($order, 'vehicle_assigned_uuid') ?: data_get($order, 'vehicle_assigned.id') ?: $existing?->vehicle_assigned_uuid,
+ 'service_rate_uuid' => data_get($input, 'service_rate_uuid') ?: data_get($order, 'service_rate_uuid') ?: $existing?->service_rate_uuid,
+ 'template_order_meta' => $this->compactTemplateOrderMeta($order, $existing),
+ 'template_payload' => $templatePayload,
+ 'template_entities' => array_values(array_filter(array_map(fn ($entity) => $this->compactTemplateEntity($entity), (array) data_get($payload, 'entities', $existing?->template_entities ?? [])))),
+ 'meta' => array_merge((array) data_get($existing, 'meta', []), $this->compactTemplateMeta(data_get($input, 'meta', []))),
+ 'updated_by_uuid' => session('user'),
+ 'created_by_uuid' => $existing?->created_by_uuid ?: session('user'),
+ ];
+ }
+
+ protected function compactTemplateOrderMeta(array $order, ?RecurringOrderSchedule $existing = null): array
+ {
+ return [
+ 'internal_id' => data_get($order, 'internal_id', data_get($existing?->template_order_meta, 'internal_id')),
+ 'pod_method' => data_get($order, 'pod_method', data_get($existing?->template_order_meta, 'pod_method')),
+ 'pod_required' => (bool) data_get($order, 'pod_required', data_get($existing?->template_order_meta, 'pod_required', false)),
+ 'adhoc' => (bool) data_get($order, 'adhoc', data_get($existing?->template_order_meta, 'adhoc', false)),
+ 'adhoc_distance' => data_get($order, 'adhoc_distance', data_get($existing?->template_order_meta, 'adhoc_distance')),
+ 'notes' => data_get($order, 'notes', data_get($existing?->template_order_meta, 'notes')),
+ 'type' => data_get($order, 'type', data_get($existing?->template_order_meta, 'type')),
+ 'meta' => $this->compactTemplateMeta(data_get($order, 'meta', data_get($existing?->template_order_meta, 'meta', []))),
+ 'time_window_start' => data_get($order, 'time_window_start', data_get($existing?->template_order_meta, 'time_window_start')),
+ 'time_window_end' => data_get($order, 'time_window_end', data_get($existing?->template_order_meta, 'time_window_end')),
+ 'required_skills' => array_values((array) data_get($order, 'required_skills', data_get($existing?->template_order_meta, 'required_skills', []))),
+ 'orchestrator_priority' => data_get($order, 'orchestrator_priority', data_get($existing?->template_order_meta, 'orchestrator_priority', 50)),
+ ];
+ }
+
+ protected function compactTemplatePlace($place): ?array
+ {
+ if (empty($place)) {
+ return null;
+ }
+
+ return array_filter([
+ 'uuid' => data_get($place, 'uuid') ?: data_get($place, 'id'),
+ 'public_id' => data_get($place, 'public_id'),
+ 'name' => data_get($place, 'name'),
+ 'phone' => data_get($place, 'phone'),
+ 'type' => data_get($place, 'type', 'place'),
+ 'address' => data_get($place, 'address'),
+ 'street1' => data_get($place, 'street1'),
+ 'street2' => data_get($place, 'street2'),
+ 'city' => data_get($place, 'city'),
+ 'province' => data_get($place, 'province'),
+ 'postal_code' => data_get($place, 'postal_code'),
+ 'neighborhood' => data_get($place, 'neighborhood'),
+ 'district' => data_get($place, 'district'),
+ 'building' => data_get($place, 'building'),
+ 'security_access_code' => data_get($place, 'security_access_code'),
+ 'country' => data_get($place, 'country'),
+ 'location' => data_get($place, 'location'),
+ 'meta' => $this->compactTemplateMeta(data_get($place, 'meta', [])),
+ ], fn ($value) => $value !== null && $value !== []);
+ }
+
+ protected function compactTemplateWaypoint($waypoint): ?array
+ {
+ if (empty($waypoint)) {
+ return null;
+ }
+
+ $place = data_get($waypoint, 'place') ?: $waypoint;
+
+ return array_filter([
+ 'place' => $this->compactTemplatePlace($place),
+ 'type' => data_get($waypoint, 'type', 'dropoff'),
+ 'order' => data_get($waypoint, 'order'),
+ 'customer_uuid' => data_get($waypoint, 'customer_uuid'),
+ 'customer_type' => data_get($waypoint, 'customer_type'),
+ 'time_window_start' => data_get($waypoint, 'time_window_start'),
+ 'time_window_end' => data_get($waypoint, 'time_window_end'),
+ 'service_time' => data_get($waypoint, 'service_time'),
+ 'notes' => data_get($waypoint, 'notes'),
+ 'pod_method' => data_get($waypoint, 'pod_method'),
+ 'pod_required' => (bool) data_get($waypoint, 'pod_required', false),
+ ], fn ($value) => $value !== null && $value !== []);
+ }
+
+ protected function compactTemplateEntity($entity): ?array
+ {
+ if (empty($entity)) {
+ return null;
+ }
+
+ return array_filter([
+ 'internal_id' => data_get($entity, 'internal_id'),
+ 'destination_uuid' => data_get($entity, 'destination_uuid') ?: data_get($entity, 'destination.id') ?: data_get($entity, 'destination.uuid'),
+ 'name' => data_get($entity, 'name'),
+ 'type' => data_get($entity, 'type', 'entity'),
+ 'description' => data_get($entity, 'description'),
+ 'photo_url' => data_get($entity, 'photo_url'),
+ 'currency' => data_get($entity, 'currency'),
+ 'weight' => data_get($entity, 'weight'),
+ 'weight_unit' => data_get($entity, 'weight_unit'),
+ 'length' => data_get($entity, 'length'),
+ 'width' => data_get($entity, 'width'),
+ 'height' => data_get($entity, 'height'),
+ 'dimensions_unit' => data_get($entity, 'dimensions_unit'),
+ 'declared_value' => data_get($entity, 'declared_value'),
+ 'sku' => data_get($entity, 'sku'),
+ 'price' => data_get($entity, 'price'),
+ 'sale_price' => data_get($entity, 'sale_price'),
+ 'meta' => $this->compactTemplateMeta(data_get($entity, 'meta', [])),
+ ], fn ($value) => $value !== null && $value !== []);
+ }
+
+ protected function compactTemplateMeta($meta): array
+ {
+ $meta = (array) ($meta ?? []);
+
+ unset($meta['_index_resource'], $meta['barcode'], $meta['qr_code'], $meta['tracking'], $meta['trackingNumber']);
+
+ return $meta;
+ }
+}
diff --git a/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php b/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php
index c3c8b8436..3e7b31d39 100644
--- a/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php
+++ b/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php
@@ -226,7 +226,7 @@ public function preliminaryQuery(Request $request)
$entities = collect($entities)->mapInto(Entity::class);
// should all be Place like
- $waypoints = collect([$pickup, ...$waypoints, $dropoff])->filter();
+ $waypoints = collect([$pickup, ...$waypoints, $dropoff])->filter();
$endpointCount = (int) ($pickup instanceof Place) + (int) ($dropoff instanceof Place);
// if facilitator is an integrated partner resolve service quotes from bridge
diff --git a/server/src/Http/Controllers/Internal/v1/SettingController.php b/server/src/Http/Controllers/Internal/v1/SettingController.php
index d1e10b2ce..4908d1db4 100644
--- a/server/src/Http/Controllers/Internal/v1/SettingController.php
+++ b/server/src/Http/Controllers/Internal/v1/SettingController.php
@@ -211,12 +211,12 @@ public function getRoutingSettings()
{
$routingSettings = Setting::lookupCompany('routing', ['router' => 'osrm', 'unit' => 'km']);
- $displayEngine = data_get($routingSettings, 'display_engine', data_get($routingSettings, 'routing_display_engine', data_get($routingSettings, 'router', 'osrm')));
- $optimizationEngine = data_get($routingSettings, 'optimization_engine', data_get($routingSettings, 'routing_optimization_engine', $displayEngine));
- $routingSettings['router'] = $displayEngine;
- $routingSettings['display_engine'] = $displayEngine;
- $routingSettings['optimization_engine'] = $optimizationEngine;
- $routingSettings['routing_display_engine'] = $displayEngine;
+ $displayEngine = data_get($routingSettings, 'display_engine', data_get($routingSettings, 'routing_display_engine', data_get($routingSettings, 'router', 'osrm')));
+ $optimizationEngine = data_get($routingSettings, 'optimization_engine', data_get($routingSettings, 'routing_optimization_engine', $displayEngine));
+ $routingSettings['router'] = $displayEngine;
+ $routingSettings['display_engine'] = $displayEngine;
+ $routingSettings['optimization_engine'] = $optimizationEngine;
+ $routingSettings['routing_display_engine'] = $displayEngine;
$routingSettings['routing_optimization_engine'] = $optimizationEngine;
// always default to km if no unit is set
@@ -244,15 +244,15 @@ public function getMapSettings()
'mapProvider' => 'leaflet',
];
- $systemMapSettings = Setting::lookup('fleet-ops.map-settings', []);
- $mapSettings = Setting::lookupFromCompany('fleet-ops.map-settings', $defaults);
+ $systemMapSettings = Setting::lookup('fleet-ops.map-settings', []);
+ $mapSettings = Setting::lookupFromCompany('fleet-ops.map-settings', $defaults);
$mapSettings['mapProvider'] = data_get($mapSettings, 'mapProvider') ?: data_get($systemMapSettings, 'mapProvider', 'leaflet');
// Source the Google Maps API key from the system-level services config
// that is managed by the core-api admin settings panel. This ensures a
// single source of truth and avoids duplicating key management.
$mapSettings['googleMapsApiKey'] = config('services.google_maps.api_key', env('GOOGLE_MAPS_API_KEY', ''));
- $mapSettings['googleMapsMapId'] = data_get($systemMapSettings, 'googleMapsMapId', '');
+ $mapSettings['googleMapsMapId'] = data_get($systemMapSettings, 'googleMapsMapId', '');
return response()->json($mapSettings);
}
@@ -289,7 +289,7 @@ public function saveMapSettings(Request $request)
public function getAdminMapSettings()
{
$defaults = [
- 'mapProvider' => 'leaflet',
+ 'mapProvider' => 'leaflet',
'googleMapsMapId' => '',
];
@@ -299,13 +299,13 @@ public function getAdminMapSettings()
public function saveAdminMapSettings(Request $request)
{
$allowedProviders = ['leaflet', 'google'];
- $mapProvider = $request->input('mapProvider', 'leaflet');
+ $mapProvider = $request->input('mapProvider', 'leaflet');
if (!in_array($mapProvider, $allowedProviders)) {
$mapProvider = 'leaflet';
}
$settings = [
- 'mapProvider' => $mapProvider,
+ 'mapProvider' => $mapProvider,
'googleMapsMapId' => (string) $request->input('googleMapsMapId', ''),
];
diff --git a/server/src/Http/Filter/OrderFilter.php b/server/src/Http/Filter/OrderFilter.php
index bd82796ef..588bf9011 100644
--- a/server/src/Http/Filter/OrderFilter.php
+++ b/server/src/Http/Filter/OrderFilter.php
@@ -59,6 +59,7 @@ public function queryForInternal()
},
'customer',
'facilitator',
+ 'recurringOrderSchedule',
]);
}
diff --git a/server/src/Http/Resources/v1/Index/Order.php b/server/src/Http/Resources/v1/Index/Order.php
index a073fa8a1..b9a80ca8d 100644
--- a/server/src/Http/Resources/v1/Index/Order.php
+++ b/server/src/Http/Resources/v1/Index/Order.php
@@ -23,21 +23,41 @@ public function toArray($request): array
$isInternal = Http::isInternalRequest();
return [
- 'id' => $this->when($isInternal, $this->id, $this->public_id),
- 'uuid' => $this->when($isInternal, $this->uuid),
- 'public_id' => $this->when($isInternal, $this->public_id),
- 'internal_id' => $this->internal_id,
- 'company_uuid' => $this->when($isInternal, $this->company_uuid),
- 'payload_uuid' => $this->when($isInternal, $this->payload_uuid),
- 'driver_assigned_uuid' => $this->when($isInternal, $this->driver_assigned_uuid),
- 'vehicle_assigned_uuid'=> $this->when($isInternal, $this->vehicle_assigned_uuid),
- 'customer_uuid' => $this->when($isInternal, $this->customer_uuid),
- 'customer_type' => $this->when($isInternal, $this->customer_type),
- 'facilitator_uuid' => $this->when($isInternal, $this->facilitator_uuid),
- 'facilitator_type' => $this->when($isInternal, $this->facilitator_type),
- 'tracking_number_uuid' => $this->when($isInternal, $this->tracking_number_uuid),
- 'order_config_uuid' => $this->when($isInternal, $this->order_config_uuid),
- 'tracking' => $this->trackingNumber ? $this->trackingNumber->tracking_number : null,
+ 'id' => $this->when($isInternal, $this->id, $this->public_id),
+ 'uuid' => $this->when($isInternal, $this->uuid),
+ 'public_id' => $this->when($isInternal, $this->public_id),
+ 'internal_id' => $this->internal_id,
+ 'company_uuid' => $this->when($isInternal, $this->company_uuid),
+ 'payload_uuid' => $this->when($isInternal, $this->payload_uuid),
+ 'driver_assigned_uuid' => $this->when($isInternal, $this->driver_assigned_uuid),
+ 'vehicle_assigned_uuid' => $this->when($isInternal, $this->vehicle_assigned_uuid),
+ 'customer_uuid' => $this->when($isInternal, $this->customer_uuid),
+ 'customer_type' => $this->when($isInternal, $this->customer_type),
+ 'facilitator_uuid' => $this->when($isInternal, $this->facilitator_uuid),
+ 'facilitator_type' => $this->when($isInternal, $this->facilitator_type),
+ 'recurring_order_schedule_uuid' => $this->when($isInternal, $this->recurring_order_schedule_uuid),
+ 'recurring_occurrence_at' => $this->when($isInternal, $this->recurring_occurrence_at),
+ 'is_recurring_generated' => $this->when($isInternal, $this->is_recurring_generated),
+ 'tracking_number_uuid' => $this->when($isInternal, $this->tracking_number_uuid),
+ 'order_config_uuid' => $this->when($isInternal, $this->order_config_uuid),
+ 'tracking' => $this->trackingNumber ? $this->trackingNumber->tracking_number : null,
+
+ 'recurring_order_schedule' => $this->when(
+ $isInternal,
+ $this->whenLoaded('recurringOrderSchedule', function () {
+ return [
+ 'id' => $this->recurringOrderSchedule->public_id,
+ 'uuid' => $this->recurringOrderSchedule->uuid,
+ 'public_id' => $this->recurringOrderSchedule->public_id,
+ 'name' => $this->recurringOrderSchedule->name,
+ 'status' => $this->recurringOrderSchedule->status,
+ 'timezone' => $this->recurringOrderSchedule->timezone,
+ 'rrule' => $this->recurringOrderSchedule->rrule,
+ 'next_occurrence_at' => $this->recurringOrderSchedule->next_occurrence_at,
+ 'generated_orders_count' => $this->recurringOrderSchedule->generated_orders_count ?? $this->recurringOrderSchedule->generatedOrders()->count(),
+ ];
+ })
+ ),
// Minimal order config - only essential fields
'order_config' => $this->when(
diff --git a/server/src/Http/Resources/v1/Index/Payload.php b/server/src/Http/Resources/v1/Index/Payload.php
index 8509a088e..cf821fe92 100644
--- a/server/src/Http/Resources/v1/Index/Payload.php
+++ b/server/src/Http/Resources/v1/Index/Payload.php
@@ -19,8 +19,8 @@ class Payload extends FleetbaseResource
public function toArray($request): array
{
$isInternal = Http::isInternalRequest();
- $pickup = $this->index_pickup_place;
- $dropoff = $this->index_dropoff_place;
+ $pickup = $this->index_pickup_place;
+ $dropoff = $this->index_dropoff_place;
return [
'id' => $this->when($isInternal, $this->id, $this->public_id),
diff --git a/server/src/Http/Resources/v1/Index/RecurringOrderSchedule.php b/server/src/Http/Resources/v1/Index/RecurringOrderSchedule.php
new file mode 100644
index 000000000..e43e0c139
--- /dev/null
+++ b/server/src/Http/Resources/v1/Index/RecurringOrderSchedule.php
@@ -0,0 +1,39 @@
+ $this->when($isInternal, $this->id, $this->public_id),
+ 'uuid' => $this->when($isInternal, $this->uuid),
+ 'public_id' => $this->when($isInternal, $this->public_id),
+ 'name' => $this->name,
+ 'description' => $this->description,
+ 'status' => $this->status,
+ 'timezone' => $this->timezone,
+ 'starts_at' => $this->starts_at,
+ 'ends_at' => $this->ends_at,
+ 'rrule' => $this->rrule,
+ 'customer_uuid' => $this->when($isInternal, $this->customer_uuid),
+ 'order_config_uuid' => $this->when($isInternal, $this->order_config_uuid),
+ 'service_rate_uuid' => $this->when($isInternal, $this->service_rate_uuid),
+ 'customer_name' => $this->whenLoaded('customer', fn () => data_get($this->customer, 'name')),
+ 'order_config_name' => $this->whenLoaded('orderConfig', fn () => data_get($this->orderConfig, 'name')),
+ 'service_rate_name' => $this->whenLoaded('serviceRate', fn () => data_get($this->serviceRate, 'service_name')),
+ 'next_occurrence_at' => $this->next_occurrence_at,
+ 'generated_orders_count' => $this->when($isInternal, fn () => $this->generated_orders_count ?? $this->generatedOrders()->count()),
+ 'materialization_horizon' => $this->materialization_horizon,
+ 'created_at' => $this->created_at,
+ 'updated_at' => $this->updated_at,
+ 'meta' => ['_index_resource' => true],
+ ];
+ }
+}
diff --git a/server/src/Http/Resources/v1/Order.php b/server/src/Http/Resources/v1/Order.php
index 132a1b94a..9c59d3fb9 100644
--- a/server/src/Http/Resources/v1/Order.php
+++ b/server/src/Http/Resources/v1/Order.php
@@ -30,26 +30,29 @@ public function toArray($request): array
$tracker = ($withTrackerData || $withEta) ? $this->resource->tracker() : null;
return $this->withCustomFields([
- 'id' => $this->when($isInternal, $this->id, $this->public_id),
- 'uuid' => $this->when($isInternal, $this->uuid),
- 'public_id' => $this->when($isInternal, $this->public_id),
- 'internal_id' => $this->internal_id,
- 'company_uuid' => $this->when($isInternal, $this->company_uuid),
- 'transaction_uuid' => $this->when($isInternal, $this->transaction_uuid),
- 'customer_uuid' => $this->when($isInternal, $this->customer_uuid),
- 'customer_type' => $this->when($isInternal, $this->customer_type ? Utils::toEmberResourceType($this->customer_type) : null),
- 'facilitator_uuid' => $this->when($isInternal, $this->facilitator_uuid),
- 'facilitator_type' => $this->when($isInternal, $this->facilitator_type ? Utils::toEmberResourceType($this->facilitator_type) : null),
- 'payload_uuid' => $this->when($isInternal, $this->payload_uuid),
- 'route_uuid' => $this->when($isInternal, $this->route_uuid),
- 'purchase_rate_uuid' => $this->when($isInternal, $this->purchase_rate_uuid),
- 'tracking_number_uuid' => $this->when($isInternal, $this->tracking_number_uuid),
- 'driver_assigned_uuid' => $this->when($isInternal, $this->driver_assigned_uuid),
- 'vehicle_assigned_uuid'=> $this->when($isInternal, $this->vehicle_assigned_uuid),
- 'has_driver_assigned' => $this->when($isInternal, $this->has_driver_assigned),
- 'is_scheduled' => $this->when($isInternal, $this->is_scheduled),
- 'order_config_uuid' => $this->when($isInternal, $this->order_config_uuid),
- 'order_config' => $this->when(
+ 'id' => $this->when($isInternal, $this->id, $this->public_id),
+ 'uuid' => $this->when($isInternal, $this->uuid),
+ 'public_id' => $this->when($isInternal, $this->public_id),
+ 'internal_id' => $this->internal_id,
+ 'company_uuid' => $this->when($isInternal, $this->company_uuid),
+ 'transaction_uuid' => $this->when($isInternal, $this->transaction_uuid),
+ 'customer_uuid' => $this->when($isInternal, $this->customer_uuid),
+ 'customer_type' => $this->when($isInternal, $this->customer_type ? Utils::toEmberResourceType($this->customer_type) : null),
+ 'facilitator_uuid' => $this->when($isInternal, $this->facilitator_uuid),
+ 'facilitator_type' => $this->when($isInternal, $this->facilitator_type ? Utils::toEmberResourceType($this->facilitator_type) : null),
+ 'payload_uuid' => $this->when($isInternal, $this->payload_uuid),
+ 'route_uuid' => $this->when($isInternal, $this->route_uuid),
+ 'purchase_rate_uuid' => $this->when($isInternal, $this->purchase_rate_uuid),
+ 'tracking_number_uuid' => $this->when($isInternal, $this->tracking_number_uuid),
+ 'driver_assigned_uuid' => $this->when($isInternal, $this->driver_assigned_uuid),
+ 'vehicle_assigned_uuid' => $this->when($isInternal, $this->vehicle_assigned_uuid),
+ 'recurring_order_schedule_uuid' => $this->when($isInternal, $this->recurring_order_schedule_uuid),
+ 'recurring_occurrence_at' => $this->when($isInternal, $this->recurring_occurrence_at),
+ 'is_recurring_generated' => $this->when($isInternal, $this->is_recurring_generated),
+ 'has_driver_assigned' => $this->when($isInternal, $this->has_driver_assigned),
+ 'is_scheduled' => $this->when($isInternal, $this->is_scheduled),
+ 'order_config_uuid' => $this->when($isInternal, $this->order_config_uuid),
+ 'order_config' => $this->when(
$isInternal,
$this->whenLoaded('orderConfig', function () {
return $this->orderConfig;
@@ -69,6 +72,9 @@ public function toArray($request): array
'vehicle_assigned' => $this->whenLoaded('vehicleAssigned', function () {
return new Vehicle($this->vehicleAssigned);
}),
+ 'recurring_order_schedule' => $this->whenLoaded('recurringOrderSchedule', function () {
+ return new RecurringOrderSchedule($this->recurringOrderSchedule);
+ }),
'tracking_number' => new TrackingNumber($this->trackingNumber),
'tracking_statuses' => $this->whenLoaded('trackingStatuses', function () {
return TrackingStatus::collection($this->trackingStatuses);
diff --git a/server/src/Http/Resources/v1/RecurringOrderSchedule.php b/server/src/Http/Resources/v1/RecurringOrderSchedule.php
new file mode 100644
index 000000000..a4d407e2a
--- /dev/null
+++ b/server/src/Http/Resources/v1/RecurringOrderSchedule.php
@@ -0,0 +1,75 @@
+ $this->when($isInternal, $this->id, $this->public_id),
+ 'uuid' => $this->when($isInternal, $this->uuid),
+ 'public_id' => $this->when($isInternal, $this->public_id),
+ 'company_uuid' => $this->when($isInternal, $this->company_uuid),
+ 'name' => $this->name,
+ 'description' => $this->description,
+ 'status' => $this->status,
+ 'timezone' => $this->timezone,
+ 'starts_at' => $this->starts_at,
+ 'ends_at' => $this->ends_at,
+ 'rrule' => $this->rrule,
+ 'last_materialized_at' => $this->last_materialized_at,
+ 'materialization_horizon' => $this->materialization_horizon,
+ 'customer_uuid' => $this->when($isInternal, $this->customer_uuid),
+ 'customer_type' => $this->when($isInternal, $this->customer_type),
+ 'facilitator_uuid' => $this->when($isInternal, $this->facilitator_uuid),
+ 'facilitator_type' => $this->when($isInternal, $this->facilitator_type),
+ 'order_config_uuid' => $this->when($isInternal, $this->order_config_uuid),
+ 'driver_assigned_uuid' => $this->when($isInternal, $this->driver_assigned_uuid),
+ 'vehicle_assigned_uuid' => $this->when($isInternal, $this->vehicle_assigned_uuid),
+ 'service_rate_uuid' => $this->when($isInternal, $this->service_rate_uuid),
+ 'customer_name' => $this->whenLoaded('customer', fn () => data_get($this->customer, 'name')),
+ 'facilitator_name' => $this->whenLoaded('facilitator', fn () => data_get($this->facilitator, 'name')),
+ 'order_config_name' => $this->whenLoaded('orderConfig', fn () => data_get($this->orderConfig, 'name')),
+ 'driver_assigned_name' => $this->whenLoaded('driverAssigned', fn () => data_get($this->driverAssigned, 'name')),
+ 'vehicle_assigned_name' => $this->whenLoaded('vehicleAssigned', fn () => data_get($this->vehicleAssigned, 'display_name')),
+ 'service_rate_name' => $this->whenLoaded('serviceRate', fn () => data_get($this->serviceRate, 'service_name')),
+ 'customer' => $this->whenLoaded('customer', fn () => $this->typedRelationship($this->customer, 'customer', CustomerIndexResource::class)),
+ 'facilitator' => $this->whenLoaded('facilitator', fn () => $this->typedRelationship($this->facilitator, 'facilitator', FacilitatorIndexResource::class)),
+ 'order_config' => $this->whenLoaded('orderConfig', fn () => $this->typedRelationship($this->orderConfig, 'order-config')),
+ 'driver_assigned' => $this->whenLoaded('driverAssigned', fn () => $this->typedRelationship($this->driverAssigned, 'driver', Driver::class)),
+ 'vehicle_assigned' => $this->whenLoaded('vehicleAssigned', fn () => $this->typedRelationship($this->vehicleAssigned, 'vehicle', Vehicle::class)),
+ 'service_rate' => $this->whenLoaded('serviceRate', fn () => $this->typedRelationship($this->serviceRate, 'service-rate', ServiceRate::class)),
+ 'template_order_meta' => $this->template_order_meta ?? [],
+ 'template_payload' => $this->template_payload ?? [],
+ 'template_entities' => $this->template_entities ?? [],
+ 'upcoming_occurrences' => $this->when($isInternal, fn () => $this->resource->getUpcomingOccurrences((int) $request->input('upcoming_limit', 25))),
+ 'history_occurrences' => $this->when($isInternal, fn () => $this->resource->getOccurrenceHistory((int) $request->input('history_limit', 25))),
+ 'next_occurrence_at' => $this->next_occurrence_at,
+ 'generated_orders_count' => $this->when($isInternal, fn () => $this->generated_orders_count ?? $this->generatedOrders()->count()),
+ 'meta' => $this->meta ?? [],
+ 'created_at' => $this->created_at,
+ 'updated_at' => $this->updated_at,
+ ];
+ }
+
+ protected function typedRelationship($model, string $type, ?string $resourceClass = null): ?array
+ {
+ if (!$model) {
+ return null;
+ }
+
+ $data = $resourceClass ? (new $resourceClass($model))->resolve() : $model->toArray();
+
+ data_set($data, 'type', $type);
+
+ return $data;
+ }
+}
diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php
index 3a3c97c37..337028b32 100644
--- a/server/src/Models/Order.php
+++ b/server/src/Models/Order.php
@@ -127,6 +127,8 @@ class Order extends Model
'time_window_end',
// Manifest (set by orchestrator commit)
'manifest_uuid',
+ 'recurring_order_schedule_uuid',
+ 'recurring_occurrence_at',
];
/**
@@ -183,6 +185,7 @@ class Order extends Model
'payload_id',
'purchase_rate_id',
'is_scheduled',
+ 'is_recurring_generated',
'qr_code',
'created_by_name',
'updated_by_name',
@@ -213,10 +216,11 @@ class Order extends Model
'dispatched_at' => 'datetime',
'started_at' => 'datetime',
// Orchestrator
- 'required_skills' => Json::class,
- 'time_window_start' => 'datetime',
- 'time_window_end' => 'datetime',
- 'orchestrator_priority' => 'integer',
+ 'required_skills' => Json::class,
+ 'time_window_start' => 'datetime',
+ 'time_window_end' => 'datetime',
+ 'orchestrator_priority' => 'integer',
+ 'recurring_occurrence_at' => 'datetime',
];
/**
@@ -387,6 +391,11 @@ public function trackingNumber(): BelongsTo
return $this->belongsTo(TrackingNumber::class)->without(['owner']);
}
+ public function recurringOrderSchedule(): BelongsTo
+ {
+ return $this->belongsTo(RecurringOrderSchedule::class, 'recurring_order_schedule_uuid', 'uuid')->withoutGlobalScopes();
+ }
+
public function trackingStatuses(): HasMany
{
return $this->hasMany(TrackingStatus::class, 'tracking_number_uuid', 'tracking_number_uuid');
@@ -751,6 +760,11 @@ public function getIsScheduledAttribute(): bool
return !empty($this->scheduled_at) && Carbon::parse($this->scheduled_at)->isValid();
}
+ public function getIsRecurringGeneratedAttribute(): bool
+ {
+ return !empty($this->recurring_order_schedule_uuid) || (bool) data_get($this->meta, 'is_recurring_generated', false);
+ }
+
/**
* Determines if the order is assigned to a driver but not yet dispatched.
*
@@ -1739,7 +1753,7 @@ public function config(): ?OrderConfig
}
}
- $company = $this->relationLoaded('company') ? $this->company : $this->company()->first();
+ $company = $this->relationLoaded('company') ? $this->company : $this->company()->first();
$orderConfig = OrderConfig::defaultOrCreate($company);
if ($orderConfig instanceof OrderConfig) {
$orderConfig->setOrderContext($this);
diff --git a/server/src/Models/Payload.php b/server/src/Models/Payload.php
index 14b6bace0..67787ee94 100644
--- a/server/src/Models/Payload.php
+++ b/server/src/Models/Payload.php
@@ -555,15 +555,15 @@ public function updateWaypoints($waypoints = [])
}
$this->loadMissing('waypointMarkers');
- $keptWaypointIds = [];
+ $keptWaypointIds = [];
$availableWaypointMarkers = $this->waypointMarkers()->get();
foreach ($waypoints as $index => $attributes) {
- $raw = $attributes;
- $type = data_get($raw, 'type', 'dropoff');
- $customerUuidIn = data_get($raw, 'customer_uuid');
+ $raw = $attributes;
+ $type = data_get($raw, 'type', 'dropoff');
+ $customerUuidIn = data_get($raw, 'customer_uuid');
$customerPubIdIn = data_get($raw, 'customer_id');
- $customerType = data_get($raw, 'customer_type', 'fleetops:contact');
+ $customerType = data_get($raw, 'customer_type', 'fleetops:contact');
if (Utils::isset($attributes, 'place') && is_array(Utils::get($attributes, 'place'))) {
$attributes = Utils::get($attributes, 'place');
@@ -599,12 +599,12 @@ public function updateWaypoints($waypoints = [])
$placeUuid = $place->uuid;
}
- $customerUuid = null;
+ $customerUuid = null;
$customerTypeNamespace = null;
if ($customerType) {
$customerTypeNamespace = Utils::getMutationType($customerType);
- $customerModel = app($customerTypeNamespace);
+ $customerModel = app($customerTypeNamespace);
if ($customerUuidIn && $customerModel->where('uuid', $customerUuidIn)->exists()) {
$customerUuid = $customerUuidIn;
@@ -642,7 +642,7 @@ public function updateWaypoints($waypoints = [])
continue;
}
- $waypointRecord = Waypoint::create($values);
+ $waypointRecord = Waypoint::create($values);
$keptWaypointIds[] = $waypointRecord->uuid;
}
diff --git a/server/src/Models/Place.php b/server/src/Models/Place.php
index d51836994..cf919215d 100644
--- a/server/src/Models/Place.php
+++ b/server/src/Models/Place.php
@@ -21,7 +21,6 @@
use Fleetbase\Traits\SendsWebhooks;
use Fleetbase\Traits\TracksApiCredential;
use Illuminate\Support\Carbon;
-use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class Place extends Model
@@ -383,8 +382,6 @@ public static function createFromGeocodingLookup(string $address, $saveInstance
/**
* Create a new Place instance from a geocoding lookup.
- *
- * @return array
*/
public static function getValuesFromGeocodingLookup(string $address): array
{
diff --git a/server/src/Models/RecurringOrderSchedule.php b/server/src/Models/RecurringOrderSchedule.php
new file mode 100644
index 000000000..e5b5a68e7
--- /dev/null
+++ b/server/src/Models/RecurringOrderSchedule.php
@@ -0,0 +1,291 @@
+ Json::class,
+ 'template_order_meta' => Json::class,
+ 'template_payload' => Json::class,
+ 'template_entities' => Json::class,
+ 'customer_type' => PolymorphicType::class,
+ 'facilitator_type' => PolymorphicType::class,
+ 'starts_at' => 'datetime',
+ 'ends_at' => 'datetime',
+ 'last_materialized_at' => 'datetime',
+ 'materialization_horizon' => 'datetime',
+ ];
+
+ protected $appends = ['is_active', 'next_occurrence_at'];
+
+ protected $filterParams = ['status', 'customer', 'type', 'scheduled_for', 'created_at', 'updated_at'];
+
+ protected $with = ['customer', 'facilitator', 'orderConfig', 'driverAssigned', 'vehicleAssigned', 'serviceRate'];
+
+ public function customer(): MorphTo
+ {
+ return $this->morphTo(__FUNCTION__, 'customer_type', 'customer_uuid');
+ }
+
+ public function facilitator(): MorphTo
+ {
+ return $this->morphTo(__FUNCTION__, 'facilitator_type', 'facilitator_uuid');
+ }
+
+ public function orderConfig(): BelongsTo
+ {
+ return $this->belongsTo(OrderConfig::class, 'order_config_uuid', 'uuid');
+ }
+
+ public function driverAssigned(): BelongsTo
+ {
+ return $this->belongsTo(Driver::class, 'driver_assigned_uuid', 'uuid')->withoutGlobalScopes();
+ }
+
+ public function vehicleAssigned(): BelongsTo
+ {
+ return $this->belongsTo(Vehicle::class, 'vehicle_assigned_uuid', 'uuid')->withoutGlobalScopes();
+ }
+
+ public function serviceRate(): BelongsTo
+ {
+ return $this->belongsTo(ServiceRate::class, 'service_rate_uuid', 'uuid')->withoutGlobalScopes();
+ }
+
+ public function generatedOrders(): HasMany
+ {
+ return $this->hasMany(Order::class, 'recurring_order_schedule_uuid', 'uuid')->withoutGlobalScopes();
+ }
+
+ public function occurrences(): HasMany
+ {
+ return $this->hasMany(RecurringOrderScheduleOccurrence::class, 'recurring_order_schedule_uuid', 'uuid');
+ }
+
+ public function scopeActive(Builder $query): Builder
+ {
+ return $query->where('status', 'active');
+ }
+
+ public function scopeNeedsMaterialization(Builder $query, Carbon $horizon): Builder
+ {
+ return $query->active()->where(function (Builder $q) use ($horizon) {
+ $q->whereNull('materialization_horizon')
+ ->orWhere('materialization_horizon', '<', $horizon);
+ });
+ }
+
+ public function getIsActiveAttribute(): bool
+ {
+ return $this->status === 'active';
+ }
+
+ public function getNextOccurrenceAtAttribute(): ?Carbon
+ {
+ return $this->previewOccurrences(now(), now()->copy()->addYear(), 1)->first();
+ }
+
+ public function hasRrule(): bool
+ {
+ return !empty($this->rrule);
+ }
+
+ public function pause(): bool
+ {
+ return (bool) $this->update(['status' => 'paused']);
+ }
+
+ public function resume(): bool
+ {
+ return (bool) $this->update(['status' => 'active']);
+ }
+
+ public function cancelSchedule(): bool
+ {
+ return (bool) $this->update(['status' => 'canceled']);
+ }
+
+ public function getRruleInstance(?Carbon $referenceDate = null): ?RRule
+ {
+ if (!$this->hasRrule()) {
+ return null;
+ }
+
+ $timezone = $this->timezone ?: 'UTC';
+ $referenceDate = $referenceDate ?: ($this->starts_at ? $this->starts_at->copy()->setTimezone($timezone) : now($timezone)->startOfDay());
+ $dtStart = ($this->starts_at ? $this->starts_at->copy()->setTimezone($timezone) : $referenceDate->copy())->second(0);
+ $rruleValue = preg_replace('/^RRULE:/i', '', trim((string) $this->rrule));
+
+ $dtStartStr = $timezone === 'UTC'
+ ? 'DTSTART:' . $dtStart->format('Ymd\THis') . 'Z'
+ : 'DTSTART;TZID=' . $timezone . ':' . $dtStart->format('Ymd\THis');
+
+ try {
+ return new RRule($dtStartStr . "\n" . 'RRULE:' . $rruleValue);
+ } catch (\Throwable $exception) {
+ \Log::warning('RecurringOrderSchedule invalid RRULE', [
+ 'schedule_uuid' => $this->uuid,
+ 'rrule' => $this->rrule,
+ 'error' => $exception->getMessage(),
+ ]);
+
+ return null;
+ }
+ }
+
+ public function previewOccurrences(Carbon $from, Carbon $to, int $limit = 10): Collection
+ {
+ $rrule = $this->getRruleInstance($from);
+
+ if (!$rrule) {
+ return collect();
+ }
+
+ $occurrences = collect();
+
+ foreach ($rrule as $occurrence) {
+ $carbon = Carbon::instance($occurrence)->setTimezone($this->timezone ?: 'UTC');
+
+ if ($this->starts_at && $carbon->lt($this->starts_at->copy()->setTimezone($this->timezone ?: 'UTC'))) {
+ continue;
+ }
+
+ if ($this->ends_at && $carbon->gt($this->ends_at->copy()->setTimezone($this->timezone ?: 'UTC'))) {
+ break;
+ }
+
+ if ($carbon->gt($to)) {
+ break;
+ }
+
+ if ($carbon->gte($from)) {
+ $occurrences->push($carbon->copy());
+ }
+
+ if ($occurrences->count() >= $limit) {
+ break;
+ }
+ }
+
+ return $occurrences;
+ }
+
+ public function getUpcomingOccurrences(int $limit = 25): array
+ {
+ $timezone = $this->timezone ?: 'UTC';
+ $preview = $this->previewOccurrences(now($timezone), now($timezone)->addYears(1), $limit);
+ $states = $this->occurrences()
+ ->where('occurrence_at', '>=', now())
+ ->with('order')
+ ->get()
+ ->keyBy(fn ($occurrence) => $occurrence->occurrence_at->toISOString());
+
+ return $preview->map(function (Carbon $occurrence) use ($states) {
+ $occurrenceUtc = $occurrence->copy()->setTimezone('UTC');
+ $state = $states->get($occurrenceUtc->toISOString());
+
+ return [
+ 'occurrence_at' => $occurrenceUtc->toISOString(),
+ 'occurrence_at_local' => $occurrence->toISOString(),
+ 'status' => $state?->status ?? 'scheduled',
+ 'reason' => $state?->reason,
+ 'order' => $state?->order ? [
+ 'id' => $state->order->public_id,
+ 'public_id' => $state->order->public_id,
+ 'status' => $state->order->status,
+ 'scheduled_at' => $state->order->scheduled_at,
+ ] : null,
+ ];
+ })->values()->all();
+ }
+
+ public function getOccurrenceHistory(int $limit = 25): array
+ {
+ return $this->occurrences()
+ ->where(function ($query) {
+ $query->where('occurrence_at', '<', now());
+ $query->orWhereIn('status', ['generated', 'skipped', 'canceled', 'completed']);
+ })
+ ->with('order')
+ ->orderByDesc('occurrence_at')
+ ->limit($limit)
+ ->get()
+ ->map(fn (RecurringOrderScheduleOccurrence $occurrence) => [
+ 'occurrence_at' => $occurrence->occurrence_at?->toISOString(),
+ 'status' => $occurrence->status,
+ 'reason' => $occurrence->reason,
+ 'order' => $occurrence->order ? [
+ 'id' => $occurrence->order->public_id,
+ 'public_id' => $occurrence->order->public_id,
+ 'status' => $occurrence->order->status,
+ 'scheduled_at' => $occurrence->order->scheduled_at,
+ ] : null,
+ ])
+ ->values()
+ ->all();
+ }
+}
diff --git a/server/src/Models/RecurringOrderScheduleOccurrence.php b/server/src/Models/RecurringOrderScheduleOccurrence.php
new file mode 100644
index 000000000..df057d3ea
--- /dev/null
+++ b/server/src/Models/RecurringOrderScheduleOccurrence.php
@@ -0,0 +1,58 @@
+ Json::class,
+ 'occurrence_at' => 'datetime',
+ ];
+
+ protected $filterParams = ['status', 'order_uuid', 'recurring_order_schedule_uuid'];
+
+ public function recurringOrderSchedule(): BelongsTo
+ {
+ return $this->belongsTo(RecurringOrderSchedule::class, 'recurring_order_schedule_uuid', 'uuid');
+ }
+
+ public function order(): BelongsTo
+ {
+ return $this->belongsTo(Order::class, 'order_uuid', 'uuid')->withoutGlobalScopes();
+ }
+}
diff --git a/server/src/Models/ServiceRate.php b/server/src/Models/ServiceRate.php
index e47b1fe9f..7576a699e 100644
--- a/server/src/Models/ServiceRate.php
+++ b/server/src/Models/ServiceRate.php
@@ -740,7 +740,7 @@ public function quoteFromPreliminaryData($entities = [], $waypoints = [], ?int $
if ($this->isAlgorithm()) {
$resolvedEndpointCount = $endpointCount ?? $this->inferEndpointCountFromStops($waypoints);
- $rateFee = $this->normalizeCalculatedMoney(Algo::exec(
+ $rateFee = $this->normalizeCalculatedMoney(Algo::exec(
$this->algorithm,
$this->buildAlgorithmVariables(
$entities,
diff --git a/server/src/Orchestration/Engines/VroomOrchestrationEngine.php b/server/src/Orchestration/Engines/VroomOrchestrationEngine.php
index cbb943f13..b5830871e 100644
--- a/server/src/Orchestration/Engines/VroomOrchestrationEngine.php
+++ b/server/src/Orchestration/Engines/VroomOrchestrationEngine.php
@@ -104,9 +104,9 @@ public function allocate(Collection $orders, Collection $vehicles, array $option
unset($job);
// ── Resolve connection config ─────────────────────────────────────────
- $baseUri = $this->resolveVroomBaseUri();
+ $baseUri = $this->resolveVroomBaseUri();
$endpointMode = $this->resolveVroomEndpointMode();
- $timeout = (int) env('VROOM_TIMEOUT', 30);
+ $timeout = (int) env('VROOM_TIMEOUT', 30);
$apiKey = $this->resolveVroomApiKey();
diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php
index 267e80ef9..92f55b043 100644
--- a/server/src/Providers/FleetOpsServiceProvider.php
+++ b/server/src/Providers/FleetOpsServiceProvider.php
@@ -65,6 +65,7 @@ class FleetOpsServiceProvider extends CoreServiceProvider
\Fleetbase\FleetOps\Console\Commands\TestEmail::class,
\Fleetbase\FleetOps\Console\Commands\ProcessMaintenanceTriggers::class,
\Fleetbase\FleetOps\Console\Commands\SendMaintenanceReminders::class,
+ \Fleetbase\FleetOps\Console\Commands\MaterializeRecurringOrders::class,
];
/**
@@ -117,6 +118,7 @@ public function boot()
$schedule->command('fleetops:dispatch-adhoc')->everyMinute()->withoutOverlapping()->storeOutputInDb();
$schedule->command('fleetops:update-estimations')->everyTenMinutes()->withoutOverlapping();
$schedule->command('fleetops:purge-service-quotes')->daily()->withoutOverlapping();
+ $schedule->command('fleetops:materialize-recurring-orders')->daily()->withoutOverlapping()->storeOutputInDb();
$schedule->command('fleetops:process-maintenance-triggers')->daily()->withoutOverlapping()->storeOutputInDb();
$schedule->command('fleetops:send-maintenance-reminders')->daily()->withoutOverlapping()->storeOutputInDb();
});
diff --git a/server/src/Support/RecurringOrderMaterializationService.php b/server/src/Support/RecurringOrderMaterializationService.php
new file mode 100644
index 000000000..b01fb48f4
--- /dev/null
+++ b/server/src/Support/RecurringOrderMaterializationService.php
@@ -0,0 +1,281 @@
+addDays($horizonDays);
+ $stats = ['materialized' => 0, 'skipped' => 0, 'errors' => 0];
+
+ RecurringOrderSchedule::needsMaterialization($horizon)
+ ->chunk(100, function ($schedules) use ($horizon, &$stats) {
+ foreach ($schedules as $schedule) {
+ try {
+ $created = $this->materializeSchedule($schedule, $horizon);
+ if ($created > 0) {
+ $stats['materialized']++;
+ } else {
+ $stats['skipped']++;
+ }
+ } catch (\Throwable $exception) {
+ $stats['errors']++;
+ \Log::error('[RecurringOrderMaterializationService] Failed to materialize schedule', [
+ 'schedule_uuid' => $schedule->uuid,
+ 'error' => $exception->getMessage(),
+ ]);
+ }
+ }
+ });
+
+ return $stats;
+ }
+
+ public function materializeSchedule(RecurringOrderSchedule $schedule, ?Carbon $horizon = null): int
+ {
+ if ($schedule->status !== 'active') {
+ return 0;
+ }
+
+ $timezone = $schedule->timezone ?: 'UTC';
+ $from = ($schedule->last_materialized_at?->copy()->setTimezone($timezone) ?? now($timezone))->startOfDay();
+ $horizon = ($horizon ?: now()->addDays((int) config('fleetops.recurring_orders.horizon_days', static::DEFAULT_HORIZON_DAYS)))->copy();
+ $occurrences = $schedule->previewOccurrences($from, $horizon->copy()->setTimezone($timezone), 500);
+
+ if ($occurrences->isEmpty()) {
+ $schedule->update([
+ 'last_materialized_at' => now(),
+ 'materialization_horizon' => $horizon,
+ ]);
+
+ return 0;
+ }
+
+ $existingStates = $schedule->occurrences()
+ ->whereBetween('occurrence_at', [$from->copy()->setTimezone('UTC'), $horizon->copy()->setTimezone('UTC')])
+ ->get()
+ ->keyBy(fn (RecurringOrderScheduleOccurrence $occurrence) => $occurrence->occurrence_at->toISOString());
+
+ $created = 0;
+
+ foreach ($occurrences as $occurrenceLocal) {
+ $occurrenceUtc = $occurrenceLocal->copy()->setTimezone('UTC');
+ $stateKey = $occurrenceUtc->toISOString();
+ $state = $existingStates->get($stateKey);
+
+ if ($state && in_array($state->status, ['generated', 'skipped', 'canceled'], true)) {
+ continue;
+ }
+
+ $order = $this->generateOrderForOccurrence($schedule, $occurrenceUtc);
+
+ RecurringOrderScheduleOccurrence::updateOrCreate(
+ [
+ 'recurring_order_schedule_uuid' => $schedule->uuid,
+ 'occurrence_at' => $occurrenceUtc,
+ ],
+ [
+ 'company_uuid' => $schedule->company_uuid,
+ 'order_uuid' => $order->uuid,
+ 'status' => 'generated',
+ ]
+ );
+
+ $created++;
+ }
+
+ $schedule->update([
+ 'last_materialized_at' => now(),
+ 'materialization_horizon' => $horizon,
+ ]);
+
+ return $created;
+ }
+
+ public function generateOrderForOccurrence(RecurringOrderSchedule $schedule, Carbon $occurrenceUtc): Order
+ {
+ $orderMeta = (array) ($schedule->template_order_meta ?? []);
+ $orderType = data_get($orderMeta, 'type') ?: data_get($schedule->orderConfig, 'key') ?: 'transport';
+
+ $order = Order::create([
+ 'company_uuid' => $schedule->company_uuid,
+ 'internal_id' => data_get($orderMeta, 'internal_id'),
+ 'customer_uuid' => $schedule->customer_uuid,
+ 'customer_type' => $schedule->customer_type,
+ 'facilitator_uuid' => $schedule->facilitator_uuid,
+ 'facilitator_type' => $schedule->facilitator_type,
+ 'order_config_uuid' => $schedule->order_config_uuid,
+ 'driver_assigned_uuid' => $schedule->driver_assigned_uuid,
+ 'vehicle_assigned_uuid' => $schedule->vehicle_assigned_uuid,
+ 'scheduled_at' => $occurrenceUtc,
+ 'pod_method' => data_get($orderMeta, 'pod_method'),
+ 'pod_required' => (bool) data_get($orderMeta, 'pod_required', false),
+ 'adhoc' => (bool) data_get($orderMeta, 'adhoc', false),
+ 'adhoc_distance' => data_get($orderMeta, 'adhoc_distance'),
+ 'notes' => data_get($orderMeta, 'notes'),
+ 'type' => $orderType,
+ 'status' => 'created',
+ 'meta' => array_merge(
+ (array) data_get($orderMeta, 'meta', []),
+ [
+ 'recurring_order_schedule_uuid' => $schedule->uuid,
+ 'recurring_order_schedule_public_id' => $schedule->public_id,
+ 'recurring_occurrence_at' => $occurrenceUtc->toISOString(),
+ 'is_recurring_generated' => true,
+ ]
+ ),
+ 'time_window_start' => data_get($orderMeta, 'time_window_start'),
+ 'time_window_end' => data_get($orderMeta, 'time_window_end'),
+ 'required_skills' => data_get($orderMeta, 'required_skills'),
+ 'orchestrator_priority' => data_get($orderMeta, 'orchestrator_priority', 50),
+ 'recurring_order_schedule_uuid' => $schedule->uuid,
+ 'recurring_occurrence_at' => $occurrenceUtc,
+ 'created_by_uuid' => $schedule->created_by_uuid,
+ 'updated_by_uuid' => $schedule->updated_by_uuid,
+ ]);
+
+ $payload = $this->buildPayloadFromBlueprint($schedule, $orderType);
+ $payload->save();
+ $payload->setWaypoints((array) ($schedule->template_payload['waypoints'] ?? []));
+ $payload->setEntities((array) ($schedule->template_entities ?? []));
+ $payload->setCurrentWaypoint($payload->getPickupOrFirstWaypoint(), false);
+ $payload->save();
+
+ $order->setPayload($payload);
+ $order->setPreliminaryDistanceAndTime();
+
+ if ($schedule->service_rate_uuid) {
+ $this->attachFreshQuoteFromServiceRate($order, $schedule);
+ }
+
+ return $order->fresh(['payload', 'trackingNumber']);
+ }
+
+ public function skipOccurrence(RecurringOrderSchedule $schedule, Carbon $occurrenceAt, ?string $reason = null, bool $cancelGeneratedOrder = true): RecurringOrderScheduleOccurrence
+ {
+ $occurrenceUtc = $occurrenceAt->copy()->setTimezone('UTC');
+ $existing = $schedule->occurrences()->where('occurrence_at', $occurrenceUtc)->first();
+
+ if ($existing?->order && $cancelGeneratedOrder && $existing->order->status !== 'canceled') {
+ $existing->order->cancel();
+ $existing->order->save();
+ }
+
+ return RecurringOrderScheduleOccurrence::updateOrCreate(
+ [
+ 'recurring_order_schedule_uuid' => $schedule->uuid,
+ 'occurrence_at' => $occurrenceUtc,
+ ],
+ [
+ 'company_uuid' => $schedule->company_uuid,
+ 'order_uuid' => $existing?->order_uuid,
+ 'status' => 'canceled',
+ 'reason' => $reason,
+ ]
+ );
+ }
+
+ protected function buildPayloadFromBlueprint(RecurringOrderSchedule $schedule, string $orderType): Payload
+ {
+ $blueprint = (array) ($schedule->template_payload ?? []);
+ $payload = new Payload([
+ 'company_uuid' => $schedule->company_uuid,
+ 'type' => data_get($blueprint, 'type', $orderType),
+ 'meta' => data_get($blueprint, 'meta', []),
+ 'payment_method' => data_get($blueprint, 'payment_method'),
+ 'cod_amount' => data_get($blueprint, 'cod_amount'),
+ 'cod_currency' => data_get($blueprint, 'cod_currency'),
+ 'cod_payment_method' => data_get($blueprint, 'cod_payment_method'),
+ ]);
+
+ if ($pickup = data_get($blueprint, 'pickup')) {
+ $payload->setPickup($this->normalizePlaceAttributes($pickup, $schedule->company_uuid), [
+ 'callback' => function ($pickupPlace, Payload $targetPayload) {
+ $targetPayload->setCurrentWaypoint($pickupPlace, false);
+ },
+ ]);
+ }
+
+ if ($dropoff = data_get($blueprint, 'dropoff')) {
+ $payload->setDropoff($this->normalizePlaceAttributes($dropoff, $schedule->company_uuid));
+ }
+
+ if ($return = data_get($blueprint, 'return')) {
+ $payload->setReturn($this->normalizePlaceAttributes($return, $schedule->company_uuid));
+ }
+
+ return $payload;
+ }
+
+ protected function normalizePlaceAttributes(array $place, string $companyUuid): array
+ {
+ unset($place['id'], $place['public_id'], $place['created_at'], $place['updated_at'], $place['deleted_at']);
+ $place['company_uuid'] = $place['company_uuid'] ?? $companyUuid;
+
+ return $place;
+ }
+
+ protected function attachFreshQuoteFromServiceRate(Order $order, RecurringOrderSchedule $schedule): void
+ {
+ $serviceRate = $schedule->serviceRate;
+ $payload = $order->payload;
+ $waypoints = collect([$payload->pickup, ...$payload->waypoints()->get()->all(), $payload->dropoff])->filter();
+ $entities = $payload->entities()->get();
+
+ if (!$serviceRate || $waypoints->count() < 2) {
+ return;
+ }
+
+ try {
+ [$amount, $lines] = $serviceRate->quoteFromPreliminaryData($entities, $waypoints, $order->distance ?? 0, $order->time ?? 0, false);
+
+ $quote = ServiceQuote::create([
+ 'request_id' => ServiceQuote::generatePublicId('request'),
+ 'company_uuid' => $serviceRate->company_uuid,
+ 'service_rate_uuid' => $serviceRate->uuid,
+ 'payload_uuid' => $payload->uuid,
+ 'amount' => $amount,
+ 'currency' => $serviceRate->currency,
+ ]);
+
+ foreach ($lines as $line) {
+ ServiceQuoteItem::create([
+ 'service_quote_uuid' => $quote->uuid,
+ 'amount' => $line['amount'],
+ 'currency' => $line['currency'],
+ 'details' => $line['details'],
+ 'code' => $line['code'],
+ ]);
+ }
+
+ $quote->updateMeta('preliminary_data', [
+ 'pickup' => $payload->pickup?->toArray(),
+ 'dropoff' => $payload->dropoff?->toArray(),
+ 'return' => $payload->return?->toArray(),
+ 'waypoints' => $payload->waypointMarkers()->with('place')->get()->map(fn ($waypoint) => array_merge($waypoint->toArray(), ['place' => $waypoint->place?->toArray()]))->toArray(),
+ 'entities' => $entities->map(fn (Entity $entity) => $entity->toArray())->toArray(),
+ ]);
+
+ $order->purchaseServiceQuote($quote);
+ } catch (\Throwable $exception) {
+ $meta = (array) ($order->meta ?? []);
+ $meta['recurring_billing_status'] = 'quote_failed';
+ $meta['recurring_billing_error'] = $exception->getMessage();
+ $meta['recurring_service_rate_uuid'] = $schedule->service_rate_uuid;
+ $order->updateQuietly(['meta' => $meta]);
+ }
+ }
+}
diff --git a/server/src/Support/Utils.php b/server/src/Support/Utils.php
index 8a5160d11..8c67ca43e 100644
--- a/server/src/Support/Utils.php
+++ b/server/src/Support/Utils.php
@@ -392,7 +392,7 @@ protected static function extractPointWktFromQueryExpression(\Illuminate\Databas
return null;
}
- if (preg_match("/POINT\\(\\s*([-+]?\\d*\\.?\\d+)\\s+([-+]?\\d*\\.?\\d+)\\s*\\)/i", $expressionValue, $matches)) {
+ if (preg_match('/POINT\\(\\s*([-+]?\\d*\\.?\\d+)\\s+([-+]?\\d*\\.?\\d+)\\s*\\)/i', $expressionValue, $matches)) {
return sprintf('POINT(%s %s)', $matches[1], $matches[2]);
}
diff --git a/server/src/routes.php b/server/src/routes.php
index d62ce4213..dc4b60a59 100644
--- a/server/src/routes.php
+++ b/server/src/routes.php
@@ -355,6 +355,14 @@ function ($router, $controller) {
$router->match(['get', 'post'], 'export', $controller('export'));
}
);
+ $router->fleetbaseRoutes('recurring-order-schedules', function ($router, $controller) {
+ $router->post('preview', $controller('preview'));
+ $router->post('{id}/pause', $controller('pause'));
+ $router->post('{id}/resume', $controller('resume'));
+ $router->post('{id}/skip-occurrence', $controller('skipOccurrence'));
+ $router->post('{id}/cancel-future', $controller('cancelFuture'));
+ $router->get('{id}/occurrences', $controller('occurrences'));
+ });
$router->fleetbaseRoutes('order-configs');
$router->fleetbaseRoutes('payloads');
$router->fleetbaseRoutes(
diff --git a/server/tests/RecurringOrderScheduleTest.php b/server/tests/RecurringOrderScheduleTest.php
new file mode 100644
index 000000000..4086dc73a
--- /dev/null
+++ b/server/tests/RecurringOrderScheduleTest.php
@@ -0,0 +1,162 @@
+newInstanceWithoutConstructor();
+ $method = new ReflectionMethod(RecurringOrderScheduleController::class, 'normalizeRecurringSeriesInput');
+
+ $method->setAccessible(true);
+
+ return $method->invoke($controller, $input, $existing);
+}
+
+it('previews weekly recurring occurrences from an rrule', function () {
+ $schedule = new RecurringOrderSchedule([
+ 'timezone' => 'Asia/Singapore',
+ 'starts_at' => Carbon::parse('2026-05-04 09:00:00', 'Asia/Singapore'),
+ 'rrule' => 'FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE',
+ ]);
+
+ $occurrences = $schedule->previewOccurrences(
+ Carbon::parse('2026-05-01 00:00:00', 'Asia/Singapore'),
+ Carbon::parse('2026-05-20 23:59:59', 'Asia/Singapore'),
+ 4
+ );
+
+ expect($occurrences->map(fn (Carbon $occurrence) => $occurrence->format('Y-m-d H:i'))->all())
+ ->toBe([
+ '2026-05-04 09:00',
+ '2026-05-06 09:00',
+ '2026-05-11 09:00',
+ '2026-05-13 09:00',
+ ]);
+});
+
+it('normalizes recurring series templates without generated entity payloads', function () {
+ $input = [
+ 'name' => 'Daily replenishment',
+ 'status' => 'active',
+ 'timezone' => 'UTC',
+ 'starts_at' => '2026-05-18T07:00:00.000Z',
+ 'rrule' => 'FREQ=WEEKLY;INTERVAL=1;BYDAY=MO',
+ 'order' => [
+ 'customer_uuid' => 'customer-uuid',
+ 'customer_type' => 'fleet-ops:contact',
+ 'order_config_uuid' => 'config-uuid',
+ 'type' => 'transport',
+ 'payload' => [
+ 'pickup' => [
+ 'uuid' => 'pickup-uuid',
+ 'name' => 'Warehouse',
+ 'address' => '1 Warehouse Road',
+ 'street1' => '1 Warehouse Road',
+ 'created_at' => '2026-01-01T00:00:00.000Z',
+ '_index_resource' => true,
+ 'location' => ['type' => 'Point', 'coordinates' => [103.8, 1.3]],
+ ],
+ 'dropoff' => [
+ 'uuid' => 'dropoff-uuid',
+ 'name' => 'Customer',
+ 'address' => '2 Customer Road',
+ 'street1' => '2 Customer Road',
+ 'updated_at' => '2026-01-01T00:00:00.000Z',
+ 'location' => ['type' => 'Point', 'coordinates' => [103.9, 1.4]],
+ ],
+ 'entities' => [
+ [
+ 'uuid' => 'entity-uuid',
+ 'public_id' => 'entity_public_id',
+ 'payload_uuid' => 'payload-uuid',
+ 'company_uuid' => 'company-uuid',
+ 'customer_uuid' => 'customer-uuid',
+ 'supplier_uuid' => 'supplier-uuid',
+ 'tracking_number_uuid' => 'tracking-uuid',
+ 'barcode' => str_repeat('x', 1024),
+ 'qr_code' => 'qr-code',
+ 'tracking' => ['status' => 'created'],
+ 'name' => 'Gift Box',
+ 'type' => 'parcel',
+ 'sku' => 'SKU-004',
+ 'weight' => '0.5',
+ 'destination_uuid' => 'dropoff-uuid',
+ ],
+ ],
+ ],
+ ],
+ ];
+
+ $normalized = normalizeRecurringSeriesInputForTest($input);
+ $entity = $normalized['template_entities'][0];
+
+ expect($normalized['template_payload']['pickup'])->toHaveKeys(['uuid', 'name', 'address', 'street1', 'location'])
+ ->and($normalized['template_payload']['pickup'])->not->toHaveKeys(['created_at', '_index_resource'])
+ ->and($entity)->toMatchArray([
+ 'name' => 'Gift Box',
+ 'type' => 'parcel',
+ 'sku' => 'SKU-004',
+ 'weight' => '0.5',
+ 'destination_uuid' => 'dropoff-uuid',
+ ])
+ ->and($entity)->not->toHaveKeys(['uuid', 'public_id', 'payload_uuid', 'company_uuid', 'customer_uuid', 'supplier_uuid', 'tracking_number_uuid', 'barcode', 'qr_code', 'tracking']);
+});
+
+it('normalizes recurring series updates without wiping existing templates', function () {
+ $existing = new RecurringOrderSchedule([
+ 'name' => 'Existing series',
+ 'status' => 'active',
+ 'timezone' => 'UTC',
+ 'starts_at' => Carbon::parse('2026-05-18T07:00:00.000Z'),
+ 'rrule' => 'FREQ=WEEKLY;INTERVAL=1;BYDAY=MO',
+ 'customer_uuid' => 'customer-uuid',
+ 'customer_type' => 'fleet-ops:contact',
+ 'order_config_uuid' => 'config-uuid',
+ 'template_order_meta' => [
+ 'type' => 'transport',
+ ],
+ 'template_payload' => [
+ 'pickup' => ['uuid' => 'pickup-uuid', 'name' => 'Warehouse'],
+ 'dropoff' => ['uuid' => 'dropoff-uuid', 'name' => 'Customer'],
+ 'entities' => [],
+ ],
+ 'template_entities' => [
+ ['name' => 'Gift Box', 'type' => 'parcel'],
+ ],
+ ]);
+
+ $normalized = normalizeRecurringSeriesInputForTest([
+ 'name' => 'Renamed series',
+ 'rrule' => 'FREQ=WEEKLY;INTERVAL=1;BYDAY=TU',
+ ], $existing);
+
+ expect($normalized['name'])->toBe('Renamed series')
+ ->and($normalized['rrule'])->toBe('FREQ=WEEKLY;INTERVAL=1;BYDAY=TU')
+ ->and($normalized['template_payload']['pickup']['uuid'])->toBe('pickup-uuid')
+ ->and($normalized['template_payload']['dropoff']['uuid'])->toBe('dropoff-uuid')
+ ->and($normalized['template_entities'][0]['name'])->toBe('Gift Box');
+});
+
+it('previews monthly recurring occurrences with monthday', function () {
+ $schedule = new RecurringOrderSchedule([
+ 'timezone' => 'UTC',
+ 'starts_at' => Carbon::parse('2026-01-15 08:30:00', 'UTC'),
+ 'rrule' => 'FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=15',
+ ]);
+
+ $occurrences = $schedule->previewOccurrences(
+ Carbon::parse('2026-01-01 00:00:00', 'UTC'),
+ Carbon::parse('2026-04-30 23:59:59', 'UTC'),
+ 4
+ );
+
+ expect($occurrences->map(fn (Carbon $occurrence) => $occurrence->format('Y-m-d H:i'))->all())
+ ->toBe([
+ '2026-01-15 08:30',
+ '2026-02-15 08:30',
+ '2026-03-15 08:30',
+ '2026-04-15 08:30',
+ ]);
+});
diff --git a/tests/unit/utils/recurring-rrule-test.js b/tests/unit/utils/recurring-rrule-test.js
new file mode 100644
index 000000000..c258ab7f7
--- /dev/null
+++ b/tests/unit/utils/recurring-rrule-test.js
@@ -0,0 +1,24 @@
+import { module, test } from 'qunit';
+import { buildRrule, parseRrule } from 'dummy/utils/recurring-rrule';
+
+module('Unit | Utility | recurring-rrule', function () {
+ test('it builds weekly recurring rules with weekdays and until', function (assert) {
+ const rrule = buildRrule({
+ frequency: 'weekly',
+ interval: 2,
+ weekdays: ['MO', 'WE'],
+ until: '2026-05-31T00:00:00.000Z',
+ });
+
+ assert.strictEqual(rrule, 'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;UNTIL=20260531T000000Z');
+ });
+
+ test('it parses recurring rule parts into editable state', function (assert) {
+ const parsed = parseRrule('FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=15');
+
+ assert.strictEqual(parsed.frequency, 'monthly');
+ assert.strictEqual(parsed.interval, 1);
+ assert.deepEqual(parsed.weekdays, []);
+ assert.strictEqual(parsed.monthday, 15);
+ });
+});
diff --git a/translations/en-us.yaml b/translations/en-us.yaml
index 104c6bd98..041afcf0c 100644
--- a/translations/en-us.yaml
+++ b/translations/en-us.yaml
@@ -64,6 +64,7 @@ menu:
orders: Orders
service-rates: Service Rates
scheduler: Scheduler
+ recurring-orders: Recurring Orders
order-config: Order Config
resources: Resources
drivers: Drivers
@@ -151,6 +152,8 @@ resource:
maintenances: Maintenances
maintenance-schedule: Maintenance Schedule
maintenance-schedules: Maintenance Schedules
+ recurring-order-schedule: Recurring Order Schedule
+ recurring-order-schedules: Recurring Order Schedules
order-config: Order Config
order-configs: Order Configs
order: Order
@@ -2179,4 +2182,4 @@ orchestrator:
stop-type-pickup: Pickup
stop-type-dropoff: Dropoff
pod-required: POD
- no-pod: No POD
+ no-pod: No POD
\ No newline at end of file