# DALSEEN Dine — Operational Events

This document inventories every operational signal the Dine module emits today: audit-log events, journal sources, inventory-movement `ref_type` codes, and KDS state transitions. It's the source-of-truth for downstream consumers (dashboards, alerts, exports, reconciliation jobs).

Focus is on **what the system writes**, not what it reads. Every entry includes:

- **Producer** — the code that writes the event
- **Consumer** — the surfaces that read it (today)
- **Idempotency** — what protects against duplicates
- **Side effects** — DB / GL / inventory writes that travel with the event

Events are grouped by category. Within each, the storage table is the source of truth — no event-bus / pub-sub system today; consumers either query the table or read derived projections (KPIs).

---

## 1 · Audit log events  (`audit_logs` table)

All audit events are recorded via `App\Support\Audit::record()`, which writes a row to `audit_logs` with `{ company_id, user_id, action, model_type, model_id, severity, before, after }`. Tenant-scoped via `BelongsToTenant`.

| `action` | Producer | Severity | Payload (`after`) | Consumer | Idempotency |
|---|---|---|---|---|---|
| `dine.order.line_voided` | `DineOrderService::voidLine()` (Phase-1) | `medium` | `{ reason, order_id, qty, reversed }` | future void-rate dashboard; compliance review | `voidLine()` early-exits if line already `voided` — no double audit |
| `dine.order.cancelled` | `DineOrdersController::ordersStatus()` (Phase-6) | `high` if `was_paid`, else `medium` | `{ order_id, ref_no, channel, was_paid, total, cancelled_at }` | future refund-anomaly alert; compliance | `transitionStatus()` early-exits on same-status no-op — no duplicate |
| `dine.menu_item.patched` | `DineMenusController::menuItemsPatch()` | `medium` | diff via `Audited` trait | menu-engineering review | `Audited` records every save; idempotent at the row level |

> **No** other operational dine endpoints (fire / serve / hold / recall / pay / aggregator accept) emit explicit audit rows today. They write directly to their domain tables; the `Audited` trait on the model captures the row-level change automatically.

---

## 2 · Journal events  (`journal_entries` table)

Two posting families emit GL journals from Dine. Both are idempotent on `(company_id, source, source_ref)`.

### `dine_order` — paid order

- **Producer:** `DineOrderPoster::post()` (called from `DineOrderService::pay()` when status flips to `paid`).
- **Trigger:** `DineOrder.status` transitions from anything → `paid`.
- **Side effects per line:**
  - DR tender(s) — one leg per `payments[]` row (`cash`, `card`, `wallet`, `softpos`, `gift_card`, `store_credit`).
  - CR revenue — channel-routed: `sales.dine_in` / `sales.takeaway` / `sales.delivery` / `sales.aggregator`. `qr` aliases `dine_in`, `kiosk` aliases `takeaway`.
  - CR `tax.vat_payable` — when `tax_total > 0`.
  - DR `cogs.food` / CR `inventory.ingredients` — bucketed from recipe-component class.
  - DR `cogs.beverage` / CR `inventory.beverage` — same, bucketed by `class='beverage'`.
- **Branch dimension:** every line carries `branch_id`.
- **Idempotency key:** `(company_id, source='dine_order', source_ref=order.id)`. Re-paying or re-running the poster returns the same `JournalEntry`.
- **Pre-conditions:** `appliesTo(business_type)` is `restaurant` only; retail tenants skip. `posting_rules.auto_post_dine` must be enabled.
- **Failure mode:** unmapped channel → `DINE_CHANNEL_UNMAPPED` (422).
- **Consumer:** trial balance, P&L, food-cost-% report, reconciliation jobs.

### `dine_order_refund` — paid order cancelled

- **Producer:** `DineRefundPoster::post()` (called from `transitionStatus(paid → cancelled)`).
- **Trigger:** `DineOrder.status` flips from `paid` → `cancelled`.
- **Side effects:** loads the original `dine_order` journal and posts a perfect mirror — debits and credits flipped per line, branch_id preserved. Net GL impact per account = 0.
- **Idempotency key:** `(company_id, source='dine_order_refund', source_ref=order.id)`.
- **Inventory:** **does NOT reverse** recipe stock — for paid orders the goods were already consumed and served. Operator records prep-loss separately if relevant. (Pre-paid cancellations DO reverse stock — see `recipe_void` movement below.)
- **Consumer:** AR/AP refund line on the cashier-anomaly report; full GL reversal on trial balance.

