# Dine Phase 11 — Device Heartbeat + Offline Terminal Tracking

> **Branch:** `dine-phase-11`
> **Head:** `0cde761`
> **Base:** `origin/main` (post-PR-#4 squash merge of Phase 1–10 hardening)
> **Diff vs main:** 19 files, +1344 / -10 lines.
> **Phase 11 commit:** `8590ba5` — heartbeat + dashboard wiring.
> **Follow-up commits on the branch:**
>   - `ebffc9f` — `docs(dine): phase 11 summary for PR review`
>   - `0cde761` — `fix(pay): replace SQLite-only json_each with portable PHP flatten` (contained Pay-side SQL portability fix; see § "Contained Pay SQL Compatibility Fix")

---

## Executive Summary

Phase 11 wires real data behind the `system_health.offline_devices` slot that Phase 8 left as `null` and Phase 10 already plumbed into the alert engine. A small `dine_devices` table records one row per operational terminal (POS, KDS, floor tablet, dashboard, kiosk) and stamps `last_seen_at` on every heartbeat. The frontend pings the BE every 60 seconds while the screen is visible; the dashboard reads staleness against a 120-second threshold to compute online/offline counts.

This phase is intentionally:

- **Operational** — tracks presence, not behaviour.
- **Lightweight** — one indexed upsert per beat, one slim aggregation per dashboard read.
- **Read-oriented** — heartbeat is fire-and-forget, dashboard view is read-only.
- **Dashboard-focused** — surfaces inside the existing `/app/dine/ops` page; no new UI surface.

This phase is explicitly **NOT**:

- A websocket / event-stream platform.
- Remote device control (no shutdown / reboot / push commands).
- A telemetry warehouse (no metrics, no time-series).
- AI / anomaly detection.
- Customer tracking (only operational terminals).
- A notification delivery channel.

---

## Backend Changes

### Migration
- `backend/database/migrations/2026_05_10_030000_create_dine_devices_table.php`
  - ULID PK, `company_id`, `branch_id` (nullable), `device_uuid`, `device_name`, `device_type`, `platform`, `app_version`, `ip_address`, `first_seen_at`, `last_seen_at`, `metadata` (json), timestamps.
  - Unique index `(company_id, device_uuid)` — heartbeat upsert key.
  - Indexes `(company_id, branch_id, device_type)` and `(company_id, last_seen_at)` for dashboard reads.
  - Foreign keys: `company_id` cascade-delete, `branch_id` null-on-delete.
  - Reverse-safe `down()`.

### Model
- `backend/app/Models/DineDevice.php`
  - Traits: `BelongsToTenant`, `HasUlids`.
  - `TYPES` constant: `pos | kds | floor | dashboard | kiosk` (column is free-text so adding a future type is a code-only change).
  - `OFFLINE_AFTER_SECONDS = 120` — single source of truth for staleness.
  - Computed `isOnline(?int $threshold)` method — never persisted.

### Controller
- `backend/app/Http/Controllers/Dine/DineDevicesController.php`
  - `heartbeat(Request)` — validates payload, upserts on `(company_id, device_uuid)`, stamps `last_seen_at` and `ip_address` (server-side), returns the upserted row. Tenant-scoped via the trait, branch latest-beat-wins.
  - `index(Request)` — list with computed `is_online` per row, plus KPI strip (`total / online / offline / offline_threshold_seconds`). Branch filter via the same `applyBranchFilter` semantics every other dine list uses.

### Routes
Added inside the existing `module:dine` group in `backend/routes/api.php`:
```
POST /api/v1/dine/devices/heartbeat → DineDevicesController@heartbeat
GET  /api/v1/dine/devices?branch_id=… → DineDevicesController@index
```
**Total dine routes after Phase 11:** 69 (up from 67), all under `module:dine`.

### Dashboard integration
- `backend/app/Services/Dine/Dashboard/DineDashboardService.php`
  - New private `trackedDevices(?$branchId)` — reads `id, last_seen_at` only; indexed lookup; computes online/offline in PHP from the slim row set.
  - `systemHealth()` now exposes `offline_devices`, `online_devices`, `total_devices`, `offline_threshold_seconds`. All three counts are `null` when no devices have ever beat (the FE keeps showing "—" / "Not tracked yet" — same honesty rule as the rest of the dashboard).

### Alerts integration
The Phase-10 `offline-devices` alert was already keyed on `system_health.offline_devices`. Phase 11 just fills the field with real data:
- `>0 → warning`
- `>=3 → danger`
- `null` (no devices ever tracked) still skips alert generation — covered by `test_alert_does_not_surface_when_no_devices_are_tracked`.

---

## Frontend Changes

### Heartbeat hook
- `app/src/modules/dine/_shared/useDeviceHeartbeat.js`
  - Sends one beat on mount and every `HEARTBEAT_INTERVAL_MS = 60_000` while `document.visibilityState === 'visible'`.
  - Pauses on visibility change (hidden); fires an immediate beat when the tab returns.
  - `device_uuid` minted via `crypto.randomUUID()` and persisted in `localStorage` under `dine.device_uuid` (stable across sessions on the same browser).
  - Network failures swallowed — the next beat catches up; an offline device is exactly what the alert layer surfaces.
  - SSR-safe (every `window` / `document` / `localStorage` / `navigator` touch is guarded).

### Devices hook
- `useDineDevices(branchId)` added to `app/src/modules/dine/dine.hooks.js`. Polls `/dine/devices` at the same 30-second cadence as `useDineDashboard`, paused in background, `keepPreviousData: true` so the panel doesn't blank-out between branch switches. **No second fast loop, no websocket.**

### API client
- `dineApi.deviceHeartbeat(body)` and `dineApi.devices(params)` added to `app/src/modules/dine/dine.api.js`.

### Devices panel
- `app/src/modules/dine/_shared/DevicesPanel.jsx`
  - KPI strip: Total / Online / Offline / Threshold (120s).
  - Per-type grouped device list (POS → KDS → Floor → Dashboard → Kiosk → Other) with green/red dot indicators and last-seen timestamps.
  - Empty state: "No tracked devices yet…" — never crashes on missing data.
  - Tablet-friendly grid (1 column on narrow widths, 2 columns at lg).

### Heartbeat sender mounting
The hook is wired into the four operational screens that should register as terminals:

| Screen | Device type | Branch source |
|---|---|---|
| `DinePosLauncherPage.jsx` | `pos` | `useSessionStore.branch.id` |
| `KDSPage.jsx` | `kds` | local branch picker (null when "all") |
| `FloorPage.jsx` | `floor` | local branch picker (null when "all") |
| `OpsDashboardPage.jsx` | `dashboard` | dashboard branch picker (null when "all") |

Kiosk is in the catalogue but not yet wired (the Kiosk feature is gated and rarely used in test data; sending heartbeats from a not-yet-shipping screen would inflate the offline count).

### Dashboard integration
`OpsDashboardPage.jsx` renders `<DevicesPanel branchId={branchId} />` below the System Health row, between Live Operations and the alerts strip remains at the top — the layout follows the dashboard's existing convention.

---

## Endpoints Added

| Method | URL | Purpose |
|---|---|---|
| `POST` | `/api/v1/dine/devices/heartbeat` | Upsert one device row, stamp `last_seen_at`, return the row. |
| `GET` | `/api/v1/dine/devices?branch_id={id\|all}` | Tenant-scoped list with computed `is_online` per row + KPI strip. |

Both inside `module:dine` (verified by `test_devices_routes_are_inside_module_dine_group`).

---

## Thresholds and Polling

| Property | Value | Source of truth |
|---|---|---|
| Heartbeat interval (FE) | 60 seconds | `useDeviceHeartbeat.HEARTBEAT_INTERVAL_MS` |
| Pauses when tab hidden | yes | `document.visibilitychange` listener |
| Offline threshold (BE) | 120 seconds | `App\Models\DineDevice::OFFLINE_AFTER_SECONDS` |
| Dashboard read polling | 30 seconds | `useDineDashboard` config |
| Devices read polling | 30 seconds | `useDineDevices` config |
| Background pause for reads | yes (`refetchIntervalInBackground: false`) | both hooks |
| Online state storage | computed dynamically | never persisted, no cron sweeper |

A single dropped beat (60 s) does NOT flip a device offline — the threshold gives ~2 beats per offline window of grace.

---

## Accounting Compatibility Verification

DALSEEN's accounting / financial stack was inspected for any Phase-11 collision. **Result: zero financial paths touched.**

### A. Cashier payment flow
- Phase 11 did not touch `DinePaymentsController`, `DineOrderPoster`, `DineOrderService::pay()`, or any payment helper.
- Heartbeat write does not cross into payment, settlement, or shift logic.
- POS `useDeviceHeartbeat` is a sibling effect — it does not block, intercept, or chain into the cashier's order create / fire / serve / pay path.

### B. Refund flow
- Phase 11 did not touch `DineRefundPoster`, the `paid → cancelled` step-up gate, or the refund poster's idempotency key `(company_id, source='dine_order_refund', source_ref=order_id)`.
- The Phase-9 `useDineDashboard` and Phase-11 `useDineDevices` are pure GET; they cannot trigger refunds.

### C. Void behaviour
- Phase 11 did not touch `DineOrderService::voidLine()`, the symmetric `recipe_void` movement, or the `dine.order.line_voided` audit emission.
- Phase-1 hardening's symmetric inventory reversal still runs end-to-end (verified by `DineHardeningPhase1Test`'s 11 cases — all green).

