Skip to content

Bay scheduling ​

A service line of a work order is booked into a bay for a window of time. Car, client and invoicing all hang off the work order; scheduling hangs off its individual lines, because a single work order routinely spreads across several bays and several days.

Only a service can be booked. WorkOrderLine::isService() gates every scheduling entry point, and UpdateAppointment::authorize() refuses a product, a note or a storage-ticket line outright — an appointment on one of those would still pull the work order into the calendar and into its appointment status.

The columns ​

work_order_lines carries the booking inline; there is no separate appointments table.

ColumnMeaning
appointment_start_atStart of the booked window. NULL means unscheduled.
appointment_end_atEnd of the window. Set by the AppointmentTaken listener when a start is written without one.
bay_idThe bay. NULL means unassigned.
appointment_user_idThe technician. Overwritten with the bay's technician whenever bay_id changes to a bay that has one.
service_statusServiceStatus; rejected, rescheduled and canceled are the values ServiceStatus::canceled() treats as gone.

Two derived values on the model, both worth knowing before touching a duration:

  • planned_duration — the related Service's duration, or WorkOrderLine::DEFAULT_APPOINTMENT_DURATION (30 minutes) when it states none.
  • appointment_duration — the booked window when appointment_end_at is after appointment_start_at, falling back to planned_duration otherwise. Shortening an appointment therefore frees the time its planned length would still have claimed.

work_orders.appointment_at mirrors the earliest appointment_start_at of the work order's lines; the SyncWorkOrderAppointmentAt listener recomputes it on every line save that touches the start.

Booking one line ​

The calendar modal (shop.calendar.show → CalendarController → Pages-less Inertia modal Shop/Calendar) is opened per line, carrying workOrder and workOrderLine in the query string. Clicking a 15-minute slot issues PUT {shop}/work-orders/{workOrder}/{workOrderLine}/appointment (UpdateAppointment).

The whole shop.calendar.* group and both appointment routes sit behind module:appointments; being under shop.workOrders.*, the appointment routes also need module:work_orders. A shop without either gets a 403. The scheduling module is something else — the staff schedule (shop.schedule.*). See Plans & modules.

Two guards sit between the click and the write:

  1. Blocked slots are refused, not confirmed. EventTimeSlot.vue disables the drag and returns early; server-side, UpdateAppointment::withValidator() runs SlotNotBlocked::conflictLabel() over the whole window the appointment will occupy — start plus appointment_duration — and fails validation on appointment_start_at.
  2. Off-schedule slots are confirmed. A slot outside the technician's day, or outside the shop's hours when the bay has no technician, raises the shared confirm dialog first (calendar.off_schedule_warning / calendar.off_schedule_shop_warning). There is a "schedule anyway" branch; for a blocked slot there is not.

The blocked-slot check is deliberately a range check, not a point check. A line with nothing booked yet falls back to its planned duration, so a 15:45 start with a 30-minute service runs into a 16:00 block even though 15:45–16:00 is free. :::

Chaining the rest of the work order ​

Once a line is booked, the calendar offers to schedule the work order's other services behind it. CalendarController::remainingLines() supplies { total, unscheduled } counts to the modal; when total is 0 the dialog never opens and the modal simply closes.

The dialog has three choices, and the middle one only appears when the work order actually mixes scheduled and unscheduled services (0 < unscheduled < total):

Choiceinclude_scheduledEffect
Schedule alltrueEvery other service is re-chained, including ones already booked elsewhere.
Only unscheduledfalseServices with an appointment_start_at keep it.
Leave them as is—Nothing is written; the calendar closes.

Either of the first two issues PUT {shop}/work-orders/{workOrder}/{workOrderLine}/appointment/remaining (ScheduleRemainingLines, behind the same module:appointments gate). Candidates are the work order's service lines, excluding the anchor, excluding cancelled ones, in order — the order they are listed on the work order, not by duration or by priority.

Each candidate is placed at the first window its bay can take after the previous one ends, so the chain is strictly forward-moving: NextBaySlot::after($cursor, $duration), then $cursor becomes that window's end.

The whole loop runs inside DB::transaction(). A chain half-applied would leave services booked against a sequence that no longer exists.

What counts as an available window ​

App\Services\NextBaySlot answers for one bay and resolves each calendar day it inspects once, so chaining N services costs a handful of queries rather than three per service.

A window is available when all of these hold:

  • It fits the service's full appointment_duration. A gap shorter than the service is passed over, not squeezed into.
  • It starts on a 15-minute boundary (STEP_MINUTES), matching the grid the calendar draws.
  • It lies inside the bay's working hours for that day.
  • It overlaps no bay block and no other non-cancelled appointment in that bay.

Working hours come from the technician when the bay has one — UserScheduleResolver::forUser() → DaySchedule::workingIntervals(), which already layers approved shift changes and unavailability over the default week. With no technician the shop's ShopSchedule row for that weekday applies.

A weekday with no saved ShopSchedule row at all leaves the bay open all day ([0, 1440]), not closed. A row with enabled = false is what closes it. This matches what the calendar shades, and it is the opposite of what most readers assume. :::

Busy time is the union of two sources: bay_block_occurrences rows overlapping the day (an all_day occurrence takes the whole day), and work_order_lines booked in the bay. Lines the current run is about to move are passed as ignoredLineIds and excluded — otherwise a "schedule all" would refuse the very slots it is freeing.

The search walks forward up to SEARCH_DAYS (30) calendar days. A service with no window in that horizon is skipped, not failed: it keeps appointment_start_at = NULL and is named in the summary.

Timezones ​

The search runs in the shop's timezone (Shop::resolvedTimezone()), because working hours and block times are wall-clock values in that zone. The moment it returns is converted back to config('app.timezone') before any caller writes it.

Do not return a shop-local CarbonImmutable from this service. Eloquent writes a datetime using the instance's own wall clock without converting it, so an 08:30 Toronto moment lands in the column as 08:30 UTC — four hours early, silently, and only in production where the shop is not on UTC. :::

Reporting back ​

ScheduleRemainingLines::asController() returns JSON and flashes the summary to the session rather than redirecting. The calendar closes itself afterwards, and closing is already an Inertia visit — a redirect of its own would race it. The flashed message names the services pushed to a later day and the ones for which no window was found.

Tenant isolation ​

Isolation here rides on the bay: a bay belongs to exactly one shop, and every writer of work_order_lines.bay_id validates it with the BelongsToShop rule, so a bay-scoped read is shop-scoped.

The action resolves the bay through Bay::forCurrentShop()->find() rather than $anchor->bay, which also avoids following a bay_id into a soft-deleted bay. bay_block_occurrences reads additionally filter on shop_id, keyed off the bay rather than Shop::current() so the service stays usable outside a request.

Route-level scoping is handled by scopeBindings() on the shop group in routes/webShop.php: another shop's {workOrder} or {workOrderLine} is a 404 before any of this runs.

Indexes ​

NextBaySlot reads a bay's bookings one day at a time, up to thirty times per chained work order. work_order_lines_bay_id_appointment_start_at_index exists for exactly that predicate — with only bay_id alone to choose from, the planner takes the time index and demotes bay_id to a post-filter, walking every booking the shop ever made.

Bay blocks are served by bay_block_occurrences_bay_period_idx, a GiST index on (bay_id, period) matching the bay_id = ? plus period && shape both NextBaySlot and SlotNotBlocked issue.

  • Multi-tenancy — how Shop::current() is resolved and faked in tests.
  • Queues & realtime — the model broadcasts that push a new booking to other open calendars.
  • Frontend — the shared confirm dialog this feature extended.