Skip to content

Inventory

Overview

Inventory is the data side of stock tracking in Upvendo: it stores a per-item, per-location quantity record and a full history of every change. The back office surfaces it through two screens, the Overview table and the History log. For the behavioural rules (how stock is checked at checkout, what happens when an item runs out, snooze behaviour), see Stock Management.

Key Purpose: Hold the current stock quantity for each item at each location, and keep an auditable history of every stock movement.

Purpose

These screens let you view current stock levels per location, add or edit an item's tracked quantity and optional expiry, delete (un-track) an item's inventory, and review the full change history.

Key Concepts

  • Inventory Record: A document in the Mongo inventories collection scoped to one item at one location (RawModels/Inventory.php). It stores quantity, reserved_quantity, an optional expiry_date, and denormalised item_name/item_plu. Records are soft-deleted (SOFT_DELETE = true).
  • Tracking = a record exists: There is no stored "track inventory" toggle. An item is considered tracked when it has an inventory record; Item::getTrackQuantity() simply returns (bool) getInventory() (RawModels/Item.php:544). Deleting the record un-tracks the item.
  • Derived track_quantity: track_quantity is exposed in item serialization (RawModels/Item.php:794) but it is derived from the presence of an inventory record, not an editable field.
  • Inventory History: Every change is written as a separate history entry with a type of Bought, Restock, or Depletion (Enums/InventoryHistoryTypes.php). The current quantity is recomputed by replaying these entries.
  • Reserved Quantity: reserved_quantity is a stored numeric field shown in the Overview table (RawModels/Inventory.php:23, views/inventory/Overview.vue:82). It is a stored column only — there is no cart/checkout reservation lifecycle in this code.
  • Live POS Inventory (MplusKassa only): An opt-in mode that overlays live stock numbers pulled from the connected MplusKassa POS onto the Overview table, instead of showing only Upvendo's own replayed quantities. The toggle appears in the Overview toolbar only when the location's active in-house POS is MplusKassa, and the setting is stored per merchant on the integration record as settings.live_pos_inventory_enabled. (Verified: toggle views/inventory/Overview.vue:507-519, gated by isMplusKassa at :123-124; persisted at upvendo-backend app/Services/BackOffice/MplusKassaIntegrationService.php:5801, read back at :901.)

Actions

Add or Edit a Tracked Item

From the Overview screen, open the Add Item dialog and pick an item, then enter a target quantity and optional expiry days (views/inventory/components/AddItemInventoryDialog.vue). Editing an existing row reuses the same dialog. The submitted payload is { item_id, location_id, quantity, expiry_days }.

Delete (Un-track) an Inventory Record

Deleting a row removes the inventory record (soft-delete), deletes the corresponding Cloudflare D1 stock entry, and fires a ReloadMenu event so the item is no longer stock-gated (Services/Inventory/InventoryService.php:122). If the same item is later re-added, the soft-deleted record is restored (InventoryService.php:112).

Review the Overview

The Overview table lists tracked items for the currently selected location with server-side pagination, search by name/PLU, and a quantity-range filter (views/inventory/Overview.vue). On MplusKassa locations the toolbar also carries a Live POS Inventory switch (see below).

Turn On Live POS Inventory (MplusKassa only)

Flip the Live POS Inventory switch in the Overview toolbar to overlay live stock straight from the connected MplusKassa POS. The switch is rendered only when the location's active in-house POS is MplusKassa; other POS providers never see it (views/inventory/Overview.vue:507-519, :123-124).

While it is on:

  • A status bar appears above the table showing a live indicator, the time of the last sync, and a manual Refresh button (Overview.vue:536-565).
  • Live stock is pulled for the items on the current page only, then patched into the table in place, so the cost does not grow with catalog size (Overview.vue:371-395, store mutation PATCH_INVENTORY_STOCKS).
  • The refresh repeats on a 30-second poll (LIVE_POLL_MS: 30000 at Overview.vue:110, loop at :397-401), and also runs immediately after any pagination, search, or filter change (Overview.vue:250).
  • Polling pauses while the browser tab is hidden and resumes with a fresh sync when it becomes visible again (Overview.vue:191-200), and stops when you leave the page (beforeUnmount).
  • Pressing Refresh syncs now and resets the next poll to a full interval from that moment (Overview.vue:411-414).

The toggle is evaluated per location and re-checked whenever you switch location (Overview.vue:176-179, :334-352). Polling errors are swallowed deliberately — a transient POS hiccup is retried on the next poll rather than raising a toast (src/store/modules/inventory.ts:145-162).

  • Read state: GET /back-office/mpluskassa/statuslive_pos_inventory_enabled
  • Toggle: POST /back-office/mpluskassa/toggle-live-inventory with { enabled } (permission manage-integration-settings)
  • Refresh: POST /back-office/mpluskassa/inventory/live-refresh with { location_id, item_ids }{ stocks }

