予約状態遷移(10状態の状態機械)
予約状態遷移(10状態の状態機械)
Section titled “予約状態遷移(10状態の状態機械)”Document ID: SRS-RES-004
Parent: SRS-ROOT-001 v0.3
Version: 0.2
Status: Draft
Depends on: SRS-RES-002(予約作成), SRS-RES-005(二重予約判定)
依存される: SRS-RES-002, SRS-RES-003(予約編集・移動), SRS-RES-005, SRS-REG-001(レジ会計), SRS-REG-002(日次締め), SRS-PAY-001/002(決済・キャンセル料), SRS-MSG-002(リマインダー), SRS-LIFF-002(LIFF予約), SRS-TEN-001(店舗作成・初期設定 v0.3 で store_settings 拡張)
本書は SRS-ROOT-001 v0.3 に従う。
予約の 10状態 を定義し、状態遷移を一元管理する。本SRSは:
- 10状態それぞれの意味と滞在条件
- 遷移マトリクス(どの状態から、どの状態へ、誰が、どのトリガで)
- 自動遷移(ジョブ駆動)と reopen / 巻き戻しの規律
- 監査ログ(
reservation_event)とoperator_action_logの役割分担 - RES-002 / RES-003 / RES-005 / REG-001 が status を読み書きする際の契約
を確定する。本家 SALON BOARD の予約一覧フィルタ(DOC-ANALYSIS-001 §5.1)の 11 ラベルを内部コードへ正規化する際、観察 #5「ラベル取得不能・会計関連の中間状態」は予約ドメインの状態として採用しない。レジ/会計画面の処理進行状態は別ドメイン(SRS-REG-001 のレジセッション等)として切り分け、予約状態は10状態に集約する(決定経緯は §13 OQ-RES-004-05 解消欄)。
判定ロジック(重複・営業時間外)は本SRSの対象外で SRS-RES-005 / SRS-MST-003 が持つ。本SRSは「いま何の状態か」「次はどこへ動けるか」「誰が動かせるか」のみを扱う。
本SRSは reservation.status の canonical source。RES-005 の「判定対象外条件」など他SRSが status 値域に依存する箇所は本SRSを参照する。
2. ユーザーストーリー
Section titled “2. ユーザーストーリー”- As a 受付, I want to LIFF/HPB から入った仮予約を確認して確定 or お断りに振り分けたい, so that 顧客が予定を確実に押さえられる
- As a 受付, I want to 来店した顧客を1クリックで「施術中」に移したい, so that カレンダー上の進捗が一目で分かる
- As a 店長, I want to 来店時刻を過ぎても来ない顧客を「無断キャンセル」として記録したい, so that リピート判定や顧客評価に反映できる
- As a オーナー, I want to 仮予約が一定時間放置されたら自動でキャンセルにしたい, so that 受付可能数を実態に合わせる
- As a 店長, I want to 誤って進めた状態を 1 段階戻せるようにしたい, so that 操作ミスを取り返せる
3. ユースケース
Section titled “3. ユースケース”3.1 状態の概観
Section titled “3.1 状態の概観”10状態を進行系と終端系に分ける:
- 進行系(中間、4状態):
tentative,confirmed,in_service,service_completed - 終端系(不可逆、6状態):
paid,declined,cancelled_by_customer,cancelled_by_store,no_show,expired
10状態の定義は §7.1 に詳述。
3.2 主シナリオ(標準導線)
Section titled “3.2 主シナリオ(標準導線)”tentative ─┐ ├─→ confirmed → in_service → service_completed → paid(新規確定)─┘3.3 代替フロー
Section titled “3.3 代替フロー”- AF-1. 直接 confirmed 作成:管理画面手動・電話受付など、サロン側が即確定する経路(RES-002 で
status: 'confirmed'直入り) - AF-2. 仮予約承認:オペレーターが LIFF/HPB由来の
tentativeをconfirmedに承認 - AF-3. 仮予約のお断り:
tentative→declined(未確定段階の店舗側拒否は declined に一本化、§7.4) - AF-4. 顧客キャンセル:
tentative | confirmed→cancelled_by_customer - AF-5. サロンキャンセル(確定後):
confirmed | in_service→cancelled_by_store(tentativeからは不可、§7.4) - AF-6. 無断キャンセルマーク:
confirmed→no_show(来店時刻 + 猶予経過後の手動操作、§7.3 (†1)) - AF-7. 自動期限切れ:
tentativeのtentative_expires_at経過 →expired(自動ジョブ、§7.6) - AF-8. 巻き戻し:誤操作で進めた中間状態を 1 段階戻す(§7.5)
3.4 例外フロー
Section titled “3.4 例外フロー”- EF-1. 不正遷移:遷移マトリクス(§7.3)に存在しない遷移要求 → 422
RESERVATION_STATE.INVALID_TRANSITION(例:tentative → cancelled_by_store、tentative → in_service、終端からのあらゆる遷移) - EF-2. 権限不足:遷移に必要な permission を持たない → 403
- EF-3. 楽観ロック衝突:
reservation.version不一致 → 409RESERVATION.CONFLICT - EF-4. no_show 猶予未経過:
reservation.starts_at + store_settings.no_show_grace_minutes >= now()の confirmed に対する no_show 要求 → 422RESERVATION_STATE.GRACE_PERIOD_NOT_PASSED - EF-5. reopen フラグ未指定:マトリクス上で
↩の遷移をreopen=trueなしで要求 → 422RESERVATION_STATE.REOPEN_FLAG_REQUIRED
4. UI仕様
Section titled “4. UI仕様”親SRS §7.12 に従い最低限の粒度で記述する。デザインシステム導入SRS(仮 SRS-UI-001)リリース時に一括改訂される前提。
4.1 画面の目的
Section titled “4.1 画面の目的”- カレンダー画面(SRS-RES-001)と予約詳細パネルから状態遷移を発火する
- 状態ごとに UI 上のラベル・バッジ・操作可能なアクションが決まる
4.2 主要要素(列挙)
Section titled “4.2 主要要素(列挙)”- 状態バッジ:予約セル/カードに 10 状態のいずれかを示す。日本語ラベル(§7.1)
- アクションメニュー:現在の状態から遷移可能な操作のみを表示する(§7.3 の遷移マトリクスに従う)。permission を満たすもののみ enabled
- キャンセル理由入力:
declined/cancelled_by_*/no_showに遷移する際は理由(任意)と顧客連絡有無の選択 - 巻き戻し導線:中間状態のアクションメニューに「ひとつ戻す」操作。
admin:reservation:reopen必須 - no_show ボタンの可視性:
reservation.starts_at + no_show_grace_minutes < now()を満たすまで非表示(誤操作防止)
4.3 バリデーション
Section titled “4.3 バリデーション”- 遷移不可な操作はそもそも UI に出さない(grayout で出す案もあるが Phase 1 は非表示)
- キャンセル系遷移はモーダル確認(誤操作防止)
5. API仕様
Section titled “5. API仕様”5.1 エンドポイント一覧
Section titled “5.1 エンドポイント一覧”| Method | Path | 用途 | 必要 permission |
|---|---|---|---|
| POST | /api/admin/reservations/:id/transitions | 状態遷移を 1 操作実行 | 遷移ごとに異なる(§9.1) |
| GET | /api/admin/reservations/:id/transitions/available | 現在状態から可能な遷移一覧(permission フィルタ後) | admin:reservation:read |
| GET | /api/admin/reservations/:id/events | 遷移ログ(タイムライン表示用) | admin:reservation:read_timeline |
設計判断:状態ごとに別エンドポイント(/approve, /check-in, …)にせず、単一の transitions エンドポイントに集約する。
- 利点:API 表面が小さく、将来 status 追加時に handler 増殖しない。FE はマトリクス参照で UI を生成可能
- 欠点:handler 内のディスパッチ分岐が大きくなる → §7.3 の遷移マトリクスを
packages/domainで table-driven に表現することで吸収 - 認可は
packages/authの専用ミドルウェアに寄せる(§9.1):requireReservationTransitionPermission(req)が body のtoから必要 permission を解決し、handler に到達する前にチェック。handler 内で個別 permission 判定をしない(親SRS §7.7.3 の「ルート定義に宣言的に貼る」原則を満たす)
5.2 zodスキーマ(方向性)
Section titled “5.2 zodスキーマ(方向性)”// POST /api/admin/reservations/:id/transitions Request{ to: 'confirmed' | 'declined' | 'in_service' | 'service_completed' | 'paid' | 'cancelled_by_customer' | 'cancelled_by_store' | 'no_show'; // 'tentative' / 'expired' は API 経由では指定不可 // - tentative:予約作成時のみ(RES-002) // - expired:自動ジョブのみ(§7.6) reopen?: boolean; // 巻き戻し意図の明示。マトリクス上 ↩ の遷移で必須 reason_code?: string; // キャンセル系の理由分類(任意、§7.7) notes?: string; // 自由記述(任意、巻き戻し時は UI 強制) version: number; // 楽観ロック}
// 注:`notify_customer` は Phase 1 では受け付けない。SRS-MSG-002(リマインダー基盤)// 実装後、Phase 2 で再導入する(保存先列を reservation_event に追加するか、// 別途 notification_request テーブルに切り出すかは MSG-002 起票時に決める)
// Response{ data: { reservation: { id, status, updated_at, version }; event: { from_status, to_status, trigger, actor: { type: 'operator', id: uuid } | { type: 'customer', id: uuid } | { type: 'system' }; occurred_at; reopen: boolean; reason_code?: string; }; // 注:reservation_event.id は bigserial のため API トップレベルに露出させない // (親SRS §7.2.2)。識別が必要な場合は (reservation_id, occurred_at) で代用 }}
// GET /api/admin/reservations/:id/transitions/available Response{ data: { available: Array<{ to: string; reopen: boolean; requires_reason: boolean; requires_notes: boolean; enabled: boolean; // permission を満たすか disabled_reason?: 'NO_PERMISSION' | 'GRACE_PERIOD_NOT_PASSED'; }>; }}5.3 エラーコード
Section titled “5.3 エラーコード”RESERVATION_STATE.INVALID_TRANSITION— 遷移マトリクスに存在しない(422)RESERVATION_STATE.GRACE_PERIOD_NOT_PASSED—no_show遷移は猶予経過後のみ(422)RESERVATION_STATE.REOPEN_FLAG_REQUIRED— 巻き戻し遷移なのにreopen=trueが立っていない(422、誤操作の早期検知)RESERVATION.CONFLICT— 楽観ロック衝突(409)
6. データモデル影響
Section titled “6. データモデル影響”6.1 reservation への変更
Section titled “6.1 reservation への変更”status の値域を Postgres text + CHECK 制約で 10 値に縛る(CREATE TYPE ... AS ENUM は使わない;値追加・名前変更時の Drizzle / マイグレーション摩擦を避ける)。
ALTER TABLE reservation ADD CONSTRAINT reservation_status_chk CHECK (status IN ( 'tentative','confirmed','in_service','service_completed', 'paid','declined','cancelled_by_customer','cancelled_by_store','no_show','expired' ));列追加:
| 列 | 型 | 制約 | 意味 |
|---|---|---|---|
tentative_expires_at | timestamptz | NULL | 仮予約の自動失効時刻。tentative で作成された予約のみ NOT NULL。confirmed 直入りは NULL |
tentative_expires_at は予約作成時に created_at + store_settings.tentative_expire_hours * '1 hour'::interval をスナップショット。設定変更(24h→48h 等)は既存予約に遡及しない(意図的)。
不変条件 CHECK 制約(status と tentative_expires_at の有無を一致させる、M-C 対応):
ALTER TABLE reservation ADD CONSTRAINT reservation_tentative_expires_chk CHECK ( (status = 'tentative' AND tentative_expires_at IS NOT NULL) OR (status <> 'tentative' AND tentative_expires_at IS NULL) );tentative から他状態(confirmed / declined / cancelled_by_customer / expired)への遷移時は、同一トランザクション内で UPDATE reservation SET status=..., tentative_expires_at=NULL を実行する(§7.10 のトランザクション境界に組込)。
6.2 reservation_event(状態遷移ログ)
Section titled “6.2 reservation_event(状態遷移ログ)”ドメインイベント。bigserial を許容(親SRS §7.2.2 の3条件を満たす:監査系ログ、外部に出ない、大量生成)。
CREATE TABLE reservation_event ( id bigserial PRIMARY KEY, -- 内部のみ。API トップレベルに露出禁止(§7.2.2) store_id uuid NOT NULL, reservation_id uuid NOT NULL, from_status text NOT NULL, to_status text NOT NULL, trigger text NOT NULL, -- 'operator' | 'customer' | 'system' operator_id uuid, -- trigger='operator' のとき NOT NULL customer_id uuid, -- trigger='customer' のとき NOT NULL reason_code text, notes text, reopen boolean NOT NULL DEFAULT false, occurred_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT reservation_event_actor_chk CHECK ( (trigger = 'system' AND operator_id IS NULL AND customer_id IS NULL) OR (trigger = 'operator' AND operator_id IS NOT NULL AND customer_id IS NULL) OR (trigger = 'customer' AND operator_id IS NULL AND customer_id IS NOT NULL) ), FOREIGN KEY (store_id, reservation_id) REFERENCES reservation(store_id, id), FOREIGN KEY (store_id, operator_id) REFERENCES operator_store_link(store_id, operator_id), FOREIGN KEY (store_id, customer_id) REFERENCES customer(store_id, id));CREATE INDEX ON reservation_event (store_id, reservation_id, occurred_at);CREATE INDEX ON reservation_event (store_id, occurred_at); -- 店舗タイムライン- RLS ENABLE + FORCE
appロールに INSERT / SELECT のみ GRANT、UPDATE / DELETE は GRANT しない(追記専用)operator_idの FK はoperator_store_link(store_id, operator_id)を参照(operator は複数店舗所属可、店舗スコープ内整合はoperator_store_linkで確保。SRS-TEN-003 で確定)- 排他CHECK で polymorphic actor の整合性を担保(M-2 対応)
6.3 operator_action_log との役割分担
Section titled “6.3 operator_action_log との役割分担”reservation_event と operator_action_log(親SRS §7.8)は併存するが、書込条件が異なる:
| trigger | reservation_event | operator_action_log |
|---|---|---|
operator | 1 行 INSERT | 1 行 INSERT(actor=operator_id) |
system | 1 行 INSERT | 1 行 INSERT(actor='system'、親SRS §7.8 に準拠) |
customer | 1 行 INSERT | 書込まない(親SRS §7.8 の actor は operator_id か 'system' のみ。customer 起点はドメインイベントのみで記録) |
operator_action_log を顧客 actor に拡張するのは親SRS改訂が必要なため、本SRSでは行わない(C-4 対応)。reservation_event に customer_id を持たせることで、顧客起点の遷移は完全に追跡可能。
reservation_event:UI/API のレスポンスに使う(タイムライン表示、リマインダー判定、レポート)operator_action_log:セキュリティ監査・運用調査(オペレーター/システム起点に限定)
6.4 store_settings への追加
Section titled “6.4 store_settings への追加”| 列 | 型 | デフォルト | 意味 |
|---|---|---|---|
tentative_expire_hours | smallint NOT NULL | 24 | 仮予約自動キャンセルまでの時間(時間単位) |
no_show_grace_minutes | smallint NOT NULL | 30 | no_show マーク可能になる予約開始時刻からの猶予(分) |
真実の在処:store_settings テーブル定義は SRS-TEN-001 が canonical。本SRSの追加分は SRS-TEN-001 v0.3 で store_settings テーブル定義に統合する(同一ブランチで両SRSを改訂、M-4 対応)。
6.5 マイグレーション計画
Section titled “6.5 マイグレーション計画”- BC-RES の追加マイグレーションで
reservation_eventを作成、reservationにstatusCHECK 制約とtentative_expires_at列を追加 - BC-TEN 側マイグレーション(SRS-TEN-001 v0.3 の改訂)で
store_settings.tentative_expire_hours/no_show_grace_minutesを追加 - 順序:TEN-001 マイグレーション → 本SRS のマイグレーション(
reservation.tentative_expires_at計算がstore_settingsに依存) - 新規 permission キーの backfill(親SRS §7.7.7 規約):本SRS は §9.1 で 11 個の新 permission キーを追加する。本マイグレーションは以下を実施する責務を持つ:
permissionテーブルへ 11 キーをINSERT ... ON CONFLICT(key) DO UPDATE SET description = EXCLUDED.description- 既存全店舗のプリセット role に対し、§9.1 のプリセット初期値表に従って
role_permissionを backfill(is_preset = trueの role 限定、ON CONFLICT DO NOTHING) - 検証:マイグレーション後、各 preset role が想定通りの permission 集合を持つことをテストで確認
7. 業務ルール
Section titled “7. 業務ルール”7.1 10状態の定義
Section titled “7.1 10状態の定義”| code | 内部コード | 日本語ラベル | 観察ID※ | 意味 | 種別 |
|---|---|---|---|---|---|
| 1 | tentative | 仮予約確定待ち | #1 | LIFF/HPB等で受信した未承認予約 | 進行 |
| 2 | confirmed | 受付待ち | #2 | 確定済み、来店日待ち | 進行 |
| 3 | in_service | 施術中 | #3 | チェックイン済み、施術進行中 | 進行 |
| 4 | service_completed | 来店処理待ち | #4 | 施術完了・会計前 | 進行 |
| 5 | paid | 会計済み | #11 | クロージング完了 | 終端 |
| 6 | declined | お断り | #6 | サロン側が未確定段階で拒否 | 終端 |
| 7 | cancelled_by_customer | お客様キャンセル | #7 | 顧客都合(仮予約・確定後どちらも) | 終端 |
| 8 | cancelled_by_store | サロンキャンセル | #8 | 店舗都合(確定後のみ) | 終端 |
| 9 | no_show | 無断キャンセル | #9 | ノーショウ(手動マーク) | 終端 |
| 10 | expired | 自動キャンセル | #10 | 仮予約の期限切れ(自動ジョブ) | 終端 |
※ DOC-ANALYSIS-001 §5.1 の予約ステータス番号。観察 #5 は予約ドメインに採用しない(§1)。レジ/会計画面の処理進行状態は SRS-REG-001 の別エンティティ(仮:register_session 等)で扱う。
7.2 状態の不変条件
Section titled “7.2 状態の不変条件”- 終端状態(
paid/declined/cancelled_*/no_show/expired)からは遷移不可。終端救済の運用は §7.5 末尾参照 tentativeへの遷移は予約作成時のみ(API 経由では起こらない)expiredへの遷移は自動ジョブのみ(API 経由では起こらない)tentativeからcancelled_by_storeへの直接遷移は禁止(未確定段階の店舗都合拒否はdeclinedに集約、§7.4、Q4 対応)no_showへの遷移はstarts_at + store_settings.no_show_grace_minutes < now()を満たす場合のみ(C-3 対応)- SRS-RES-005 の二重予約判定における判定対象ステータス:
{tentative, confirmed, in_service, service_completed}(進行系のみ、paidを含む終端は除外、§7.8、M-5 対応)
7.3 遷移マトリクス
Section titled “7.3 遷移マトリクス”行 = from、列 = to。
| from \ to | tent | conf | in_svc | svc_done | paid | decl | canc_cu | canc_st | no_show | exp |
|---|---|---|---|---|---|---|---|---|---|---|
| tentative | - | ✅ | - | - | - | ✅ | ✅ | - | - | ⚙ |
| confirmed | - | - | ✅ | - | - | - | ✅ | ✅ | ✅(†1) | - |
| in_service | - | ↩ | - | ✅ | - | - | - | ✅(†2) | - | - |
| service_completed | - | - | ↩ | - | ✅ | - | - | - | - | - |
| paid | - | - | - | - | - | - | - | - | - | - |
| declined | - | - | - | - | - | - | - | - | - | - |
| cancelled_by_customer | - | - | - | - | - | - | - | - | - | - |
| cancelled_by_store | - | - | - | - | - | - | - | - | - | - |
| no_show | - | - | - | - | - | - | - | - | - | - |
| expired | - | - | - | - | - | - | - | - | - | - |
凡例:
✅通常遷移(オペレーター操作 / 顧客操作)↩巻き戻し遷移:reopen=true必須 +admin:reservation:reopen権限必須(§7.5)⚙自動遷移(システム、ジョブ)-不可(INVALID_TRANSITION)
注:
- (†1)
confirmed → no_showはreservation.starts_at + store_settings.no_show_grace_minutes < now()の場合のみ。それ以外はGRACE_PERIOD_NOT_PASSED - (†2)
in_service → cancelled_by_storeは施術中の急病等の例外運用。理由必須
7.4 遷移トリガと actor
Section titled “7.4 遷移トリガと actor”| 遷移 | 主トリガ | trigger / actor_type |
|---|---|---|
| (新規) → tentative | 予約作成(LIFF / HPB 受信 / 電話の取り置き) | customer or operator |
| (新規) → confirmed | 予約作成(管理画面手動 / LIFF 即時確定店舗) | operator or customer |
| tentative → confirmed | 承認操作 | operator |
| tentative → declined | 拒否操作(未確定段階の店舗都合拒否はすべてここに集約) | operator |
| tentative → cancelled_by_customer | LIFF からの取消 / 電話受付代行 | customer or operator |
| tentative → expired | 期限切れジョブ | system |
| confirmed → in_service | チェックイン操作 | operator |
| confirmed → cancelled_by_customer | 顧客取消 | customer or operator |
| confirmed → cancelled_by_store | サロン都合(確定後) | operator |
| confirmed → no_show | ノーショウマーク | operator |
| in_service → service_completed | 施術完了マーク | operator |
| in_service → cancelled_by_store | 急病等の中断 | operator |
| service_completed → paid | 会計確定(SRS-REG-001 のレジセッション完了から発火) | operator |
| in_service → confirmed (↩) | 誤チェックインの取消 | operator (reopen) |
| service_completed → in_service (↩) | 誤完了マークの取消 | operator (reopen) |
declined と cancelled_by_store の境界(Q4 対応):
declined:未確定(tentative)段階での店舗側拒否。「最初から受け入れない」意図cancelled_by_store:確定後(confirmed/in_service)の店舗都合キャンセル。「一度受けたが続行できない」意図- 両者は KPI(受諾率/キャンセル率)、キャンセル料責務(SRS-PAY-002)、通知文面(SRS-MSG-002)が異なるため意味を分ける
7.5 reopen / 巻き戻しの設計
Section titled “7.5 reopen / 巻き戻しの設計”- 中間状態間での1段階の巻き戻しのみ許可(マトリクス上の
↩)in_service → confirmedservice_completed → in_service
- 2段階以上の巻き戻しは不可。誤って大きく進めた場合は連続して巻き戻す
- API 上で
reopen=trueを必須とし、立っていない場合REOPEN_FLAG_REQUIRED(誤操作の早期検知) admin:reservation:reopenpermission 必須- UI で
notes(理由)必須
終端状態からの reopen は Phase 1 でサポートしない(Codex 指摘の追加論点2 対応)。誤って終端化された予約の救済は以下の運用フローで対応:
operator_action_logおよびreservation_eventを参照し、誤遷移の経緯を確認(誰が・いつ・どの理由で)- 当該顧客に対し新規
confirmed予約を再作成する(同じスタッフ・時間枠・メニュー、料金スナップショットも揃える) - 新規予約の
notesに「誤って〇〇により取消された予約<元 reservation_id>の再作成」と明記 - 元の終端予約は触らない(履歴として残す)
この方針は、終端 reopen を許した場合の設備リリース(§7.9)の巻き戻し・ベットされた整合性問題を避けるため。Phase 1 運用後の声で OQ-RES-004-01 として再検討する。
7.6 自動遷移ジョブ(graphile-worker)
Section titled “7.6 自動遷移ジョブ(graphile-worker)”| ジョブ名 | 起動 | 対象 | 動作 |
|---|---|---|---|
expire_tentative_reservations | 5分間隔 cron | status='tentative' かつ tentative_expires_at < now() | tentative → expired |
tentative_expires_at スナップショット方式(minor 対応):
- 予約作成時に
reservation.tentative_expires_at = created_at + store_settings.tentative_expire_hours * '1 hour'::intervalを計算してスナップショット - ジョブは
WHERE status='tentative' AND tentative_expires_at < now() AND deleted_at IS NULLの単純クエリで対象抽出(店舗ごとの設定 join 不要、性能・実装シンプル) - 設定変更(24h→48h)は既存の tentative 予約に遡及しない(意図的な挙動)
- システム遷移は
trigger='system',operator_id=NULL,customer_id=NULL,reservation_eventとoperator_action_logの両方に1行ずつ書く(§6.3) - 1 回の起動で店舗ごとに 1 トランザクションにまとめる(テナント分離は
SET LOCAL app.current_store_idで確保、親SRS §7.1.7)。graphile-worker のjob_keyに店舗 ID を入れて同店舗の同時実行を排他
confirmed から no_show への自動遷移は Phase 1 で行わない(誤動作リスクが高く運用混乱を招く。手動マーク運用のみ。OQ-RES-004-02)
7.7 監査の連動
Section titled “7.7 監査の連動”- すべての遷移で
reservation_eventに 1 行 INSERT(同一トランザクション) trigger='operator'またはtrigger='system'の遷移はoperator_action_logにも 1 行 INSERT(親SRS §7.8、action='reservation.transition'、diffに{from, to, reason_code, reopen}を含める)trigger='customer'の遷移はoperator_action_logには書かない(§6.3、親SRS §7.8 への準拠)
7.8 SRS-RES-005 との整合(判定対象の確定)
Section titled “7.8 SRS-RES-005 との整合(判定対象の確定)”SRS-RES-005 の二重予約判定で対象に含めるステータスを本SRSで canonical 定義:
status IN ('tentative','confirmed','in_service','service_completed')AND deleted_at IS NULL- 終端(
paid/declined/cancelled_*/no_show/expired)はすべて判定対象外 paidを判定対象に含めない理由:会計済みは過去の完了予約であり、「まだ枠を占有しているアクティブな予約」ではない(M-5 対応)。同一スタッフが同時刻に paid 予約を持っていても、それは過去の業務記録であり新規予約と競合しない
SRS-RES-005 v0.3 はこの定義を canonical source として参照する(同一ブランチで RES-005 を v0.3 に改訂、C-1 対応)。
7.9 reservation_equipment.is_active との同期
Section titled “7.9 reservation_equipment.is_active との同期”設備の EXCLUDE 制約から外す is_active=false の更新タイミングを、本SRS の遷移ハンドラ内で確定する:
| 遷移先 | reservation_equipment.is_active |
|---|---|
declined / cancelled_by_customer / cancelled_by_store / no_show / expired | 当該予約の全行を false に UPDATE |
paid | 変更しない(履歴として有効。EXCLUDE 制約はキャンセル系のみで外す) |
| 上記以外(中間遷移、巻き戻し) | 変更しない |
これにより SRS-RES-005 の OQ-RES-005-01(is_active 更新タイミング)が解消される。リポジトリ層に「キャンセル系遷移時のフック」を一箇所で実装する。SRS-RES-005 v0.3 はこの責務を本SRS §7.9 に委任する旨を明記する。
7.10 トランザクション境界
Section titled “7.10 トランザクション境界”1 回の遷移リクエストは以下を単一トランザクションで実行:
reservationをWHERE id=? AND version=?で UPDATE。更新する列:status= 新ステータスtentative_expires_at=tentativeから他状態(confirmed/declined/cancelled_by_customer/expired)への遷移時はNULLに更新(§6.1 不変条件 CHECK 制約に従うため必須、M-C)updated_at=now()version=version + 1
reservation_eventINSERToperator_action_logINSERT(trigger='customer'の場合は skip)- キャンセル系なら
reservation_equipment.is_active=falseの UPDATE(§7.9)
楽観ロック競合(行 1 の影響行 0)なら全体ロールバック。
8. 非機能要件
Section titled “8. 非機能要件”- 状態遷移 API:p95 200ms 以下(イベント INSERT × 最大2 + 楽観ロック UPDATE)
expire_tentative_reservationsジョブ:5 分以内に拾う、店舗単位で同時 1 ジョブまで(同店舗 race を避ける、graphile-worker の job key で店舗 ID を使う)reservation_event保持期間:監査ログと同様 1 年(親SRS §6.3)。それ以前は別テーブルにアーカイブ(Phase 3 検討、OQ-RES-004-06)
9. セキュリティ・認可
Section titled “9. セキュリティ・認可”9.1 使用する permission キー
Section titled “9.1 使用する permission キー”| key | 用途 | プリセット初期値 |
|---|---|---|
admin:reservation:read | 利用可能遷移一覧 / 予約詳細参照 | 全員 |
admin:reservation:read_timeline | /events イベントタイムライン参照(reason_code / notes / customer 起点履歴を含む) | owner / manager / receptionist |
admin:reservation:approve_tentative | tentative→confirmed | owner / manager / receptionist |
admin:reservation:decline | tentative→declined | owner / manager |
admin:reservation:cancel_by_store | confirmed/in_service→cancelled_by_store(tentative からは禁止) | owner / manager |
admin:reservation:check_in | confirmed→in_service | 全員 |
admin:reservation:complete_service | in_service→service_completed | 全員 |
admin:reservation:finalize_payment | service_completed→paid | owner / manager / receptionist |
admin:reservation:mark_no_show | confirmed→no_show | owner / manager |
admin:reservation:cancel_by_customer_proxy | 電話等の代行で operator が顧客取消を入力 | owner / manager / receptionist |
admin:reservation:reopen | 中間状態の 1 段戻し(in_service↔confirmed、service_completed↔in_service) | owner / manager |
portal:reservation:cancel(顧客が LIFF から自分の予約を取り消す)は本SRSのスコープ外(SRS-LIFF-002 で定義)。本SRS は admin スコープに集中する。
認可ミドルウェアの所在(M-1 対応):
packages/authにrequireReservationTransitionPermission(req)を実装req.body.toと現在のreservation.status(同一 transaction で取得)から必要 permission を解決し、requirePermission(key)を呼ぶ- 解決テーブル(
(from, to) → permission_key[])はpackages/auth内の const として持つ。packages/domainの状態機械テーブルとの整合性は型で担保(共通の遷移列挙を参照) - handler 内で permission 判定をしない(親SRS §7.7.3 の「ルート定義に宣言的に貼る」原則)
9.2 RLS
Section titled “9.2 RLS”reservation_event は新規テーブル。store_id で RLS ENABLE + FORCE、app ロールに INSERT / SELECT のみ GRANT(追記専用)。
ALTER TABLE reservation_event ENABLE ROW LEVEL SECURITY;ALTER TABLE reservation_event FORCE ROW LEVEL SECURITY;CREATE POLICY tenant_isolation ON reservation_event USING (store_id = current_setting('app.current_store_id')::uuid);GRANT SELECT, INSERT ON reservation_event TO app;-- UPDATE / DELETE は意図的に GRANT しない10. 受け入れ基準(Given-When-Then)
Section titled “10. 受け入れ基準(Given-When-Then)”- GWT-1 標準導線:tentative→confirmed→in_service→service_completed→paid を順に遷移できる、各ステップで
reservation_eventが 1 行追加 - GWT-2 不正遷移(飛ばし):tentative→in_service は 422
INVALID_TRANSITION - GWT-3 不正遷移(tentative→cancelled_by_store):tentative→cancelled_by_store は 422
INVALID_TRANSITION(declined 一本化、Q4) - GWT-4 終端不可:paid の予約に対する全ての transitions API は 422
INVALID_TRANSITION - GWT-5 自動期限切れ:
tentative_expires_at < now()のtentative予約に対しジョブがexpired化、reservation_event.trigger='system'、operator_id=NULL、customer_id=NULL、operator_action_log.actor='system' - GWT-6 巻き戻し(権限あり):
admin:reservation:reopen持ちで service_completed→in_service がreopen=true付きで成功 - GWT-7 巻き戻し(権限なし):reopen 権限なしのオペレーターが service_completed→in_service を要求 → 403
- GWT-8 巻き戻しフラグ未指定:reopen が必要な遷移を
reopen=falseで要求 → 422REOPEN_FLAG_REQUIRED - GWT-9 no_show 猶予未経過:
starts_at + no_show_grace_minutes >= now()の confirmed に対し no_show 要求 → 422GRACE_PERIOD_NOT_PASSED - GWT-10 no_show 猶予経過後:
starts_at + no_show_grace_minutes < now()の confirmed に対し no_show 要求 → 成功 - GWT-11 楽観ロック:古い
versionで遷移要求 → 409RESERVATION.CONFLICT - GWT-12 RES-005 整合(終端除外):
status='cancelled_by_store'/paid/expiredの予約はexistingStaffReservationsクエリ結果に含まれない - GWT-13 設備リリース:confirmed→cancelled_by_store の遷移後、当該予約の
reservation_equipment.is_activeがすべて false になり、同設備同時刻の新規予約が成功する - GWT-14 監査二重書き(operator):operator 起点の遷移で
reservation_eventとoperator_action_logに 1 行ずつ追加 - GWT-15 監査の customer 例外:customer 起点の遷移で
reservation_eventには記録されるがoperator_action_logには書かれない - GWT-16 RLS:店舗 X のセッションは店舗 Y の
reservation_eventを読めない - GWT-17 利用可能遷移 API(confirmed):confirmed の予約に対し
/transitions/availableが[in_service, cancelled_by_customer, cancelled_by_store, no_show?]を返す。no_showのenabledは猶予経過後のみ true、それ以外は false +disabled_reason='GRACE_PERIOD_NOT_PASSED' - GWT-18 利用可能遷移 API(tentative):tentative の予約に対し owner プリセットで
/transitions/availableが[confirmed, declined, cancelled_by_customer]のみを返す。cancelled_by_storeは permission を持っていても返らない(マトリクス上 INVALID_TRANSITION のため)。no_showも返らない(M-A 対応、Q4 declined 一本化の検証) - GWT-19 customer 起点遷移:LIFF からの取消で
reservation_event.trigger='customer',customer_id=NOT NULLが記録される - GWT-20 actor 排他CHECK:
trigger='customer'でoperator_idを入れて INSERT 試行 → DB CHECK 制約違反 - GWT-21 tentative_expires_at 不変条件:tentative 作成時に
tentative_expires_atがセットされ、confirmed 直入りでは NULL であること。tentative→confirmed遷移時に同一トランザクションでtentative_expires_at=NULLに更新されないと CHECK 違反(M-C 対応)
11. テスト計画
Section titled “11. テスト計画”- Unit (
packages/domain/reservation/state-machine.ts):- 遷移マトリクスの table-driven test:10 × 10 = 100 セルを網羅、許可 / 拒否を明示
- 終端からの遷移要求は全て INVALID_TRANSITION
tentative → cancelled_by_storeは INVALID_TRANSITION(declined 一本化)- reopen の許可 / 拒否
- GRACE_PERIOD_NOT_PASSED / REOPEN_FLAG_REQUIRED の発火条件
- Unit (
packages/auth):requireReservationTransitionPermissionの解決テーブルが §7.3 マトリクスと整合する table-driven test - Integration (
apps/api+ Testcontainers Postgres):GWT-1〜21 を網羅(tentative_expires_atCHECK 違反検知、available(tentative)の permission フィルタ後の出力を含む) - Job test:
expire_tentative_reservationsを fake clock で発火、tentative_expires_at < now()の tentative のみ expired 化することを確認 - Contract:
/transitionsおよび/transitions/availableの zod schema を FE/BE 間で照合
12. 関連ジョブ(graphile-worker)
Section titled “12. 関連ジョブ(graphile-worker)”| ジョブ名 | 起動 | 種別 |
|---|---|---|
expire_tentative_reservations | 5分 cron | 自動遷移 |
将来検討(Phase 1 では不採用):
auto_no_show_overdue_confirmed:誤動作リスクが高く却下(OQ-RES-004-02)notify_status_change:SRS-MSG-002(リマインダー基盤)で吸収
13. Open Questions
Section titled “13. Open Questions”| # | 内容 | 締切の目安 |
|---|---|---|
| OQ-RES-004-01 | 終端状態からの reopen を Phase 1 で許すか。本SRSでは不採用とし §7.5 の代替運用フロー(新規予約再作成)で対応。設備整合・通知整合の複雑さを避けるため。運用開始後の声で再検討 | Phase 1 中盤の運用判断 |
| OQ-RES-004-02 | confirmed の予約が予約終了時刻を過ぎた場合の自動 no_show マーク。誤動作リスクと省力化のトレードオフ。Phase 1 では手動運用のみ | Phase 2 開始時 |
| OQ-RES-004-03 | LIFF からの顧客操作で起こせる遷移(tentative→cancelled_by_customer 等)の権限境界。本SRSは admin スコープを定義したが、portal 経路は SRS-LIFF-002 で詳細化 | SRS-LIFF-002 起票時 |
| OQ-RES-004-04 | paid 予約の修正(金額修正・返金)の表現。新たな状態 refunded を追加するか、paid のままレジ側で処理するか | SRS-REG-001 / SRS-PAY-002 起票時 |
| OQ-RES-004-06 | reservation_event のサイズ管理。1 予約あたり最大 10 イベント程度想定だが、巻き戻し多発店舗で肥大化する場合のアーカイブ戦略 | Phase 3 運用時 |
| OQ-RES-004-08 | reservation_event の安定識別子。Phase 1 は (reservation_id, occurred_at) で代用するが、UI の再取得・並べ替えで曖昧化する場合は public_id を UUIDv7 で追加(minor 対応) | Phase 2 UI 拡充時 |
解消済み(v0.2 で決着):
OQ-RES-004-05→ 観察 #5 を予約ドメインから外し、service_completed → paidの一括遷移を残すかpayment_in_progressを廃止。service_completed → paidを唯一の正規経路に確定。レジの処理進行状態は SRS-REG-001 のレジセッションエンティティで別途管理(Q2 / 追加論点1 対応)OQ-RES-004-07 観察 #5 の本家での実体→ 解消。本プロダクトでは予約状態として採用せず、SRS-REG-001 で別ドメインとして扱う(同上)
14. 変更履歴
Section titled “14. 変更履歴”| Version | Date | Author | Change |
|---|---|---|---|
| 0.1 | 2026-04-25 | yudai | 初版起票(Draft)。観察記録 DOC-ANALYSIS-001 §5.1 の 11 ラベルを内部コードへ正規化、遷移マトリクス・自動ジョブ・監査連動を定義 |
| 0.2 | 2026-04-25 | yudai | Codex (gpt-5.4 high) レビュー反映。11→10状態へ縮約:観察 #5 を予約ドメインから外し、payment_in_progress を SRS-REG-001 のレジセッションへ移譲(Q2)。declined 一本化:tentative→cancelled_by_store を禁止、未確定段階の店舗都合は declined のみ(Q4)。no_show 判定を starts_at + no_show_grace_minutes に修正(C-3)。operator_action_log の actor 拡張を撤回、customer 起点は reservation_event のみ(C-4)。reservation_event.id の API 露出禁止(C-5)。actor を operator_id / customer_id に分離 + 排他CHECK + 複合FK(M-2)。認可ミドルウェアを packages/auth に明文化(M-1)。notify_customer を Phase 1 から除去(M-3)。paid を RES-005 判定対象から除外(M-5)。tentative_expires_at スナップショット方式採用(minor)、/events を read_timeline 別 permission 化(minor)。SRS-TEN-001 v0.3 / SRS-RES-005 v0.3 と同一ブランチで同期改訂(C-1 / M-4)。終端 reopen の代替運用フローを §7.5 に明記 |