---

## 3 · Inventory movement events  (`stock_movements` table)

Every `stock_movements` row is the operational source-of-truth for inventory changes. The table is append-only — there are no updates / deletes; corrections are a new symmetric movement. `ref_type` identifies the producer.

| `ref_type` | Producer | Sign of `qty` | Side effects | Reverses on |
|---|---|---|---|---|
| `recipe_consume` | `DineOrderService::moveRecipeStock()` (called from `addLine()` / `create()`) | negative (decrements on-hand) | inserts movement row; updates `inventory_levels.on_hand`; auto-posts inventory-movement GL via `InventoryMovementPoster` | `recipe_void` (line void), or stays put for paid-cancelled orders |
| `recipe_void` | `DineOrderService::reverseRecipeStock()` (Phase-1, called from `voidLine()` AND `transitionStatus(non-paid → cancelled)`) | positive (re-credits on-hand) | symmetric to `recipe_consume`; same GL accounts in mirror | terminal — no further reversal |

**Idempotency:** the dine path doesn't deduplicate by movement; the `voidLine()` early-exit on already-voided status guarantees at most one `recipe_void` per line. Cancellation iterates `lines` once and marks each voided.

**Consumer:** branch on-hand levels (`InventoryLevel`); FEFO/expiry queries; food-cost-% computation; future inventory-variance reports.

---

## 4 · Order lifecycle transitions  (`dine_orders.status`)

State machine is enforced in `DineOrderService::transitionStatus()`. All transitions are operational (no GL effect) except `paid` and `cancelled-after-paid`.

```
                ┌──────────┐
                │   open   │
                └────┬─────┘
                     │
              ┌──────▼──────────┐
              │ sent_to_kitchen │ ── emits KDS tickets (idempotent per
              └──────┬──────────┘    order×station — Phase 2)
                     │
              ┌──────▼──────┐
              │   ready     │
              └──────┬──────┘
                     │
              ┌──────▼──────┐
              │   served    │ ── stamps `served_at` (Phase 1)
              └──────┬──────┘
                     │
              ┌──────▼──────┐
              │    paid     │ ── DineOrderPoster (LOCKED — see below)
              └──────┬──────┘
                     ▼
              ┌─────────────┐
              │  cancelled  │ ── stamps `cancelled_at` (Phase 1)
              └─────────────┘     · from non-paid: reverses recipe stock
                                   · from paid: DineRefundPoster mirror
                                   · TERMINAL (Phase 3)
```

- **`paid` lock (Phase 3):** only allowed exit is `cancelled` (refund). Any other transition raises `STATUS_LOCKED`.
- **`cancelled` lock (Phase 3):** terminal. Any out-bound transition raises `STATUS_LOCKED`.
- **Same-status no-op:** all transitions early-exit if `oldStatus === newStatus` — covers retry storms.
- **Refund step-up (Phase 6):** `paid → cancelled` requires an `X-Step-Up-Ticket` for permission `dine.orders.refund`. Controller-level check in `DineOrdersController::ordersStatus()`.

### Line-level transitions  (`dine_order_lines.status`)

```
queued → firing → ready
  │
  ├── held (excluded from KDS emit, totals unchanged)
  │     │
  │     └── recall → queued
  │
  └── voided (Phase-1: emits +recipe_void; recomputes totals)
```

---

## 5 · KDS events  (`kds_tickets`, `kds_ticket_lines` tables)

| Event | Producer | Idempotency | Consumer |
|---|---|---|---|
| ticket emit | `DineOrderService::emitKdsTickets()` (Phase 2 rewrite) | one ticket per `(order_id, station_id)`; skips if exists | `DineKitchenController::kdsAggregate()` (operator board); future delay-alert |
| ticket bump | `DineKitchenController::kdsTicketAdvance()` | `same-status no-op` semantics on the controller | KDS expo screen |
| line advance | `DineKitchenController::kdsLineAdvance()` | per-row state machine; idempotent on same-status | future per-station SLA report |