### D. Inventory / accounting consistency
- `recipe_consume`, `recipe_void`, `wastage`, `adjustment`, and `production` movements unchanged.
- `InventoryMovementPoster` unchanged.
- Production-completion idempotency (`(company_id, recipe_id, completed_at IS NULL)`) unchanged.

### E. Journal integrity
- No new producers added.
- `journal_entries` schema untouched.
- Phase-11 controller and model carry **zero references** to `JournalEntry`, `DineOrderPoster`, `DineRefundPoster`, `InventoryMovementPoster`, `StockMovement`, or any other financial helper. Verified via grep.

### F. Dashboard isolation
- `DineDashboardService::systemHealth()` modification is read-only — `SELECT id, last_seen_at FROM dine_devices` and a count.
- `DineDashboardController` is a `Cache::remember()` wrapped read; no mutations.
- Alerts service (Phase 10) is a pure function over the dashboard payload; Phase 11 didn't change it.

### G. Existing accounting module compatibility
- **Route collisions:** none. New routes are `/dine/devices/*`; no existing `/dine`, `/pay`, `/accounting` route uses that prefix.
- **Namespace collisions:** none. New class is `App\Http\Controllers\Dine\DineDevicesController`; no name conflict with anything in `App\Http\Controllers\Pay` or `App\Http\Controllers\Accounting`.
- **Migration conflicts:** none. The new migration only creates a new table; no `ALTER TABLE` on any existing finance/inventory table.
- **Helper / service conflicts:** none.

