Skip to content

Tire storage ​

A shop keeps a client's off-season tire set on its premises between two swaps. The set is recorded as a storage ticket: which set, which storage space, which slot (section, row, column) and a tag code. A ticket is opened by the work order that brings the set in and closed by the work order that hands it back.

How it works ​

Storage rides on services, not products. services.storage_direction (App\Entities\ServiceStorageDirection: inbound, outbound) marks a service as a storage service. A service line on a work order whose service has a direction renders the storage panel resources/js/Elements/StorageTicket.vue (from ServiceLineCard.vue).

  1. Inbound line (the set comes in). The panel shows the ticket's slot and code, and lets the user pick one of the car's tire sets or create one, then pick a slot. Each choice is a PUT to shop.workOrders.storage-ticket.update, which creates the ticket on the first write and links it through work_order_lines.storage_ticket_id.
  2. Outbound line (the set goes back). The panel offers the car's open tickets (SelectStorageTicket.vue, fed by api.car.storage-tickets) and links the chosen one with shop.workOrders.storage-ticket.assign (AssignStorageTicket). Its fields are read-only.
  3. Billing. BillWorkOrder runs ProcessWorkOrderStorage: for every service line with a ticket, an inbound service sets date_in, an outbound one sets date_out, both to the work order's invoice_date (or now).

A ticket is therefore only stored once the inbound work order is billed. Before that it holds its slot as reserved.

Picking a slot ​

resources/js/Modals/Shop/SelectStorageSlot.vue lists the shop's storage spaces and draws every section as a rows × columns grid. Spaces and sections come from the shared Inertia prop current.storageSpaces (loaded with storageSpaces.sections in HandleInertiaRequests); occupancy comes from GET shop.storage-spaces.occupied-slots (GetOccupiedStorageSlotsController), which returns an OccupiedSlotData per open ticket that has a slot:

StateMeaning
freeno open ticket on the slot — the only clickable state
occupiedan open ticket with date_in set
reservedan open ticket with date_in null (its work order is not billed yet)
currentthe slot of the ticket being edited

A space with no sections falls back to a free-text location.

Creating a tire set from the panel ​

"New set" opens the car's tire set drawer (Modals/FormSetOfTires.vue, posting to car.set-of-tires.store). On save the panel re-fetches the car's sets (api.car.set-of-tires) and assigns the newest one to the ticket.

Data model ​

TableColumns that carry meaning
storage_spacesshop_id, name, capacity — the sum of rows * columns over its sections, kept by SyncCapacity on StorageSectionSaved
storage_sectionsstorage_space_id, name, rows, columns
storage_ticketsstorage_space_id, storage_section_id, row, column, set_of_tire_id, code, location, note, date_in, date_out, soft deletes
set_of_tires / tiresthe car's sets (type: winter, summer, all_season) and one tire per position
work_order_lines.storage_ticket_idthe line that brings the set in or takes it out

StorageTicket::workOrderLine() is the line without appointment_start_at (the drop-off), workOrderLineOut() the one with it (the scheduled pickup).

Code. A ticket created without a code gets TAG-<id> in StorageTicket::booted(). The request still accepts an explicit code.

One open ticket per slot. Enforced twice:

  • EnsureStorageSlotIsAvailable (app/Actions/WorkOrder/), run by StorageTicketController::update whenever storage_section_id, row and column are all present. It rejects a section that belongs to another storage space than storage_space_id, a row or column beyond the section's size, and a slot held by another ticket with no date_out (the ticket being edited is ignored).
  • The partial unique index storage_tickets_open_slot_unique on (storage_section_id, "row", "column") WHERE date_out IS NULL AND deleted_at IS NULL, which catches two saves racing past the validation. Closed tickets keep their coordinates as history.

Status ​

StorageTicket::status is computed, in this order:

  1. date_out set → closed
  2. workOrderLineOut loaded and present → planned
  3. date_in older than a year → overdue
  4. date_in set → stored
  5. otherwise → null (reserved, not checked in)

The storage space page (StorageSpaceController::show) counts its planned and overdue stats from this same attribute, so a pickup scheduled on an overdue set counts as planned.

Technical surface ​

KindValueSource
RoutePUT shop.workOrders.storage-ticket.updateStorageTicketController::update
RoutePUT shop.workOrders.storage-ticket.assignAssignStorageTicket
RouteGET shop.storage-spaces.occupied-slotsGetOccupiedStorageSlotsController
Gatemodule:tire_storage on shop.storage-spaces.*, shop.workOrders.storage-ticket.update and .assign; WithinModuleLimit(Module::STORAGE_SPACES) on StorageSpaceController@storeroutes/webShop.php, Plans & modules
RouteGET api.car.set-of-tires, GET api.car.storage-ticketsSetOfTiresController, GetCarStorageTicketsController
ActionEnsureStorageSlotIsAvailable, ProcessWorkOrderStorageapp/Actions/WorkOrder/
ListenerSyncCapacity on StorageSectionSavedapp/Listeners/StorageSpace/SyncCapacity.php
DataStorageTicketData, OccupiedSlotData, SetOfTireDataapp/Data/

Permissions ​

  • The storage ticket routes authorize update on the work order. Every id they accept is checked against the current shop: BelongsToShop(StorageSpace), SetOfTireBelongsToShop, StorageSectionBelongsToShop. The slot check only runs after that validation, so another shop's tickets can neither block a slot nor be probed through the error.
  • AssignStorageTicket 404s when the line is not on the work order or the ticket's space belongs to another shop.
  • Plan. Every shop.storage-spaces.* route and both storage ticket routes sit behind module:tire_storage (403 when the shop's plan leaves it off). Creating a storage space is also capped by the storage_spaces count limit, a validation error on name. See Plans & modules.
  • occupied-slots authorizes viewAny on StorageSpace (shop users) and filters tickets through storageSpace → forCurrentShop(), which throws without a current shop.

Gotchas ​

  • JSON endpoints return raw enum values. hydrateEnums() only runs on Inertia page props. A SetOfTireData fetched from api.car.set-of-tires carries type: 'winter', not an enum object — the panel reads its label with SetOfTireType.from(set.type).label.
  • planned needs the relation loaded. The accessor only reads workOrderLineOut when it is already loaded, to avoid a query per ticket. A ticket serialized without it reports stored or overdue even when a pickup is booked; eager load workOrderLineOut wherever the status is shown.
  • A reserved slot stays reserved until billing. An inbound work order that is never billed keeps its ticket's slot out of the picker. Deleting the line frees it: DeleteStorageTicket soft-deletes a ticket with no date_in along with its line, and keeps a checked-in one, which holds a stored set or its history. Declining or abandoning the work order without deleting the line does not.
  • The storage space page eager loads recalls. Serializing a ticket reaches CarData, whose open_recalls_count counts recalls with two queries per car unless recalls and makeRecalls are loaded. StorageSpaceController::show loads both; keep them when changing that query.