(Verified: upvendo-backoffice src/store/modules/inventory.ts:122-162; upvendo-backend routes/api/backoffice/mpluskassa.php:27-30, MplusKassaIntegrationController::toggleLiveInventory / refreshLiveInventory.)

Review the History

The History screen shows every stock movement (quantity, type, date) and a detail dialog with the entry's note (views/inventory/History.vue, views/inventory/components/InventoryHistory.vue).

Location

  • Back-office Routes: /inventory/overview and /inventory/history (file-routed from src/pages/inventory/overview.vue and src/pages/inventory/history.vue; there is no bare /inventory page). Navigation target is inventory-overview (src/navigation/vertical/index.ts:85).
  • Backend Controller: app/Http/Controllers/Api/InventoryController.php
  • API Routes (routes/api/backoffice/inventories.php, prefix /inventories):
    • GET /inventories/ — overview
    • GET /inventories/has-tracked — whether the location has any tracked items
    • POST /inventories/ — create history / update inventory
    • DELETE /inventories/ — delete inventory record
  • Live POS Inventory routes (MplusKassa only, routes/api/backoffice/mpluskassa.php lines 27-30, prefix /mpluskassa): GET /mpluskassa/status (returns live_pos_inventory_enabled), POST /mpluskassa/toggle-live-inventory, POST /mpluskassa/inventory/live-refresh. Controller: app/Http/Controllers/Api/BackOffice/MplusKassaIntegrationController.php.
  • Vue Components: src/views/inventory/Overview.vue, src/views/inventory/History.vue, and dialogs under src/views/inventory/components/

Concepts

Inventory Record

A per-item, per-location document in the inventories collection holding the current quantity.

Stock Level

The current quantity on the inventory record, recomputed by replaying history.

Inventory History

An append-only log of Bought / Restock / Depletion entries used to derive the current quantity.

Tracking

An item is tracked when it has an inventory record; un-tracking means deleting that record. (No stored toggle.)


Fields

Quantity

PropertyValue
Field IDquantity
LabelQuantity
TypeNumber
RequiredYes
Validationnumeric, min 0 (Http/Requests/BackOffice/Inventory/CreateHistoryRequest.php)
ScopePer item, per location

Description: The absolute target stock level you want the item to have at this location. On save, the service compares it to the current quantity and automatically derives a Restock (if the new value is higher) or Depletion (if lower) history entry; a delta of zero writes no history entry (InventoryService.php:205).

Expiry Days

PropertyValue
Field IDexpiry_days
LabelExpiry Days
TypeNumber
RequiredNo (nullable)
Validationinteger, min 0 (CreateHistoryRequest.php)

Description: Optional shelf life in days. The expiry date is computed in the location's timezone — start-of-day when expiry_days is 0, otherwise end-of-day of the target date (InventoryService.php:170). Expired stock reports as 0 for customer-facing checks (RawModels/Inventory.php:44).

The Add/Edit payload contains only these editable fields plus item_id and location_id. There is no track_inventory/track_stock toggle, no low_stock_threshold, no allow_backorders, and no stock_location_id in this feature.


Stored Inventory Record Fields

These are read-only fields persisted on the record and shown in the Overview table (RawModels/Inventory.php, views/inventory/Overview.vue):

FieldNotes
item_name / item_pluDenormalised item label and PLU
quantityCurrent stock, derived by replaying history
reserved_quantityStored number shown in the table (no reservation lifecycle)
expiry_date / expiry_daysOptional expiry; expiry_days is shown as a derived column
last_updatedupdated_at, rendered in the location's timezone

Inventory History

History is the source of truth for the current quantity. Each entry records a quantity delta and a type:

TypeMeaning
BoughtAutomatic deduction when a customer order is committed
RestockStock added (manual increase, or initial record creation)
DepletionStock removed (manual decrease)

Remaining stock is recomputed by replaying all entries for the record: Restock adds, Bought and Depletion subtract (InventoryService.php:56). If the replayed total goes below zero (and the caller does not allow selling without stock), the operation aborts with HTTP 400 "Not enough remaining stock for :itemName".

The History table columns are item, quantity, type, and date; a detail dialog shows the entry note (views/inventory/components/InventoryHistory.vue:44).


Business Logic

How a Save Derives History

Save inventory (target quantity, optional expiry_days)


Find existing record (or create one, restoring a soft-deleted one)

        ├── expiry_days set? → recompute expiry_date in location timezone

        ├── delta = target - current
        │     ├── target > current → Restock entry (delta)
        │     ├── target < current → Depletion entry (delta)
        │     └── target = current → no history entry


Replay history → new quantity (abort 400 if negative)


Push new quantity to Cloudflare D1 + fire ReloadMenu

Automatic Deduction on Orders

