二重予約判定
二重予約判定
Section titled “二重予約判定”Document ID: SRS-RES-005 Parent: SRS-ROOT-001 v0.5 Version: 0.4 Status: Implemented (API + Web UI) Last Updated: 2026-05-06 Depends on: SRS-MST-001 v0.6, SRS-MST-002 v0.2, SRS-MST-005 v0.2, SRS-CUS-001 v0.2, SRS-RES-002 v0.5, SRS-RES-004 v0.3 依存される: SRS-RES-002, SRS-RES-003, SRS-RES-006, SRS-RES-007, SRS-PAY-002
本書は SRS-ROOT-001 v0.5 に従う。
予約作成・編集・チャネル統合で共通に使える二重予約判定ロジックの契約を固定する。
v0.4 の主要変更点:
- 設備 EXCLUDE 制約は
0009_reservation_equipment_exclude.sqlで実装済(v0.3 の「未実装」表現を撤回) force_double_booking→force_staff_concurrencyリネーム(equipment overlap には効かない旨明記)- 部分インデックス
(store_id, staff_id, starts_at) WHERE staff_id IS NOT NULLを §7.10 に明記 - conflict-check API は専用 rate limit bucket 600 req/min/store
- staff 行 FOR UPDATE の deadlock 回避規律(旧 staff_id / 新 staff_id を UUID 昇順 で lock)
2. ユーザーストーリー
Section titled “2. ユーザーストーリー”v0.3 と同じ。
3. ユースケース
Section titled “3. ユースケース”3.1 呼び出しコンテキスト
Section titled “3.1 呼び出しコンテキスト”| ID | 呼び出し元 | タイミング | 入力 | 出力 |
|---|---|---|---|---|
| UC-1 | RES-002 予約作成 | 保存時 | 新規予約候補(staff/equipment/customer/時間) | errors + warnings |
| UC-2 | RES-003 予約編集・移動 | 保存時 | 候補 + excludeReservationId | errors + warnings |
| UC-3 | UI プレビュー | ダイアログ内の時間変更 | 予約候補 | warnings のみ |
| UC-4 | RES-006 LIFF 予約 (Phase 2) | 空き枠表示 / 確定時 | 時間候補 | bool + 理由 |
| UC-5 | RES-007 チャネル統合 (Phase 2) | 取り込み時 | 取り込み候補 | 取り込み可否 + 衝突リスト |
3.2 主シナリオ
Section titled “3.2 主シナリオ”v0.3 と同じ。設備 EXCLUDE は最終関門。
3.3 代替フロー
Section titled “3.3 代替フロー”- AF-1 自己除外: 編集時
excludeReservationId - AF-2 指名なし: スタッフ判定対象外
- AF-3 終端・論理削除: SRS-RES-004 §7.8 を canonical(
status IN ('tentative','confirmed','in_service','service_completed') AND deleted_at IS NULL) - AF-4 プレビュー: error も warning として返す
3.4 例外フロー
Section titled “3.4 例外フロー”- EF-1 設備 EXCLUDE 違反(PG
23P01): API 層でRESERVATION.DOUBLE_BOOKED_EQUIPMENT409 - EF-2 不整合な入力: domain で
InvalidInput422 - EF-3 設備自動割当不足(group 内空きなし):
RESERVATION.NO_EQUIPMENT_AVAILABLE422
4. UI仕様
Section titled “4. UI仕様”v0.3 と同じ。
| コード | 種別 | UI ガイド |
|---|---|---|
RESERVATION.STAFF_CONCURRENT_LIMIT | error | ダイアログ内インラインエラー(force_staff_concurrency 持ちは押し通し可) |
RESERVATION.DOUBLE_BOOKED_EQUIPMENT | error | 同上、設備名で示す(force でも bypass 不可) |
RESERVATION.NO_EQUIPMENT_AVAILABLE | error | 自動割当不足、group 内空きなし |
RESERVATION.CUSTOMER_DOUBLE_BOOKED | warning | 確認ダイアログ(ack_customer_double_booked) |
RESERVATION.STAFF_OFF_SHIFT | warning | インライン注意表示 |
RESERVATION.OUT_OF_BUSINESS_HOURS | warning / error | permission 依存 |
5. API仕様
Section titled “5. API仕様”5.1 domain 関数
Section titled “5.1 domain 関数”export type ConflictInput = { storeId: Uuid; candidate: { startsAt: Instant; endsAt: Instant; bufferMinutesAfter: number; staffId: Uuid | null; customerId: Uuid; equipmentRequirements: EquipmentRequirement[]; // group + quantity }; excludeReservationId?: Uuid; maxConcurrentPerStaff: number; existingStaffReservations: ExistingReservation[]; existingCustomerReservations: ExistingReservation[]; // 設備は EXCLUDE 制約 + auto-assign 結果で判定};
export type EquipmentRequirement = { equipmentGroupId: Uuid; quantity: number;};
export function checkReservationConflicts(input: ConflictInput): ConflictOutput;domain 関数は IO を持たない。equipment auto-assign は呼出側(RES-002 / RES-003)が事前に実施し、結果を reservation_equipment_assignments INSERT 時の EXCLUDE で確定する。
5.2 プレビュー API
Section titled “5.2 プレビュー API”| Method | Path | 用途 | 必要 permission |
|---|---|---|---|
| POST | /api/admin/reservations/conflict-check | 保存前の空き確認 | admin:reservation:create |
| POST | /api/portal/reservations/conflict-check | LIFF 空き枠判定 (Phase 2) | portal 認証 |
専用 rate limit bucket: 600 req/min/store(一般 API 100/min とは別 bucket)。FE は 300ms デバウンス必須。
Response:
{ data: { bookable: boolean; errors: ConflictError[]; warnings: ConflictWarning[]; }}5.3 エラー / 警告コード
Section titled “5.3 エラー / 警告コード”v0.3 + 以下追加:
| code | 種別 | 意味 |
|---|---|---|
RESERVATION.NO_EQUIPMENT_AVAILABLE | error | equipment_group 内に空きなし |
RESERVATION.STAFF_NOT_BOOKABLE | error | MST-001 §6.3.1 連動(is_bookable=false) |
6. データモデル影響
Section titled “6. データモデル影響”6.1 スタッフ判定: アプリ層のみ
Section titled “6.1 スタッフ判定: アプリ層のみ”受付可能数(同一スタッフの並列上限 N)は EXCLUDE 制約で表現できないため、アプリ層 count + SELECT FOR UPDATE で排他制御する。
6.2 設備判定: EXCLUDE 制約(実装済)
Section titled “6.2 設備判定: EXCLUDE 制約(実装済)”0009_reservation_equipment_exclude.sql で導入済:
ALTER TABLE reservation_equipment_assignments ADD CONSTRAINT reservation_equipment_assignments_no_overlap EXCLUDE USING GIST ( store_id WITH =, equipment_id WITH =, tstzrange(starts_at, ends_at, '[)') WITH && ) WHERE (is_active = true);- 制約名:
reservation_equipment_assignments_no_overlap - キャンセル系遷移時に
is_active=falseで対象外化(RES-004 §7.9)
6.3 顧客重複: クエリベース
Section titled “6.3 顧客重複: クエリベース”DB 制約は置かない。domain 関数内で existingCustomerReservations を以下相当で抽出:
SELECT id, starts_at, ends_at, buffer_minutes_afterFROM reservationsWHERE store_id = :storeId AND customer_id = :customerId AND status IN ('tentative','confirmed','in_service','service_completed') AND deleted_at IS NULL AND (id <> :excludeReservationId OR :excludeReservationId IS NULL) AND tstzrange(starts_at, ends_at, '[)') && tstzrange(:candStart, :candEnd, '[)')6.4 受付可能数: SRS-TEN-001 v0.4 が canonical
Section titled “6.4 受付可能数: SRS-TEN-001 v0.4 が canonical”store_settings.max_concurrent_reservations_per_staff smallint NOT NULL DEFAULT 1 は TEN-001 v0.4 が所有。本SRSは値の参照のみ。
6.5 マイグレーション計画
Section titled “6.5 マイグレーション計画”0009_reservation_equipment_exclude.sql: 既に実装済(変更なし)0019_reservation_phase1_finalize.sql:force_double_booking→force_staff_concurrencypermission rename + backfill、partial index 確認
partial index は 0006_reservation.sql で既に reservation_store_staff_starts_idx (store_id, staff_id, starts_at) WHERE staff_id IS NOT NULL として実装済(実装側で確認)。
7. 業務ルール
Section titled “7. 業務ルール”7.1 判定対象の 3 軸
Section titled “7.1 判定対象の 3 軸”| 軸 | 対象 | ブロック / 警告 | 実装 |
|---|---|---|---|
| スタッフ | reservations.staff_id 一致 | error(受付可能数超過、force_staff_concurrency で押し通し可) | アプリ層 + SELECT FOR UPDATE |
| 設備 | reservation_equipment_assignments.equipment_id 一致 | error(force でも bypass 不可) | DB EXCLUDE |
| 顧客 | reservations.customer_id 一致 | warning | アプリ層クエリ |
7.2 判定対象の時間範囲
Section titled “7.2 判定対象の時間範囲”v0.3 と同じ。
7.3 スタッフ判定(受付可能数ベース)
Section titled “7.3 スタッフ判定(受付可能数ベース)”v0.3 と同じ。
7.4 スタッフ未指名の扱い
Section titled “7.4 スタッフ未指名の扱い”v0.3 と同じ(判定対象外、後日割当時に再判定)。
7.5 顧客重複の警告仕様
Section titled “7.5 顧客重複の警告仕様”v0.3 と同じ。
7.6 終端・論理削除の除外
Section titled “7.6 終端・論理削除の除外”SRS-RES-004 §7.8 が canonical。
7.7 race condition 対策
Section titled “7.7 race condition 対策”スタッフ軸(アプリ層 count + 行ロック):
-- 予約作成 / 編集トランザクション内、判定前に実行SELECT id FROM staff WHERE store_id = :storeId AND id = :staffId FOR UPDATE;deadlock 回避規律:
- 編集で staff_id 変更を伴う場合(旧 staff_id ≠ 新 staff_id)、両方の行をロックする必要がある
- ロック取得順は UUID 昇順 に統一(
(MIN(old, new), MAX(old, new))の順) - これにより並行リクエストが同じ順序で lock を取得し、deadlock が起きない
設備軸: EXCLUDE 制約で race-safe。
顧客軸: warning のみのため race 許容。
7.8 自己除外(編集時)
Section titled “7.8 自己除外(編集時)”v0.3 と同じ。
7.9 シフト外・営業時間外の判定位置
Section titled “7.9 シフト外・営業時間外の判定位置”本SRSの対象外。checkBusinessHours(MST-003)と checkShiftCoverage(MST-004)が兄弟関数。
7.10 判定パフォーマンス
Section titled “7.10 判定パフォーマンス”- 単一予約作成あたり読込む
existingは当該日 ± 1 日に絞る - インデックス
reservations(store_id, staff_id, starts_at) WHERE staff_id IS NOT NULLpartial(指名なし予約は判定対象外なので partial で十分)reservations(store_id, customer_id, starts_at)btree
- 目標: 判定処理自体は p95 30ms 以下
8. 非機能要件
Section titled “8. 非機能要件”- conflict-check API p95 300ms
- 専用 rate limit bucket 600 req/min/store を超えたら 429
- 可観測性: error / warning の件数とコード分布を構造化ログに出す。顧客重複 warning の ack 率が高すぎる場合、UI 設計の見直しシグナルとする
9. セキュリティ・認可
Section titled “9. セキュリティ・認可”9.1 使用する permission キー
Section titled “9.1 使用する permission キー”| key | 用途 | プリセット初期値 |
|---|---|---|
admin:reservation:create | プレビュー API(admin)利用 | RES-002 と共通 |
admin:reservation:force_staff_concurrency | スタッフ並列上限警告押し通し | owner / manager(rename 元: force_double_booking) |
LIFF(portal)側は portal 認証のみで通す。
重要: force_staff_concurrency は staff concurrent のみに効く。equipment EXCLUDE は最終関門として常に機能する。
9.2 RLS
Section titled “9.2 RLS”本SRSで新規テーブルは作成しない。reservations / reservation_equipment_assignments の RLS は RES-002 系 migration で ENABLE + FORCE 済。
10. 受け入れ基準(Given-When-Then)
Section titled “10. 受け入れ基準(Given-When-Then)”- GWT-1 並列上限=1 で重複ブロック →
RESERVATION.STAFF_CONCURRENT_LIMIT - GWT-2 並列上限=2 で 2 本目通る → 成功
- GWT-3 並列上限=2 で 3 本目ブロック → エラー
- GWT-4 バッファ込みで判定 → ブロック
- GWT-5 バッファ後は通る → 成功
- GWT-6 指名なしはスキップ → 成功
- GWT-6b force_staff_concurrency=true で staff 上限超過 → 成功
- GWT-7 設備重複ブロック →
RESERVATION.DOUBLE_BOOKED_EQUIPMENT(force でも同じ) - GWT-8 設備の接続境界 → 半開区間で成功
- GWT-8b force_staff_concurrency=true でも equipment overlap → ブロック
- GWT-8c equipment_group 空き不足 →
RESERVATION.NO_EQUIPMENT_AVAILABLE
キャンセル・除外
Section titled “キャンセル・除外”- GWT-9 終端は無視(status=cancelled_*/no_show/expired/declined/paid 全て)
- GWT-10 自己除外(編集)→ 自分自身は重複扱いにならず成功
- GWT-11 顧客重複は警告 →
ack_customer_double_booked=trueで成功
プレビュー・race condition
Section titled “プレビュー・race condition”- GWT-12 プレビュー API(bookable=true)→
{bookable: true, errors: [], warnings: []} - GWT-13 プレビュー API(bookable=false)→
{bookable: false, errors: [DOUBLE_BOOKED_EQUIPMENT]} - GWT-14 race condition(スタッフ)→ SELECT FOR UPDATE で待たされ片方エラー
- GWT-15 race condition(設備)→ EXCLUDE 制約で片方失敗
- GWT-15b deadlock 回避: 旧/新 staff の同時編集 2 件 → UUID 昇順 lock で deadlock せず両方順次成功
テナント分離
Section titled “テナント分離”- GWT-16 RLS テナント分離 → 別店舗 reservation は
existingに入らない
Rate limit
Section titled “Rate limit”- GWT-17 conflict-check 専用 bucket 600 req/min を超えたら 429
11. テスト計画
Section titled “11. テスト計画”- Unit (
packages/domain/reservation/conflict.ts): table-driven、3 軸、自己除外、終端除外、equipment_group requirement 評価 - Integration (
apps/api): GWT-1〜17、EXCLUDE race condition、SELECT FOR UPDATE deadlock 回避、RLS、partial index 効果 - Contract: プレビュー API zod schema
12. 関連ジョブ(graphile-worker)
Section titled “12. 関連ジョブ(graphile-worker)”- なし(判定はリクエスト同期処理)
13. Open Questions
Section titled “13. Open Questions”| # | 内容 | 状態 |
|---|---|---|
| OQ-RES-005-02 | Phase 2 で明細レベルのスタッフ割当を導入する場合の判定モデル変更 | Open(Phase 2) |
| OQ-RES-005-03 | スタッフ個別の受付可能数 override | Open(Phase 2) |
| OQ-RES-005-04 | is_active=false の reservation_equipment_assignments 蓄積時の GIST index 肥大化 | Open(Phase 3) |
解消済み:
v0.1 OQ:→ Closed v0.2reservation_menuにstarts_at/ends_at持たせるかv0.1 OQ: EXCLUDE でスタッフ判定するか→ Closed v0.2v0.1 OQ: 並行施術→ Phase 1 スコープ外OQ-RES-005-01→ SRS-RES-004 §7.9 が canonical で Closedis_active更新タイミング
14. 変更履歴
Section titled “14. 変更履歴”| Version | Date | Author | Change |
|---|---|---|---|
| 0.1 | 2026-04-22 | yudai | 初版起票 |
| 0.2 | 2026-04-23 | yudai | 本家互換へモデル変更、EXCLUDE 採用 |
| 0.3 | 2026-04-25 | yudai | RES-004 同期、status canonical 委譲 |
| 0.4 | 2026-05-05 | yudai (with Codex co-design) | Parent v0.5 同期。EXCLUDE 制約は 0009 で実装済(「未実装」表現撤回)。force_double_booking → force_staff_concurrency リネーム、equipment overlap には効かない旨を §7.1 / §9.1 / GWT-8b に明記。partial index WHERE staff_id IS NOT NULL を §7.10 に明記。conflict-check API の専用 rate limit bucket 600 req/min を §5.2 に追加。staff 行 FOR UPDATE の deadlock 回避規律(UUID 昇順)を §7.7 に追加。equipment_group + quantity 連動を ConflictInput 型に反映 |