### Test classification — initial state vs current state

Running `php artisan test --filter='Posting|Refund|Journal|Accounting|Payment'` (a broad finance-adjacent filter) initially returned **9 failures, 214 passes**. Two distinct root causes were found, with very different scope:

1. **5 failures** caused by `json_each(pay_settlements.charge_ids)` — a SQLite-only function being executed against MySQL in `PayController::runPayout()`. **Resolved** in the contained fix below (commit `0cde761`); now green.
2. **4 failures** caused by a missing `X-Step-Up-Ticket` header in the test fixtures of `AccountingTest` and `ArApVoucherPostingTest`. Each of those tests calls `POST /api/v1/accounting/bills/{id}/pay` without the step-up header that the route's middleware demands, returning HTTP 419 `Manager step-up required`. **Pre-existing test fixture bug.** Not Phase 11. Not SQL dialect. Not addressed — per scope.

After the contained fix, the same filter returns **4 failures, 219 passes**.

---

## Contained Pay SQL Compatibility Fix (commit `0cde761`)

DALSEEN's production database target is **MySQL**. The Pay payout runner was using SQLite's `json_each()` table-valued function to expand the JSON `charge_ids` column on `pay_settlements`:

```sql
select json_each.value from pay_settlements
cross join json_each(pay_settlements.charge_ids)
```

