Skip to content

Stock Management

Overview

Stock Management is the behavioural side of inventory: how tracked items are deducted when orders are placed, how stock is gated at checkout, and how out-of-stock items behave on Online Ordering and Kiosk. The underlying data (records, history, the Overview/History screens) is described in Inventory.

Key Purpose: Keep customer-facing availability correct by deducting stock on orders, blocking over-selling, and respecting per-order and out-of-stock rules.

Purpose

This covers what happens when stock changes: automatic deduction on committed orders, the checkout stock check, per-order maximum enforcement, and edge synchronisation.

Key Concepts

  • Tracking = a record exists: An item is stock-managed only when it has an inventory record at that location. Item::getTrackQuantity() returns (bool) getInventory() (RawModels/Item.php:544). There is no stored "track stock" toggle.
  • Stock = replayed history: The current quantity is recomputed from the inventory history (Restock adds, Bought/Depletion subtract) rather than read from a single mutable counter (Services/Inventory/InventoryService.php:56).
  • Untracked items are unlimited: An item with no inventory record is never blocked; Item::getStock() returns 99999 in that case (RawModels/Item.php:529).
  • Max Order Limit: A per-item cap on quantity in a single order, enforced when stock is managed (InventoryService.php:344, Item::getMaxOrderLimit() RawModels/Item.php:264).
  • D1 Sync + ReloadMenu: After any stock change the new quantity is pushed to Cloudflare D1 and a ReloadMenu event fires so ordering channels reflect availability (InventoryService.php:227).
  • There is no per-item out-of-stock snooze: no snooze_time_out_of_stock_items field and no SnoozeOutOfStockItemOptions enum exist anywhere in the backend. Every snooze in the product is a channel pause: the single SnoozeOptions enum (accept / 20 / 40 / 60 min / rest-of-day / dont_accept) drives the Online Ordering pause (SnoozeOnlineOrderingRequest, POST /back-office/online-ordering/{locationId}/snooze) and the Table QR pause (SnoozeQrOrderingRequest, POST /back-office/qr-ordering/{locationId}/snooze), which write online_ordering_setting.snoozed_until and qr_ordering_setting.snoozed_until and are independent of each other. Neither touches individual out-of-stock items; a tracked item simply becomes unavailable at quantity 0. (Verified: app/Enums/SnoozeOptions.php is the only snooze enum under app/Enums/; OnlineOrderingService::snooze, QrOrderingService::snooze; routes/api/backoffice/online-ordering.php:10, routes/api/backoffice/qr-ordering.php:11.)

Actions

Deduct Stock on an Order (automatic)

When a customer order is committed, the system groups ordered quantities per item (including linked items referenced by sub-modifiers), and for each tracked item writes a Bought history entry and recomputes the quantity (InventoryService.php:316). Untracked items are skipped.

Check Stock at Checkout

Before committing, the system checks each tracked item: if the remaining stock would go below zero and selling-without-stock is not allowed, it aborts with HTTP 400 "Not enough remaining stock for :itemName" (InventoryService.php:368).

Enforce Per-Order Maximum

If an item has a maxOrderLimit and the ordered quantity exceeds it, the order is rejected with a 400 stating the maximum allowed (InventoryService.php:344).

Adjust Stock Manually

Saving a new target quantity from the Overview screen derives a Restock or Depletion and recomputes the stock. See Inventory for the screen and payload details.

Location

  • Back-office Routes: /inventory/overview and /inventory/history (nav target inventory-overview). There is no bare /inventory page.
  • Backend Controller: app/Http/Controllers/Api/InventoryController.php
  • Stock service: app/Services/Inventory/InventoryService.php
  • Vue Components: src/views/inventory/Overview.vue, src/views/inventory/History.vue

Stock Operations

Restock (increase)

Saving a higher target quantity derives a Restock entry.

Current quantity: 10
Save target: 30
→ Restock entry of 20, new quantity 30

Depletion (decrease)

Saving a lower target quantity derives a Depletion entry.

Current quantity: 30
Save target: 25
→ Depletion entry of 5, new quantity 25

Bought (automatic deduction)

A committed order writes a Bought entry per tracked item.

Customer orders 2x Pizza (tracked)
Current quantity: 10
→ Bought entry of 2, new quantity 8

Business Logic

Order Stock Flow

Order committed


Group ordered quantity per item
(includes linked items via sub-modifiers)


