コンテンツにスキップ

二重予約判定

Document ID: SRS-RES-005 Parent: SRS-ROOT-001 v0.3 Version: 0.3 Status: Draft Depends on: SRS-MST-001(スタッフ管理), SRS-MST-002(メニュー管理), SRS-MST-005(設備管理), SRS-CUS-001(顧客登録・検索), SRS-RES-002(予約作成), SRS-RES-004(予約状態遷移、reservation.status の canonical source) 依存される: SRS-RES-002(予約作成、EF-1 / EF-7 / GWT-4 / GWT-5 / GWT-11 / GWT-12), SRS-RES-003(予約編集・移動), SRS-RES-006(オンライン予約受付), SRS-RES-007(複数チャネル予約統合), SRS-PAY-002(キャンセル料)

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


予約作成・編集・チャネル統合で共通に使える二重予約判定ロジックの契約を固定する。

本家 SALON BOARD は「1予約 = 1スタッフ」「受付可能数で同一スタッフの並列予約を制御」するモデルを採用しており、Phase 1 はこれに準拠する。判定の対象軸(スタッフ/設備/顧客)・時間範囲の算出・受付可能数・race condition 対策・更新時の自己除外を一本化し、呼び出し側(RES-002/003/006/007)が判定ロジックを重複実装しないようにする。

判定そのものは domain 層の純関数 + DB 制約(設備のみ)に寄せ、各呼び出し側の API は warnings / errors を受け取って UI/レスポンスに変換するだけにする。


  • As a 受付, I want to 同じスタッフの並列予約が上限を超えそうなときにブロックしてほしい, so that 実際に対応できない予約を入れてトラブルにならない
  • As a 店長, I want to カラー放置中に同じスタイリストが別の客のカットを受けられるようにしてほしい, so that スタッフの空き時間を有効活用して売上を伸ばせる
  • As a 店長, I want to シャンプー台が空いているか自動で判定してほしい, so that 設備配分の手作業が減る
  • As a 受付, I want to 同じ顧客の時間が別予約と被るケースは警告だけにしてほしい, so that 親子同伴・掛け持ちの正当ケースが通る
  • As a オペレーター, I want to 予約を登録する前に「この時間で空いてるか」を軽く確認できるようにしてほしい, so that 無駄な保存エラーを避けられる

ID呼び出し元タイミング入力出力
UC-1SRS-RES-002 予約作成保存時新規予約候補(staff/equipment/customer/時間枠)errors + warnings
UC-2SRS-RES-003 予約編集・移動保存時予約候補 + exclude_reservation_iderrors + warnings
UC-3UI プレビューダイアログ内の時間変更のたび予約候補warnings のみ(保存前は errors も警告扱い)
UC-4SRS-RES-006 LIFF 予約空き枠表示/確定時時間候補空きかどうかのブール+理由コード
UC-5SRS-RES-007 チャネル統合取り込み時取り込み候補取り込み可否+衝突リスト
  1. 呼び出し側が判定入力(時間枠・スタッフ・設備明細・顧客ID・除外ID)を組み立てる
  2. domain 関数 checkReservationConflicts(input) を呼ぶ
  3. 判定結果 { errors: ConflictError[], warnings: ConflictWarning[] } が返る
  4. 呼び出し側は
    • errors があれば保存しない(RES-002 の EF-1 / EF-7 経路、409 返却)
    • warnings のみなら UI に警告表示、ユーザー確認(ack_* フラグ)後に保存続行
  5. DB INSERT 時、設備の EXCLUDE 制約が最終関門として働く(race condition で判定後に別トランザクションが先に挿入したケースを拾う)
  6. スタッフ並列上限は SELECT FOR UPDATE で排他制御(§7.7)
  • AF-1. 自己除外:編集時は exclude_reservation_id を渡す。自分自身の既存行は判定対象外
  • AF-2. 指名なし予約staff_id = NULL の予約はスタッフ判定の対象外(§7.3)
  • AF-3. 終端・論理削除予約:終端ステータスまたは deleted_at IS NOT NULL の予約は判定対象外。具体的な値域は SRS-RES-004 §7.8 を canonical source として参照(status IN ('tentative','confirmed','in_service','service_completed') AND deleted_at IS NULL が判定対象)
  • AF-4. プレビュー:UC-3 では error も warning として返す(保存前なので区別する意味が薄い)
  • EF-1. DB EXCLUDE 制約で衝突(設備):アプリ層判定を通り抜けたが制約で弾かれた場合、Postgres が 23P01 を返す → API 層で RESERVATION.DOUBLE_BOOKED_EQUIPMENT に変換
  • EF-2. 不整合な入力:domain 関数の入力バリデーションで InvalidInput を投げる(422)