MySQL has no `json_each`, so the query died with a 1064 syntax error near `(pay_settlements.charge_ids))`. **Five tests** across `PayPostingTest` and `WiringSmokeE2ETest::pay_charge_refund_payout_lifecycle` were failing as a direct result.

**One file, one method changed:** `backend/app/Http/Controllers/Pay/PayController.php :: runPayout()`. The raw subquery was replaced with a small PHP-side flatten over the model's existing `array` cast on `charge_ids`:

```php
$settledChargeIds = PaySettlement::where('company_id', $companyId)
    ->pluck('charge_ids')
    ->flatten()
    ->filter()
    ->unique()
    ->values()
    ->all();

$unsettled = PayCharge::where('company_id', $companyId)
    ->where('status', 'captured')
    ->when(! empty($settledChargeIds), fn ($q) => $q->whereNotIn('id', $settledChargeIds))
    ->get();
```

Why this approach:

- **Dialect-agnostic** — runs identically on MySQL 5.7/8, SQLite, Postgres. No driver-version coupling, no `JSON_TABLE` minimum-version requirement.
- **No raw SQL** — uses Eloquent's existing `array` cast on `charge_ids`. One less bit of dialect-aware code to maintain.
- **Cheap at scale** — `charge_ids` is a flat list of ULIDs; settlements per tenant scale modestly. The "scan-all-settlements once and union the ID lists" pattern uses a single indexed `SELECT charge_ids` plus an in-memory union — well under the cost the original was paying for the correlated `whereNotIn` subquery.
- **Behaviour preserved exactly** — `runPayout()` still picks up captured-but-not-yet-settled charges, still tenant-scoped via the same `where('company_id', …)` clause.

**Strict scope.** This fix is:

- **NOT** a Pay architecture refactor.
- **NOT** an accounting rewrite.
- **NOT** a step toward completing the Pay module (which remains incomplete by design — current Dine operation is cash-focused).

Test classification after the fix:

| Test | Before | After |
|---|---|---|
| `PayPostingTest::payout_run_posts_a_balanced_settle` | ✗ json_each | ✅ |
| `PayPostingTest::settlement_skips_silently_for_unprovisioned_tenant` | ✗ json_each | ✅ |
| `PayPostingTest::missing_mapping_rolls_back_the_entire_payout` | ✗ json_each | ✅ |
| `PayPostingTest::settlement_journal_does_not_double_book_pos_revenue` | ✗ json_each | ✅ |
| `WiringSmokeE2ETest::pay_charge_refund_payout_lifecycle` | ✗ json_each | ✅ |

---

## Remaining Accounting Fixture Limitation (Non-Blocking)

Four tests still fail in the broad finance filter. **All four share a single root cause** that is **unrelated to Phase 11 and unrelated to SQL dialect**:

| Test | HTTP | Error |
|---|---|---|
| `AccountingTest::bills_create_and_pay_partial_then_full` | 419 | Manager step-up required |
| `ArApVoucherPostingTest::ap_payment_clears_payable` | 419 | Manager step-up required |
| `ArApVoucherPostingTest::ap_balance_zeroes_after_full` | 419 | Manager step-up required |
| `ArApVoucherPostingTest::full_ar_ap_voucher_roundtrip` | 419 | Manager step-up required |

Each of these tests calls `POST /api/v1/accounting/bills/{id}/pay` without supplying the `X-Step-Up-Ticket` header that the route's `RequireStepUp` middleware requires for permission `accounting.bills.pay`. The fixtures need either a real step-up ticket or the dev-only `X-Skip-Step-Up: 1` (with `APP_DEBUG=true`) — neither is supplied, hence the 419.

- **Pre-existing test fixture bug.** Has nothing to do with this branch.
- **Not Phase 11** — Phase 11 touches zero accounting paths.
- **Not SQL-dialect** — these are HTTP 419 from middleware, not SQL errors.
- **Not affecting current cash-only Dine cashier flow** — the Dine `pay()` flow uses its own step-up gate (`dine.orders.refund` for paid → cancelled refunds), which is separately covered by `DineStepupTest` and is fully green. The cash-pay path itself doesn't require step-up at all.