**Routing rule (Phase 2):** a station with `menu_categories` JSON populated only sees lines whose `menu_item.menu_category_id` ∈ that array. A station with `menu_categories=null` or `[]` is a catch-all (legacy). Stations with no matching lines for an order receive **no** ticket (no empty-ticket noise).

---

## 6 · Aggregator events  (`aggregator_orders` table)

Inbound from partner platforms (Talabat, Jahez, Hungerstation, Mrsool — production webhooks pending; today everything is the stub provider).

| Event | Producer | Side effects | Consumer |
|---|---|---|---|
| inbox arrival | `DineController::aggregatorInboxStubArrival()` (test-only) | inserts `aggregator_orders` row with `status=pending` | inbox drawer in `/dine-pos` |
| accept | `DineController::aggregatorInboxAccept()` | flips inbox row to `accepted`; **inserts a `dine_orders` row** (channel=`aggregator`, status=`open`, ref_no=`AGG-<partner>-<ref>`); calls `AggregatorProvider::acceptOrder()` ack | KDS via standard order flow |
| reject | `DineController::aggregatorInboxReject()` | flips inbox row to `rejected` with `reason`; calls partner reject ack | inbox drawer |

**Idempotency:** `accept` returns the existing `dine_order_id` if the row was already accepted. `reject` no-ops if not pending. Cross-tenant guard returns `404` (information-hiding).

**Failure mode:** the partner ack is best-effort — a network failure on `acceptOrder()` does NOT roll back the local accept. Partner sees the divergence and retries their webhook; the FE shows the local row as accepted.

---

## 7 · Delivery events  (`delivery_orders` table)

| Event | Producer | Side effects |
|---|---|---|
| dispatch | `DineController::deliveriesAssign()` | sets `rider_name`, `rider_phone`, `eta` |
| delivered | `DineController::deliveriesDelivered()` | flips `status=delivered` |

No journal effect — the financial close happens at the `dine_orders` level (channel=`delivery` → `sales.delivery` revenue).

---

## 8 · Self-order events  (`kiosk_sessions`, `qr_flow_events`)

| Event | Producer | Side effects |
|---|---|---|
| kiosk session upsert | `DineController::kioskSessionUpsert()` | upserts `kiosk_sessions` (stage tracking: welcome / browse / checkout) |
| QR scan | `DineController::qrScan()` | inserts `qr_flow_events` row |

These are intake-side events. They do not yet land on the menu/order surface — when phone-side ordering ships, this is where the trail will reach the dine_orders table.

---

## 9 · Production batch events  (`production_batches` table)

| Event | Producer | Idempotency | Side effects |
|---|---|---|---|
| batch complete | `DineInventoryController::productionComplete()` | upserts on `(company_id, recipe_id, completed_at IS NULL)` — at most one open batch per recipe per day | flips `status=done`; sets `completed_by`, `completed_at`, `qty`, `notes` |

**Step-up:** requires `X-Step-Up-Ticket` for `dine.production.approve`. No GL effect today (no finished-good cost rollup yet).

**Consumer:** `DineInventoryController::productionIndex()`; future production-completion dashboard tile.

---

## 10 · Cross-cutting invariants

- **Tenant isolation** — every event is scoped via `BelongsToTenant`; cross-tenant resource access returns `404` (no leak about existence).
- **Branch isolation** — every list endpoint applies `applyBranchFilter()` from `Concerns\DineControllerHelpers`; the `'all'` sentinel is treated as no-filter.
- **Audit immutability** — `audit_logs` is append-only; corrections are a new audit row referencing the original.
- **Journal immutability** — `journal_entries` likewise; corrections are reversal entries (`dine_order_refund`).
- **Inventory immutability** — `stock_movements` likewise; corrections are symmetric movements (`recipe_consume` / `recipe_void`).
- **Step-up tickets** — single-use, time-bound. Revoked on use. Bypass `X-Skip-Step-Up: 1` honored only when `APP_DEBUG=true` (test env).

---

## 11a · Device heartbeat  (Phase 11)

The frontend POSTs a lightweight heartbeat from every operational
screen (POS, KDS, Floor, Operations dashboard, Kiosk):

