diff --git a/addon/components/order/panel-header.hbs b/addon/components/order/panel-header.hbs
index 21940ee15..44ae0d1df 100644
--- a/addon/components/order/panel-header.hbs
+++ b/addon/components/order/panel-header.hbs
@@ -10,6 +10,9 @@
+ {{#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..484177fa7
--- /dev/null
+++ b/addon/components/recurring-order-schedule/details.hbs
@@ -0,0 +1,82 @@
+
+
+
+
+
ID
+
{{n-a @resource.public_id}}
+
+
+
Status
+
{{smart-humanize @resource.status}}
+
+
+
Timezone
+
{{n-a @resource.timezone}}
+
+
+
Next Occurrence
+
{{n-a @resource.next_occurrence_at}}
+
+
+
Customer
+
{{n-a @resource.customer.name}}
+
+
+
Order Type
+
{{n-a @resource.order_config.name}}
+
+
+
Service Rate
+
{{n-a @resource.service_rate.service_name "No default service rate"}}
+
+
+
Starts At
+
{{n-a @resource.starts_at}}
+
+
+
Ends At
+
{{n-a @resource.ends_at}}
+
+
+
Recurrence Rule
+
{{n-a @resource.rrule}}
+
+
+
+
+
+ {{#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 schedule.
+ {{/if}}
+
+
+ {{#if @resource.description}}
+
+ {{@resource.description}}
+
+ {{/if}}
+
+
+
\ No newline at end of file
diff --git a/addon/components/recurring-order-schedule/details.js b/addon/components/recurring-order-schedule/details.js
new file mode 100644
index 000000000..4d5f85d54
--- /dev/null
+++ b/addon/components/recurring-order-schedule/details.js
@@ -0,0 +1,28 @@
+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 ?? [];
+ }
+
+ @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..68b224523 100644
--- a/addon/controllers/operations/orders/index.js
+++ b/addon/controllers/operations/orders/index.js
@@ -5,6 +5,7 @@ import { action } from '@ember/object';
export default class OperationsOrdersIndexController extends Controller {
@service orderActions;
+ @service recurringOrderScheduleActions;
@service orderSocketEvents;
@service leafletMapManager;
@service mapDrawer;
@@ -77,10 +78,28 @@ export default class OperationsOrdersIndexController extends Controller {
helpText: this.intl.t('common.refresh'),
},
{
- text: this.intl.t('common.new'),
- type: 'primary',
+ icon: 'calendar-days',
+ wrapperClass: 'hidden md:flex',
+ helpText: 'View recurring order schedules',
+ onClick: () => this.recurringOrderScheduleActions.modal.manage(),
+ },
+ {
icon: 'plus',
- onClick: this.orderActions.transition.create,
+ type: 'primary',
+ text: this.intl.t('common.new'),
+ renderInPlace: true,
+ items: [
+ {
+ label: 'Create new order',
+ icon: 'plus',
+ onClick: this.orderActions.transition.create,
+ },
+ {
+ label: 'Create recurring schedule',
+ icon: 'arrows-rotate',
+ onClick: () => this.recurringOrderScheduleActions.modal.create(),
+ },
+ ],
},
{
text: this.intl.t('common.export'),
@@ -385,6 +404,15 @@ export default class OperationsOrdersIndexController extends Controller {
permission: 'fleet-ops dispatch order',
isVisible: (order) => order.canBeDispatched,
},
+ {
+ label: 'Create recurring schedule',
+ icon: 'arrows-rotate',
+ fn: (order) => this.recurringOrderScheduleActions.modal.createFromOrder(order),
+ permission: 'fleet-ops create recurring-order-schedule',
+ },
+ {
+ separator: true,
+ },
{
label: this.intl.t('common.cancel-resource', { resource: this.intl.t('resource.order') }),
icon: 'ban',
diff --git a/addon/controllers/operations/orders/index/details.js b/addon/controllers/operations/orders/index/details.js
index b9a1255b1..d3259745c 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: 'Create recurring schedule',
+ icon: 'arrows-rotate',
+ fn: () => this.recurringOrderScheduleActions.modal.createFromOrder(this.model),
+ },
{
separator: true,
},
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/services/recurring-order-schedule-actions.js b/addon/services/recurring-order-schedule-actions.js
new file mode 100644
index 000000000..6eaaacefc
--- /dev/null
+++ b/addon/services/recurring-order-schedule-actions.js
@@ -0,0 +1,206 @@
+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,
+ });
+ },
+ };
+
+ 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);
+ }
+ }
+}
diff --git a/addon/utils/recurring-order-blueprint.js b/addon/utils/recurring-order-blueprint.js
new file mode 100644
index 000000000..b8e07e1c7
--- /dev/null
+++ b/addon/utils/recurring-order-blueprint.js
@@ -0,0 +1,145 @@
+import serializeModel from '@fleetbase/ember-core/utils/serialize-model';
+import serializeArray from '@fleetbase/ember-core/utils/serialize-model-array';
+
+function normalizeCustomerType(order) {
+ if (order.customer_type) {
+ return order.customer_type;
+ }
+
+ const customerType = order.customer?.customer_type;
+ return customerType ? `fleet-ops:${customerType}` : null;
+}
+
+function normalizeFacilitatorType(order) {
+ if (order.facilitator_type) {
+ return order.facilitator_type;
+ }
+
+ const facilitatorType = 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;
+ }
+
+ return store.createRecord('place', {
+ ...place,
+ id: place.id ?? place.public_id ?? undefined,
+ });
+}
+
+function createDraftWaypoint(store, waypoint) {
+ return store.createRecord('waypoint', {
+ ...waypoint,
+ id: waypoint.id ?? waypoint.public_id ?? undefined,
+ place: createDraftPlace(store, waypoint.place),
+ });
+}
+
+function createDraftEntity(store, entity) {
+ return store.createRecord('entity', {
+ ...entity,
+ id: entity.id ?? entity.public_id ?? undefined,
+ });
+}
+
+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 order = store.createRecord('order', {
+ customer: source.customer ?? null,
+ customer_uuid: source.customer_uuid ?? source.customer?.id ?? null,
+ customer_type: source.customer_type ?? normalizeCustomerType(source),
+ facilitator: source.facilitator ?? null,
+ facilitator_uuid: source.facilitator_uuid ?? source.facilitator?.id ?? null,
+ facilitator_type: source.facilitator_type ?? normalizeFacilitatorType(source),
+ order_config: source.order_config ?? source.orderConfig ?? null,
+ order_config_uuid: source.order_config_uuid ?? source.order_config?.id ?? null,
+ driver_assigned: source.driver_assigned ?? source.driverAssigned ?? null,
+ driver_assigned_uuid: source.driver_assigned_uuid ?? source.driver_assigned?.id ?? null,
+ vehicle_assigned: source.vehicle_assigned ?? source.vehicleAssigned ?? null,
+ vehicle_assigned_uuid: source.vehicle_assigned_uuid ?? source.vehicle_assigned?.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: serializeModel(payload.pickup),
+ dropoff: serializeModel(payload.dropoff),
+ return: serializeModel(payload.return),
+ waypoints: serializeArray(payload.waypoints),
+ entities: serializeArray(payload.entities),
+ 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 ?? {},
+ },
+ };
+}
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/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/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/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/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/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..b3bca6eb2
--- /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/Internal/v1/RecurringOrderScheduleController.php b/server/src/Http/Controllers/Internal/v1/RecurringOrderScheduleController.php
new file mode 100644
index 000000000..8345563c0
--- /dev/null
+++ b/server/src/Http/Controllers/Internal/v1/RecurringOrderScheduleController.php
@@ -0,0 +1,170 @@
+validateRecurringSchedulePayload($request);
+ if ($validationError) {
+ return $validationError;
+ }
+
+ return parent::createRecord($request);
+ }
+
+ public function updateRecord(Request $request, string $id)
+ {
+ $validationError = $this->validateRecurringSchedulePayload($request);
+ if ($validationError) {
+ return $validationError;
+ }
+
+ return parent::updateRecord($request, $id);
+ }
+
+ 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 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));
+
+ return response()->json([
+ 'occurrences' => $this->buildUpcomingOccurrences($schedule, $limit),
+ ]);
+ }
+
+ protected function validateRecurringSchedulePayload(Request $request): ?JsonResponse
+ {
+ $input = $request->input('recurring_order_schedule', $request->all());
+ $validator = Validator::make($input, [
+ 'name' => ['required', 'string'],
+ 'rrule' => ['required', 'string'],
+ 'timezone' => ['required', 'string'],
+ 'starts_at' => ['required', 'date'],
+ 'order' => ['required', 'array'],
+ ]);
+
+ if ($validator->fails()) {
+ return response()->error($validator->errors()->all());
+ }
+
+ return null;
+ }
+}
diff --git a/server/src/Http/Resources/v1/Index/Order.php b/server/src/Http/Resources/v1/Index/Order.php
index a073fa8a1..8cd98ab69 100644
--- a/server/src/Http/Resources/v1/Index/Order.php
+++ b/server/src/Http/Resources/v1/Index/Order.php
@@ -35,6 +35,9 @@ public function toArray($request): array
'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,
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..fb3203beb
--- /dev/null
+++ b/server/src/Http/Resources/v1/Index/RecurringOrderSchedule.php
@@ -0,0 +1,38 @@
+ $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' => $this->whenLoaded('customer', fn () => $this->customer),
+ 'order_config' => $this->whenLoaded('orderConfig', fn () => $this->orderConfig),
+ 'service_rate' => $this->whenLoaded('serviceRate', fn () => $this->serviceRate),
+ 'next_occurrence_at' => $this->next_occurrence_at,
+ '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..284882564 100644
--- a/server/src/Http/Resources/v1/Order.php
+++ b/server/src/Http/Resources/v1/Order.php
@@ -46,6 +46,9 @@ public function toArray($request): array
'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),
@@ -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..e7e902f8f
--- /dev/null
+++ b/server/src/Http/Resources/v1/RecurringOrderSchedule.php
@@ -0,0 +1,52 @@
+ $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' => $this->whenLoaded('customer', fn () => $this->customer),
+ 'facilitator' => $this->whenLoaded('facilitator', fn () => $this->facilitator),
+ 'order_config' => $this->whenLoaded('orderConfig', fn () => $this->orderConfig),
+ 'driver_assigned' => $this->whenLoaded('driverAssigned', fn () => $this->driverAssigned),
+ 'vehicle_assigned' => $this->whenLoaded('vehicleAssigned', fn () => $this->vehicleAssigned),
+ 'service_rate' => $this->whenLoaded('serviceRate', fn () => $this->serviceRate),
+ 'template_order_meta' => $this->template_order_meta ?? [],
+ 'template_payload' => $this->template_payload ?? [],
+ 'template_entities' => $this->template_entities ?? [],
+ 'upcoming_occurrences' => $this->when($isInternal, $this->getUpcomingOccurrences((int) $request->input('upcoming_limit', 25))),
+ 'next_occurrence_at' => $this->next_occurrence_at,
+ 'meta' => $this->meta ?? [],
+ 'created_at' => $this->created_at,
+ 'updated_at' => $this->updated_at,
+ ];
+ }
+}
diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php
index 3a3c97c37..77fbb49b6 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',
@@ -217,6 +220,7 @@ class Order extends Model
'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.
*
diff --git a/server/src/Models/RecurringOrderSchedule.php b/server/src/Models/RecurringOrderSchedule.php
new file mode 100644
index 000000000..90067f3c3
--- /dev/null
+++ b/server/src/Models/RecurringOrderSchedule.php
@@ -0,0 +1,407 @@
+ 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 getApiPayloadFromRequest(Request $request): array
+ {
+ $input = parent::getApiPayloadFromRequest($request);
+
+ return $this->normalizeApiPayload($input);
+ }
+
+ public function updateRecordFromRequest(Request $request, $id, ?callable $onBefore = null, ?callable $onAfter = null, array $options = [])
+ {
+ $builder = $this->where(function ($q) use ($id) {
+ $publicIdColumn = $this->getQualifiedPublicId();
+
+ $q->where($this->getQualifiedKeyName(), $id);
+ if ($this->isColumn($publicIdColumn)) {
+ $q->orWhere($publicIdColumn, $id);
+ }
+ });
+
+ $companyUuid = session('company');
+ if ($companyUuid && $this->isColumn($this->qualifyColumn('company_uuid'))) {
+ $builder->where($this->qualifyColumn('company_uuid'), $companyUuid);
+ }
+
+ $builder = $this->applyDirectivesToQuery($request, $builder);
+ $record = $builder->first();
+
+ if (!$record) {
+ throw new \Exception($this->getApiHumanReadableName() . ' not found');
+ }
+
+ $input = parent::getApiPayloadFromRequest($request);
+ $input = $this->normalizeApiPayload($input, $record);
+ $input = $this->fillSessionAttributes($input, [], ['updated_by_uuid']);
+
+ if (is_callable($onBefore)) {
+ $before = $onBefore($request, $record, $input);
+ if ($before instanceof \Illuminate\Http\JsonResponse) {
+ return $before;
+ }
+ }
+
+ $keys = array_keys($input);
+
+ foreach ($keys as $key) {
+ if ($this->isInvalidUpdateParam($key)) {
+ throw new \Exception('Invalid param "' . $key . '" in update request!');
+ }
+ }
+
+ $input = \Illuminate\Support\Arr::except($input, ['uuid', 'public_id', 'deleted_at', 'updated_at', 'created_at']);
+ try {
+ $record->update($input);
+ } catch (\Exception $e) {
+ throw new \Exception(app()->hasDebugModeEnabled() ? $e->getMessage() : 'Failed to update ' . $this->getApiHumanReadableName());
+ }
+
+ if (isset($options['return_object']) && $options['return_object'] === true) {
+ return $record;
+ }
+
+ if (is_callable($onAfter)) {
+ $after = $onAfter($request, $record, $input);
+ if ($after instanceof \Illuminate\Http\JsonResponse) {
+ return $after;
+ }
+ }
+
+ $record->refresh();
+
+ $with = $request->or(['with', 'expand'], []);
+ if (!empty($with)) {
+ $record->load($with);
+ }
+
+ $withCount = $request->array('with_count', []);
+ if (!empty($withCount)) {
+ $record->loadCount($withCount);
+ }
+
+ return static::mutateModelWithRequest($request, $record);
+ }
+
+ protected function normalizeApiPayload(array $input, ?self $existing = null): array
+ {
+ $order = (array) ($input['order'] ?? []);
+ $payload = (array) data_get($order, 'payload', []);
+
+ if (!$existing && empty($order)) {
+ throw new \Exception('Recurring order schedule requires an order payload.');
+ }
+
+ 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' => [
+ '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' => 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' => 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)),
+ ],
+ 'template_payload' => [
+ 'pickup' => data_get($payload, 'pickup', data_get($existing?->template_payload, 'pickup')),
+ 'dropoff' => data_get($payload, 'dropoff', data_get($existing?->template_payload, 'dropoff')),
+ 'return' => data_get($payload, 'return', data_get($existing?->template_payload, 'return')),
+ 'waypoints' => array_values((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' => data_get($payload, 'meta', data_get($existing?->template_payload, 'meta', [])),
+ ],
+ 'template_entities' => array_values((array) data_get($payload, 'entities', $existing?->template_entities ?? [])),
+ 'meta' => array_merge((array) data_get($existing, 'meta', []), (array) data_get($input, 'meta', [])),
+ 'updated_by_uuid' => session('user'),
+ 'created_by_uuid' => $existing?->created_by_uuid ?: session('user'),
+ ];
+ }
+}
diff --git a/server/src/Models/RecurringOrderScheduleOccurrence.php b/server/src/Models/RecurringOrderScheduleOccurrence.php
new file mode 100644
index 000000000..f662d39d7
--- /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/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..cb62339a2
--- /dev/null
+++ b/server/src/Support/RecurringOrderMaterializationService.php
@@ -0,0 +1,287 @@
+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
+ {
+ return DB::transaction(function () use ($schedule, $occurrenceUtc) {
+ $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/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..15197de4a
--- /dev/null
+++ b/server/tests/RecurringOrderScheduleTest.php
@@ -0,0 +1,48 @@
+ '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('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