コンテンツにスキップ

二重予約判定

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_bookingforce_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)

v0.3 と同じ。


ID呼び出し元タイミング入力出力
UC-1RES-002 予約作成保存時新規予約候補(staff/equipment/customer/時間)errors + warnings
UC-2RES-003 予約編集・移動保存時候補 + excludeReservationIderrors + warnings
UC-3UI プレビューダイアログ内の時間変更予約候補warnings のみ
UC-4RES-006 LIFF 予約 (Phase 2)空き枠表示 / 確定時時間候補bool + 理由
UC-5RES-007 チャネル統合 (Phase 2)取り込み時取り込み候補取り込み可否 + 衝突リスト

v0.3 と同じ。設備 EXCLUDE は最終関門。

  • 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 として返す
  • EF-1 設備 EXCLUDE 違反(PG 23P01): API 層で RESERVATION.DOUBLE_BOOKED_EQUIPMENT 409
  • EF-2 不整合な入力: domain で InvalidInput 422
  • EF-3 設備自動割当不足(group 内空きなし): RESERVATION.NO_EQUIPMENT_AVAILABLE 422

v0.3 と同じ。

コード種別UI ガイド
RESERVATION.STAFF_CONCURRENT_LIMITerrorダイアログ内インラインエラー(force_staff_concurrency 持ちは押し通し可)
RESERVATION.DOUBLE_BOOKED_EQUIPMENTerror同上、設備名で示す(force でも bypass 不可
RESERVATION.NO_EQUIPMENT_AVAILABLEerror自動割当不足、group 内空きなし
RESERVATION.CUSTOMER_DOUBLE_BOOKEDwarning確認ダイアログ(ack_customer_double_booked
RESERVATION.STAFF_OFF_SHIFTwarningインライン注意表示
RESERVATION.OUT_OF_BUSINESS_HOURSwarning / errorpermission 依存

packages/domain/reservation/conflict.ts
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 で確定する。

MethodPath用途必要 permission
POST/api/admin/reservations/conflict-check保存前の空き確認admin:reservation:create
POST/api/portal/reservations/conflict-checkLIFF 空き枠判定 (Phase 2)portal 認証

専用 rate limit bucket: 600 req/min/store(一般 API 100/min とは別 bucket)。FE は 300ms デバウンス必須。

Response:

{
data: {
bookable: boolean;
errors: ConflictError[];
warnings: ConflictWarning[];
}
}

v0.3 + 以下追加:

code種別意味
RESERVATION.NO_EQUIPMENT_AVAILABLEerrorequipment_group 内に空きなし
RESERVATION.STAFF_NOT_BOOKABLEerrorMST-001 §6.3.1 連動(is_bookable=false)

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)

DB 制約は置かない。domain 関数内で existingCustomerReservations を以下相当で抽出:

SELECT id, starts_at, ends_at, buffer_minutes_after
FROM reservations
WHERE 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は値の参照のみ。

  • 0009_reservation_equipment_exclude.sql: 既に実装済(変更なし)
  • 0019_reservation_phase1_finalize.sql: force_double_bookingforce_staff_concurrency permission 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 として実装済(実装側で確認)。


対象ブロック / 警告実装
スタッフreservations.staff_id 一致error(受付可能数超過、force_staff_concurrency で押し通し可)アプリ層 + SELECT FOR UPDATE
設備reservation_equipment_assignments.equipment_id 一致error(force でも bypass 不可DB EXCLUDE
顧客reservations.customer_id 一致warningアプリ層クエリ

v0.3 と同じ。

7.3 スタッフ判定(受付可能数ベース)

Section titled “7.3 スタッフ判定(受付可能数ベース)”

v0.3 と同じ。

v0.3 と同じ(判定対象外、後日割当時に再判定)。

v0.3 と同じ。

SRS-RES-004 §7.8 が canonical。

スタッフ軸(アプリ層 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 許容。

v0.3 と同じ。

7.9 シフト外・営業時間外の判定位置

Section titled “7.9 シフト外・営業時間外の判定位置”

本SRSの対象外。checkBusinessHours(MST-003)と checkShiftCoverage(MST-004)が兄弟関数。

  • 単一予約作成あたり読込む existing は当該日 ± 1 日に絞る
  • インデックス
    • reservations(store_id, staff_id, starts_at) WHERE staff_id IS NOT NULL partial(指名なし予約は判定対象外なので partial で十分
    • reservations(store_id, customer_id, starts_at) btree
  • 目標: 判定処理自体は p95 30ms 以下

  • conflict-check API p95 300ms
  • 専用 rate limit bucket 600 req/min/store を超えたら 429
  • 可観測性: error / warning の件数とコード分布を構造化ログに出す。顧客重複 warning の ack 率が高すぎる場合、UI 設計の見直しシグナルとする

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 は最終関門として常に機能する

本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_EQUIPMENTforce でも同じ
  • GWT-8 設備の接続境界 → 半開区間で成功
  • GWT-8b force_staff_concurrency=true でも equipment overlap → ブロック
  • GWT-8c equipment_group 空き不足 → RESERVATION.NO_EQUIPMENT_AVAILABLE
  • GWT-9 終端は無視(status=cancelled_*/no_show/expired/declined/paid 全て)
  • GWT-10 自己除外(編集)→ 自分自身は重複扱いにならず成功
  • GWT-11 顧客重複は警告 → ack_customer_double_booked=true で成功
  • 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 せず両方順次成功
  • GWT-16 RLS テナント分離 → 別店舗 reservation は existing に入らない
  • GWT-17 conflict-check 専用 bucket 600 req/min を超えたら 429

  • 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

  • なし(判定はリクエスト同期処理)

#内容状態
OQ-RES-005-02Phase 2 で明細レベルのスタッフ割当を導入する場合の判定モデル変更Open(Phase 2)
OQ-RES-005-03スタッフ個別の受付可能数 overrideOpen(Phase 2)
OQ-RES-005-04is_active=falsereservation_equipment_assignments 蓄積時の GIST index 肥大化Open(Phase 3)

解消済み:

  • v0.1 OQ: reservation_menustarts_at/ends_at 持たせるか → Closed v0.2
  • v0.1 OQ: EXCLUDE でスタッフ判定するか → Closed v0.2
  • v0.1 OQ: 並行施術 → Phase 1 スコープ外
  • OQ-RES-005-01 is_active 更新タイミング → SRS-RES-004 §7.9 が canonical で Closed

VersionDateAuthorChange
0.12026-04-22yudai初版起票
0.22026-04-23yudai本家互換へモデル変更、EXCLUDE 採用
0.32026-04-25yudaiRES-004 同期、status canonical 委譲
0.42026-05-05yudai (with Codex co-design)Parent v0.5 同期。EXCLUDE 制約は 0009 で実装済(「未実装」表現撤回)。force_double_bookingforce_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 型に反映