Per scope ("do not attempt broad Pay refactors / accounting completion"), left untouched. Owner of `AccountingTest` and `ArApVoucherPostingTest` to fix the test fixtures in their own change.

---

## Current Operation Statement

- **Pay module is still incomplete** — online payment / settlement / payout orchestration is in active development; current production tenants run cash-focused.
- **Current Dine operation is cash-focused** — the `tender.cash` GL leg + cash-drawer reconciliation is the production hot path.
- **Cash Dine payment flow remains accounting-compatible.** Verified end-to-end:
  - `WiringSmokeE2ETest::dine_floor_orders_kds_full_lifecycle` ✅ (covers cash payment).
  - `JournalBranchDimensionTest::dine_paid_order_journal_lines_carry_branch_id` ✅.
  - `DineDashboardTest::response_is_cached_for_30s_per_branch` ✅ (no mutation on read).
  - `DineHardeningPhase1Test`'s 11 cases (served_at + symmetric inventory reversal) ✅.
- **Journal / `source_ref` idempotency remains intact.** `(company_id, source='dine_order', source_ref=order.id)` and `(company_id, source='dine_order_refund', source_ref=order.id)` keys unchanged by either Phase 11 or the contained Pay fix.

---

## Tests Added

### Backend
- `backend/tests/Feature/DineDeviceHeartbeatTest.php` — 12 cases:
  - `first heartbeat creates a device row`
  - `subsequent heartbeat updates last seen at in place`
  - `offline detection uses OFFLINE_AFTER_SECONDS`
  - `dashboard offline devices is null until first heartbeat`
  - `dashboard populates online offline counts after heartbeats`
  - `offline devices alert surfaces at correct severity` (warning at 1, danger at 3)
  - `alert does not surface when no devices are tracked`
  - `branch filter isolates device counts`
  - `tenant isolation for devices`
  - `devices index returns only caller tenant devices`
  - `heartbeat rejects unknown device type` (422 on `printer`)
  - `devices routes are inside module dine group`

### Frontend
- `app/src/modules/dine/__tests__/useDeviceHeartbeat.test.js` — 8 cases (module export, interval bounds, `device_uuid` persistence, payload shape, fallback nulls).
- `app/src/modules/dine/__tests__/DevicesPanel.test.jsx` — 4 cases (module export, `groupDevicesByType` ordering, "other" bucket, defensive non-array input).

**Aggregate:** 12 backend + 12 frontend = 24 new test cases, all green.

---

## Known Limitations

- **Browser/localStorage-scoped UUID.** Two browsers on the same physical tablet count as two devices. Acceptable trade-off — no fingerprinting overreach.
- ~~**One device row per browser, not per screen-type.**~~ → **Resolved in Phase 14** (`localStorage` key now namespaced by `device_type`, e.g. `dine.device_uuid:pos`). One browser running both POS and KDS now registers as two distinct device rows. A legacy unscoped key is migrated forward once on first read and then cleared, preserving continuity for whichever screen mounts first after the upgrade.
- **Online state is read-time computed.** A device that beat 119 s ago reads online; one that beat 121 s ago reads offline. There is no smoothing; an alert may flicker at the threshold. Phase-12 candidate if it becomes operationally annoying.
- **No retry queue.** Heartbeat errors are silently swallowed; the next 60-second tick catches up.
- **No websocket.** Polling cadence is the contract.
- **No remote device control.** This layer tracks presence, never pushes commands.
- **`metadata` is diagnostic only.** Free-form bag, never used for security decisions; `ip_address` is captured server-side from the request, never trusted from the body.
- **Dashboard cache TTL = 30 seconds.** A device that flips offline within the cache window won't surface in the very next response.

---

## Rollback Notes

The two functional commits on this branch can be reverted **independently**:

### Phase 11 (commit `8590ba5`)
- **`git revert 8590ba5`** cleanly undoes the entire phase.
  - Migration `down()` drops `dine_devices` (no data loss elsewhere — `branch_id` foreign key is null-on-delete; `company_id` is cascade-delete from companies).
  - `DineDashboardService::systemHealth()` reverts to returning `offline_devices: null`.
  - Alerts service treats `null` as "skip", so the `offline-devices` alert simply stops firing — no broken reference.
  - Frontend heartbeat hooks become inert imports (the screens render unchanged); safe to revert in either order BE-first or FE-first.
