コンテンツにスキップ

予約状態遷移(10状態の状態機械)

予約状態遷移(10状態の状態機械)

Section titled “予約状態遷移(10状態の状態機械)”

Document ID: SRS-RES-004 Parent: SRS-ROOT-001 v0.5 Version: 0.3 Status: Implemented (API + Web UI) Last Updated: 2026-05-06 Depends on: SRS-TEN-001 v0.4, SRS-TEN-002 v0.2, SRS-TEN-003 v0.2, SRS-RES-002 v0.5, SRS-RES-005 v0.4 依存される: SRS-RES-002, SRS-RES-003, SRS-REG-001, SRS-REG-002, SRS-PAY-001/002, SRS-MSG-002, SRS-LIFF-002

本書は SRS-ROOT-001 v0.5 に従う。


予約の 10 状態 を定義し、状態遷移を一元管理する。Phase 1 の主要変更点:

  • operator_action_log 二重書き責務を transition handler の 1 トランザクションに明記
  • reservation_event_actor_polymorphism CHECK の論理欠陥を修正(trigger='operator' AND operator_id IS NOT NULL 強制)
  • actor_kind enum('operator','system')operator_action_log に追加(TEN-002 連動)
  • OQ-RES-004-04(paid 予約の修正・返金)を REG-001 の visit_refundclosed
  • permission resolver は packages/auth(pure)、Hono middleware は apps/api(layer 分離)
  • 8 個の細粒度 transition permission を維持(統合キーは廃止)

判定ロジック(重複・営業時間外)は本SRSの対象外で SRS-RES-005 / SRS-MST-003 が持つ。本SRSは「いま何の状態か」「次はどこへ動けるか」「誰が動かせるか」のみを扱う。

本SRSは reservation.status の canonical source。RES-005 の「判定対象外条件」など他SRSが status 値域に依存する箇所は本SRSを参照する。


  • As a 受付, I want to LIFF/HPB から入った仮予約を確認して確定 or お断りに振り分けたい, so that 顧客が予定を確実に押さえられる
  • As a 受付, I want to 来店した顧客を 1 クリックで「施術中」に移したい, so that カレンダー上の進捗が一目で分かる
  • As a 店長, I want to 来店時刻を過ぎても来ない顧客を「無断キャンセル」として記録したい
  • As a オーナー, I want to 仮予約が一定時間放置されたら自動でキャンセルにしたい
  • As a 店長, I want to 誤って進めた状態を 1 段階戻せるようにしたい

10 状態を進行系 4 + 終端系 6 に分類:

  • 進行系: tentative, confirmed, in_service, service_completed
  • 終端系: paid, declined, cancelled_by_customer, cancelled_by_store, no_show, expired
tentative ─┐
├─→ confirmed → in_service → service_completed → paid
(新規確定)─┘
  • AF-1〜AF-7: v0.2 と同じ
  • AF-8 巻き戻し: 中間状態の 1 段階戻し(reopen)
  • EF-1 不正遷移: 422 RESERVATION_STATE.INVALID_TRANSITION
  • EF-2 権限不足: 403
  • EF-3 楽観ロック衝突: 409
  • EF-4 no_show 猶予未経過: 422 RESERVATION_STATE.GRACE_PERIOD_NOT_PASSED
  • EF-5 reopen フラグ未指定: 422 RESERVATION_STATE.REOPEN_FLAG_REQUIRED
  • EF-6 transition handler の operator_action_log 二重書き失敗: 全体 rollback

v0.2 と同じ。状態バッジ・アクションメニュー・キャンセル理由入力・巻き戻し導線・no_show ボタンの可視性。


MethodPath用途必要 permission
POST/api/admin/reservations/:id/transitions状態遷移resolver で動的解決(§9.1)
GET/api/admin/reservations/:id/transitions/available可能な遷移一覧admin:reservation:read
GET/api/admin/reservations/:id/events遷移ログタイムラインadmin:reservation:read_timeline

5.1.1 認可解決の責務分離(親 §7.7.3)

