WaterPunch
← ブログ一覧

注文が厨房に届くまで ― MasterOrder の KDS / スタッフ画面と signals の設計

KDSSSEFirestoreKITEI_QRMasterOrder個人開発

はじめに

来客注文 API の冪等性 の記事で、厨房に同じ注文が2枚届かない話を書きました。今回はその先——注文が確定してから、厨房タブレットやスタッフ画面の未提供一覧に載るまで の経路です。

MasterOrder では REST で正本を書き、signals で「更新があった」と知らせ、UI は Server API から再取得します。Firestore Realtime を厨房に order 単位で直結させないのは、最初から意図した設計です。

厨房UIに必要なこと

要求 意味
遅延 確定から数秒以内に一覧に載る
取りこぼし ピーク中に1件も消えない
重複表示 同じ注文が2枚並ばない

最初は「order ドキュメントを onSnapshot すればリアルタイム」と思いがちです。実際には使っていません。

だから signals(更新通知)+ REST 再取得(正本) に寄せました。

注文確定から signals まで

来客 UI は Firestore を使いません。POST /sessions/order/{sessionId} で Server に送ります(冪等性は 前回 参照)。

OrderController.saveOrder() の流れ:

// 1. メニュー解決・価格検証・在庫減算
// 2. FirestoreActiveOrderRepository.save()
//    shops/{shopId}/active_sessions/{sessionId}/orders/{orderId}
// 3. orderSseService.broadcast(shopId, sessionId, orderId)
// 4. applyFirestoreSessionTotalsForOrderAdded()

永続化先:

shops/{shopId}/active_sessions/{sessionId}/orders/{orderId}
order_lookup/{orderId}  ← 逆引き

注文の中身は signals に載せません。OrderSseService.broadcast()FirestoreOrderSignalPublisher.publish() で、1 doc だけ version を bump します。

// shops/{shopId}/signals/order に merge
patch.put("version", FieldValue.increment(1));
patch.put("updatedAt", FieldValue.serverTimestamp());
patch.put("eventType", "ORDER_UPDATED");  // または REFRESH_ALL
patch.put("targetSessionId", sessionId);
patch.put("targetOrderId", orderId);

更新のたびに doc 1件。order 件数に比例しないのが読み取り課金的に効きます。

Server → SSE → REST

各 API ノードの FirestoreActiveSessionSync は、そのノードに SSE 接続がある shopId だけ リスナーを張ります。店舗あたり最大2本です。

リスナー パス 役割
セッション active_sessions where isActive==true 卓一覧・入退店
注文シグナル signals/order(1 doc) 注文更新のベル

個別 order ドキュメントの snapshot listener は使いません。

シグナル doc が更新されると orderSseService.broadcastLocally() へ。注文を処理したノードは、リスナー attach 前の取りこぼしを防ぐため ローカル SSE にも即 fan-out します。

firestoreOrderSignalPublisher.publish(shopId, event);  // 他ノード向け
if (hasLocalSubscribers(shopId)) {
    broadcastLocally(shopId, event);  // 当ノード SSE 向け
}

publish 直後 250ms は shouldSuppressListenerEcho() でリスナー echo を抑止。同じイベントが二重配信されないようにしています。

スタッフ SDK は GET /orders/events/sse?shopId= に接続します(Firebase ID トークン + SSE チケット)。

connectOrderEvents(shopId, handlers) {
    return sse.connectAsync({
        url: apiBaseUrl + staffPaths.orderEventsSse(),
        query: { shopId: shopId },
        eventName: 'order-update',
        onMessage: handlers.onOrderUpdate
    });
}

SSE ペイロード例:

{
  "type": "ORDER_UPDATED",
  "shopId": 1,
  "sessionId": "AbCdEfGhIj",
  "orderId": 12345678
}
type スタッフ UI の反応
ORDER_UPDATED 未提供注文を再取得 + 該当卓カード更新
REFRESH_ALL セッション一覧 + 未提供注文を再取得
SESSION_OPENED / UPDATED 卓一覧同期
SESSION_CLOSED 退店処理(false close を API で検証)

厨房一覧(KDS 相当)は SSE を直接描画しません。createPendingOrdersLoader が debounce 後に REST を叩きます。

staffSdk.getPendingOrders(shopId)  // GET /orders/pending?shopId=
    .then(list => renderOrders(list));

Server 側はアクティブセッションを集め、PREPARING かつ remainingQuantity > 0 だけ返します。buildPendingOrdersSignature(list) で前回と同一なら silent reload 時は DOM を触らない——重複描画とちらつきの抑制です。新規注文は onNewOrders でチャイム。

固定QR(KITEI_QR)と都度発行(TSUDO_HAKKO)