For each item:
├── maxOrderLimit set and exceeded? → abort 400 (max allowed)

├── item status inactive/unavailable/hidden?
│       ├── pre-commit check → abort 400 "currently unavailable"
│       └── commit phase → log warning, continue

└── has inventory record (tracked)?
        ├── No  → skip (unlimited)
        └── Yes → remaining = current - ordered
                  ├── remaining < 0 and not sell-without-stock (check) → abort 400
                  └── commit → write Bought entry, recompute quantity
                              (logs a warning if it goes negative)

Availability on the Storefront

Customer-facing availability is derived from the recomputed stock and expiry. Item::getStock(forCustomerFacing: true) returns 0 for expired stock and the quantity otherwise; an item with no inventory record returns 99999 (always available) (RawModels/Item.php:529). After any change, D1 is updated and ReloadMenu fires so channels refresh.

Storefront-only: exactly how an out-of-stock item is shown or hidden, and any cart re-validation behaviour, is handled by the Online Ordering and Kiosk surfaces and is out of scope for this backend feature.


Customer Impact

Online Ordering & Kiosk

  • A tracked item at quantity 0 is treated as out of stock; how each storefront presents it is surface behaviour, not a backend setting.
  • An expired record reports 0 stock for customers even if a quantity is stored.
  • Untracked items (no inventory record) are always available.
  • Stock changes propagate via Cloudflare D1 + ReloadMenu, so channels reflect availability after a change.

Relations

Depends On

  • Inventory: Records and history that hold the quantity (see Inventory)
  • Menu Items: Stock is keyed by item; maxOrderLimit and status come from the item
  • Locations: Quantities are per location

Affects

  • Online Ordering: Item availability and checkout gating
  • Kiosk: Item availability
  • Transactions: A committed order deducts stock (Bought history)
  • Cloudflare D1: Edge availability cache

Business Rules

  • Stock is recomputed from inventory history (Restock adds, Bought/Depletion subtract); a negative result is rejected with 400 "Not enough remaining stock for :itemName" unless selling-without-stock is allowed (InventoryService.php:56, InventoryService.php:368).
  • Only items with an inventory record are stock-gated; an item without one is unlimited (Item::getStock() returns 99999, RawModels/Item.php:529).
  • maxOrderLimit, when set, caps the quantity per single order and returns a 400 with the maximum when exceeded (InventoryService.php:344).
  • A committed order writes a Bought history entry per tracked item and recomputes the quantity; at commit time a negative result is logged (not aborted) when selling-without-stock applies (InventoryService.php:370).
  • Every stock change pushes the new quantity to Cloudflare D1 and fires a ReloadMenu event (InventoryService.php:227).

FAQs

  • "What happens when a customer orders the last unit?" The order writes a Bought history entry and the quantity reaches 0; the item is then out of stock and each storefront decides how to present it.
  • "Can I sell an item that has no inventory record?" Yes — items without a record are not stock-gated and are treated as unlimited.
  • "What is the difference between Depletion and Bought?" Bought is the automatic deduction when an order is committed; Depletion is a manual downward adjustment.
  • "How is a per-order quantity limit enforced?" Via the item's maxOrderLimit; exceeding it returns a 400 with the maximum allowed.
  • "How fast do stock changes reach customers?" Each change updates Cloudflare D1 and fires ReloadMenu, so channels reflect the new availability shortly after.
  • "Can I snooze a single out-of-stock item?" No. The only snooze controls are the Online Ordering and Table QR channel pauses, which pause the whole channel rather than one item.

Troubleshooting

Problem: Item shows out of stock incorrectly

Causes:

  1. Quantity is actually 0 (replay the history to confirm).
  2. The record has expired — expired stock reports 0 for customers.
  3. Edge cache (D1) not yet refreshed.

Solutions:

  1. Save a higher target quantity (a Restock).
  2. Clear or extend the expiry on the record.
  3. Saving any change re-pushes to D1 and fires ReloadMenu.

Problem: Stock not deducting on orders

Causes:

  1. The item has no inventory record, so it is not tracked.
  2. The item was added under a different location.

Solutions:

  1. Add an inventory record for the item at the location.
  2. Confirm the item/location pairing.

Examples

Tracked item (stored record)

json
{
  "item_id": "item-123",
  "location_id": "location-amsterdam",
  "quantity": 25
}

A committed order of 2 writes a Bought entry and the quantity becomes 23.