注記:親SRS §7.12 に従い最低限の粒度。UI は呼び出し側 SRS(RES-002/003)で定義され、本SRS は呼び出し側に返す warning/error の意味論のみを定める。

4.1 返却される warning / error の UI 表現ガイド

Section titled “4.1 返却される warning / error の UI 表現ガイド”
コード種別UI ガイド
RESERVATION.STAFF_CONCURRENT_LIMITerrorダイアログ内インラインエラー。衝突した予約時刻・受付可能数を示す
RESERVATION.DOUBLE_BOOKED_EQUIPMENTerror同上、設備名で示す
RESERVATION.CUSTOMER_DOUBLE_BOOKEDwarning確認ダイアログ(ack_customer_double_booked チェック)
RESERVATION.STAFF_OFF_SHIFTwarningインライン注意表示(保存ブロックしない)
RESERVATION.OUT_OF_BUSINESS_HOURSwarning / errorpermission 依存(RES-002 EF-2)

5.1 domain 関数(契約ファースト)

Section titled “5.1 domain 関数(契約ファースト)”

本SRS の一次成果物は packages/domain/reservation の純関数。API エンドポイントは 5.2 のプレビュー用のみ。RES-002/003 の POST/PUT は内部で同関数を呼ぶ。

// packages/domain/reservation/conflict.ts(方向性)
export type ConflictInput = {
storeId: Uuid;
candidate: {
startsAt: Instant; // 予約の開始
endsAt: Instant; // 予約の終了(顧客表示値、バッファ含まず)
bufferMinutesAfter: number; // バッファ(メニュー最大値のスナップショット)
staffId: Uuid | null; // null = 指名なし → スタッフ判定スキップ
customerId: Uuid;
equipmentSegments: EquipmentSegment[]; // 設備占有区間
};
excludeReservationId?: Uuid; // 編集時の自己除外
maxConcurrentPerStaff: number; // store_settings から引き込む
existingStaffReservations: ExistingReservation[]; // repo から引き込んだスタッフ関連予約
existingCustomerReservations: ExistingReservation[]; // 顧客関連予約
// 設備は DB EXCLUDE 制約で判定するため existing は不要
};
export type EquipmentSegment = {
equipmentId: Uuid;
startsAt: Instant;
endsAt: Instant;
};
export type ExistingReservation = {
id: Uuid;
startsAt: Instant;
endsAt: Instant;
bufferMinutesAfter: number;
};
export type ConflictOutput = {
errors: ConflictError[];
warnings: ConflictWarning[];
};
// 純関数:IO なし、時刻計算とカウント判定のみ
export function checkReservationConflicts(input: ConflictInput): ConflictOutput;
  • Instant は Temporal.Instant(または Temporal polyfill)。タイムゾーン依存の計算はしない
  • existing* は呼出側が事前に repo 経由で引き込む。domain 層は IO を持たない(親SRS モノレポ方針、DDD軽量版)
  • 設備は EXCLUDE 制約で DB 側に寄せるため、domain 関数の判定対象外(INSERT 時に制約がキャッチ)

5.2 プレビュー API(UC-3 / UC-4 用)

Section titled “5.2 プレビュー API(UC-3 / UC-4 用)”
MethodPath用途必要 permission
POST/api/admin/reservations/conflict-check保存前の空き確認admin:reservation:create
POST/api/portal/reservations/conflict-checkLIFF の空き枠判定(UC-4)portal 認証

Request:RES-002 の POST と同形(ただし statusnotesoverride_business_hours などの作成専用項目は無視)。

Response

{
data: {
bookable: boolean; // errors が空か
errors: ConflictError[];
warnings: ConflictWarning[];
}
}
code種別意味
RESERVATION.STAFF_CONCURRENT_LIMITerrorスタッフの並列予約数が受付可能数を超過
RESERVATION.DOUBLE_BOOKED_EQUIPMENTerror設備の時間重複
RESERVATION.CUSTOMER_DOUBLE_BOOKEDwarning同一顧客の時間重複(§7.5)
RESERVATION.STAFF_OFF_SHIFTwarningシフト外(保存ブロックしない、RES-002 EF-3)
RESERVATION.OUT_OF_BUSINESS_HOURSwarning / error営業時間外・定休日(RES-002 EF-2、permission 依存)