固定QR 都度発行
来客入店 卓 QR → POST /sessions/fixed-qr/open スタッフがセッション発行 + PIN QR
卓一覧 UI 席数ベースの卓グリッド 発行セッション中心
セッション一覧 Firestore 直読(有効時) REST + SSE
注文・厨房一覧 REST /orders/pending + SSE 同左
Server の active_sessions リスナー 直読 ON 時は省略可 通常どおり

固定QR だけ Client 直読を許しています。staff-session-mode-sdk.js でモード判定し、staff-firestore-sdk.jsactive_sessionsonSnapshot 購読。Security Rules + Custom Claims で shopId をスコープ。書き込みは Server のみ です。

function staffFirestoreSessionsDirectReadActive() {
    return firestoreDirectReadEnabled && resolvedSessionMode() === 'KITEI_QR';
}

卓グリッドは Firestore 直読で低レイテンシ。注文行の詳細と厨房一覧は REST 正本。SSE で ORDER_UPDATED が来たら refreshSessionCard(sessionId) で合計・注文数だけ API から差分更新します。

固定席グリッドは doc が軽く更新も多い。都度発行はセッション数が可変で REST の方が素直、という切り分けです。卓グリッドの読み取り最適化は 別記事 に詳細があります。

厨房の未提供一覧は、どちらのモードも GET /orders/pending + SSE トリガーで共通です。

遅延・取りこぼし・重複への手当て

困りごと 主な対策
遅延 publish ノードの broadcastLocally、SSE 接続時の REFRESH_ALL、pending loader の immediate: true(500ms debounce 後 fetch)
取りこぼし scheduleFirestoreShopAttachShopPendingOrdersWarmup.warmShop()、オフライン時の IndexedDB スナップショット(offline-staff.js
重複表示 pending signature 比較、listener echo 抑制(250ms)、dashboard debounce(1500ms)

ORDER_UPDATED で order doc を1件 merge しないのも意図的です。部分提供(serveOrderLine)がある以上、pending 全体を取り直す方が一覧と整合します。API が落ちても厨房画面が真っ白になるより、少し古い一覧を見せた方が現場ではマシ、という判断でオフライン fallback を入れています。

直読と REST の使い分け

データ 経路 理由
来客注文 POST Server REST のみ 認可・在庫・価格・冪等性
アクティブ注文の正本 Firestore(Server write) 営業フロア
厨房未提供一覧 Server REST read PREPARING + remaining は Server 側ロジック
更新通知 signals/order + SSE fan-out を1 doc に閉じる
固定QR 卓一覧 Client Firestore read(任意) 軽量 doc・Rules で shop スコープ
卓の合計・注文数(KITEI) SSE 後に REST patch 合計は Server 計算を正とする
メニュー・在庫 KV / Server REST 倉庫とフロアの分担

三原則の優先順位は、セキュリティ(来客は Firestore 不可)→ 安定性(signals が欠けても手動 refresh で復旧)→ 使いやすさ(KITEI 卓グリッドだけ Client 直読で体感速度)です。

シーケンス図

sequenceDiagram
    participant Guest as 来客ブラウザ
    participant API as OrderController
    participant FS as Firestore
    participant Sig as signals/order
    participant Sync as FirestoreActiveSessionSync
    participant SSE as OrderSseService
    participant Staff as スタッフ画面
    participant Pending as GET /orders/pending

    Guest->>API: POST /sessions/order/{sessionId}
    API->>FS: save orders/{orderId}
    API->>Sig: publish ORDER_UPDATED
    API->>SSE: broadcastLocally (同一ノード)
    Sig-->>Sync: snapshot listener (各ノード)
    Sync->>SSE: broadcastLocally
    SSE->>Staff: SSE order-update
    Staff->>Pending: getPendingOrders (debounce 500ms)
    Pending->>FS: findPendingForShop (Server)
    Pending-->>Staff: PREPARING 一覧
    Staff->>Staff: renderOrders (signature 比較)

固定QR 時の卓グリッド(並行経路):

sequenceDiagram
    participant FS as Firestore active_sessions
    participant StaffFS as staff-firestore-sdk
    participant Staff as スタッフ卓グリッド
    participant API as Server REST

    StaffFS->>FS: onSnapshot (Client 直読)
    FS-->>StaffFS: セッション ADDED/MODIFIED
    StaffFS->>Staff: renderKiteiQrFromStaffCaches
    Note over Staff,API: 注文確定後
    Staff->>API: SSE → refreshSessionCard(sessionId)
    API-->>Staff: 合計・orderCount 更新

Realtime = Firestore リスナー全面採用、ではありません。signals は Realtime、正本読み取りは REST——この役割分担が、読み取り課金と UX の両立点だと思っています。なぜ全面 Realtime をやめたかは 別記事 に詳しく書きました。