Section titled “5.1.1 認可解決の責務分離(親 §7.7.3)”
  • packages/auth/src/reservation-transitions.ts
    • resolveReservationTransitionPermission({ to, reopen, fromStatus }): PermissionKey (pure 純関数)
  • apps/api/src/middleware/require-reservation-transition-permission.ts
    • body 検証 → resolver 呼出 → requirePermission(key)
  • handler 内で permission 判定をしない
{
to: 'confirmed' | 'declined' | 'in_service' | 'service_completed'
| 'paid' | 'cancelled_by_customer' | 'cancelled_by_store' | 'no_show';
reopen?: boolean;
reason_code?: string;
notes?: string;
version: number;
}

to: 'tentative' / 'expired' は API 経由不可(前者は予約作成時、後者は自動ジョブ)。

  • RESERVATION_STATE.INVALID_TRANSITION (422)
  • RESERVATION_STATE.GRACE_PERIOD_NOT_PASSED (422)
  • RESERVATION_STATE.REOPEN_FLAG_REQUIRED (422)
  • RESERVATION.CONFLICT (409)

6.1 reservations への変更(v0.2 から維持)

Section titled “6.1 reservations への変更(v0.2 から維持)”

statustext + CHECK IN (10 values)tentative_expires_at 列の不変条件 CHECK も維持。

6.2 reservation_event の actor CHECK 修正(v0.3 で修正)

Section titled “6.2 reservation_event の actor CHECK 修正(v0.3 で修正)”
ALTER TABLE reservation_event
DROP CONSTRAINT IF EXISTS reservation_event_actor_polymorphism;
ALTER TABLE reservation_event
ADD CONSTRAINT reservation_event_actor_polymorphism
CHECK (
(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)
OR
(trigger = 'system' AND operator_id IS NULL AND customer_id IS NULL)
);

v0.2 の CHECK は trigger='operator' AND operator_id IS NOT NULL を強制していなかった。v0.3 で修正。

6.3 operator_action_log への変更(TEN-002 連動)

Section titled “6.3 operator_action_log への変更(TEN-002 連動)”

actor_kind enum('operator','system') 列を追加(SRS-TEN-002 §6.1 で詳述)。本SRSは「reservation transition」記録時の使い方を定める:

  • actor_kind='operator', operator_id NOT NULL: operator / customer 起点(customer 起点は audit log 対象外、§7.7 参照)
  • actor_kind='system', operator_id NULL: 自動失効ジョブ等

tentative_expire_hours, no_show_grace_minutes の列定義は SRS-TEN-001 v0.4 (0011) が canonical。本SRSは値の参照のみ。

0019_reservation_phase1_finalize.sql で:

  1. reservation_event_actor_polymorphism CHECK 修正
  2. operator_action_log.diff GIN index 追加
  3. force_* permission rename
  4. 11 transition permission の backfill(既存 0010 の延長として実施)

v0.2 と同じ表(code 1〜10、進行系・終端系)。

v0.2 と同じ。終端遷移不可、tentative への遷移は予約作成時のみ、expired は自動のみ、tentative → cancelled_by_store 禁止、no_show 猶予条件、判定対象 status 4 値。

v0.2 と同じ(10 × 10 セル、 -)。

7.4 declined と cancelled_by_store の境界

Section titled “7.4 declined と cancelled_by_store の境界”

v0.2 と同じ。

v0.2 と同じ。終端からの reopen は Phase 1 でサポートしない(代替運用フロー: 新規予約再作成)。

v0.2 と同じ。expire_tentative_reservations 5 分 cron。SRS-WRK-001 の fan-out 規約に従い、expire-tentative-reservations(system scheduler)→ expire-tentative-for-store(store ごと)の 2 段構成で実装。