```
POST /api/v1/dine/devices/heartbeat
Body: { device_uuid, device_type, device_name?, platform?, app_version?, branch_id?, metadata? }
```

The endpoint upserts a row in `dine_devices` keyed on
`(company_id, device_uuid)` and stamps `last_seen_at`. It never
returns the full device list — that's a separate read endpoint
(`GET /dine/devices`) consumed by the operations dashboard.

| Property | Value |
|---|---|
| **Heartbeat interval (FE)** | 60 seconds while the tab is visible |
| **Pause when hidden** | yes — `document.visibilitychange` listener |
| **Offline threshold (BE)** | `App\Models\DineDevice::OFFLINE_AFTER_SECONDS` = 120 seconds |
| **`is_online` storage** | computed dynamically (NOT persisted) — no cron sweep |
| **Device types** | `pos`, `kds`, `floor`, `dashboard`, `kiosk` (column is a free-text string so future types land without a migration) |
| **`device_uuid` lifetime** | minted once per browser via `crypto.randomUUID()`, persisted in `localStorage` under `dine.device_uuid` |
| **Tenant scope** | automatic via `BelongsToTenant` on `DineDevice`; `(company_id, device_uuid)` is unique so two tenants can never collide |
| **Branch scope** | latest beat wins — a device that moves between branches just updates its `branch_id` |

### Dashboard surface

`system_health` now exposes:

```
{
  "offline_devices": 2,           // null if no devices have ever beat
  "online_devices":  3,           // null if no devices have ever beat
  "total_devices":   5,           // null if no devices have ever beat
  "offline_threshold_seconds": 120
}
```

When no devices have beat in yet, all three counters surface as
`null` so the FE keeps rendering "—" / "Not tracked yet" — exactly
the same honesty rule the rest of the dashboard follows.

### Alert wiring

Phase-10's `offline-devices` alert (severity ladder `>0 warning`,
`>=3 danger`) now fires from real data. Null values still skip
alert generation; only numeric counts trigger.

### What's deliberately out of scope

- **No websocket / event stream.** The dashboard polls every 30s
  and so does the heartbeat sender — ops awareness, not realtime.
- **No remote device control.** We track presence; we don't push.
- **No customer tracking.** Only operational terminals.
- **No cron sweeper.** Online state is computed on read.
- **No retries / circuit breakers on the FE.** Network failures
  are swallowed so a transient 5xx doesn't bubble up — the next
  60s tick catches up, and a stuck-offline device is exactly what
  the alerts panel exists to surface.
- **No PII in `metadata`.** The bag is for diagnostic extras only
  (display resolution, station id, etc.); `ip_address` is captured
  server-side from the request, never trusted from the body.

---

## 11 · Operational alerts  (Phase 10 — read-only)

The Phase-8 `/dine/dashboard` payload exposes an `alerts: []` array
computed from the same six sections it returns. Alerts are produced
by `App\Services\Dine\Dashboard\DineOperationalAlertsService` — a
pure function over the assembled dashboard payload, no DB queries
of its own. The list is cached for the same 30s TTL as the rest of
the dashboard, so adding alerts to the response added zero
round-trips and zero load on the cashier flow.

Each alert object carries:

```
{
  "id": "delayed-orders",
  "severity": "warning",                     // info | warning | danger | critical
  "title":  "Delayed orders",
  "message": "3 order(s) past the kitchen prep threshold.",
  "source_section": "live_operations",       // which dashboard section it came from
  "metric": "delayed_orders",                // matching key inside that section
  "value": 3,
  "threshold": 0,                            // first cutoff that trips the alert
  "recommended_action": "Check the delayed orders and the kitchen queue.",
  "branch_id": "branch-uuid-or-all",
  "generated_at": "2026-05-10T14:00:00+00:00"
}
```

Alerts are sorted **critical → danger → warning → info**, stable
within ties. The frontend re-applies the same sort defensively in
`OperationalAlertsPanel.jsx`.

### Alert types and thresholds