各エラー/警告は detail に衝突相手の reservation_id / staff_id / equipment_id / 時刻区間 / 現在並列数を含める。UI で「どの予約と被ったか」「あと何枠か」を表示するために必要。


6.1 スタッフ判定:DB 制約なし(アプリ層のみ)

Section titled “6.1 スタッフ判定:DB 制約なし(アプリ層のみ)”

受付可能数(同一スタッフの並列上限 N)を EXCLUDE 制約では表現できない(EXCLUDE は「重複を許さない」ものであり「N 件まで許す」を表現できない)。スタッフ判定はアプリ層のカウントベース判定 + SELECT FOR UPDATE で排他制御(§7.7)とする。

reservation_equipment に EXCLUDE 制約を張る。設備は「1台を同時刻に2予約が使うことはない」ため EXCLUDE が効く。

ALTER TABLE reservation_equipment ADD CONSTRAINT no_equipment_overlap
EXCLUDE USING GIST (
store_id WITH =,
equipment_id WITH =,
tstzrange(starts_at, ends_at, '[)') WITH &&
) WHERE (is_active = true);

キャンセル済み予約は is_active = false にして EXCLUDE 対象から外す。

6.3 顧客重複チェック(制約ではなくクエリ)

Section titled “6.3 顧客重複チェック(制約ではなくクエリ)”

同一顧客の時間重複は基本許可(RES-002 §7.8)のため DB 制約は置かない。domain 関数内で、以下クエリ相当の existingCustomerReservations から判定:

SELECT id, starts_at, ends_at, buffer_minutes_after
FROM reservation
WHERE store_id = :storeId
AND customer_id = :customerId
AND status IN ('tentative','confirmed','in_service','service_completed') -- 進行系のみ、SRS-RES-004 §7.8 canonical
AND deleted_at IS NULL
AND (id <> :excludeReservationId OR :excludeReservationId IS NULL)
AND tstzrange(starts_at, ends_at, '[)') && tstzrange(:candStart, :candEnd, '[)')

store_settings に以下を追加:

デフォルト意味
max_concurrent_reservations_per_staffsmallint NOT NULL1スタッフ1名の同時刻最大予約数

Phase 1 は店舗共通の1値。スタッフ個別の上書き(staff.max_concurrent_override)は Phase 2 で検討。

  • BC-RES 初回マイグレーション(RES-002 と同一)で以下を含める:
    1. reservation_equipment.is_active
    2. 設備 EXCLUDE 制約 no_equipment_overlap
  • store_settings.max_concurrent_reservations_per_staff の追加責務は SRS-TEN-001 v0.3 が canonical source(BC-TEN マイグレーションで作成済み)。本SRSではマイグレーション責務を持たない(v0.3 で M-B 対応)
  • Drizzle のマイグレーションファイルは packages/db/migrations 配下に生成(親SRS §8 技術スタック)

