Appearance
KDS (Kitchen Display System)
Overview
The Kitchen Display System (KDS) is a device type that shows incoming orders to kitchen staff. A KDS is registered as a device on a location, and the KDS application (a separate mobile/desktop app) displays the location's active orders so staff can track them, prioritise them, and mark items and orders as done.
In the back office you create and manage the KDS device itself, choose the kitchen station it serves, and — through a KDS Device Profile — set its operational timings and sound. The per-screen presentation (layout, filters, strike-through, item summary, ticket appearance) is configured from within the KDS app. The order workflow (moving an order through In Progress, Ready, and Complete) is driven by the KDS app calling the backend KDS API.
The KDS app is its own repository (upvendo-kds). Today it stores its display settings on the device only and does not send them to the backend, so the PUT /kds/settings payload documented below is the API contract rather than a description of what the shipped app does. See "The KDS App" below. (Verified: upvendo-kds src/api/real.ts lines 15-39; src/stores/settings.ts lines 15-19.)
Promotion status (verified 2026-07-30). The
upvendo-kdsapp is going through its first promotion to production. This document describes the build currently onorigin/production(9d4dc2c, "feat(board): show the party size on per-person dishes"), in which the Prioritise / Hold / Recall controls are non-functional stubs and settings are device-only. A larger Tier-1 build sits onorigin/testingbehind draft PR #15 — it wires those controls and corrects the API contract mismatches below, but is not yet live. Re-verify this doc againstorigin/productiononce PR #15 merges.The backend has moved a long way ahead of the shipped app. Prep-station item filtering (
KitchenDisplayServicelines 83-131), recall / release / unprioritize (routes/api.phplines 262-264), customer-arrived / estimated-arrival (lines 268-269) and settings versioning (line 271 plusKdsSettingsSchema) all exist server-side with no client consumer. The app/API divergence is wider now, not narrower.Known contract mismatches on the production build. The shipped app sends payloads the production backend rejects, on three separate calls. The HTTP wrapper serialises the body verbatim — there is no camelCase→snake_case transformation anywhere (
upvendo-kds/src/api/http.tslines 59-63) — and the real adapter is the default (mocks requireVITE_USE_MOCKS === 'true', and there is no committed.env.production;src/api/index.tslines 5-8).
Call App sends Backend requires Result POST /device-auth/activate{ code }(src/api/real.tslines 16-21)activation_code(ActivateKioskRequest.phplines 31-35)422 PUT /kds/mark-item{ itemId, isDone }(src/api/real.tslines 30-32)item_id,is_done(MarkItemRequest.phplines 24-27)422 PUT /kds/mark-modifier{ itemId, modifierId, isDone }(src/api/real.tslines 33-35)item_id,modifier_id,is_done(MarkModifierRequest.phplines 24-27)422 PUT /kds/{id}/{state}inProgress(statePath(),src/api/real.tslines 7-13)in-progress(routes/api.phpline 254)404
Key Purpose: Display a location's orders to kitchen staff for preparation, and let staff advance each order through its status flow.
Key Concepts
- KDS device: A device whose type is "Kitchen Display", registered to a location. It is created through the back-office device flow and bound to a KDS station at provisioning; its
display_type(Prep / Expeditor) is derived from that station, not chosen. (Verified:DeviceTypes::KitchenDisplay = 'Kitchen Display'in upvendo-backendapp/Enums/DeviceTypes.php; subscription SKUkdsinapp/Enums/SubscriptionProductSku.php;DeviceService::genDevicelines 212-231.) - Order status flow: The KDS bump chain is Queued, In Progress, Ready, Complete, and the backend enforces it as a forward-only, skippable progression rather than a strict one-step machine. A kitchen can complete a Queued ticket in one gesture; re-sending the status an order is already in succeeds as a no-op; only a request that would move an order backwards, or one against a non-kitchen status such as Cancelled, is rejected with a 400. (Verified:
app/Services/KitchenDisplay/KitchenDisplayService.phplines 316-401, the sharedadvanceOrderStatus()behind all three bump routes.) - Marking items / modifiers done: The API accepts an individual modifier or a whole line item being marked done (
is_done). Marking an item done also marks all of its modifiers done. (Verified:KitchenDisplayService::markModifierlines 183-205 andmarkItemlines 207-228.) On the production KDS build these two calls are rejected with a 422 because the app sends camelCase field names — see the contract-mismatch note in the Overview. - Record locking: Each order or item update takes a mutual-exclusion database lock (10-second TTL) on that record. A request that finds the lock held waits — up to 20 attempts 100 ms apart, roughly 2 seconds — so concurrent taps on the same ticket serialise instead of failing. A 409 Conflict is returned only if the lock is still held after that retry budget. (Verified:
withLock/withTransactionLock, lines 157-176 and 240-272;acquireDatabaseLockWithRetryinapp/Traits/CachingTrait.phplines 117-138, defaults fromcache.lock_retry_attempts/cache.lock_retry_delay_micros.) - Priority and hold: An order can be flagged as priority (moved up) or placed on hold. Prioritise sets
priority = true; hold setspriority = falseandhold = true. Their counterpartsunprioritize(clearspriority) andrelease(clearsholdonly, leavingpriority) exist too. (Verified:prioritizeOrder/holdOrderlines 445-473;releaseOrder/unprioritizeOrderlines 540-570.) None of these endpoints is called by the KDS app -- its Prioritize and Hold buttons are visual stubs with no click handler. (Verified: upvendo-kdssrc/components/board/BumpDrawer.vuelines 51-65.) - Station routing: A Kitchen Display bound to a prep station receives only the items in that station's categories, and tickets left with no items are suppressed. A display bound to an expo station, bound to nothing, or bound to a station that has since been deleted resolves no filter and sees the whole board. This routing is keyed off the device's
kds_station_id, not offdisplay_typeand not off the device's storedsetting. (Verified:KitchenDisplayService::ordersline 83 andresolvePrepStationCategoryIdslines 116-131;TransactionRepository::kdsPaginatedlines 603-660.) - Filtering (device settings): A KDS device's
settingcan record an order-source filter (all / kiosk / online ordering / delayed fulfillment) and a category filter, but nothing enforces either one -- the order query never reads the device'ssetting. Do not confuse these with station routing above, which is a different mechanism and is enforced. (Verified:KitchenDisplayService::orderslines 74-105 andTransactionRepository::kdsPaginatedlines 603-660 read no devicesetting; the KDS app does not filter client-side either.) - White-label label exclusion: The order query unconditionally excludes any transaction whose
item_labelisHendrickxorVanhoutte. Orders labelled for those two white-label brands never reach any KDS, regardless of device settings. (Verified:app/Repositories/TransactionRepository.phpline 607; constants atapp/Constants.phplines 758 and 760.)
Actions
These actions are exposed by the KDS API and triggered from the KDS app.
Mark Order In Progress
Move an order forward to "In Progress". Accepted from any earlier point on the chain; a re-send when the order is already In Progress succeeds as a no-op. Rejected with a 400 only if the order is already past In Progress or is in a non-kitchen status. (Verified: markOrderInProgress, lines 403-408, delegating to advanceOrderStatus; route PUT /kds/{id}/in-progress.)
Mark Order Ready
Move an order forward to "Ready". The order does not have to be In Progress first — Queued straight to Ready is valid. Rejected with a 400 only if the order is already Complete or is in a non-kitchen status. (Verified: markOrderReady, lines 410-415; route PUT /kds/{id}/ready.)
Mark Order Complete
Finalise an order as "Complete" from anywhere earlier on the chain. This bulk-marks every remaining line item as done and closes any linked QR table-ordering session. A re-send when the order is already Complete does not rewrite the order, but the completion side-effects re-run so a crashed earlier attempt repairs itself. (Verified: markOrderComplete, lines 416-443; route PUT /kds/{id}/complete.)
Recall a Completed Order
Bring a completed order back onto the board: PUT /kds/{id}/recall sets order_status back to Ready, stamps recalled_at (so a client can reset the ticket timer), clears hold, and appends the status log in the same save. Rejected with a 400 ("Order's status has to be complete to recall") unless the order's raw status is Complete. (Verified: recallOrder, lines 504-537; route PUT /kds/{id}/recall.) Not wired from the KDS app — the board app bar's Recall button has no click handler.
Release or Un-prioritise an Order
PUT /kds/{id}/release clears hold only, deliberately leaving priority untouched. PUT /kds/{id}/unprioritize clears priority — the toggle-off counterpart to prioritize. (Verified: releaseOrder / unprioritizeOrder, lines 540-570.)
Record Customer Arrival / Estimated Arrival
POST /kds/customer-arrived stamps customer_arrived_at; POST /kds/estimated-arrival stores estimated_arrival_at as an absolute timestamp (now + the submitted minutes, last write wins). Both take the order id in the body, not the path. (Verified: customerArrived / estimatedArrival, lines 573-597.)
Mark Item / Modifier Done
Toggle the is_done flag on a line item or a single modifier. (Verified: markItem / markModifier; routes PUT /kds/mark-item, PUT /kds/mark-modifier.)
Prioritise or Hold an Order
Flag an order as priority to move it up, or place it on hold. (Verified: prioritizeOrder / holdOrder; routes PUT /kds/{id}/prioritize, PUT /kds/{id}/hold.) Not wired from the KDS app -- the drawer's Prioritize and Hold buttons render but have no click handler, so neither endpoint is reachable from the shipped app. (Verified: upvendo-kds src/components/board/BumpDrawer.vue lines 51-65.)
Update / Read KDS Settings
PUT /kds/settings saves the device's layout, filters, timers, sound, item-summary, and ticket-appearance settings on the device record. GET /kds/settings returns {settings, settings_version, profile} — the device's stored blob, its generation counter, and the bound KDS Device Profile (or null). (Verified: updateSetting line 673, getSettings lines 780-794.) Neither is called by the KDS app -- the app persists its settings to local storage instead, so both are currently unused in practice. (Verified: upvendo-kds src/api/real.ts lines 15-39; src/stores/settings.ts lines 15-19.)
Prep-station devices cannot change whole-order state
A device bound to a prep station is item-cross-out only. Every whole-order transition — in-progress, ready, complete, recall, hold, release, prioritize, unprioritize — returns a 403 ("Order changes are handled by the expo station"); those are owned by the expo station. mark-item, mark-modifier, customer-arrived and estimated-arrival stay allowed. Expo-bound, unassigned and dangling-station devices are unaffected. (Verified: assertOrderTransitionAllowedForDevice, lines 146-155.)
Location
- Back-office route:
/device-management/devices(KDS is created and managed through the Devices page; there is no separate/devices/kdspage). What the page shows depends on the merchant's POS provider: a merchant on the first-party Upvendo POS sees two tabs, Devices and Stations (the prep/expo station manager described under "KDS Stations" below); every other merchant sees only the Devices tab — the Stations tab is not rendered at all. It is not hidden-but-erroring and not empty: neither the tab nor its panel exists in the page, so nothing is requested and no error appears. (Verified: file-based route fromupvendo-backoffice/src/pages/device-management/devices/index.vue; tabs atsrc/views/devices/Devices.vuelines 258-262, the Stations tab carryingv-if="isFirstPartyPos"at line 261 and its panel the samev-ifat line 268; the signal isisFirstPartyPosat line 73.) - Kitchen Reports:
/reports/kitchen— a separate merchant-facing speed-of-service report. See "Kitchen Reports" below. - Backend controller:
app/Http/Controllers/Api/KitchenDisplayController.php(there is noKdsController.php). - Backend service:
app/Services/KitchenDisplay/KitchenDisplayService.php. - KDS app:
upvendo-kds-- a separate first-party Vue 3 + Capacitor application that consumes the/kdsAPI and renders the tickets. Its in-app screens are at the app routes/boardand/setting(these are app routes, not back-office routes). (Verified: upvendo-kdssrc/router/index.tslines 4-9.)
KDS in the Back Office
In the back office, KDS is one of the device types you can add from the Devices page (/device-management/devices).
The Kitchen Display tile is gated on the first-party Upvendo POS. It is always listed in the device-type picker, but it is shown disabled (with an explanatory tooltip) unless the merchant's POS provider is the first-party Upvendo POS, and the backend enforces the same rule: DeviceService::store() rejects a Kitchen Display create for a non-first-party merchant with a 403, "This feature is only available to merchants using the first-party Upvendo POS." The gate is create-only — existing KDS devices are grandfathered and keep working. (Verified: upvendo-backoffice/src/constants.ts lines 19-24 and 50-57 FIRST_PARTY_POS_REQUIRED_DEVICE_TYPES = ['POS', 'Kitchen Display']; src/views/devices/components/dialogs/SelectDeviceTypeDialog.vue lines 43-50; upvendo-backend app/Services/BackOffice/DeviceService.php lines 749-761. The older isTestEnv gate on this device type no longer exists.)
A merchant becomes first-party by selecting the Upvendo POS provider through POST /pos/select-provider. Only test-flagged merchants can self-select it in production: the upvendo provider is staged 'active' => true, 'test_only' => true (config/pos-providers.php:51-52); the test_only filter hides it from live merchants in GET /back-office/merchant/reseller-providers, and FirstPartyPosProvisioningService::selectFirstPartyPos() rejects a live merchant with a 403 (FirstPartyPosProvisioningService.php:70-77). The only other writer is the artisan-only demo seeder (PosDemoSeederService, used by pos:seed-demo and the test-account command), which calls selectFirstPartyPos() directly and bypasses that validation. (Verified: config/pos-providers.php lines 34-54; app/Services/BackOffice/ResellerService.php lines 666-676; app/Http/Requests/BackOffice/Pos/SelectPosProviderRequest.php lines 28-37; app/Services/Pos/PosDemoSeederService.php line 254.)
When adding a KDS device, the back-office flow collects:
Device Type
| Property | Value |
|---|---|
| Value | Kitchen Display |
| Where | Device type selector when adding a device |
(Verified: DATA_TYPE_OPTION_DEVICE in src/constants.ts; type union 'Kiosk' | 'Kitchen Display' | 'Printer' in src/types/DeviceDetailTypes.ts.)
Station
| Property | Value |
|---|---|
| Field ID | kds_station_id |
| Type | Single-select (radio), nullable |
| Options | Expo — whole kitchen (null), an existing station of the device's location, or a station created inline |
| Default | Expo — whole kitchen |
Description: The provisioning wizard asks "Which station will this display serve?". Choosing Expo — whole kitchen leaves kds_station_id null and the screen sees the full board. Choosing an existing prep station routes only that station's categories to the screen. Choosing Create opens an inline name + prep/expo + categories form which POSTs /back-office/devices/stations and continues with the returned id. The first Kitchen Display at a location skips this dialog entirely and gets the Expo default silently. (Verified: SelectStationDialog.vue lines 17-30 and 110; NewDeviceDialogs.vue lines 63-64 and 138.)
display_type is derived, not entered. The client no longer submits it: StoreDeviceRequest validates kds_station_id instead, and DeviceService::genDevice() derives display_type from the bound station's role (a prep station gives Prep; an expo station or no station gives Expeditor). It is still echoed to the KDS app read-only on GET /device-auth/user, where the app shows it as a row on its General settings screen. (Verified: app/Http/Requests/BackOffice/Device/StoreDeviceRequest.php lines 48-62; app/Services/BackOffice/DeviceService.php lines 212-231; app/Enums/DisplayTypes.php; app/Services/AuthDeviceService.php line 89; upvendo-kds src/components/settings/GeneralSection.vue lines 56-58.)
The station step is first-party-POS gated. POST /back-office/devices/{id}/station and the whole /back-office/devices/stations group carry the first-party-pos middleware, which 403s unless the merchant is on the first-party Upvendo POS. Combined with the create-time gate above, a merchant who has not adopted first-party POS neither reaches this step nor can add a KDS device at all. (Verified: routes/api/backoffice/devices.php lines 65-77 and 102-106; app/Http/Middleware/EnsureFirstPartyPos.php lines 42-53; alias registered at bootstrap/app.php line 56.)
The device is also given a name and a location as part of the standard device-creation flow, the same as other device types.
KDS Device Profiles
Some KDS display settings are now managed in the back office. A KDS Device Profile is a named settings bundle created from Device Management → Device Profiles (the New Device Profile dialog offers a Kiosk family and a Kitchen Display family) and bound to a device from the device drawer's profile card. The profile owns the operational half of the settings — preparation time, auto-hold of scheduled orders, reset-timer-on-recall, all four timer buckets and the whole sound group — and materialises them into every bound device's setting on assign or edit. Layout, order-source filter, category filter, strike-through, item summary and ticket appearance stay device-local. See "Device-local vs profile-owned" below for the exact split.
Like stations, this whole surface is gated: /back-office/kds-profiles carries the first-party-pos middleware (403 otherwise), and the back office hides the Kitchen Display profile family unless the merchant is on the first-party Upvendo POS. (Verified: routes/api/backoffice/kds-profiles.php line 31; upvendo-backoffice/src/views/device-profiles/forms/components/NewDeviceProfileDialog.vue lines 36-49, the KDS option added only when isFirstPartyPosActive() at line 43.)
Two profile surfaces are not hidden the way the Stations tab is, and a non-first-party merchant can still walk into them. On Device Profiles, the type filter always offers "Kitchen Display"; picking it together with a location calls the gated endpoint and surfaces the 403 message. And on a grandfathered Kitchen Display device, opening the drawer's profile card loads the KDS profile picker, which also 403s — but the back office reports every 403 from that one endpoint as a permission problem, so the message reads "You need the 'View Device Profile' permission to assign a Kitchen Display profile." even though the real barrier is the first-party gate. (Verified: upvendo-backoffice/src/views/device-profiles/DeviceProfiles.vue lines 131-135 add the type unconditionally and lines 154-161 fetch from /back-office/kds-profiles; src/store/modules/kdsProfile.ts lines 151-154 map any 403 to optionsForbidden; message at src/plugins/i18n/locales/modules/en/devices.ts line 49; src/views/devices/components/setup-device/device-profile-section/ProfileDialog.vue line 119.)
KDS Stations
Who can see this at all: the Stations tab appears on /device-management/devices only for a merchant whose POS provider is the first-party Upvendo POS. For any other merchant the tab is absent — there is nothing to click, nothing loads, and no error is shown. Everything in this section therefore describes what a first-party merchant sees. (Verified: upvendo-backoffice/src/views/devices/Devices.vue line 261 <VTab v-if="isFirstPartyPos" value="stations"> and line 268 <div v-if="isFirstPartyPos" …data-testid="devices-stations-panel">; the signal at line 73 is isFirstPartyPosProvider(getSelectedPOSProvider), the same one the add-device tiles use. Covered by test/views/devices/DevicesStationsTabGate.test.ts lines 96-119.)
The Stations tab manages a location's prep and expo stations. The station form collects:
| Field | Notes |
|---|---|
| Name | Free text, e.g. "Grill", "Cold line" |
| Role | prep or expo |
| Categories | Menu categories this station is responsible for. Shown only for a prep station. |
| Printer sinks | Editable, order-significant list of printer devices (Routing section) |
Screen sinks are not typed in — they are derived from which Kitchen Displays currently carry this station's id, so the station record never stores one. The stations table lists Name, Role, Categories, Routing, Assigned (device count) and row actions.
A Kitchen Display binds to exactly one station, or to none (which reads as "Expo — whole kitchen"). Binding is POST /back-office/devices/{id}/station with { "kds_station_id": "<id>" }, or null to unbind. Station CRUD is POST /back-office/devices/stations, GET /back-office/devices/stations/location/{locationId}, and GET | PUT | DELETE /back-office/devices/stations/{id}, on VIEW_DEVICES for reads and EDIT_DEVICES for writes. (Verified: routes/api/backoffice/devices.php lines 65-77 and 102-106; app/RawModels/KdsStation.php lines 68-81, 99-120 and 143-152; upvendo-backoffice/src/views/devices/components/stations/StationsTab.vue, StationFormFields.vue.)
Availability: stations work in production for a merchant whose POS provider is the first-party Upvendo POS, and are unreachable for everyone else on both sides. Server-side, every /back-office/devices/stations route and the POST /back-office/devices/{id}/station binding sit behind the first-party-pos middleware, which returns a 403 "This feature is only available to merchants using the first-party Upvendo POS." Client-side, the Stations tab and its panel are now rendered behind that same first-party check, so a non-first-party merchant never mounts the tab and never fires the request — the earlier behaviour, where the tab was visible to everyone and 403'd on page load, is gone. The related hand-off from the printer-added dialog ("set up ticket routing") is gated on the same signal and does nothing for a non-first-party merchant. (Verified: routes/api/backoffice/devices.php line 65 and lines 104-105; app/Http/Middleware/EnsureFirstPartyPos.php lines 42-53, alias at bootstrap/app.php line 56; upvendo-backoffice/src/views/devices/Devices.vue lines 261, 268 and goToStations() lines 208-210.)
Printer sinks do not print order tickets yet. A station's printer sinks are stored and returned, but no order fires a kitchen ticket from them: the only production callers of the kitchen-ticket enqueue are a developer endpoint and the printer-pairing test ticket, and the station model records that always_print and fallback_chain are stored but not evaluated. Do not tell a merchant that category-to-printer routing prints tickets today. (Verified: app/RawModels/KdsStation.php lines 30-32; the only PrintJobService::enqueueKitchenTicket callers are app/Http/Controllers/Api/Print/PrintDevController.php line 64 and app/Services/Orchestrators/PrinterPairingOrchestrator.php line 342.)
The KDS App
The KDS app is its own repository, upvendo-kds: a Vue 3 + Capacitor 7 rewrite of the legacy Flutter KDS, shipped to Elo Android tablets and iPad with Capgo over-the-air bundle updates, app ID com.upvendo.kds. (Verified: upvendo-kds capacitor.config.ts lines 3-19; README.md lines 3-11 and 22.)
Activation
A device is activated with a code from the back office. The app posts the code to POST /device-auth/activate as {code}, which the backend rejects — the endpoint requires activation_code — and on success stores the returned token and device ID under the keys upv_kds.token and upv_kds.deviceId (Capacitor Preferences on a tablet, localStorage on the web build). Any 401 or 403 from the API clears both and returns the app to the activation screen. (Verified: upvendo-backend routes/api/guest.php line 91; upvendo-kds src/stores/auth.ts lines 7-8 and 21-32, src/api/http.ts line 66, src/main.ts line 23.)
Board layouts
The app has four boards, selected in its own Settings > Layout screen: Tiled, Split, Rail, and Take Out. Take Out is a master-detail pane -- a scrollable list of compact cards with a detail pane for the selected order -- rather than a grid of tickets. (Verified: upvendo-kds src/domain/layout.ts line 3; src/pages/BoardPage.vue lines 137-161; src/components/board/TakeOutBoard.vue lines 20-38.)
Interactions
Tapping a line item is wired to cross it out and cascade to its modifiers; tapping a single modifier crosses only that modifier. (On the production build the underlying mark-item / mark-modifier calls are rejected — see the contract-mismatch note in the Overview.) Tapping the order-type colour band on a ticket opens the bump drawer, which lists the order's items with check-boxes plus one button that advances the order to its next status. (Verified: upvendo-kds src/components/board/OrderCard.vue lines 38-64; src/components/board/BumpDrawer.vue lines 69-116.)
The drawer's Prioritize and Hold buttons, and the board app bar's Recall and Holds buttons, are visual stubs with no click handler. (Verified: upvendo-kds src/components/board/BumpDrawer.vue lines 51-65; src/pages/BoardPage.vue lines 83-100.)
In the app, status movement is forward-only -- Queued to In Progress to Ready to Complete, with no step backwards. Un-marking an item or modifier (is_done: false) is the only undo the app offers. (Verified: upvendo-kds src/domain/order.ts lines 33-38.) The API itself does support a backwards step (PUT /kds/{id}/recall), but the app's Recall button is not wired to it.
Realtime
The app subscribes to a Firebase Realtime Database path, orders/{deviceId}/latest-order-timestamp; each ping triggers a refetch of GET /kds/orders. Realtime is a no-op when the VITE_FIREBASE_DB_URL environment variable is unset, in which case the board only updates on its own fetches. (Verified: upvendo-kds src/stores/realtime.ts lines 10-19; src/firebase.ts lines 19-22.)
Alert sounds
The app ships three named alert tones -- Alert Bells Echo (alert_bells_echo), Attention Bell Ding (attention_bell_ding), and Modern Classic Door Bell (modern_classic_door_bell) -- choosable separately for new orders and for updated orders, and played from public/sounds/*.wav. These live only in the app; the PUT /kds/settings payload has no tone field. (Verified: upvendo-kds src/domain/settingsDefaults.ts lines 49-55; src/composables/useSound.ts lines 8-10.)
Settings stay on the device
Everything on the app's Settings screen is written to localStorage under the key upv_kds.settings. The app's API client exposes only activate, getUser, listOrders, updateOrderState, markItem, markModifier, and registerFcmToken -- there is no settings call, and the app's own README records backend sync via PUT /kds/settings as "a later slice". Settings therefore do not follow a merchant between screens and are lost if the app's storage is cleared. (Verified: upvendo-kds src/stores/settings.ts lines 15-19 and 164-167; src/api/real.ts lines 15-39; README.md lines 83-86.)
The backend does return the stored setting document to the device on GET /device-auth/user, but the app never reads it -- it takes only the device's name, display type, and location from that response. (Verified: upvendo-backend app/Services/AuthDeviceService.php lines 74-86; upvendo-kds src/components/settings/GeneralSection.vue lines 31-33 and 52-67.)
The practical consequence is that the defaults a merchant sees on a freshly activated screen are the app's, not Constants::$DEFAULT_KDS_SETTING. Where they differ:
| Setting | Backend default | KDS app default |
|---|---|---|
| Layout | Split | Tiled |
| Caution time (every order type) | 0 seconds | 05:00 (300 seconds) |
| Volume | 100, on a 0-100 integer scale | 0.5, on a 0-1 scale |
| Item-summary position | Left | Bottom |
Preparation time is no longer a divergence: the backend default is now 900 seconds, the same as the app's 15:00. Late time defaults to zero on both sides. The app's values are what govern the screen. (Verified: upvendo-backend app/Constants.php lines 391-444, constant DEFAULT_KDS_PREPARATION_TIME_SECONDS at line 388; upvendo-kds src/domain/settingsDefaults.ts lines 16-24 and src/stores/settings.ts lines 22, 45, 61.)
KDS Device Settings (KDS API)
These are the settings accepted by PUT /kds/settings and validated by UpdateSettingsRequest. They are stored on the device under setting. Defaults come from Constants::$DEFAULT_KDS_SETTING. (Verified: app/Http/Requests/KitchenDisplay/UpdateSettingsRequest.php lines 33-91; defaults in app/Constants.php lines 391-444.)
This is the API contract. The KDS app does not call this endpoint and does not read the stored setting back. For a device with no KDS Device Profile bound, the defaults in this section are what sits on the device record -- not what a merchant sees on screen. For a device that is bound to a profile, the profile-owned leaves listed under "Device-local vs profile-owned" below are what sits there instead. See "The KDS App" above for the values the app actually uses. (Verified: upvendo-kds src/api/real.ts lines 15-39; KitchenDisplayService::resolveAuthoritativeSetting lines 712-736.)
A fresh Kitchen Display is not always provisioned with the constant verbatim: orders.preparation_time_seconds is seeded from the location's customer prep time (average_prep_time, minutes → seconds) when that location has a positive one configured. It is a one-time seed, not a live link — a later edit to the location never moves it, and a location with no average keeps the 900-second default. (Verified: app/Services/BackOffice/DeviceService.php lines 236-265.)
Concurrency token (settings_version)
PUT /kds/settings accepts an optional settings_version — the value the client last read from GET /kds/settings. When it is present and older than the device's current version (because a back-office profile push landed in between), the save is rejected with a 409, "These KDS settings were updated elsewhere. Reload before saving." A client that omits it keeps last-writer-wins. The token is transport-only and is stripped before the blob is stored. (Verified: UpdateSettingsRequest.php line 33 — ['sometimes', 'nullable', 'integer', 'min:0']; KitchenDisplayService::updateSetting lines 673-703.)
Device-local vs profile-owned
When a Kitchen Display is bound to a KDS Device Profile, the settings blob has two halves with different owners:
| Owner | Paths |
|---|---|
| Device-local (the device always wins; a profile never overrides these) | layout; filter.order_source.*; filter.categories.*; orders.strike_through_individual_modifier; item_summary.position; item_summary.include_all_items; ticket_appearance.assign_color_to_ticker_headers; ticket_appearance.order_type.* |
| Profile-owned (the assigned profile wins, server-side) | orders.preparation_time_seconds; orders.automatically_hold_scheduled_orders; orders.reset_timer_on_recall; timers.for_here.*; timers.to_go.*; timers.pickup.*; timers.delivery.*; sound.mute_order_sound; sound.volume; sound.play_sounds_when.* |
Assignment is the push: assigning or editing a profile writes exactly the profile-owned paths into every bound device's Device.setting and bumps that device's settings_version. There is no read-time merge — GET /kds/settings returns the already-materialised blob. (Verified: app/Services/KitchenDisplay/KdsSettingsSchema.php lines 22-55.)
Layout
| Property | Value |
|---|---|
| Field ID | layout |
| Type | Enum (string) |
| Required | Yes |
| Options | Tiled, Split, Take Out, Rail |
| Default | Split |
Description: How tickets are arranged on the KDS screen. The valid values are exactly Tiled, Split, Take Out, and Rail. (There is no "Full" layout.) (Verified: app/Enums/KDSLayouts.php lines 7-10.)
Order Source Filter
| Property | Value |
|---|---|
| Field ID | filter.order_source |
| Type | Object of booleans |
| Required | Yes (each key required|boolean) |
| Keys | all, kiosk, online_ordering, delayed_fullfillment |
| Default | all true |
Description: Which order sources this KDS is meant to show. Not enforced anywhere -- the value is validated and stored, but the order query never reads it, so switching a source off does not remove those orders from the screen. (Verified: UpdateSettingsRequest lines 35-39; defaults Constants.php lines 394-399; KitchenDisplayService::orders lines 74-105 and TransactionRepository::kdsPaginated lines 603-660 read no device setting.)
Note the exact key names: delayed_fullfillment (spelled this way in code). There is no in_house or third_party source.
Category Filter
| Property | Value |
|---|---|
| Field ID | filter.categories |
| Type | Array of { id, value } |
| Required | The key must be sent (present|array), but an empty array is valid; each entry's id is required|string, value is required|boolean |
| Default | { uncategorized: true } |
Description: Which menu categories this KDS is meant to show. Submitted as a list of { id, value } objects and re-keyed into an id => value map before saving. present rather than required: Laravel's required fails on [], and the live Vue KDS client legitimately sends an empty list, so required would 422 every settings PUT from it. Not enforced anywhere -- like the source filter, it is stored but never applied to the order query; category routing comes from the device's bound station instead. (Verified: UpdateSettingsRequest lines 49-51; reformatFilterCategory lines 612-621; default Constants.php lines 400-402; KitchenDisplayService::orders lines 74-105.)
The available category IDs are returned by GET /kds/categories. (Verified: categories(), lines 796-802.)
Order Behaviour
| Field ID | Type | Required | Default |
|---|---|---|---|
orders.strike_through_individual_modifier | boolean | Yes | true |
orders.reset_timer_on_recall | boolean | Yes | true |
orders.automatically_hold_scheduled_orders | boolean | Yes | true |
orders.preparation_time_seconds | integer | Yes | 900 |
Description: Ticket behaviour options -- whether finished modifiers are struck through, whether the timer resets when an order is recalled, whether scheduled orders are auto-held, and the ticket target time in seconds. The prep-time default is 900 (15 minutes), matching the KDS app; the old 0 default made every ticket read as instantly overdue. (Verified: UpdateSettingsRequest lines 52-56; defaults Constants.php lines 404-409, constant DEFAULT_KDS_PREPARATION_TIME_SECONDS at line 388.)
A one-shot command, php artisan kds:repair-prep-time-defaults, exists to flip stored 0s on KDS devices and profiles to 900. It refuses to run against production without --force; the command records that the affected stored-0 fleet is non-production. (Verified: app/Console/Commands/DataRepair/RepairKdsPrepTimeDefaults.php lines 50 and 57-64.)
Timers
| Field ID | Type | Required | Default |
|---|---|---|---|
timers.for_here.caution_time_seconds | integer | Yes | 0 |
timers.for_here.late_time_seconds | integer | Yes | 0 |
timers.to_go.caution_time_seconds | integer | Yes | 0 |
timers.to_go.late_time_seconds | integer | Yes | 0 |
timers.pickup.caution_time_seconds | integer | Yes | 0 |
timers.pickup.late_time_seconds | integer | Yes | 0 |
timers.delivery.caution_time_seconds | integer | Only if timers.delivery is sent | not in $DEFAULT_KDS_SETTING |
timers.delivery.late_time_seconds | integer | Only if timers.delivery is sent | not in $DEFAULT_KDS_SETTING |
Description: Per-order-type caution and late thresholds, in seconds, for For Here, To Go, Pickup and Delivery orders. These drive the on-screen colour/urgency state. The timers.delivery bucket is sometimes rather than required, because the live Vue KDS client collapses its four-type local model onto the three legacy buckets and never sends it; when the bucket is absent from a PUT the stored subtree is preserved, not dropped. When it is sent, both children are mandatory. (Verified: UpdateSettingsRequest lines 57-76; defaults Constants.php lines 410-423; preservation in KdsSettingsSchema::preserveAbsentPaths via KitchenDisplayService::resolveAuthoritativeSetting lines 712-736.)
Item Summary
| Field ID | Type | Required | Default |
|---|---|---|---|
item_summary.position | string | Yes | Left |
item_summary.include_all_items | boolean | Yes | true |
Description: Position of the item-summary panel and whether it includes all items. (Verified: UpdateSettingsRequest lines 77-79; defaults Constants.php lines 424-427.)
Sound
| Field ID | Type | Required | Default |
|---|---|---|---|
sound.mute_order_sound | boolean | Yes | false |
sound.volume | integer | Yes | 100 |
sound.play_sounds_when.caution_time | boolean | Yes | true |
sound.play_sounds_when.updated_order | boolean | Yes | true |
Description: Whether order sounds are muted, the volume level, and which events play a sound (reaching caution time, or an updated order). This API payload has no named alert-sound selector -- only mute, volume, and the play-when toggles. The KDS app does have one: three named tones, chosen locally and never sent to the backend (see "Alert sounds" above). (Verified: UpdateSettingsRequest lines 80-85; defaults Constants.php lines 428-435; upvendo-kds src/domain/settingsDefaults.ts lines 49-55.)
Ticket Appearance
| Field ID | Type | Required | Default |
|---|---|---|---|
ticket_appearance.assign_color_to_ticker_headers | boolean | Yes | true |
ticket_appearance.order_type.for_here | string (hex) | Yes | #12CDD4 |
ticket_appearance.order_type.to_go | string (hex) | Yes | #4C9AE9 |
ticket_appearance.order_type.pickup | string (hex) | Yes | #D7E76D |
Description: Whether to colour ticket headers, and the hex colour assigned to each order type (For Here, To Go, Pickup). (Verified: UpdateSettingsRequest lines 86-91; defaults Constants.php lines 436-443.)
KDS API Reference
All routes are under the /kds prefix and require a Kitchen Display device token (middleware type:kds, tenant:kds). (Verified: routes/api.php lines 241-279.)
| Method & Path | Action |
|---|---|
POST /kds/fcm-token | Register a Firebase Cloud Messaging token for push |
GET /kds/orders | Paginated list of the location's active orders. ?status=completed instead returns the last 24 hours of Complete orders — the recall panel's feed |
GET /kds/items | Paginated list of line items |
GET /kds/metrics/summary | Device-scoped speed-of-service tile: today, last_hour, by_type for the device's own location |
PUT /kds/mark-modifier | Mark a modifier done / not done (item_id, modifier_id, is_done) |
PUT /kds/mark-item | Mark a line item done / not done (item_id, is_done) |
PUT /kds/{id}/in-progress | Advance order to In Progress |
PUT /kds/{id}/ready | Advance order to Ready |
PUT /kds/{id}/complete | Advance order to Complete (marks remaining items done, closes QR session) |
PUT /kds/{id}/prioritize | Flag order as priority |
PUT /kds/{id}/hold | Place order on hold |
PUT /kds/{id}/recall | Complete back to Ready; stamps recalled_at, clears hold. 400 unless the order is Complete |
PUT /kds/{id}/release | Clear hold only (leaves priority) |
PUT /kds/{id}/unprioritize | Clear priority |
POST /kds/customer-arrived | Stamp customer_arrived_at. Body: order_id |
POST /kds/estimated-arrival | Store estimated_arrival_at as now + minutes. Body: order_id, minutes (0-480) |
PUT /kds/settings | Save device display settings (optional settings_version concurrency token) |
GET /kds/settings | Read {settings, settings_version, profile} for this device |
GET /kds/categories | List categories for the category filter |
recall, release, unprioritize, customer-arrived and estimated-arrival are location-scoped — another location's order returns a plain 404. recall and release/unprioritize are also part of the whole-order set a prep-station device is 403'd out of; customer-arrived and estimated-arrival are not.
Several of these are backend-only with no client consumer yet: the shipped KDS app calls only activate, getUser, listOrders, updateOrderState, markItem, markModifier and registerFcmToken, so GET /kds/items, GET /kds/metrics/summary, GET|PUT /kds/settings, GET /kds/categories and all five Tier-1 routes above are unused in practice. (Verified: routes/api.php lines 241-279; request fields from MarkItemRequest, MarkModifierRequest, CustomerArrivedRequest, EstimatedArrivalRequest; upvendo-kds src/api/real.ts lines 15-39.)
Kitchen Reports
A merchant-facing speed-of-service report lives at the back-office route /reports/kitchen (nav group Reports → Kitchen), backed by GET /back-office/kds-reports/summary?location_id&from&to, which returns {window, overall, by_type, by_daypart}.
Gating first — do not tell a merchant this report exists. The report requires the merchant to be on the first-party Upvendo POS: the endpoint sits behind the first-party-pos middleware (403 otherwise), and both the nav item and the page carry firstPartyPosOnly, enforced by isFirstPartyPosActive() in the nav and in the router guard, so typing the URL does not reach it either. Because the upvendo provider is staged 'test_only' => true (config/pos-providers.php:52), only test-flagged merchants can select first-party POS on their own in production — so in practice the nav entry and the page are hidden for every live merchant. (A test-flagged merchant, or a demo/test merchant seeded by pos:seed-demo / the test-account command, is first-party and does see it.) The view-kitchen-reports permission is not the barrier: it merges into the platform-wide permission set that the merchant-owner wildcard expands, so it never fails for an owner. (Verified: routes/api/backoffice/kds-reports.php lines 17-25; upvendo-backoffice/src/pages/reports/kitchen/index.vue lines 6-13; src/@layouts/plugins/casl.ts lines 88-104 and 149-153; src/navigation/vertical/index.ts lines 26-41.)
What the page shows:
- Avg. time to ready — Queued → marked ready
- Avg. time to complete — Queued → completed
- Tickets — kitchen tickets in range
- Tickets by daypart chart — Morning, Lunch, Afternoon, Dinner, Late night, plus an Unknown bucket
- By order type table — For here, Takeout, Pickup, Delivery, Unknown, each with tickets / avg. ready / avg. complete
Filters are a location and a date range, offered as Today, Last 7 days, Last 30 days or Custom. All three of location_id, from and to are required, dates must be Y-m-d, and a range wider than 92 days is rejected with "The reporting range is too large." Times are shown in the location's timezone. (Verified: app/Http/Resources/BackOffice/KitchenReport/KitchenReportResource.php lines 26-32; app/Http/Requests/BackOffice/KitchenReport/KitchenReportRequest.php lines 38 and 54-75; upvendo-backoffice/src/plugins/i18n/locales/modules/en/kitchen-reports.ts.)
Order Ticket Display
The KDS app renders each order as a ticket built from the /kds/orders data. A ticket has a header band (customer name, order time, countdown) coloured by its SLA timer state, an order-type colour band that is the only tap target for opening the bump drawer, and the item/modifier rows below it. The same item rows are reused by the Take Out board's detail pane. An optional item-summary panel aggregates item quantities across the board, placed to the left or along the bottom. A per-person (MPLUS bestelbeperking) dish additionally shows its party size — under the dish name on the card, and inline in the bump drawer. (Verified: upvendo-kds src/components/board/OrderCard.vue lines 42-72; src/components/board/ItemRows.vue lines 13-36; src/components/board/BumpDrawer.vue line 84; src/components/board/TakeOutDetail.vue; src/components/board/ItemSummaryPanel.vue; src/pages/BoardPage.vue lines 36-41 and 128, 164.)
What the backend guarantees:
- Orders returned to a KDS are limited to its location, exclude orders already Complete, exclude orders dated more than one hour in the future, and only include orders whose payment
statusis Complete. (Verified:KitchenDisplayService::orders, lines 74-105.) - Orders whose
item_labelisHendrickxorVanhoutteare excluded unconditionally. (Verified:TransactionRepository::kdsPaginatedline 607.) - Each line item carries an
is_doneflag, and each modifier carries its ownis_done. (Verified:markItem/markModifier.) - Each line item carries
persons— the party size of an MPLUS bestelbeperking dish,nullon every other line — and itsquantityis the dish count, not article units.PerPersonLine::dishQuantitydivides the stored article units bypersonswhenpersons > 1and the units divide evenly, so a gourmet dish for six reads "2x Gourmetschotel / For 6 people", not "12x". A line whose units do not divide evenly is deliberately left as-is rather than reporting a made-up count. The label is always English: the KDS app ships one locale file and maps nl / fr / de to the same English messages, so it renders "For {count} people" regardless of locale. The same payload backs the POS channel-order detail view, so the dish-count semantics apply there too. (Verified:Transaction::populateItemsKdslines 1370-1379;TransactionItem::getPersons/getDishQuantitylines 105-120;App\Support\PerPersonLine::dishQuantitylines 26-31;app/Http/Resources/Pos/ChannelOrderResource.phpline 41; upvendo-kdssrc/plugins/i18n/index.tsline 4 andsrc/plugins/i18n/locales/en.tsline 31.) - For a display bound to a prep station, each order's items are filtered to that station's categories and tickets left with no items are dropped. An item that carries no category is kept on every prep board — Deliveroo and other aggregators snapshot
category: nullon every line, and over-showing a ticket is safer than silently dropping food off every screen. Prep boards are filtered and then paginated in PHP, sototalandmax_pagedescribe the filtered list. (Verified:Transaction::populateItemsKdslines 1348-1367;TransactionRepository::kdsPaginatedlines 634-660 andpaginatePrepBoardlines 672-698.)
Business Logic
Order Status Flow
Forward-only, skippable, idempotent. Ranks: Queued 1, In Progress 2, Ready 3, Complete 4 (an order with an empty order_status ranks 0 and is bumpable to anything).
Queued ──┐
│
In Progress ──┐ PUT /kds/{id}/in-progress
│ │ PUT /kds/{id}/ready
Ready ───┼────┼──┐ PUT /kds/{id}/complete
│ │ │
▼ ▼ ▼ Any FORWARD jump is valid (Queued → Complete in one tap).
Complete Re-sending the CURRENT status = success no-op.
├── all remaining line items marked is_done
└── linked QR table-ordering session closed
400 "This order can no longer be updated from the kitchen display"
only when the current status ranks ABOVE the target, or is a
non-kitchen status (rank null — e.g. Cancelled, payment statuses).
PUT /kds/{id}/recall Complete → Ready (the one backwards step),
stamps recalled_at, clears hold.The status is re-read under the record lock before the comparison, and the status-log entry (carrying device_id and station_id) plus the state flip land in one save so they can never tear. The idempotent branch performs no order write and fires no board ping, but the completion side-effects still run so a crashed earlier attempt repairs itself. (Verified: KitchenDisplayService::advanceOrderStatus lines 344-401, kitchenStatusRank lines 272-282, appendTransactionStatusLogs lines 303-314, recallOrder lines 504-537.)
Concurrency
Update request for an order or item
│
▼
Try to acquire the record's database lock (10-second TTL,
unique per-invocation owner → genuine mutual exclusion)
│
┌────┴──────────────────────────────────┐
▼ ▼
Lock acquired Lock already held
│ │
▼ ▼
Apply change, then fire Retry: up to 20 attempts,
PushOrderEvent('updated_order') 100 ms apart (~2 s total)
│ │
▼ ┌─────────┴─────────┐
Release lock ▼ ▼
Acquired in time Still held after
│ the retry budget
▼ ▼
Apply change Return 409 ConflictSettings writes take a third, separate lock so a device save cannot race a back-office profile push; exhausting its retry budget returns a differently-worded 409, "These KDS settings are being updated elsewhere. Please try again." Do not confuse that with the settings_version stale-write 409 ("...Reload before saving."), which is a version check, not a lock, and never retries. (Verified: withLock / withTransactionLock, lines 157-176 and 240-272; withDeviceSettingLock lines 746-762; acquireDatabaseLockWithRetry in app/Traits/CachingTrait.php lines 117-138.)
Filtering an Order onto a KDS
Order exists
│
▼
location_id matches the KDS device's location?
│
▼
order_status != Complete, payment status == Complete,
order_date <= now + 1 hour?
│
▼
item_label is NOT Hendrickx and NOT Vanhoutte?
│
▼
Is this device bound to a PREP station?
(the device's source and category filter settings
are NOT applied at any point — station routing
keys off device.kds_station_id, not `setting`)
│
┌────┴──────────────────────┐
▼ no ▼ yes
Expo / unassigned / Keep only items whose category is in the
dangling station station's allow-list, plus every item with
│ NO category (aggregator safety). Drop the
▼ ticket entirely if nothing is left.
Whole order shown │
▼
Filtered ticket shown(Verified: KitchenDisplayService::orders location + status + date filter, lines 74-105, and resolvePrepStationCategoryIds lines 116-131; item_label exclusion in TransactionRepository::kdsPaginated line 607; item filtering in Transaction::populateItemsKds lines 1348-1367 and empty-ticket suppression in TransactionRepository::paginatePrepBoard lines 672-698. Neither orders() nor kdsPaginated() reads the device setting, and the KDS app does not filter client-side.)
Customer Impact
KDS does not directly face customers, but affects:
Order Timing
- An efficient KDS workflow means faster orders.
- Timer thresholds (caution / late, per order type) help staff prioritise.
Order Accuracy
- Clear modifier display helps staff produce correct orders.
Notifications
- Order updates and completions emit a
PushOrderEvent, which downstream notification logic can use (for example, telling a customer their order is ready). (Verified:PushOrderEventemitted inwithLock/withTransactionLock; downstream consumer behaviour not-verified-here.) - Completing an order closes any linked QR table-ordering session. (Verified:
TableQrOrderingService::closeSessionByOrderNocalled on complete, line 237.)
Relations
Depends On
- Locations: A KDS is registered to a location and only shows that location's orders.
- Categories: Used by the KDS category filter.
- Transactions: Orders and line items shown on the KDS are transactions / transaction items.
- Subscription: KDS is a paid device type (SKU
kds).
Affects
- Order status: Updated as staff advance orders.
- QR table ordering: Sessions are closed when an order is completed.
- Push events:
PushOrderEventis emitted on updates and completion.
Related Features
Business Rules
- Order status moves forward only, but may skip: any forward jump (including Queued straight to Complete) is accepted, re-sending the current status is a success no-op, and a 400 is returned only when the order already ranks past the target or sits in a non-kitchen status. (Verified:
KitchenDisplayService::advanceOrderStatuslines 344-401.) - Retiring a printer silently rewrites station routing.
PrinterPairingOrchestrator::retire()callsKdsStationService::detachPrinterFromRouting()with the printer's device id and no location (app/Services/Orchestrators/PrinterPairingOrchestrator.php:326), so the printer is dropped from the sinks of every station at every site of the merchant — not just the one it was serving. A re-homed printer is therefore removed from its old site's routing as well. Re-pairing does not restore the sinks; they must be set again. (Contrast:160, the re-home path, which passes the previous location and detaches only there.) PUT /kds/{id}/recallis the one backwards step: Complete → Ready, stampingrecalled_atand clearinghold, and rejected with a 400 unless the order's raw status is Complete. (Verified:recallOrderlines 504-537.)- A KDS only lists orders for its own location whose payment
statusis Complete, whoseorder_statusis not yet Complete, and whoseorder_dateis no more than one hour in the future.GET /kds/orders?status=completedis the exception — it returns the last 24 hours of Complete orders for the recall panel. (Verified:KitchenDisplayService::orders, lines 74-105.) - Orders whose
item_labelisHendrickxorVanhoutteare excluded from every KDS unconditionally, whatever the device's settings say. (Verified:TransactionRepository::kdsPaginatedline 607.) - The device's order-source and category filter settings are stored but never applied -- they do not affect which orders reach a KDS. (Verified:
KitchenDisplayService::orderslines 74-105 andTransactionRepository::kdsPaginatedlines 603-660 read no devicesetting.) - Station routing is applied, and is a separate mechanism: a device bound to a prep station receives only that station's categories (plus every uncategorised item) and empty tickets are suppressed; expo, unassigned and dangling-station devices see the whole board. It keys off
device.kds_station_id, notdisplay_typeand notsetting. (Verified:resolvePrepStationCategoryIdslines 116-131.) - A prep-station device is 403'd out of every whole-order transition — in-progress, ready, complete, recall, hold, release, prioritize, unprioritize — with "Order changes are handled by the expo station". Item and modifier marking, customer-arrived and estimated-arrival remain allowed. (Verified:
assertOrderTransitionAllowedForDevicelines 146-155.) - Each order or item modification takes a mutual-exclusion database lock with a 10-second TTL. A concurrent request waits (up to 20 attempts 100 ms apart) and usually succeeds; a 409 Conflict is returned only if the lock is still held after that budget. (Verified: lines 157-176, 240-272;
app/Traits/CachingTrait.phplines 117-138.) - When an order is marked Complete, all of its line items not already marked done are bulk-updated to
is_done = truein a single operation. (Verified: lines 417-421.) - Completing an order also closes any linked QR table-ordering session via
TableQrOrderingService::closeSessionByOrderNo; an order with no session is the normal case and is not an error. (Verified: lines 423-442.) - Marking a line item done also marks all of that item's modifiers done. (Verified:
markItem, lines 207-228.) - The valid KDS layouts in the API are exactly Tiled, Split, Take Out, and Rail; Split is the API default. (Verified:
KDSLayouts.php; defaultConstants.phpline 392.) The KDS app offers the same four boards but defaults to Tiled. (Verified: upvendo-kdssrc/domain/layout.tsline 3;src/stores/settings.tsline 22.) display_type(Prep / Expeditor) is derived from the bound station and routes nothing by itself. It is a read-only compat field echoed to the app. (Verified:DeviceService::genDevicelines 212-231;KitchenDisplayService::ordersmakes no use of it.)- A KDS Device Profile owns preparation time, auto-hold-scheduled, reset-timer-on-recall, all four timer buckets and the whole sound group, and materialises them into every bound device's
settingon assign or edit. Layout, filters, strike-through, item summary and ticket appearance stay device-local. (Verified:app/Services/KitchenDisplay/KdsSettingsSchema.phplines 22-55.) - Kitchen Display device creation, KDS stations, KDS Device Profiles and Kitchen Reports are all restricted to merchants whose POS provider is the first-party Upvendo POS. The device-create gate is create-only, so existing KDS devices are grandfathered. (Verified:
DeviceService.phplines 749-761;routes/api/backoffice/devices.phplines 65 and 104-105;routes/api/backoffice/kds-profiles.phpline 31;routes/api/backoffice/kds-reports.phpline 22.) - For a merchant who is not on the first-party Upvendo POS, the Devices page has no Stations tab. The tab and its panel are rendered behind the same first-party check as the add-device tiles, so the station manager is absent rather than visible-and-erroring. (Verified:
upvendo-backoffice/src/views/devices/Devices.vuelines 261 and 268;test/views/devices/DevicesStationsTabGate.test.tslines 96-104.)
FAQs
- "Why can I not mark an order as Ready?" The order does not have to be In Progress first — the backend accepts any forward jump. A 400 here means the order has already moved past Ready (it is Complete) or is in a status the kitchen display cannot act on, such as Cancelled. Use Recall to bring a Complete order back to Ready. (Verified:
advanceOrderStatuslines 344-401.) - "What happens if two staff members update the same order or item at the same time?" The backend takes a mutual-exclusion lock on that record. The second request waits for it (roughly two seconds' worth of retries) and then usually succeeds, so both taps land in order. A 409 Conflict is returned only when the lock is still held after those retries. (Verified: lines 157-176, 240-272;
app/Traits/CachingTrait.phplines 117-138.) - "Can I un-mark an item?" Yes --
PUT /kds/mark-item(andmark-modifier) acceptsis_done: false, which sets the flag back to false. There is also areset_timer_on_recallsetting in the API payload. (Verified:markItem/markModifier;UpdateSettingsRequestline 54.) A dedicated recall endpoint does exist —PUT /kds/{id}/recallmoves a Complete order back to Ready — but the shipped KDS app does not call it: in the app, un-marking an item is still the only undo, because its status map is forward-only and the app bar's Recall button has no click handler. (Verified:recallOrderlines 504-537; upvendo-kdssrc/domain/order.tslines 33-38,src/pages/BoardPage.vuelines 83-91.) - "Why is there no Stations tab on my Devices page?" Because your merchant account is not running the first-party Upvendo POS. The Stations tab (prep/expo stations, printer routing) is rendered only for first-party merchants — for everyone else it is not on the page at all, so there is nothing to enable, no permission to grant, and no error to clear. The whole
/back-office/devices/stationsAPI is gated the same way. Whether you can become first-party self-serve depends on your account: theupvendoPOS provider is staged —'active' => true, 'test_only' => true— so in production the Upvendo POS tile is offered during onboarding only to merchants flaggedis_test. A live merchant cannot select it until Upvendo dropstest_only. (Verified:upvendo-backoffice/src/views/devices/Devices.vuelines 261 and 268;routes/api/backoffice/devices.phpline 65;config/pos-providers.phplines 51-52;app/Services/BackOffice/ResellerService.phplines 675 and 731.) - "How does category filtering work?" The device's own category-filter setting does nothing — it is stored as
{ id => enabled }but neither the backend order query nor the KDS app applies it. What does filter by category is the station a Kitchen Display is bound to: a prep station's categories decide which items reach that screen. (Verified:UpdateSettingsRequestlines 49-51 andreformatFilterCategorylines 612-621 only validate and store;resolvePrepStationCategoryIdslines 116-131 is the real filter.) - "Does completing an order notify the customer?" Completing an order emits a
PushOrderEvent, which downstream logic can use to send notifications. The completion itself also closes any linked QR table-ordering session. (Verified:PushOrderEvent,closeSessionByOrderNo; the customer-notification step itself is not-verified-here.) - "Where do I configure the KDS layout, timers, and sound?" It depends on the setting. Layout, order-source and category filters, strike-through, item summary and ticket appearance are per-screen and set in the KDS app's own Settings page. Preparation time, auto-hold of scheduled orders, reset-timer-on-recall, the timer buckets and the sound group are owned by a KDS Device Profile in the back office and pushed to every device bound to it. In the back office you also choose which station a display serves (which decides its Prep / Expeditor role), not a display type. Note that the shipped KDS app still writes everything to its own local storage and does not call
PUT /kds/settingsorGET /kds/settings, so what a merchant edits on the screen today is per-screen, does not sync to the back office, and is lost if the app's storage is cleared or the device is re-activated on different hardware. (Verified:KdsSettingsSchema.phplines 22-55;SelectStationDialog.vuelines 17-30; upvendo-kdssrc/stores/settings.tslines 15-19 and 164-167,src/api/real.tslines 15-39.)
Troubleshooting
Problem: KDS device will not activate, or items will not mark done, on the production build
Cause: The app build on origin/production sends field names the API does not accept — code instead of activation_code on activation, and camelCase itemId / modifierId / isDone instead of item_id / modifier_id / is_done when marking done. Both return a 422. Order-state changes hit /kds/{id}/inProgress instead of /kds/{id}/in-progress and return a 404. See the contract-mismatch table in the Overview.
Solution: There is no back-office or device-side workaround. The fix is on origin/testing behind PR #15 and needs to be promoted.
Problem: Orders not appearing on the KDS
Causes:
- The KDS device is assigned to a different location than the order.
- The order's payment is not yet complete, or the order is already Complete.
- The order is scheduled more than one hour in the future.
- The order's
item_labelisHendrickxorVanhoutte-- those are excluded from every KDS. (Verified:TransactionRepository::kdsPaginatedline 607.) - The KDS screen has not refreshed. Live updates depend on a Firebase Realtime Database ping, which is disabled when the app's
VITE_FIREBASE_DB_URLis unset. (Verified: upvendo-kdssrc/stores/realtime.tslines 10-19.) - The screen is bound to a prep station and none of the order's items fall in that station's categories -- the ticket is suppressed entirely. Items carrying no category are always kept, so this only affects fully categorised orders. (Verified:
resolvePrepStationCategoryIdslines 116-131;TransactionRepository::paginatePrepBoardlines 672-698.)
Solutions:
- Confirm the KDS device's location matches the order's location.
- Confirm the order is paid and not already completed.
- Wait until the order is within the one-hour window.
- For a white-label labelled order, this is expected -- it will never appear on a KDS.
- Reopen the board to force a refetch, and have engineering confirm the app's Firebase configuration.
- Check which station the display is bound to on Devices → Stations, and either add the missing categories to that station or move the display to Expo — whole kitchen. This tab only exists for merchants on the first-party Upvendo POS; a merchant with a grandfathered Kitchen Display cannot see it, and cannot change the binding from the back office at all. (Verified:
upvendo-backoffice/src/views/devices/Devices.vuelines 261 and 268;routes/api/backoffice/devices.phplines 104-105.)
Not a cause: the device's order-source or category filter settings. They are stored but never applied, so they cannot be why an order is missing — station binding is the mechanism that actually filters. (Verified: KitchenDisplayService::orders lines 74-105 and TransactionRepository::kdsPaginated lines 603-660 read no device setting.)
Problem: A 409 / "being modified by another user" error
Cause: Another request held the lock on that order or item for longer than the server's own retry budget. The backend already waits and retries (about 20 attempts, 100 ms apart) before giving up, so a 409 means sustained contention, not a single overlapping tap.
Solution: Wait a moment and retry; the lock releases automatically once the other update finishes (or after its 10-second TTL). If it recurs on one ticket, check whether two devices are being tapped on the same order continuously.
Problem: A 409 on saving KDS settings
Cause: Two different things share this status code on PUT /kds/settings. "These KDS settings are being updated elsewhere. Please try again." is lock contention with a back-office profile push, and retrying works. "These KDS settings were updated elsewhere. Reload before saving." is the settings_version stale-write guard — a profile push landed after this client last read the settings, and the save was refused so it could not silently revert the pushed values. That one does not retry.
Solution: For the first, retry. For the second, reload the settings (GET /kds/settings) and re-apply the change on top of the fresh version.
Problem: Cannot advance an order's status
Cause: The order has already moved past the target status, or it is in a status the kitchen display cannot act on (for example Cancelled). Skipping forward is allowed, so "not in the required prior status" is no longer a cause.
Solution: If the order is already Complete and needs to go back on the board, use Recall (PUT /kds/{id}/recall), which moves it to Ready. Otherwise check the order's actual status — a cancelled or unpaid order cannot be bumped from the kitchen display at all.
Problem: KDS device type is greyed out when adding a device
Cause: The Kitchen Display tile is disabled unless the merchant's POS provider is the first-party Upvendo POS. The backend enforces the same rule and rejects the create with a 403, "This feature is only available to merchants using the first-party Upvendo POS." (Verified: upvendo-backoffice/src/constants.ts lines 50-57; src/views/devices/components/dialogs/SelectDeviceTypeDialog.vue lines 43-50; app/Services/BackOffice/DeviceService.php lines 749-761.)
Solution: For a live merchant there is nothing to do themselves — the upvendo POS provider is staged 'active' => true, 'test_only' => true, so onboarding offers it only to merchants flagged is_test, and a direct API call for a live merchant is refused with "Unsupported POS provider." Existing KDS devices are unaffected: the gate is create-only and they keep working.
Problem: There is no Stations tab on the Devices page
Cause: The merchant is not on the first-party Upvendo POS. Both the tab and the panel behind it are rendered only when that check passes, so the station manager is absent, not hidden-and-broken and not empty. Nothing is fetched, so there is also no error message to go looking for. (Earlier builds did render the tab for everyone and 403'd on page load; that is fixed.) (Verified: upvendo-backoffice/src/views/devices/Devices.vue lines 261 and 268; test/views/devices/DevicesStationsTabGate.test.ts lines 96-104; the API behind it, routes/api/backoffice/devices.php line 65, is gated by app/Http/Middleware/EnsureFirstPartyPos.php lines 42-53.)
Solution: Expected for a live merchant, and not something they can change themselves — the upvendo POS provider is staged 'active' => true, 'test_only' => true, so first-party POS can be selected through onboarding only by merchants flagged is_test. Use the Devices tab. A merchant with a grandfathered Kitchen Display keeps that device working, but cannot manage its station or its KDS profile from the back office: the station step, the Stations tab and the KDS profile endpoints are all first-party-only. (Verified: config/pos-providers.php lines 51-52; routes/api/backoffice/devices.php lines 65 and 104-105; routes/api/backoffice/kds-profiles.php line 31.)
Problem: The KDS profile picker says I need the "View Device Profile" permission
Cause: Usually the message is literal — the role holds EDIT_DEVICES but not VIEW_DEVICE_PROFILE. But it is also what a non-first-party merchant with a grandfathered Kitchen Display sees, because the back office turns any 403 from /back-office/kds-profiles/options into that one permission message, and the first-party-pos middleware returns a 403 too. (Verified: upvendo-backoffice/src/store/modules/kdsProfile.ts lines 151-154; message at src/plugins/i18n/locales/modules/en/devices.ts line 49; gate at routes/api/backoffice/kds-profiles.php line 31.)
Solution: If the account is on the first-party Upvendo POS, grant the role the View Device Profile permission. If it is not, the permission is not the barrier and granting it changes nothing — KDS Device Profiles are first-party-only.
Problem: KDS settings changed on one screen did not appear on another (or were lost)
Cause: The KDS app stores its settings in local storage on that one device and never sends them to the backend, so nothing a merchant edits on the screen syncs anywhere. (Verified: upvendo-kds src/stores/settings.ts lines 15-19 and 164-167; src/api/real.ts lines 15-39.)
Solution: Configure each KDS screen separately, and re-apply settings after clearing app storage or re-activating a device. The fleet-wide alternative is a KDS Device Profile in the back office, which does push its half of the settings (prep time, auto-hold, reset-on-recall, timers, sound) to every bound device — but it is available only to merchants on the first-party Upvendo POS, and the shipped KDS app does not read the pushed values back anyway.
Examples
Default KDS settings on the device record (Constants::$DEFAULT_KDS_SETTING, the PUT /kds/settings shape)
These are the backend defaults. The KDS app uses its own, different, defaults on screen -- see "The KDS App" above.
json
{
"layout": "Split",
"filter": {
"order_source": {
"all": true,
"kiosk": true,
"online_ordering": true,
"delayed_fullfillment": true
},
"categories": [
{ "id": "uncategorized", "value": true }
]
},
"orders": {
"strike_through_individual_modifier": true,
"reset_timer_on_recall": true,
"automatically_hold_scheduled_orders": true,
"preparation_time_seconds": 900
},
"timers": {
"for_here": { "caution_time_seconds": 0, "late_time_seconds": 0 },
"to_go": { "caution_time_seconds": 0, "late_time_seconds": 0 },
"pickup": { "caution_time_seconds": 0, "late_time_seconds": 0 }
},
"item_summary": {
"position": "Left",
"include_all_items": true
},
"sound": {
"mute_order_sound": false,
"volume": 100,
"play_sounds_when": {
"caution_time": true,
"updated_order": true
}
},
"ticket_appearance": {
"assign_color_to_ticker_headers": true,
"order_type": {
"for_here": "#12CDD4",
"to_go": "#4C9AE9",
"pickup": "#D7E76D"
}
}
}Mark a line item done
http
PUT /kds/mark-item
{
"item_id": "<transaction_item_id>",
"is_done": true
}Mark a single modifier done
http
PUT /kds/mark-modifier
{
"item_id": "<transaction_item_id>",
"modifier_id": "<modifier_unique_id>",
"is_done": true
}KDS Best Practices
Setup
- Register the KDS device to the correct location -- it only shows that location's orders.
- Choose the right station when provisioning: a prep station for a preparation screen (it will receive only that station's categories, and cannot change whole-order status), or Expo — whole kitchen for an assembly / dispatch screen that sees everything and owns the bumps. The device's Prep / Expeditor label is derived from that choice, not entered.
Filtering
- Route by station, not by the device's stored order-source and category filters — those are stored but never enforced, so changing them does nothing.
- Give each prep station the categories it is genuinely responsible for. Anything uncategorised (aggregator lines, for example) shows on every prep board by design.
Display Settings
- Set caution and late timers per order type to surface urgency.
- Pick the layout (Tiled, Split, Take Out, or Rail) that suits the screen and volume.
- Screen-local settings (layout, filters, strike-through, item summary, ticket appearance) must be configured on each screen individually -- they do not sync anywhere.
- Operational settings (prep time, auto-hold, reset-on-recall, timers, sound) belong in a KDS Device Profile so the whole fleet stays consistent. Note the shipped KDS app does not yet read the pushed values back.
Workflow
- Bump orders forward. Skipping ahead is allowed (a Queued ticket can go straight to Complete), and re-tapping the status an order is already in is harmless.
- Recall is the one way back: it returns a Complete order to Ready. The API supports it; the shipped app's Recall button is not wired up yet.
- Un-mark an item (
is_done: false) to re-open one that was marked done by mistake.