コンテンツにスキップ

予約状態遷移(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を参照する。


  • 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 操作ミスを取り返せる

10状態を進行系終端系に分ける:

  • 進行系(中間、4状態)tentative, confirmed, in_service, service_completed
  • 終端系(不可逆、6状態)paid, declined, cancelled_by_customer, cancelled_by_store, no_show, expired

10状態の定義は §7.1 に詳述。

tentative ─┐
├─→ confirmed → in_service → service_completed → paid
(新規確定)─┘
  • AF-1. 直接 confirmed 作成:管理画面手動・電話受付など、サロン側が即確定する経路(RES-002 で status: 'confirmed' 直入り)
  • AF-2. 仮予約承認:オペレーターが LIFF/HPB由来の tentativeconfirmed に承認
  • AF-3. 仮予約のお断りtentativedeclined未確定段階の店舗側拒否は declined に一本化、§7.4)
  • AF-4. 顧客キャンセルtentative | confirmedcancelled_by_customer
  • AF-5. サロンキャンセル(確定後)confirmed | in_servicecancelled_by_storetentative からは不可、§7.4)
  • AF-6. 無断キャンセルマークconfirmedno_show(来店時刻 + 猶予経過後の手動操作、§7.3 (†1))
  • AF-7. 自動期限切れtentativetentative_expires_at 経過 → expired(自動ジョブ、§7.6)
  • AF-8. 巻き戻し:誤操作で進めた中間状態を 1 段階戻す(§7.5)
  • EF-1. 不正遷移:遷移マトリクス(§7.3)に存在しない遷移要求 → 422 RESERVATION_STATE.INVALID_TRANSITION(例:tentative → cancelled_by_storetentative → in_service、終端からのあらゆる遷移)
  • EF-2. 権限不足:遷移に必要な permission を持たない → 403
  • EF-3. 楽観ロック衝突reservation.version 不一致 → 409 RESERVATION.CONFLICT
  • EF-4. no_show 猶予未経過reservation.starts_at + store_settings.no_show_grace_minutes >= now() の confirmed に対する no_show 要求 → 422 RESERVATION_STATE.GRACE_PERIOD_NOT_PASSED
  • EF-5. reopen フラグ未指定:マトリクス上で の遷移を reopen=true なしで要求 → 422 RESERVATION_STATE.REOPEN_FLAG_REQUIRED

親SRS §7.12 に従い最低限の粒度で記述する。デザインシステム導入SRS(仮 SRS-UI-001)リリース時に一括改訂される前提。

  • カレンダー画面(SRS-RES-001)と予約詳細パネルから状態遷移を発火する
  • 状態ごとに UI 上のラベル・バッジ・操作可能なアクションが決まる
  • 状態バッジ:予約セル/カードに 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() を満たすまで非表示(誤操作防止)
  • 遷移不可な操作はそもそも UI に出さない(grayout で出す案もあるが Phase 1 は非表示)
  • キャンセル系遷移はモーダル確認(誤操作防止)

MethodPath用途必要 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 の「ルート定義に宣言的に貼る」原則を満たす)
// 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';
}>;
}
}
  • RESERVATION_STATE.INVALID_TRANSITION — 遷移マトリクスに存在しない(422)
  • RESERVATION_STATE.GRACE_PERIOD_NOT_PASSEDno_show 遷移は猶予経過後のみ(422)
  • RESERVATION_STATE.REOPEN_FLAG_REQUIRED — 巻き戻し遷移なのに reopen=true が立っていない(422、誤操作の早期検知)
  • RESERVATION.CONFLICT — 楽観ロック衝突(409)

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_attimestamptzNULL仮予約の自動失効時刻。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_eventoperator_action_log(親SRS §7.8)は併存するが、書込条件が異なる:

triggerreservation_eventoperator_action_log
operator1 行 INSERT1 行 INSERT(actor=operator_id
system1 行 INSERT1 行 INSERT(actor='system'、親SRS §7.8 に準拠)
customer1 行 INSERT書込まない(親SRS §7.8 の actoroperator_id'system' のみ。customer 起点はドメインイベントのみで記録)

operator_action_log を顧客 actor に拡張するのは親SRS改訂が必要なため、本SRSでは行わない(C-4 対応)。reservation_event に customer_id を持たせることで、顧客起点の遷移は完全に追跡可能。

  • reservation_event:UI/API のレスポンスに使う(タイムライン表示、リマインダー判定、レポート)
  • operator_action_log:セキュリティ監査・運用調査(オペレーター/システム起点に限定)
デフォルト意味
tentative_expire_hourssmallint NOT NULL24仮予約自動キャンセルまでの時間(時間単位)
no_show_grace_minutessmallint NOT NULL30no_show マーク可能になる予約開始時刻からの猶予(分)

真実の在処store_settings テーブル定義は SRS-TEN-001 が canonical。本SRSの追加分は SRS-TEN-001 v0.3 で store_settings テーブル定義に統合する(同一ブランチで両SRSを改訂、M-4 対応)。

  • BC-RES の追加マイグレーションで reservation_event を作成、reservationstatus CHECK 制約と 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 キーを追加する。本マイグレーションは以下を実施する責務を持つ:
    1. permission テーブルへ 11 キーを INSERT ... ON CONFLICT(key) DO UPDATE SET description = EXCLUDED.description
    2. 既存全店舗のプリセット role に対し、§9.1 のプリセット初期値表に従って role_permission を backfill(is_preset = true の role 限定、ON CONFLICT DO NOTHING
    3. 検証:マイグレーション後、各 preset role が想定通りの permission 集合を持つことをテストで確認

code内部コード日本語ラベル観察ID※意味種別
1tentative仮予約確定待ち#1LIFF/HPB等で受信した未承認予約進行
2confirmed受付待ち#2確定済み、来店日待ち進行
3in_service施術中#3チェックイン済み、施術進行中進行
4service_completed来店処理待ち#4施術完了・会計前進行
5paid会計済み#11クロージング完了終端
6declinedお断り#6サロン側が未確定段階で拒否終端
7cancelled_by_customerお客様キャンセル#7顧客都合(仮予約・確定後どちらも)終端
8cancelled_by_storeサロンキャンセル#8店舗都合(確定後のみ)終端
9no_show無断キャンセル#9ノーショウ(手動マーク)終端
10expired自動キャンセル#10仮予約の期限切れ(自動ジョブ)終端

※ DOC-ANALYSIS-001 §5.1 の予約ステータス番号。観察 #5 は予約ドメインに採用しない(§1)。レジ/会計画面の処理進行状態は SRS-REG-001 の別エンティティ(仮:register_session 等)で扱う。

  1. 終端状態(paid / declined / cancelled_* / no_show / expired)からは遷移不可。終端救済の運用は §7.5 末尾参照
  2. tentative への遷移は予約作成時のみ(API 経由では起こらない)
  3. expired への遷移は自動ジョブのみ(API 経由では起こらない)
  4. tentative から cancelled_by_store への直接遷移は禁止(未確定段階の店舗都合拒否は declined に集約、§7.4、Q4 対応)
  5. no_show への遷移は starts_at + store_settings.no_show_grace_minutes < now() を満たす場合のみ(C-3 対応)
  6. SRS-RES-005 の二重予約判定における判定対象ステータス:{tentative, confirmed, in_service, service_completed}(進行系のみ、paid を含む終端は除外、§7.8、M-5 対応)

行 = from、列 = to。

from \ totentconfin_svcsvc_donepaiddeclcanc_cucanc_stno_showexp
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_showreservation.starts_at + store_settings.no_show_grace_minutes < now() の場合のみ。それ以外は GRACE_PERIOD_NOT_PASSED
  • (†2) in_service → cancelled_by_store は施術中の急病等の例外運用。理由必須
遷移主トリガtrigger / actor_type
(新規) → tentative予約作成(LIFF / HPB 受信 / 電話の取り置き)customer or operator
(新規) → confirmed予約作成(管理画面手動 / LIFF 即時確定店舗)operator or customer
tentative → confirmed承認操作operator
tentative → declined拒否操作(未確定段階の店舗都合拒否はすべてここに集約operator
tentative → cancelled_by_customerLIFF からの取消 / 電話受付代行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)が異なるため意味を分ける
  • 中間状態間での1段階の巻き戻しのみ許可(マトリクス上の
    • in_service → confirmed
    • service_completed → in_service
  • 2段階以上の巻き戻しは不可。誤って大きく進めた場合は連続して巻き戻す
  • API 上で reopen=true を必須とし、立っていない場合 REOPEN_FLAG_REQUIRED(誤操作の早期検知)
  • admin:reservation:reopen permission 必須
  • UI で notes(理由)必須

終端状態からの reopen は Phase 1 でサポートしない(Codex 指摘の追加論点2 対応)。誤って終端化された予約の救済は以下の運用フローで対応:

  1. operator_action_log および reservation_event を参照し、誤遷移の経緯を確認(誰が・いつ・どの理由で)
  2. 当該顧客に対し新規 confirmed 予約を再作成する(同じスタッフ・時間枠・メニュー、料金スナップショットも揃える)
  3. 新規予約の notes に「誤って〇〇により取消された予約 <元 reservation_id> の再作成」と明記
  4. 元の終端予約は触らない(履歴として残す)

この方針は、終端 reopen を許した場合の設備リリース(§7.9)の巻き戻し・ベットされた整合性問題を避けるため。Phase 1 運用後の声で OQ-RES-004-01 として再検討する。

7.6 自動遷移ジョブ(graphile-worker)

Section titled “7.6 自動遷移ジョブ(graphile-worker)”
ジョブ名起動対象動作
expire_tentative_reservations5分間隔 cronstatus='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_eventoperator_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)

  • すべての遷移で 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 に委任する旨を明記する。

1 回の遷移リクエストは以下を単一トランザクションで実行:

  1. reservationWHERE id=? AND version=? で UPDATE。更新する列:
    • status = 新ステータス
    • tentative_expires_attentative から他状態(confirmed / declined / cancelled_by_customer / expired)への遷移時は NULL に更新(§6.1 不変条件 CHECK 制約に従うため必須、M-C)
    • updated_atnow()
    • versionversion + 1
  2. reservation_event INSERT
  3. operator_action_log INSERT(trigger='customer' の場合は skip)
  4. キャンセル系なら reservation_equipment.is_active=false の UPDATE(§7.9)

楽観ロック競合(行 1 の影響行 0)なら全体ロールバック。


  • 状態遷移 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)

key用途プリセット初期値
admin:reservation:read利用可能遷移一覧 / 予約詳細参照全員
admin:reservation:read_timeline/events イベントタイムライン参照(reason_code / notes / customer 起点履歴を含む)owner / manager / receptionist
admin:reservation:approve_tentativetentative→confirmedowner / manager / receptionist
admin:reservation:declinetentative→declinedowner / manager
admin:reservation:cancel_by_storeconfirmed/in_service→cancelled_by_store(tentative からは禁止)owner / 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電話等の代行で 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/authrequireReservationTransitionPermission(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 の「ルート定義に宣言的に貼る」原則)

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=NULLcustomer_id=NULLoperator_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 で要求 → 422 REOPEN_FLAG_REQUIRED
  • GWT-9 no_show 猶予未経過starts_at + no_show_grace_minutes >= now() の confirmed に対し no_show 要求 → 422 GRACE_PERIOD_NOT_PASSED
  • GWT-10 no_show 猶予経過後starts_at + no_show_grace_minutes < now() の confirmed に対し no_show 要求 → 成功
  • GWT-11 楽観ロック:古い version で遷移要求 → 409 RESERVATION.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_eventoperator_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_showenabled は猶予経過後のみ 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 排他CHECKtrigger='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 対応)

  • 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_at CHECK 違反検知、available(tentative) の permission フィルタ後の出力を含む)
  • Job testexpire_tentative_reservations を fake clock で発火、tentative_expires_at < now() の tentative のみ expired 化することを確認
  • Contract/transitions および /transitions/available の zod schema を FE/BE 間で照合

ジョブ名起動種別
expire_tentative_reservations5分 cron自動遷移

将来検討(Phase 1 では不採用):

  • auto_no_show_overdue_confirmed:誤動作リスクが高く却下(OQ-RES-004-02)
  • notify_status_change:SRS-MSG-002(リマインダー基盤)で吸収

#内容締切の目安
OQ-RES-004-01終端状態からの reopen を Phase 1 で許すか。本SRSでは不採用とし §7.5 の代替運用フロー(新規予約再作成)で対応。設備整合・通知整合の複雑さを避けるため。運用開始後の声で再検討Phase 1 中盤の運用判断
OQ-RES-004-02confirmed の予約が予約終了時刻を過ぎた場合の自動 no_show マーク。誤動作リスクと省力化のトレードオフ。Phase 1 では手動運用のみPhase 2 開始時
OQ-RES-004-03LIFF からの顧客操作で起こせる遷移(tentative→cancelled_by_customer 等)の権限境界。本SRSは admin スコープを定義したが、portal 経路は SRS-LIFF-002 で詳細化SRS-LIFF-002 起票時
OQ-RES-004-04paid 予約の修正(金額修正・返金)の表現。新たな状態 refunded を追加するか、paid のままレジ側で処理するかSRS-REG-001 / SRS-PAY-002 起票時
OQ-RES-004-06reservation_event のサイズ管理。1 予約あたり最大 10 イベント程度想定だが、巻き戻し多発店舗で肥大化する場合のアーカイブ戦略Phase 3 運用時
OQ-RES-004-08reservation_event の安定識別子。Phase 1 は (reservation_id, occurred_at) で代用するが、UI の再取得・並べ替えで曖昧化する場合は public_id を UUIDv7 で追加(minor 対応)Phase 2 UI 拡充時

解消済み(v0.2 で決着):

  • OQ-RES-004-05 service_completed → paid の一括遷移を残すか → 観察 #5 を予約ドメインから外し、payment_in_progress を廃止。service_completed → paid唯一の正規経路に確定。レジの処理進行状態は SRS-REG-001 のレジセッションエンティティで別途管理(Q2 / 追加論点1 対応)
  • OQ-RES-004-07 観察 #5 の本家での実体 → 解消。本プロダクトでは予約状態として採用せず、SRS-REG-001 で別ドメインとして扱う(同上)

VersionDateAuthorChange
0.12026-04-25yudai初版起票(Draft)。観察記録 DOC-ANALYSIS-001 §5.1 の 11 ラベルを内部コードへ正規化、遷移マトリクス・自動ジョブ・監査連動を定義
0.22026-04-25yudaiCodex (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)、/eventsread_timeline 別 permission 化(minor)。SRS-TEN-001 v0.3 / SRS-RES-005 v0.3 と同一ブランチで同期改訂(C-1 / M-4)。終端 reopen の代替運用フローを §7.5 に明記