name: vendix-restaurant-ops
description: >
Suite restaurante para Vendix: modelado plato/insumo, recetas/BOM, producción de
sub-recetas en lote, fire-to-kitchen con descuento y COGS, mesas con cuenta
abierta y split financiero, KDS en tiempo real vía SSE, menú con carta/secciones/
combos/ventanas horarias/ingeniería BCG, e integración POS con gating por
industria. Trigger: When working on restaurant suite, recipes, BOM, prepared
products, ingredient flags, kitchen fire, KDS, kitchen tickets, table sessions,
open tabs, bill split, menu engineering, menu availability windows, or any
store whose industries includes restaurant.
license: Apache-2.0
metadata:
author: rzyfront
version: "1.2"
scope: [root]
auto_invoke:
- "Editing recipes, BOM explosion, or sub-recipe production orders"
- "Editing kitchen-fire, fire-to-kitchen, or kitchen tickets / KDS"
- "Editing tables, table sessions, or order split logic"
- "Editing menus, menu sections, menu availability windows, or menu engineering"
- "Modifying the POS for restaurant flow (fire, open table, split bill)"
- "Working with order_items.inventory_consumed_at_fire flag"
- "Working with product_type_enum='prepared' or the is_sellable/is_ingredient/is_combo/is_batch_produced flags"
- "Editing industry gating so that only restaurant stores see restaurant_ops"
- "Adding or adjusting the POS stock-vs-KDS decision modal (skipKds) for prepared+track_inventory+stock>0 products"
- "Adding or adjusting KDS card urgency tiers (warning / danger) driven by preparation_time_minutes"
- "Wiring the KDS ticket detail modal (recipe + actions replica)"
- "Modifying the POS payment close-out against an open table (table_session_id, applyPosPaymentToTableSession, table status cleaning)"
- "Modifying the POS open-table flow that propagates an optional customer to the session and draft order"
When to Use
- A store has
restaurantinstores.industriesand needs the full restaurant suite. - Touching any of:
recipes,recipe_items,production_orders,kitchen_tickets,kitchen_ticket_items,tables,table_sessions,menus,menu_sections,menu_section_items,menu_availability_windows, ororder_items.inventory_consumed_at_fire. - Wiring the POS for restaurant flow (open table, send to kitchen, split bill).
- Editing the menu/carta UI, menu builder, or menu engineering (BCG) analytics.
- Debugging why a
preparedproduct is not consumed at fire, or why a paid order double-discounts inventory.
Domain Model Overview
A restaurant store works on three orthogonal product axes encoded as columns on
products (no new entity for "dishes" — products is reused):
| Column | Default | Meaning |
|---|---|---|
product_type |
physical |
New value prepared flags a dish composed by a recipe. |
is_sellable |
true |
Shown on POS / carta / ecommerce. |
is_ingredient |
false |
Eligible as a component in recipe_items. |
is_combo |
false |
Combo/menú fijo (composed by recipe items pointing to sellable products). |
is_batch_produced |
false |
Sub-receta produced in a production_order (has its own stock). |
stock_unit / purchase_unit / purchase_to_stock_factor |
null |
Integer-unit stock rule (see §Unit rule). |
Combinations cover the 4 cases: harina (false/true), agua dual (true/true),
camiseta retail (true/false), archivado (false/false). The retail catalog
stays intact because the defaults match the existing semantics.
Unit Rule (Critical, MVP)
stock_levels.quantity_*, inventory_cost_layers.quantity, and movements are
Int in the schema. Recipes need fractional quantities (grams, ml, portions).
Do not migrate the inventory core to Decimal for the MVP. Each ingredient
is stored in its integer minimum stock unit (e.g. grams, ml, units) and
referenced by integer quantity in the recipe. The purchase-to-stock factor
converts at purchase time. Waste (waste_percent per item and per recipe) and
yield (yield_quantity, yield_unit) are decimal factors; the final consumed
quantity is rounded to integer in the stock unit.
- Residual risk: rounding accumulates in recipes with many tiny components. Mitigate by working in milli-units (mg, µl) when needed — still integer.
- A future migration to
Decimal(18,4)is deferred and must follow the anti-destructive rules invendix-prisma-migrationsand the global §6 ofCLAUDE.md.
Recipes / BOM
recipesis 1:1 logical with apreparedproduct (recipes.product_idis unique per store). The recipe ownsrecipe_items, each pointing to acomponent_product_id(an ingredient, possibly anotherpreparedsub-recipe) with an integerquantityand a per-itemwaste_percent.RecipesService.explodeBom(recipeId, multipliers)is the single entry point used bykitchen-fireandproduction-ordersto flatten a recipe into the list of leaf ingredients to consume. It is the only place that knows how to walk sub-recipes. Always use it; do not re-implement traversal.- Anti-cycle validation: a component cannot be the product itself. Detect transitive cycles with DFS on save.
- The recipe editor UI is at
apps/frontend/src/app/private/modules/store/restaurant-ops/recipes/and usesapp-multi-selectorto pick ingredients (is_ingredient=true) and CVA inputs forquantityandwaste_percent(seevendix-zoneless-signals).
Production of Sub-recipes (batch stock)
production_ordersis the only flow that produces stock for ais_batch_produced=trueproduct.complete()runs in a single Prisma transaction:- For each
recipe_item, callStockLevelManager.updateStockwithmovement_type='consumption'and a negative quantity, applying the multiplicative waste(1 + line_waste/100) * (1 + recipe_waste/100). - Compute
produced_qty = planned_qty * (1 - waste_percent/100) * yield_factor. - Call
StockLevelManager.updateStockwithmovement_type='production'and a positive quantity on the prepared product, withunit_cost = Σ(FIFO costs consumed) / produced_qty. - Update the order to
completed; emitproduction.completedafter the commit (failure to emit must not roll back the production).
- For each
- Accounting:
production.completed.finished_goodsandproduction.completed.ingredient_consumedare registered in the default account mappings (DR 1435 / CR 1435) — an intra-inventory value transfer.
Fire-to-Kitchen (the seam)
Inventory + COGS for prepared items are consumed at fire-to-kitchen, not
at payment. The flow lives in kitchen-fire:
kitchen-fire.service.ts:fireOrderItems(orderId, orderItemIds[])is transactional: for each item, if the product ispreparedand has a recipe, it callsRecipesService.explodeBomand consumes the leaf ingredients throughStockLevelManager.updateStockwithmovement_type='consumption'and a negative quantity. It then:- Sets
order_items.inventory_consumed_at_fire = truefor each fired item. - Creates a
kitchen_ticket+kitchen_ticket_items(KDS picks them up). - Computes
cogsTotal = Σ(FIFO costs consumed). - Emits
kitchen.firedafter commit.
- Sets
- The auto-entry mapping is
kitchen.fired.cogs/kitchen.fired.inventory(DR 6135 / CR 1435) — handled byAccountingEventsListenerandAutoEntryService.onKitchenFired(same pattern asonOrderCompleted). payments.service.ts:updateInventoryFromOrder(around line 2546) now has a one-line guard:if (item.inventory_consumed_at_fire === true) continue;This is the anti-double-discount rule — payment skips items already consumed at fire. Do not remove this guard when refactoring payments.- Idempotency: re-firing the same
order_itemis a no-op because the flag is set;fireOrderItemsreturns the skipped item ids and errors if all items were skipped.
Fase K — Recipe-less fire (Gap 3) + KITCHEN_TICKET_NO_RECIPE
A prepared product with no active recipe is still fireable to the
kitchen. fireOrderItems partitions firedItemIds into:
preparedItems(active recipe): consume leaf ingredients, recognize COGS.recipeLessItems(no active recipe OR inactive recipe): no BOM, no stock movement,cogsTotalstays 0 for these rows. The flaginventory_consumed_at_fire=trueis still flipped so the payment path skips them and the anti-double-discount invariant holds. The kitchen cooks them manually (the stock of leaf ingredients is the operator's concern, not the system's).
startPreparation(ticketId) adds a hard guard: if ANY item in the ticket
has no active recipe, the transition to in_preparation is rejected with
KITCHEN_TICKET_NO_RECIPE (422, apps/backend/src/common/errors/error-codes.ts).
The guard is per-ticket (not per-item) because the state model transitions
the whole ticket. The operator can either attach a recipe first or mark
the ticket as delivered directly to bypass in_preparation.
Invariant — new meaning of inventory_consumed_at_fire:
"disparado a KDS, el pago no lo toca, COGS puede ser 0" (not the old
"consumido de receta"). The payment path guard at
payments.service.ts:2554 (if (item.inventory_consumed_at_fire === true) continue;)
DOES NOT distinguish between the two cases — and that is correct: in both
cases the KDS owns the item and the payment must not double-discount.
Tables and Open Tab
tablesis a per-store floor entity (pos_x,pos_y,status).table_sessionsis the open tab. Opening a session callsorders.service.createto make anordersrow indraftstate and links the session to it viaorder_id. Adding items to the session appendsorder_itemsto that draft order (no fire happens automatically — the POS decides when to fire).- Closing a session marks
closed_at; it does not finish the order. The order finishes when paid through the normal payment flow. - Bill split (
split-order.service.ts) is purely financial:splitOrderByItems(orderId, itemGroups[])andsplitOrderByAmount(orderId, nSplits, mode)create N sub-orders from the source.- Sub-orders propagate
inventory_consumed_at_fire=truefrom the sourceorder_items. Split must never create new consumption movements; inventory is already gone (taken at fire). The flag propagation is what keepspayments.updateInventoryFromOrderfrom re-discounting.
Fase K — POS end-to-end (crear → mesa → cliente → cobrar)
The restaurant POS supports three fulfillment types (mostrador, delivery, consumo) and an optional customer binding. The flow must work end-to-end without DB migrations and stay aligned with the existing table / customer APIs. Key invariants, encoded during the K-feature stabilization:
orders.customer_idisInt?nullable at the Prisma layer. Thecreate-order.dtodeclares it@IsOptional() @IsInt() @Min(1)so a counter / table-less sale can omit it;orders.service.createskips theusers.findUniqueFK lookup when null and persistscustomer_id ?? nullon the row. Symmetrically,payments.service.processSaleWithPaymentforwards the cart customer only when the cart has one. There is no "Cliente General / id=1" sentinel — true anonymous is the source of truth.OpenTableSessionDto.customer_id?already existed (Fase E). ThePosOpenTableModalComponentnow exposes a[customer]input and forwards it in theopenTableSessioncall, so opening a table attaches the customer (if any) to the session and the linked draft order.- The cart merge key in
pos-cart.service.processAddToCartisproduct.id + variant_id + skipKds(boolean). TheskipKdsfield is part of the identity because two same-product lines with different KDS-vs-stock decisions must NOT collapse — they take different code paths at fire time. PosOrderCreateModalComponent.onConfirmre-routes so the table-session branch always wins when a session is open. The pre-existinghasUnfiredPreparedItemscheck used to fall through to the retailcreateRetailDraftpath on consumption sales, which silently orphaned the table. The fix: if a session is open, the modal always callsappendToTableAndFire(withskipKdslines filtered out of the fire list).preparedItemIdsFromOrderand the cart-levelhasUnfiredPreparedItemsboth skipskipKdslines when deciding whether to fire the kitchen.PosPaymentInterfaceComponent(the cobro modal) gained an inline table picker for theconsumofulfillment: a CTA "Abrir mesa" embeds the samePosOpenTableModalComponentused by the create flow. The picker writes topickedTableIdandpickedSessionIdsignals;canProcessPaymentfalls back totableId() ?? pickedTableId()so the Cobrar button unblocks.processSaleWithPayment(cart, payment, user, tableSessionId?)is the new 4-arg signature that forwardstable_session_idto the backend.PaymentsService.createOrUpdateOrderFromPosbranches ondto.table_session_id. When present, it delegates toapplyPosPaymentToTableSession(new helper): loads the session, validates it belongs to the request store and is still open, optionally appends neworder_itemsto the existing draft order, re-derivessubtotal_amount/tax_amount/discount_amount/grand_totalfrom the merged items (re-running promotion + coupon quote), persists the totals, marks the sessionclosed_at = now(), and transitions the table tostatus='cleaning'(matchingTableSessionsService.closeSessionsemantics — without this the table staysoccupiedforever after a POS close-out and blocks the nextopenTableSessioncall on the same table). The order then flows through the normal payment / inventory / journal pipeline; no special-casing downstream.- The fire / payment path now filters
skipKdsin three places: the cart-levelhasUnfiredPreparedItems(POS component), thepreparedItemIdsFromOrderhelper (create modal), and the actual fire loops infireCounterOrderandfireKitchenFromCompletedOrder. Lines withskipKds=trueare excluded fromorder_item_idssent to the kitchen andinventory_consumed_at_fireis not set on them — their stock is consumed at payment time as a regularsalemovement, not at fire. - The orders list (
orders-list.component) is loaded vialoadComponent(lazy), so the constructor runs fresh on each navigation. TheOrdersComponenthost subscribes torouter.eventsand increments areloadTicksignal onNavigationEndto/admin/orders(and excluding detail sub-routes). The list binds[reloadTrigger]="reloadTick()"and re-fetches on tick change via aneffect. The bug "POS sale doesn't show up in /admin/orders/sales until I hit F5" is fixed without backend changes —findAllwas already correct.
KDS (Kitchen Display System)
- KDS uses SSE on subject
kitchen:{store_id}— same pattern asnotifications:{store_id}innotifications-sse.service.ts. TheKdsSseServiceon the frontend wrapsEventSourcewith exponential backoff (1s → 30s), sends asnapshotevent on connect (lastwindowMinutesof tickets), and merges incoming events into a signal consumed by the KDS board page. - Ticket / item states:
pending → in_preparation → ready → delivered(pluscancelled). All transitions are server-emitted SSE events (ticket.created,ticket.started,ticket.ready,ticket.delivered,ticket.cancelled). - The KDS board is at
apps/frontend/src/app/private/modules/store/restaurant-ops/kds/with a 4-column layout. Reuseapp-sticky-header,app-stats,app-card,app-button,app-badge,app-icon,app-toast,app-spinner.
Fase K — KDS card urgency (Gap 5)
KDS card urgency is driven by products.preparation_time_minutes (also on
product_variants.preparation_time_minutes, exposed via the single
KITCHEN_TICKET_INCLUDE so snapshot and every SSE event carry it). The
board computes the smallest prep time across the ticket's items;
missing/0/negative values contribute the default of 10 minutes.
- Warning tier:
elapsed >= smallest_prep * 60s(amber border + label). - Danger tier:
elapsed >= (smallest_prep + 5) * 60s(red border + label). - Both tiers are suppressed in terminal states (
delivered,cancelled). - Legacy
--urgentclass is kept as a backward-compat alias for--warning; do not delete without auditing old screenshots.
The shared now ticker is pushed to every card from the board; one timer
for the whole page (not per card).
Fase K — KDS ticket detail modal (Gap 4)
Clicking a KDS card body opens kds-ticket-detail-modal
(apps/frontend/src/app/private/modules/store/restaurant-ops/kds/components/kds-ticket-detail-modal/)
showing:
- Order header (number, table, status, elapsed).
- The ticket items with quantities, names, notes, and prep time.
- The active recipe for each item via
RecipesService.getByProduct, cached perproduct_idin a localMapto avoid hammering the API. Graceful degradation to "Receta no disponible" on 403/404 (per R7, we never block the modal on a missing recipe nor touch permissions). - Replica of the board actions (Start / Ready / Deliver / Cancel) that re-emit to the parent handlers so the SSE pipeline stays the source of truth.
The modal is live: the board derives the ticket from the SSE-fed
tickets() signal by id, so any board event updates the modal in real
time. The actions footer uses (click)="$event.stopPropagation()" so
clicking a button inside the modal never re-opens it.
Fase K — KDS state in order detail (Gap 2)
GET /api/store/orders/:id (orders.service.ts:findOne) now includes
kitchen_ticket_items (ordered desc by id) on every order_item. The
order detail page surfaces a "Cocina: <estado>" badge per item with
the colour map: pending→neutral, in_preparation→warning, ready→success,
delivered→info, cancelled→error. Non-fired items show no badge. The
helper kitchenStateFor(item) prefers a non-terminal (in-flight) row
over the most recent terminal row, so the badge tracks the active state
even after re-fires.
Menu / Carta
- A
menusis a named carta withmenu_sections, each withmenu_section_items(a product reference, sort order). - Availability:
menu_availability_windowsare windows perday_of_weekstart_time+end_time("HH:mm"), either at menu level (menu_id) or section level (menu_section_id). The post-filter uses the sameIntl.DateTimeFormatalgorithm asschedule-validation.service.ts:getDateInTimezone(do not duplicate timezone math).
- Combos are not a new model. A combo is a
product_type='prepared'withis_combo=true, whoserecipe_itemspoint to sellable products (or other combos). The combo's price is the product's own price (vendix-calculated-pricing); at fire, the recipe is exploded and each component is consumed. - Menu engineering (BCG):
menu-engineering.service.tsaggregates popularity (units sold in a window) × margin (price − recipe unit cost) and classifies products as estrella / caballo / puzzle / perro. The engineering view lives atapps/frontend/src/app/private/modules/store/restaurant-ops/menus/pages/menu-engineering-page/. - The public carta (
@Public()atcatalog.service.ts:27/461) now addsis_sellable: trueto the where clause and post-filters results by the active availability window. Do not remove the@Public()decorator.
POS Integration
- The POS (
apps/frontend/src/app/private/modules/store/pos/) sendsis_sellable=truein its product list filters so pure ingredients are never shown to the cashier. - The 3 outputs are
openTable,fireKitchen,splitBill— wired intopos-cart.component.tsand the mobile footer. They delegate topos-restaurant-integration.service.tswhich calls thekitchen-fire,tables, andsplit-orderservices. - The retail POS path is unchanged: defaults preserve
is_sellable=trueon all retail products.
Fase K — POS stock-vs-KDS decision (Gap 1, skipKds)
A prepared product that also tracks inventory and has stock > 0 is
ambiguous: cook-from-scratch (consume ingredients at fire) vs sellable
item (consume its own stock on payment). The POS surfaces
pos-prepared-choice-modal
(apps/frontend/src/app/private/modules/store/pos/components/pos-prepared-choice-modal/)
to the cashier at add-to-cart time. The choice persists as
CartItem.skipKds:
skipKds=false(default, "Producir por KDS"): the item is included infireOrderItems. Stock of leaf ingredients is consumed at fire; the product's own stock is not touched.skipKds=true("Usar stock"): the item is excluded fromfireOrderItems(POS component filters theorder_item_idslist). No kitchen ticket is created. The product's own stock is consumed at payment as a regularsalemovement.
The flag is purely cart-local — it is NOT persisted to
order_items, so no DB migration is required. Retail-only stores never
see the modal (gated on isRestaurantMode()). Combined with the
inventory_consumed_at_fire guard in updateInventoryFromOrder, this
preserves the anti-double-discount invariant without schema changes.
Industry Gating (inverse rule)
restaurant_ops is the panel UI module that aggregates
recipes / production / kds / tables / menus. The gating is inverse:
retail, manufacturing, and service industries list restaurant_ops
in INDUSTRY_HIDDEN_MODULES; restaurant does not. The 3-layer crossing
(industry ∩ store ∩ user) in getModulesHiddenByIndustries already
ensures that, if the industry hides a module, neither store nor user can
enable it. Do not relax this rule.
Permissions
The seed (permissions-roles.seed.ts) registers the restaurant_ops
permission keys used by the controllers:
store:recipes:create|read|update|deletestore:production_orders:create|read|updatestore:kitchen_fire:create|read|updatestore:tables:create|read|update|deletestore:table_sessions:create|read|updatestore:menus:create|read|update|deletestore:menu_engineering:read
Source of Truth (paths)
- Backend modules:
apps/backend/src/domains/store/{recipes,production,kitchen-fire,tables,menus}/ - Frontend modules:
apps/frontend/src/app/private/modules/store/restaurant-ops/{recipes,production,kds,tables,menus}/ - Schemas:
apps/backend/prisma/schema.prisma+ the migration20260613000000_restaurant_suite_foundation/migration.sql(Fase A) and20260613000001_fire_to_kitchen_inventory_flag/migration.sql(Fase D). - Scopes:
apps/backend/src/prisma/services/store-prisma.service.ts(11 new models instore_scoped_models). - Account mappings + auto-entries:
apps/backend/src/domains/store/accounting/auto-entries/.
Anti-patterns
- Implementing a "dish" entity instead of reusing
productswithproduct_type='prepared'. - Recursing recipes by hand in controllers/services — always call
RecipesService.explodeBom. - Discounting
preparedinventory at payment — it must be at fire, and theinventory_consumed_at_fireguard must stay inpayments.updateInventoryFromOrder. - Splitting the bill by re-creating consumption movements — split is financial; inventory is already gone.
- Re-emitting the same SSE event before commit — emit only after the Prisma transaction commits, otherwise listeners may see events for state that was rolled back.
- Adding
restaurant_opsto therestaurantindustry inINDUSTRY_HIDDEN_MODULES— the rule is inverse;restaurantkeeps it visible. - Migrating inventory quantities to
Decimalin this MVP — defer with a dedicated migration plan that followsvendix-prisma-migrationsrules.