対象ブロック/警告実装
スタッフreservation.staff_id が一致する予約を時間重複でカウントerror(受付可能数超過時)アプリ層 + SELECT FOR UPDATE
設備reservation_equipment.equipment_id が一致error(ブロック)DB EXCLUDE
顧客reservation.customer_id が一致warning(許可)アプリ層クエリ
  • スタッフ軸reservation.starts_at 〜 reservation.ends_at + reservation.buffer_minutes_after
    • バッファはスタッフ拘束時間に含める(RES-002 §7.2 と整合:表示 ends_at にはバッファを含めないが、判定では加算
    • 候補側(新規予約)も同様にバッファ込みの区間で判定
  • 設備軸reservation_equipment.starts_at 〜 ends_at(設備占有は独立時間枠、バッファは関係しない)
  • 顧客軸reservation.starts_at 〜 reservation.ends_at(表示値、バッファ含めない)。同伴・掛け持ちはバッファ時間帯に被っても実害がないため

区間の境界は半開区間 [)(開始時刻含む、終了時刻含まない)。11:00-12:0012:00-13:00 は重複しない。

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

Section titled “7.3 スタッフ判定のロジック(受付可能数ベース)”
candidateRange = [candidate.startsAt, candidate.endsAt + candidate.bufferMinutesAfter)
overlapping = existingStaffReservations
.filter(r => r.id !== excludeReservationId)
.filter(r => rangeOverlaps(
candidateRange,
[r.startsAt, r.endsAt + r.bufferMinutesAfter)
))
if overlapping.length >= maxConcurrentPerStaff:
→ error STAFF_CONCURRENT_LIMIT

具体例(受付可能数 = 2、田中の予約状況)

既存予約 A: 13:00-14:30 (カット+カラー, buffer 10min) → 拘束 13:00-14:40
既存予約 B: 14:00-14:30 (カット, buffer 0min) → 拘束 14:00-14:30
(カラー放置中に別客カット)
新規候補 C: 14:00-14:30 (カット, buffer 0min) → 拘束 14:00-14:30
14:00-14:30 の区間で:
A の拘束区間 [13:00, 14:40) と重複 → カウント 1
B の拘束区間 [14:00, 14:30) と重複 → カウント 2
計 2 件 >= 受付可能数 2 → ❌ STAFF_CONCURRENT_LIMIT
新規候補 D: 14:35-15:00 (カット, buffer 0min) → 拘束 14:35-15:00
14:35-15:00 の区間で:
A の拘束区間 [13:00, 14:40) と重複 → カウント 1
B の拘束区間 [14:00, 14:30) と重複なし
計 1 件 < 受付可能数 2 → ✅ OK

reservation.staff_id = NULL(指名なし)の予約はスタッフ判定の対象外

  • 指名なし予約が何件重なっても、スタッフ軸ではブロックしない(割当前なので拘束対象のスタッフがいない)
  • 後日スタッフを割り当てた時点(RES-003 の編集フロー)で改めて判定が走る
  • 同一顧客の時間重複は warning として返す(エラーではない)
  • 呼出側は warningsCUSTOMER_DOUBLE_BOOKED が含まれていたらユーザー確認を経て ack_customer_double_booked=true を立てて再送(RES-002 §7.8 の ack フロー)
  • 監査ログには ack=true の痕跡を残す(RES-002 §7.7 の customer_double_booked フラグ)
  • 判定対象外の条件は SRS-RES-004 §7.8 を canonical source として参照する。具体的には終端ステータス(paid / declined / cancelled_by_customer / cancelled_by_store / no_show / expired)または deleted_at IS NOT NULL の予約はすべて判定対象外
  • スタッフ・顧客軸:existingStaffReservations / existingCustomerReservations のクエリで進行系のみ抽出(§6.3)
  • 設備軸:reservation_equipment.is_active = false で EXCLUDE 対象外。is_active の更新責務は SRS-RES-004 §7.9 で確定(キャンセル系遷移ハンドラ内で UPDATE)

スタッフ軸(アプリ層カウント)

並列の2リクエストが同時に「あと1枠空いてる」と判断して両方 INSERT するケースを防ぐ。

-- 予約作成トランザクション内、判定前に実行
SELECT id FROM staff WHERE store_id = :storeId AND id = :staffId FOR UPDATE;

staff 行をロックすることで、同一スタッフへの並行 INSERT がシリアライズされる。トランザクションが短い(予約作成 p95 300ms)ので、待ち時間は実用上問題ない。

設備軸(DB EXCLUDE 制約):制約自体が race-safe。追加の排他制御は不要。

顧客軸(警告のみ):race 許容。ack 後の二重送信で同じ警告が再度返ってもユーザー体験的に問題なし。

  • excludeReservationId が指定された場合
    • スタッフ・顧客軸:existing クエリで reservation.id <> :excludeReservationId で除外
    • 設備軸:既存の reservation_equipment 行を UPDATE(行 id 維持)するフローで、EXCLUDE 制約が自動的に自行を除外する
  • RES-003(編集)では設備の reservation_equipment行 id を維持する実装が必要。本節を参照

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

Section titled “7.9 シフト外・営業時間外の判定位置”
  • これらは本SRS の判定対象(呼出側が別途判定)。本SRS は「時間区間の重複・並列数」に限定
  • ただし呼出側の利便性のため、同じ domain 層に checkBusinessHours(storeId, range) / checkShiftCoverage(staffId, range) を兄弟として置く。本SRSでは契約のみ言及、詳細は SRS-MST-003 / SRS-MST-004 で定義
  • 単一予約作成あたりの判定で読み込む existing当該日 ± 1日に絞る(跨日予約は美容室ではレアだが cutover 日対応)
  • インデックス:
    • reservation(store_id, staff_id, starts_at) btree — スタッフ並列判定
    • reservation(store_id, customer_id, starts_at) btree — 顧客重複判定
  • 目標:判定処理自体は p95 30ms 以下(予約作成 API 全体の p95 300ms のうち)

親SRS §6 に従う。特記事項:

  • 性能:§7.10 参照
  • 可観測性:判定結果(error / warning の件数とコード分布)を構造化ログに出し、“許可された警告” の比率を観測可能にする。顧客重複 warning の ack 率が高すぎる場合、警告 UI が無視されているシグナルとして Phase 3 で検討

key用途プリセット初期値
admin:reservation:createプレビュー API(admin)利用RES-002 と共通
  • プレビュー API は作成権限と同一の permission で守る(作成できない人に空き情報を見せる意味が薄い)
  • LIFF(portal)側は portal 認証のみで通す。店舗公開情報の範囲内

本SRS で新規テーブルは作成しない。reservation / reservation_equipment の RLS は RES-002 マイグレーションで ENABLE + FORCE 済み。


10. 受け入れ基準(Given-When-Then)

Section titled “10. 受け入れ基準(Given-When-Then)”
  • GWT-1 並列上限=1 で重複ブロック:Given 受付可能数=1、スタッフ A が 10:00-11:00 の予約を持つ / When 同スタッフで 10:30-11:30 の予約を作成 / Then RESERVATION.STAFF_CONCURRENT_LIMIT が返る
  • GWT-2 並列上限=2 で2本目は通る:Given 受付可能数=2、スタッフ A が 13:00-14:30 (buffer 10min) の予約を持つ / When 同スタッフで 14:00-14:30 を作成 / Then 成功(カラー放置中に別客カット)
  • GWT-3 並列上限=2 で3本目はブロック:Given 受付可能数=2、スタッフ A が 13:00-14:30 と 14:00-14:30 の予約を持つ / When 同スタッフで 14:00-14:20 を作成 / Then RESERVATION.STAFF_CONCURRENT_LIMIT
  • GWT-4 バッファ込みで判定:Given 受付可能数=1、スタッフ A の 10:00-11:00 予約 (buffer 10min) / When 同スタッフで 11:05-11:30 を作成 / Then ブロック(拘束区間は 11:10 まで)
  • GWT-5 バッファ後は通る:Given 受付可能数=1、スタッフ A の 10:00-11:00 予約 (buffer 10min) / When 同スタッフで 11:10-12:00 を作成 / Then 成功(半開区間 [)
  • GWT-6 指名なしはスキップ:Given スタッフ A の 10:00-11:00 予約あり / When staff_id = NULL(指名なし)で 10:30-11:30 を作成 / Then 成功
  • GWT-7 設備重複ブロック:Given シャンプー台 1 が 10:00-10:30 占有 / When 同設備を 10:15-10:45 で指定 / Then RESERVATION.DOUBLE_BOOKED_EQUIPMENT
  • GWT-8 設備の接続境界:Given シャンプー台 1 が 10:00-10:30 占有 / When 同設備を 10:30-11:00 で指定 / Then 成功(半開区間)
  • GWT-9 終端は無視:Given スタッフ A の 10:00-11:00 予約が status=cancelled_by_store / When 同スタッフで 10:30-11:30 を作成(受付可能数=1) / Then 成功(paid / expired / declined / cancelled_by_customer / no_show でも同様、SRS-RES-004 §7.8)
  • GWT-10 自己除外(編集):Given 予約 R(10:00-11:00)が存在 / When R を excludeReservationId=R.id で 10:30-11:30 に移動 / Then 自分自身は重複扱いにならず成功
  • GWT-11 顧客重複は警告:Given 顧客 C の予約 10:00-11:00 が存在 / When 同顧客で 10:30-11:30 の予約を ack_customer_double_booked=true 付きで作成 / Then 成功、レスポンスに warnings: [CUSTOMER_DOUBLE_BOOKED]
  • GWT-12 プレビュー API(bookable=true):Given 空き時間 / When POST /api/admin/reservations/conflict-check / Then { bookable: true, errors: [], warnings: [] }
  • GWT-13 プレビュー API(bookable=false):Given 設備衝突あり / When プレビュー API / Then { bookable: false, errors: [DOUBLE_BOOKED_EQUIPMENT] }
  • GWT-14 race condition(スタッフ):Given 受付可能数=1、2つのリクエストが同時に同スタッフ同時刻を INSERT / When 片方がコミット / Then もう片方は SELECT FOR UPDATE で待たされ、判定時点で「上限到達」となりエラー
  • GWT-15 race condition(設備):Given 2つのリクエストが同時に同設備同時刻を INSERT / When 片方がコミット / Then もう片方は EXCLUDE 制約で失敗し RESERVATION.DOUBLE_BOOKED_EQUIPMENT に変換される
  • GWT-16 RLS テナント分離:Given 店舗 X のセッション / When 店舗 Y の予約時間と被る時間で判定 / Then 店舗 Y の予約は existing に入らず「空き」と判定される

  • Unit(packages/domain/reservation/conflict.ts:純関数の table-driven test で以下を網羅
    • スタッフ軸:受付可能数 1/2/3 のケース × 重複あり/なし/境界値(バッファ0・バッファあり・半開境界)/ 指名なし / キャンセル
    • 顧客軸:重複あり(warning)/ 自己除外 / キャンセル
    • 入力バリデーション
  • Integration(apps/api + Testcontainers Postgres)
    • RES-002 からの呼び出し経路で GWT-1〜16 を網羅
    • EXCLUDE 制約の race condition テスト(2トランザクション同時 INSERT で片方が失敗することを確認)
    • SELECT FOR UPDATE の排他テスト
    • RLS 分離テスト(GWT-16)
  • Contract:プレビュー API の zod schema を FE/BE 間で照合

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

#内容締切の目安
OQ-RES-005-02Phase 2 で明細レベルのスタッフ割当を導入する場合の判定モデル変更。予約レベルの staff_id + 受付可能数ベースから、明細区間ベースへの切替が必要。domain 関数のインタフェースに影響Phase 2 企画時
OQ-RES-005-03スタッフ個別の受付可能数上書き(staff.max_concurrent_override)を Phase 2 で入れるか。新人=1・ベテラン=3 のような運用ニーズPhase 2 企画時
OQ-RES-005-04is_active=falsereservation_equipment 行が蓄積した場合の GIST インデックス肥大化。定期 VACUUM か物理削除ジョブかPhase 3 運用時

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

  • v0.1 OQ: reservation_menustarts_at/ends_at を持たせるか → 不要。本家互換モデル採用により、スタッフ判定は reservation レベルで完結。reservation_menu へのスキーマ追加なし
  • v0.1 OQ: EXCLUDE 制約でスタッフ判定するか → 不採用。受付可能数(N件まで許可)は EXCLUDE では表現できないため、アプリ層カウント + SELECT FOR UPDATE に決定
  • v0.1 OQ: 並行施術(同一予約内で別スタッフが同時作業) → Phase 1 スコープ外。本家互換で「1予約=1スタッフ、放置中は別予約として並列」で運用

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

  • OQ-RES-005-01 reservation_equipment.is_active の更新タイミングSRS-RES-004 §7.9 が canonical。状態遷移ハンドラ内で更新する責務を本SRSから RES-004 に委任

VersionDateAuthorChange
0.12026-04-22yudai初版起票(Draft)。明細レベルのスタッフ割当前提で EXCLUDE 制約ベースの判定を設計
0.22026-04-23yudai本家 SALON BOARD 互換へモデル変更。スタッフ判定を予約レベルの staff_id × 受付可能数(カウントベース)に簡素化。EXCLUDE 制約はスタッフ軸では不採用(N 件許可を表現不能)、設備軸のみ維持。reservation_menu へのスキーマ追加(starts_at/ends_at/is_active)を撤回。store_settings.max_concurrent_reservations_per_staff を追加。race condition 対策を SELECT FOR UPDATE に変更。§7.3 に受付可能数ベースの判定ロジック・具体例を記載
0.32026-04-25yudaiSRS-RES-004 v0.2 と同期改訂reservation.status の canonical source を SRS-RES-004 §7.8 に委譲。判定対象の値域を tentative/confirmed/in_service/service_completed の進行系4状態 + deleted_at IS NULL に確定(旧 cancelled / no_show の語彙を新コードに置換)。OQ-RES-005-01(is_active 更新タイミング)を SRS-RES-004 §7.9 の確定により解消。§3.3 AF-3、§6.3 クエリ、§7.6、GWT-9 を更新