| ID | Source section · metric | Severity ladder | Recommended action |
|---|---|---|---|
| `delayed-orders` | `live_operations.delayed_orders` | `>0 warning · ≥5 danger` | Check the delayed orders and the kitchen queue. |
| `stuck-tickets` | `kitchen_health.stuck_tickets` | `>0 danger · ≥5 critical` | Escalate to the kitchen supervisor and inspect stuck stations. |
| `low-stock` | `inventory_signals.low_stock_count` | `>0 warning · ≥10 danger` | Review low stock items and prepare a purchase or transfer request. |
| `high-voids` | `cashier_risk.voids_today` | `≥5 warning · ≥12 danger` | Review the void audit trail and cashier activity. |
| `high-refunds` | `cashier_risk.refunds_today` | `≥3 warning · ≥8 danger` | Review refund reasons and manager approvals. |
| `aggregator-pending` | `system_health.aggregator_inbox_pending` | `>0 info · ≥10 warning` | Review pending aggregator orders and confirm ingestion status. |
| `offline-devices` | `system_health.offline_devices` | `>0 warning · ≥3 danger` | Inspect terminals and network — restart offline devices. |
| `sync-failures` | `system_health.sync_failures_today` | `>0 danger` | Check the offline outbox and manually retry failed syncs. |
| `queue-backlog` | `system_health.queue_backlog` | `>0 warning · ≥50 danger` | Inspect queue workers and clear the backlog. |
| `failed-payments` | `system_health.failed_payments_today` | `>0 danger` | Review failed payments — terminal, gateway, or wallet error? |
| `wastage-spike` | `inventory_signals.wastage_movements_today` | `≥5 warning` | Review wastage reasons and recent kitchen activity. |
| `adjustment-spike` | `inventory_signals.adjustment_movements_today` | `≥5 warning` | Review adjustment reasons in the inventory movement log. |

> **Paid-order cancellation alerts** are folded into `high-refunds`.
> The Phase-8 schema does not expose a separate
> `cancelled_paid_orders_today` field — `cashier_risk.refunds_today`
> already counts paid-order cancellations
> (`dine.order.cancelled` audit events with `was_paid=true`). Per
> Phase-10's "do not fake unavailable signals" rule we do NOT emit
> a duplicate alert just to populate that name.

### Null-handling contract

System-health signals that aren't tracked yet (see §12 below)
surface as `null` in the dashboard payload. The alerts service
treats `null` and non-numeric values as "skip" — **no fake alert
is emitted**. The frontend continues to render those slots as
"—" with an italic "Not tracked yet" note inside the System
Health section. This is the central honesty rule of both Phase 9
and Phase 10.

### What's deliberately out of scope

The alerts layer ships **no**:

- AI / anomaly detection / ML scoring
- Persistent dismissals (FE has a session-local dismiss only — no
  BE state, no per-user store)
- Notification delivery (push / SMS / WhatsApp / email)
- Customer-facing alerts
- Loyalty / marketing / engagement signals
- Websocket / event-stream / realtime channel
- Background workers or queues for delivery
- Per-tenant threshold customisation (thresholds are constants in
  `DineOperationalAlertsService`; tune one cutoff, the same number
  applies everywhere — no scattered magic constants)

If those become operationally necessary later, they go in their
own phase. The alerts layer keeps Phase-10 simple, explainable,
and useful for small / medium restaurants — the people who'll
actually read the alerts off a tablet during service.

---

## 12 · What's NOT emitted today

For dashboard / alert authors: the following operational signals are **not** in the system yet. They're flagged here so phase-8 dashboards don't pretend to consume them.

- **Open-shift activity** — no `pos_shifts` event for dine yet (the retail POS has `pos_shifts`; dine reuses these implicitly via the `pay()` shift_id parameter, but there's no dine-specific shift event).
- **Printer success/failure** — `print_check` is a status-flag flip; no actual printer dispatch or failure record.
- **Sync failures** — no client-to-server sync log for the offline outbox (retail POS has it; dine doesn't have offline mode yet).
- ~~**Device heartbeat** — no `dine_devices` table; KDS displays / kiosk terminals don't ping.~~ → **Tracked** as of Phase 11. See §11a above.
- **Tip / service-charge separation** — pay endpoint accepts `tip` and `service_charge` but they're rolled into `total` and not separated as their own GL legs.