7.7 監査の連動(v0.3 で責務明記

Section titled “7.7 監査の連動(v0.3 で責務明記)”

すべての遷移で同一トランザクション内に以下を実行:

  1. reservationsWHERE id=? AND version=? で UPDATE
  2. reservation_event INSERT
  3. operator_action_log INSERT(trigger='customer' の場合は skip)

operator_action_log への INSERT は handler の責務として明示。reservation_event だけ書いて operator_action_log を書き忘れる実装は禁止。

7.8 SRS-RES-005 との整合(判定対象の確定)

Section titled “7.8 SRS-RES-005 との整合(判定対象の確定)”

判定対象 status:

status IN ('tentative','confirmed','in_service','service_completed')
AND deleted_at IS NULL

終端は判定対象外(paid を含む、§7.8 v0.2 と同じ)。

7.9 reservation_equipment_assignments.is_active 同期

Section titled “7.9 reservation_equipment_assignments.is_active 同期”
遷移先is_active
declined / cancelled_by_customer / cancelled_by_store / no_show / expired当該予約の全行を false に UPDATE
paid変更しない(履歴として有効、EXCLUDE 対象継続)
上記以外変更しない

1 回の遷移リクエストは 1 トランザクション:

  1. reservations UPDATE
    • status 更新
    • tentative_expires_atNULL に更新(tentative から他状態への遷移時のみ)
    • version + 1
    • updated_at = now()
  2. reservation_event INSERT
  3. operator_action_log INSERT(trigger=‘customer’ は skip)
  4. キャンセル系なら reservation_equipment_assignments.is_active=false UPDATE

7.11 paid 予約の修正・返金(OQ-RES-004-04 closed)

Section titled “7.11 paid 予約の修正・返金(OQ-RES-004-04 closed)”
  • paid 状態の修正は SRS-REG-001 の visit.refunded_amount + visit_refund 子テーブルで表現
  • paid → refunded のような新状態は導入しない
  • 詳細は SRS-REG-001 §7.2 / §7.4 参照

  • 状態遷移 API: p95 200ms 以下
  • expire_tentative_reservations ジョブ: 5 分以内に拾う
  • reservation_event 保持期間: 1 年(親 §6.3)

9.1 使用する permission キー(8 transition keys 維持

Section titled “9.1 使用する permission キー(8 transition keys 維持)”
key用途プリセット初期値
admin:reservation:read利用可能遷移一覧 / 詳細全員
admin:reservation:read_timeline/events イベントタイムラインowner / manager / receptionist
admin:reservation:approve_tentativetentative→confirmedowner / manager / receptionist
admin:reservation:declinetentative→declinedowner / manager
admin:reservation:cancel_by_storeconfirmed/in_service→cancelled_by_storeowner / manager
admin:reservation:check_inconfirmed→in_service全員
admin:reservation:complete_servicein_service→service_completed全員
admin:reservation:finalize_paymentservice_completed→paidowner / manager / receptionist
admin:reservation:mark_no_showconfirmed→no_showowner / manager
admin:reservation:cancel_by_customer_proxy顧客取消代理入力owner / manager / receptionist
admin:reservation:reopen中間状態 1 段戻しowner / manager

resolver pseudocode:

function resolveReservationTransitionPermission({ from, to, reopen }) {
if (reopen) return PERMISSIONS.ADMIN_RESERVATION_REOPEN;
switch (to) {
case 'confirmed':
// from='tentative' → approve_tentative
// from='in_service' → reopen (上で処理済)
return PERMISSIONS.ADMIN_RESERVATION_APPROVE_TENTATIVE;
case 'declined': return PERMISSIONS.ADMIN_RESERVATION_DECLINE;
case 'in_service': return PERMISSIONS.ADMIN_RESERVATION_CHECK_IN;
case 'service_completed': return PERMISSIONS.ADMIN_RESERVATION_COMPLETE_SERVICE;
case 'paid': return PERMISSIONS.ADMIN_RESERVATION_FINALIZE_PAYMENT;
case 'cancelled_by_customer': return PERMISSIONS.ADMIN_RESERVATION_CANCEL_BY_CUSTOMER_PROXY;
case 'cancelled_by_store': return PERMISSIONS.ADMIN_RESERVATION_CANCEL_BY_STORE;
case 'no_show': return PERMISSIONS.ADMIN_RESERVATION_MARK_NO_SHOW;
}
}

reservation_event は新規テーブルとしてすでに 0008 で導入済(store_id RLS ENABLE + FORCE)。app に SELECT/INSERT のみ GRANT、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
  • GWT-3 不正遷移: tentative→cancelled_by_store は 422
  • GWT-4 終端不可: paid からの全遷移は 422
  • GWT-5 自動失効: tentative_expires_at < now() の tentative がジョブで expired 化、actor_kind='system', operator_id=NULL
  • GWT-6 巻き戻し成功: admin:reservation:reopen 持ちで service_completed→in_service が reopen=true で成功
  • GWT-7 巻き戻し権限なし: 403
  • GWT-8 巻き戻しフラグ未指定: 422 REOPEN_FLAG_REQUIRED
  • GWT-9 no_show 猶予未経過: 422 GRACE_PERIOD_NOT_PASSED
  • GWT-10 no_show 猶予経過後: 成功
  • GWT-11 楽観ロック: 古い version → 409
  • GWT-12 RES-005 整合: 終端は existing 判定対象外
  • GWT-13 設備リリース: cancelled_by_store 後、equipment_assignments.is_active=false
  • GWT-14 監査二重書き: operator 起点遷移で reservation_eventoperator_action_log が 1 行ずつ
  • GWT-15 customer 起点例外: customer 起点遷移で reservation_event のみ、operator_action_log には書かれない
  • GWT-16 RLS: 店舗 X セッションは店舗 Y reservation_event を読めない
  • GWT-17 利用可能遷移 API (confirmed): 候補リスト + permission フィルタ後 enabled 状態
  • GWT-18 利用可能遷移 API (tentative): cancelled_by_store は INVALID なので返らない
  • GWT-19 customer 起点遷移: reservation_event.trigger='customer', customer_id NOT NULL
  • GWT-20 actor CHECK 違反: trigger='operator' AND operator_id IS NULL で INSERT → DB エラー
  • GWT-21 tentative_expires_at 不変条件: tentative 作成時セット、tentative→confirmed で同 tx 内 NULL 化
  • GWT-22 actor_kind: operator_action_log 行が actor_kind='operator' または 'system' で記録される
  • GWT-23 二重書き失敗 rollback: operator_action_log INSERT 失敗時、reservations UPDATE と reservation_event INSERT も rollback

  • Unit (packages/domain/reservation/state-machine.ts): 10×10 マトリクスの table-driven、終端不可、reopen、grace、reopen flag、resolver 8 keys
  • Unit (packages/auth/reservation-transitions.ts): resolver 純関数の table-driven
  • Integration (apps/api): GWT-1〜23、CHECK 違反、二重書き rollback、RLS
  • Job test: expire_tentative_reservations を fake clock で発火
  • Contract: /transitions および /transitions/available の zod schema

  • expire-tentative-reservations (system scheduler, cron 5 分)
  • expire-tentative-for-store (store-scoped fan-out)

詳細は SRS-WRK-001。


#内容状態
OQ-RES-004-01終端からの reopen を Phase 2 で許すかOpen(Phase 1 不採用、運用後再検討)
OQ-RES-004-02confirmed の自動 no_show マークOpen(Phase 2)
OQ-RES-004-03LIFF 顧客操作で起こせる遷移の権限境界Open(SRS-LIFF-002)
OQ-RES-004-04paid 予約の修正・返金Closed(REG-001 visit.refunded_amount + visit_refund で吸収)
OQ-RES-004-06reservation_event のサイズ管理(archive)Open(Phase 3)
OQ-RES-004-08reservation_event の安定識別子Open(Phase 2 UI 拡充時)

VersionDateAuthorChange
0.12026-04-25yudai初版起票
0.22026-04-25yudai11→10 状態縮約、declined 一本化、no_show grace、actor 分離、認可ミドルウェア責務
0.32026-05-05yudai (with Codex co-design)Parent v0.5 同期。reservation_event_actor_polymorphism CHECK の論理欠陥修正(trigger=‘operator’ で operator_id NOT NULL 強制)。operator_action_log.actor_kind 採用。operator_action_log 二重書き責務を §7.7 / §7.10 に明記。permission resolver は packages/auth、Hono middleware は apps/api(layer 分離)。OQ-RES-004-04 を visit_refund (REG-001) で closed。force_* 命名整理(reservation 系は force_business_hours / force_staff_concurrency)。migration 番号 0019 確定