When an order is committed, manageLocationStocks writes a Bought history entry per tracked item and recomputes the quantity (InventoryService.php:316). Only items that have an inventory record (getTrackQuantity() true) are deducted; untracked items are skipped and effectively unlimited (Item::getStock() returns 99999 when no record exists, RawModels/Item.php:529).


Relations

Depends On

  • Menu Items: Inventory is keyed by item
  • Locations: Inventory is keyed by location (quantities are independent per location)

Affects

  • Online Ordering / Kiosk: Item availability and stock gating (see Stock Management)
  • Transactions: A committed order writes Bought history and deducts stock
  • Cloudflare D1: Edge stock cache is updated on every change

Business Rules

  • Remaining stock is recomputed by replaying the full history (Restock adds, Bought/Depletion subtract); a result below zero aborts with 400 "Not enough remaining stock for :itemName" unless the caller allows selling without stock (InventoryService.php:56).
  • A save with a quantity delta of zero writes no history entry (InventoryService.php:215).
  • An item is "tracked" only while an inventory record exists; deleting the record un-tracks the item and makes it unlimited again (Item::getTrackQuantity(), RawModels/Item.php:544).
  • Deleting an inventory record soft-deletes it, removes the Cloudflare D1 stock entry, and fires ReloadMenu (InventoryService.php:122). Re-adding the item restores the soft-deleted record (InventoryService.php:112).
  • Expiry dates are computed in the location's timezone — end-of-day for future dates, start-of-day when expiry_days is 0 (InventoryService.php:170).

FAQs

  • How is "tracking" turned on for an item? By adding an inventory record for it at a location. There is no toggle; track_quantity is derived from whether a record exists.
  • Can stock differ per location? Yes. Records are per item and per location, so each location keeps an independent quantity.
  • Does stock update automatically on orders? Yes. A committed order writes a Bought history entry and recomputes the quantity for each tracked item.
  • Is there an undo for a stock change? Not directly, but every change is in the history, and you can save a new target quantity to correct it (which derives a compensating Restock/Depletion).
  • What does Reserved Quantity mean? It is a stored field displayed in the Overview table. This feature does not implement a cart/checkout reservation lifecycle.
  • What is the difference between Bought and Depletion? Bought is the automatic deduction when a customer order is committed; Depletion is a manual downward adjustment.
  • What is the "Live POS Inventory" switch on the Overview page? An opt-in mode that overlays live stock numbers from your connected MplusKassa POS onto the Overview table. It refreshes the items on the visible page every 30 seconds (pausing while the browser tab is hidden), shows a status bar with the last sync time, and offers a manual Refresh. It is stored per merchant and re-evaluated per location.
  • Why don't I see the Live POS Inventory switch? It only renders when the selected location's active in-house POS is MplusKassa. On any other POS provider — or with no POS connected — the switch is not shown.

Troubleshooting

Problem: Item shows out of stock but the count looks right

Causes:

  1. Quantity is correct but the item has expired (expiry_date passed) — expired stock reports as 0 for customers.
  2. You are viewing a different location's record.
  3. Edge cache (D1) not yet refreshed.

Solutions:

  1. Check / clear the expiry on the record.
  2. Confirm the selected location.
  3. Saving any change re-pushes to D1 and fires ReloadMenu.

Problem: An untracked item is still sellable when "out of stock"

Cause: The item has no inventory record, so it is not stock-gated (getStock() returns 99999).

Solution: Add an inventory record for the item at the location to start tracking it.


Problem: Quantity will not save / "Not enough remaining stock"

Cause: The replayed history would drop below zero.

Solution: Save a higher target quantity (a Restock) or review the History to find the unexpected deduction.


Examples

Add a tracked item (request payload)

json
{
  "item_id": "item-123",
  "location_id": "location-amsterdam",
  "quantity": 50,
  "expiry_days": 7
}

The service creates the record (if absent), derives a Restock history entry for the difference, computes an end-of-day expiry 7 days out in the location timezone, then pushes the new quantity to D1.

Adjust down to a target value

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

If the current quantity is 50, this derives a Depletion history entry of 10 and the new quantity becomes 40.

Stored inventory record (Overview row)

json
{
  "id": "inv-456",
  "item_id": "item-123",
  "item_name": "Special Burger",
  "item_plu": "1001",
  "location_id": "location-amsterdam",
  "quantity": 40,
  "reserved_quantity": 0,
  "expiry_days": "7",
  "last_updated": "2026-06-16 09:00"
}

Inventory history entries

json
[
  { "type": "Restock", "quantity": 50, "date": "2026-06-16 09:00" },
  { "type": "Bought", "quantity": 2, "date": "2026-06-16 12:30" },
  { "type": "Depletion", "quantity": 8, "date": "2026-06-16 14:00" }
]

Replayed: 50 − 2 − 8 = 40 remaining.