はじめに
来客注文 API の冪等性 の記事で、厨房に同じ注文が2枚届かない話を書きました。今回はその先——注文が確定してから、厨房タブレットやスタッフ画面の未提供一覧に載るまで の経路です。
MasterOrder では REST で正本を書き、signals で「更新があった」と知らせ、UI は Server API から再取得します。Firestore Realtime を厨房に order 単位で直結させないのは、最初から意図した設計です。
厨房UIに必要なこと
| 要求 | 意味 |
|---|---|
| 遅延 | 確定から数秒以内に一覧に載る |
| 取りこぼし | ピーク中に1件も消えない |
| 重複表示 | 同じ注文が2枚並ばない |
最初は「order ドキュメントを onSnapshot すればリアルタイム」と思いがちです。実際には使っていません。
- 未提供注文が複数セッション × 複数 order に分散し、リスナー本数と読み取り課金が膨らむ
- マルチノード構成で、書いたノードと SSE 接続先ノードがずれる
- 厨房が欲しいのは1 order の差分より、未提供一覧の正しいスナップショット
だから 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.js が active_sessions を onSnapshot 購読。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) |
| 取りこぼし | scheduleFirestoreShopAttach、ShopPendingOrdersWarmup.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 をやめたかは 別記事 に詳しく書きました。