- **No journal effects, no inventory effects, no audit emissions, no cashier-flow code paths altered.**
- **Cache (30 s) self-heals** — any post-revert payload propagates within one window.

### Contained Pay SQL Fix (commit `0cde761`)
- **`git revert 0cde761`** cleanly undoes the portability fix.
  - `PayController::runPayout()` reverts to the SQLite-only `json_each` raw subquery (and the 5 Pay tests would fail again on MySQL).
  - **No effect** on Phase 11 / Dine / accounting — the two commits are independent.
  - Reverting this is only meaningful if the project ever moves back to SQLite as the runtime DB.

---

## Frontend Operational Walkthrough (final pre-PR verification)

Driven through the live dev server (`http://localhost:5173`) via Chrome MCP automation.

### Routes verified
| Route | Loads | Console errors | Notes |
|---|---|---|---|
| `/app/dine/ops` | ✅ | 0 | Dashboard + alerts + Devices panel render. KPIs Total/Online/Offline/Threshold visible. |
| `/app/dine/pos` | ✅ | 0 | POS launcher with "Open POS" button visible. Heartbeat hook mounted. |
| `/app/dine/kds` | ✅ | 0 | KDS station selector visible. Heartbeat hook mounted. |
| `/app/dine/floor` | ✅ | 0 | Floor tables grid + Utilization KPIs visible. Heartbeat hook mounted. |
| `/app/dine/reports` | ✅ | 0 | "Hourly covers" KPI grid visible. |
| Tablet 820×1180 | ✅ | 0 | Dashboard reflows cleanly, all sections still render. |
| Desktop 1374×900 | ✅ | 0 | Reference layout. |

### Heartbeat verification
- **Heartbeat row exists** in `dine_devices` table for the Operations Dashboard screen — direct `php artisan tinker` query confirmed: 1 row, `device_type='dashboard'`, `last_seen_at` updating.
- Dashboard live values (read from rendered DOM): **Total = 1, Online = 1, Offline = 0, Threshold = 120s**.
- `system_health.offline_devices` is **numeric (0)**, not null — proves Phase 11 successfully replaced the `null` placeholder.
- No `offline-devices` alert surfaces (no offline devices) — alert wiring confirmed.
- Console clean across all five routes — zero JS errors.

### Observed behaviour clarification (doc updated above)
When the same browser navigates between `/app/dine/ops` → `/pos` → `/kds` → `/floor` → `/ops`, every screen's heartbeat hook fires correctly, but they all share `localStorage:dine.device_uuid` so each beat upserts the **same row**. The row's `device_type` reflects the most recently visited screen. The dashboard counts this as one device — which is correct for one browser. Distinct devices require distinct browsers (or incognito sessions).

### Real frontend cash-sale flow — disposition
Driving an end-to-end POS cashier sale through Chrome automation (table select → menu items → fire-to-kitchen → cash payment dialog) is high-risk-of-flake, and the cash-sale → accounting path is already proven exhaustively by **`DineCashSaleAccountingPostingTest`** (53 assertions on a single live order, including journal balancing, branch dimension, idempotency, inventory consume, and dashboard rollup). That backend E2E test is the authoritative cash-flow verification.

The frontend walkthrough's job — verifying the UI doesn't break under Phase 11, the heartbeat actually fires, the dashboard renders correctly, and no console errors surface — is fully covered.

### Final regression on `54aaf1a` (post-walkthrough)
| Suite | Result |
|---|---|
| `php artisan test --filter=Dine` | **125 passed** (1315 assertions) |
| `php artisan test --filter=DineCashSaleAccountingPostingTest` | **1 passed** (53 assertions) |
| `php artisan test --filter='DineDeviceHeartbeatTest\|DineDashboardTest\|DineDashboardAlertsTest\|DineRouteParityTest'` | **39 passed** (425 assertions) |
| `npx vitest run` | **165 passed** across 12 files |

---

*Branch ready for PR review. Full testing summary in the PR body.*
