PRD: Table & floor allocation
| Module | Commerce (CORE-03) | PRD ID | PRD-FLR-001 |
| Status | Shipped | Owner | Commerce squad |
| Date | 2026-06-15 | Version | v1.0 |
| Packages | @nx/commerce · @nx/sale · @nx/core · apps/sale-renderer | URD | FLR |
TL;DR
Gives an F&B merchant a working floor plan and live table occupancy. The owner designs the floor once - a named layout holding a tree of zones (floor → room → table, up to two levels deep) whose leaf zones carry units (tables) with a capacity, a position on the canvas, and a style - and builds or edits the whole tree in one atomic aggregate operation. At the POS, opening a dine-in order occupies one or more units; the floor plan shows each unit as free or busy in real time, staff can find free tables, transfer a party from one zone to another, and the table is freed when its usage is completed. Every occupancy change broadcasts a live event so all terminals stay in sync, and each order keeps a snapshot of the unit and its zone path.
1. Context & Problem
Onboarding stands up an organization and its merchants (PRD-ORG-001), but a sit-down restaurant runs its day on something onboarding does not provide: a map of the room and who is sitting where. Without it, a server cannot see at a glance which tables are open, the kitchen has no anchor for a dine-in order, and "move table 4 to the patio" is a verbal handoff with nothing recorded.
Two distinct jobs hide behind "tables". One is design - laying out the physical space (zones, tables, seats, where each sits on the screen) - a back-office, infrequent task that belongs to the merchant's catalogue. The other is occupancy - which table a live order is on, right now - a high-frequency, real-time POS concern. Modelling them as one editable blob would make the floor map churn on every seating, and modelling occupancy without a stable map would leave orders pointing at nothing.
This increment separates the two cleanly: a stable, merchant-scoped floor model (layout / zone / unit) authored as an atomic tree, and a live usage record that binds a sale order to one or more units, drives the colour of every tile on the floor plan, and is the source of truth for availability, transfer, split, and merge.
2. Goals & Non-Goals
Goals
- Model a merchant's physical floor as a named layout → zone tree → unit graph, with units carrying capacity, canvas placement, and style.
- Author the whole floor in one atomic aggregate (create and smart-update), with a depth cap and a layout-rooted guard.
- Occupy units by opening a dine-in sale order; drive a live floor plan that shows each unit free or busy.
- Find free tables - available units in a zone, and available child zones where every table is free.
- Transfer a party between zones, and keep allocations correct when an order is split or merged.
- Broadcast a real-time event on every occupancy change; keep an allocation snapshot on the order.
Non-Goals
- Reservations as a booking product (wait-lists, deposits, calendar) - the usage model supports a reservation principal, but the booking experience is a separate increment.
- Pricing or service charge per zone/table - prices live in fares; allocation carries no money.
- A graphical floor-plan editor UI - this PRD specifies the model and its operations; the canvas tooling is an
apps/sale-rendererconcern. - KDS / kitchen routing and the order lifecycle itself (Sale) - allocation references an order, it does not own it.
3. Success Metrics
| Metric | Target / signal |
|---|---|
| Floor integrity | A failed sub-step in an aggregate leaves no half-built tree - layout, zones, and units commit together or not at all |
| Depth safety | No aggregate ever persists a zone tree deeper than the two-level cap, or a zone not rooted in its layout |
| Occupancy truth | A unit shows busy exactly while it has a reserved/active/success usage, and free otherwise |
| Availability accuracy | "Free tables" returns only units with no live usage; a zone is offered only when all its tables are free |
| Live sync | Every occupy / free / transfer reflects on all POS terminals without a manual refresh |
4. Personas & Use Cases
| Persona | Goal in this feature |
|---|---|
| Owner / Manager | Lay out the floor once - zones, tables, seats, positions - and edit it as the space changes |
| Server / Cashier | See which tables are free, seat a party, move it, and free the table when guests leave |
| Host | Read the live floor at a glance and pick an open table of the right size |
Core scenario: an owner designs "Ground Floor" as a layout with two rooms, each holding several tables, in one save. During service a server opens a dine-in order on Table 7; the tile turns busy on every terminal. A walk-in of six arrives - the server filters for free tables seating six, seats them, then later transfers them from the main room to the patio in one action. When each party leaves, completing the order's usage frees the table back to green.
5. User Stories
- As an owner, I design my floor as zones and tables in one save, so the map is complete and consistent the moment it exists.
- As an owner, I edit the layout - add a room, remove a table, reposition seats - through one smart-update without rebuilding it.
- As a server, I see free vs busy tables live, so I never seat a party on an occupied table.
- As a server, I open an order on a table and it immediately shows busy to everyone, so the floor is never double-sold.
- As a server, I transfer a seated party to another zone in one action, and the old table frees while the new one fills.
- As a server, I complete a table when guests leave and it turns free again, so the next party can be seated.
6. Functional Requirements
| # | Requirement | URD ref |
|---|---|---|
| FR-1 | A merchant models its floor as a named layout holding a tree of zones; leaf zones own units with capacity, placement, and style | URD-FLR-001..003 |
| FR-2 | A layout is created with its whole nested zone tree in one atomic aggregate | URD-FLR-004 |
| FR-3 | A layout aggregate updates by the id-convention: id-only = delete (cascades), id+data = update, no id = create | URD-FLR-005 |
| FR-4 | A zone is itself create/update-able as an aggregate with nested sub-zones and units, and zones can be batch-created | URD-FLR-006 |
| FR-5 | Depth is capped at two levels below the layout root; a deeper tree, or a zone not rooted in its layout, is refused before persistence | URD-FLR-007 |
| FR-6 | A layout aggregate is readable with a configurable max depth - full tree for back-office, shallow for the POS | URD-FLR-008 |
| FR-7 | Layouts, zones, and units carry a lifecycle status (Activated / Deactivated / Archived) and are soft-deleted; each is also managed standalone | URD-FLR-009..010 |
| FR-8 | Opening a dine-in sale order on one or more units creates an occupancy usage that marks those units busy | URD-FLR-011 |
| FR-9 | A usage carries a reservation window; with no end given, a default 90-minute window applies | URD-FLR-012 |
| FR-10 | Occupancy follows reserved/active → success → completed (freed) or cancelled; a terminal usage cannot be cancelled again | URD-FLR-013 |
| FR-11 | A unit is busy while it holds a reserved/active/success usage and free otherwise; the POS can query free units and fully-free child zones | URD-FLR-014..015 |
| FR-12 | A party can be transferred between zones - source usages cancelled, the target zone's units occupied per order, atomically | URD-FLR-016 |
| FR-13 | Splitting an order clones its active usages onto each new order; merging moves them to the surviving order | URD-FLR-017 |
| FR-14 | Every occupancy change broadcasts a real-time floor-plan event to all terminals | URD-FLR-018 |
| FR-15 | A usage may capture guest details, and the order/reservation keeps an allocation snapshot (unit + zone path + guest) | URD-FLR-019..020 |
| FR-16 | All allocation operations are scoped per merchant (x-merchant-id) and gated by allocation permissions | URD-FLR-021 |
Full requirement text and acceptance criteria live in the Commerce URD - FLR. This PRD references them rather than restating them.
7. Non-Functional Requirements
| Area | Requirement |
|---|---|
| Atomicity | The floor model (layout + zones + units) and each occupancy operation (start, transfer, split, merge) are all-or-nothing transactions |
| Depth safety | A recursive-tree guard caps zone depth and rejects non-layout-rooted references before anything is written |
| Real-time | Occupancy changes push live events so every POS terminal reflects the floor without polling |
| Tenancy & authz | All operations scoped per merchant (x-merchant-id); floor design gated by allocation-layout/zone/unit permissions, occupancy by allocation-usage permissions |
| Performance | Availability resolves a zone's units through a single recursive query; the floor read caps depth so the POS fetches only what it renders |
| Durability | Each order keeps an allocation snapshot (unit + zone path + guest) so the assignment survives on the document independent of the live usage rows |
| i18n | Layout, zone, and unit names are bilingual ({ en, vi }) |
8. UX & Flows
Occupancy lifecycle
Seat, see live, transfer
The floor model surfaces in back-office as a layout editor (zones, tables, capacities, positions) and in the POS (apps/sale-renderer) as the live floor plan that colours each tile by occupancy and powers seat / find-free / transfer / complete.
9. Data & Domain
| Entity | Role |
|---|---|
AllocationLayout | A merchant's named floor map; root of the zone tree; carries canvas style |
AllocationZone | A region in the tree (floor / room / table grouping); self-nesting up to two levels under the layout |
AllocationUnit | A seatable unit (table); carries capacity, canvas placement, and style; belongs to a leaf zone |
AllocationUsage | A live occupancy binding a unit to a sale order (or reservation), with a reservation window and a status that drives the floor colour |
Conceptual only - the full schema and invariants live in the commerce domain model. Relations are soft references; integrity is enforced in the aggregate and usage services, not by database constraints.
10. Dependencies & Assumptions
Depends on
- Merchant (MER, PRD-ORG-001) - every layout, zone, unit, and usage is scoped to a merchant.
- Sale orders (Sale) - a usage occupies units on behalf of a dine-in order; split / merge / payment drive its lifecycle.
@nx/core- the allocation entity models, statuses, and the live socket channel.
Assumptions
- The merchant's business type is dine-in (F&B); counter / retail merchants do not need a floor model.
- Zone depth in practice is at most floor → room → table; the two-level cap reflects that.
- An order is the usual occupancy principal; the reservation principal is supported by the model but its booking UX is out of scope here.
11. Risks & Open Questions
| Risk / question | Mitigation / status |
|---|---|
| A half-built floor on a failed aggregate (zones but no units) | The whole tree is one transaction - any failure rolls back fully |
| A runaway or cyclic zone tree | Depth is capped at two levels and non-layout-rooted references are refused before persistence |
| Two parties seated on one table (double-sell) | A unit is busy while any reserved/active/success usage exists; availability excludes occupied units |
| Floor plan drifts between terminals | Every occupancy change broadcasts a live event consumed by all POS terminals |
| Allocation lost if usage rows change | Each order keeps an allocation snapshot (unit + zone path + guest) on its own document |
| Stale-window tables never freed | A default 90-minute reservation window is recorded; completion frees the table explicitly |
12. Release Plan & Launch Criteria
| Aspect | Plan |
|---|---|
| Phase | P2 - FLR in the URD feature catalog |
| Rollout | F&B / dine-in merchants; no feature flag |
| Migration | None - new allocation entities; existing merchants gain an empty floor model |
| Launch criteria | A layout with its zone tree and units saves atomically; depth and layout-rooted guards hold; opening a dine-in order occupies units and turns them busy live; free-table queries exclude occupied units; transfer moves a party atomically; completing frees the table; each order keeps its allocation snapshot |
| Monitoring | Aggregate failure rate by reason (depth, not-rooted), occupancy event-delivery lag, available-query latency |
13. FAQ
Why are the floor map and table occupancy separate? Because they change at completely different rates. The map is a rare back-office edit; occupancy churns every seating. Keeping them apart means the live floor never rewrites the map, and orders always point at a stable unit.
How deep can my floor go? Two levels below the layout root - in practice floor → room → table. An aggregate that would go deeper, or that points a zone at the wrong layout, is refused before anything saves.
When does a table show busy? While it holds a usage that is reserved, active, or paid (success). Once that usage is completed or cancelled, the table is free again.
What happens to the old table when I transfer a party? The transfer is atomic: the source usages are cancelled and the target zone's units are occupied for each affected order, so the old table frees and the new one fills together.
Do I lose the table assignment if the live usage changes? No - each order keeps an allocation snapshot of its unit and the unit's zone path (plus guest details), so the assignment survives on the order itself.
Can I find an open table of the right size? Yes - the POS queries available units in a zone (and child zones where every table is free), which you can filter by capacity.
References
- URD: Commerce - FLR
- Sibling PRDs: Organization & merchant · Receipt templates · Retail business type
- Module: Commerce - overview + capabilities
- Related module: Sale - orders that occupy units
- Developer: @nx/commerce · @nx/sale · @nx/core