Appearance
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).
- 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
PUTtoshop.workOrders.storage-ticket.update, which creates the ticket on the first write and links it throughwork_order_lines.storage_ticket_id. - Outbound line (the set goes back). The panel offers the car's open tickets (
SelectStorageTicket.vue, fed byapi.car.storage-tickets) and links the chosen one withshop.workOrders.storage-ticket.assign(AssignStorageTicket). Its fields are read-only. - Billing.
BillWorkOrderrunsProcessWorkOrderStorage: for every service line with a ticket, an inbound service setsdate_in, an outbound one setsdate_out, both to the work order'sinvoice_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:
| State | Meaning |
|---|---|
| free | no open ticket on the slot — the only clickable state |
| occupied | an open ticket with date_in set |
| reserved | an open ticket with date_in null (its work order is not billed yet) |
| current | the 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
| Table | Columns that carry meaning |
|---|---|
storage_spaces | shop_id, name, capacity — the sum of rows * columns over its sections, kept by SyncCapacity on StorageSectionSaved |
storage_sections | storage_space_id, name, rows, columns |
storage_tickets | storage_space_id, storage_section_id, row, column, set_of_tire_id, code, location, note, date_in, date_out, soft deletes |
set_of_tires / tires | the car's sets (type: winter, summer, all_season) and one tire per position |
work_order_lines.storage_ticket_id | the 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 byStorageTicketController::updatewheneverstorage_section_id,rowandcolumnare all present. It rejects a section that belongs to another storage space thanstorage_space_id, a row or column beyond the section's size, and a slot held by another ticket with nodate_out(the ticket being edited is ignored).- The partial unique index
storage_tickets_open_slot_uniqueon(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:
date_outset →closedworkOrderLineOutloaded and present →planneddate_inolder than a year →overduedate_inset →stored- 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
| Kind | Value | Source |
|---|---|---|
| Route | PUT shop.workOrders.storage-ticket.update | StorageTicketController::update |
| Route | PUT shop.workOrders.storage-ticket.assign | AssignStorageTicket |
| Route | GET shop.storage-spaces.occupied-slots | GetOccupiedStorageSlotsController |
| Gate | module:tire_storage on shop.storage-spaces.*, shop.workOrders.storage-ticket.update and .assign; WithinModuleLimit(Module::STORAGE_SPACES) on StorageSpaceController@store | routes/webShop.php, Plans & modules |
| Route | GET api.car.set-of-tires, GET api.car.storage-tickets | SetOfTiresController, GetCarStorageTicketsController |
| Action | EnsureStorageSlotIsAvailable, ProcessWorkOrderStorage | app/Actions/WorkOrder/ |
| Listener | SyncCapacity on StorageSectionSaved | app/Listeners/StorageSpace/SyncCapacity.php |
| Data | StorageTicketData, OccupiedSlotData, SetOfTireData | app/Data/ |
Permissions
- The storage ticket routes authorize
updateon 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. AssignStorageTicket404s 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 behindmodule:tire_storage(403 when the shop's plan leaves it off). Creating a storage space is also capped by thestorage_spacescount limit, a validation error onname. See Plans & modules. occupied-slotsauthorizesviewAnyonStorageSpace(shop users) and filters tickets throughstorageSpace→forCurrentShop(), which throws without a current shop.
Gotchas
- JSON endpoints return raw enum values.
hydrateEnums()only runs on Inertia page props. ASetOfTireDatafetched fromapi.car.set-of-tirescarriestype: 'winter', not an enum object — the panel reads its label withSetOfTireType.from(set.type).label. plannedneeds the relation loaded. The accessor only readsworkOrderLineOutwhen it is already loaded, to avoid a query per ticket. A ticket serialized without it reportsstoredoroverdueeven when a pickup is booked; eager loadworkOrderLineOutwherever 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:
DeleteStorageTicketsoft-deletes a ticket with nodate_inalong 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, whoseopen_recalls_countcounts recalls with two queries per car unlessrecallsandmakeRecallsare loaded.StorageSpaceController::showloads both; keep them when changing that query.
Related
- Multi-tenancy — shop scoping and route model binding
- Documents — storage lines on invoice totals