コンテンツにスキップ

メニュー管理

Document ID: SRS-MST-002 Parent: SRS-ROOT-001 v0.5 Version: 0.2 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-MST-001 v0.6, SRS-MST-005 依存される: SRS-RES-002 v0.5, SRS-RES-003, SRS-RES-005 v0.4

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


店舗スコープのメニューマスタを管理する。Phase 1 で扱うのは以下の 4 実体。

  1. menu_category — 1 段カテゴリ
  2. menu — 予約可能なメニュー本体
  3. menu_staff_eligibility — スタッフ対応ホワイトリスト
  4. menu_equipment_requirement — 設備要件(equipment_group_id 参照、quantity 列を持つ)

本SRSは予約作成時に必要な以下の canonical source である。

  • 料金スナップショット契約
  • 所要時間契約
  • 担当可能スタッフ判定契約
  • 設備要件契約(group + 数量)

  • 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 予約入力 UI の探索コストを下げられる

3.1 主シナリオ(メニュー作成)

Section titled “3.1 主シナリオ(メニュー作成)”
  1. オペレーターが「設定 > メニュー」を開く
  2. カテゴリを選択、または新規作成する
  3. name, duration_minutes, price_excl_tax, tax_rate_pct, buffer_minutes_after, staff_assignment_policy を入力する
  4. 保存時にサーバが tax_amount, price_incl_taxstore_settings.rounding_policy に従って算出する
  5. 新メニューをカテゴリ末尾へ追加する
  6. operator_action_logmenu.create を記録する
  • AF-1 カテゴリ作成: menu_category を追加し、そのカテゴリにメニューを紐付ける
  • AF-2 スタッフ対応更新: PUT /api/admin/menus/:menuId/staff-eligibility で staff 集合を bulk replace
  • AF-3 設備要件更新: PUT /api/admin/menus/:menuId/equipment-requirements{ equipment_group_id, quantity }[] を bulk replace
  • AF-4 メニュー並び替え: 同一カテゴリ内で reorder
  • AF-5 カテゴリ並び替え: menu_category.display_order を collection ETag 付きで更新
  • AF-6 退役: terminated_at を立てて新規予約候補から外す
  • AF-7 復活: terminated_at = NULL に戻し、カテゴリ末尾へ戻す
  • EF-1 If-Match 不一致: 409 MENU.VERSION_MISMATCH
  • EF-2 reorder の collection ETag 不一致: 409 MENU.COLLECTION_ETAG_MISMATCH
  • EF-3 duration_minutes < 1: 422 MENU.DURATION_INVALID
  • EF-4 buffer_minutes_after > 240: 422 MENU.BUFFER_INVALID
  • EF-5 price_excl_tax < 0: 422 MENU.PRICE_INVALID
  • EF-6 staff_assignment_policy='whitelist' かつ eligibility 0 件: 保存自体は許可、ただし RES-002 で予約投入不可
  • EF-7 設備要件で quantity < 1 または同一 group 重複: 422 MENU.EQUIPMENT_REQUIREMENT_INVALID
  • EF-8 他店舗メニュー操作: 404(RLS により非可視)

カテゴリとメニューを 1 画面で管理し、予約入力で使う順序と利用可否を確定する。

  • カテゴリ一覧
  • 選択カテゴリ配下のメニュー一覧
  • メニュー作成・編集ダイアログ
  • カテゴリ作成・編集ダイアログ
  • スタッフ eligibility 編集モーダル
  • 設備 requirement 編集モーダル(group + quantity)
  • 退役 / 復活ボタン
  • reorder UI
  • カテゴリ名: 1..100 文字、trim 後空文字不可
  • メニュー名: 1..200 文字、trim 後空文字不可
  • duration_minutes: 1..1440
  • buffer_minutes_after: 0..240
  • tax_rate_pct: 8 or 10(zod レベルでガード、DB CHECK は §7.16 で撤去済み)
  • price_excl_tax: 0 以上
  • staff_assignment_policy: any | whitelist
  • 設備要件: quantity 1..99、同一 equipment_group_id 重複不可

MethodPath用途permission楽観ロック
GET/api/admin/menu-categoriesカテゴリ一覧admin:menu:read-
POST/api/admin/menu-categoriesカテゴリ作成admin:menu:create-
PATCH/api/admin/menu-categories/:categoryIdカテゴリ更新admin:menu:updateIf-Match: <version>
PUT/api/admin/menu-categories/orderカテゴリ並び替えadmin:menu:updateIf-Match: <collection_etag>
GET/api/admin/menus?limit=&cursor=&category_id=&terminated=メニュー一覧admin:menu:read-
GET/api/admin/menus/:menuIdメニュー詳細admin:menu:read-
POST/api/admin/menusメニュー作成admin:menu:create-
PATCH/api/admin/menus/:menuIdメニュー更新admin:menu:updateIf-Match: <version>
PUT/api/admin/menus/order同一カテゴリ内 reorderadmin:menu:updateIf-Match: <collection_etag>
POST/api/admin/menus/:menuId/retire退役admin:menu:retireIf-Match: <version>
POST/api/admin/menus/:menuId/restore復活admin:menu:retireIf-Match: <version>
GET/api/admin/menus/:menuId/staff-eligibility対応スタッフ読取admin:menu:read-
PUT/api/admin/menus/:menuId/staff-eligibility対応スタッフ更新admin:menu:updateIf-Match: <version>
GET/api/admin/menus/:menuId/equipment-requirements設備要件読取admin:menu:read-
PUT/api/admin/menus/:menuId/equipment-requirements設備要件更新admin:menu:updateIf-Match: <version>

menu_category の delete API は Phase 1 で提供しない。

PUT /api/admin/menus/:menuId/equipment-requirements
{
requirements: [
{ equipment_group_id: uuid, quantity: number /* 1..99 */ }
]
}
code意味HTTP
MENU.NOT_FOUND行なし / RLS越境404
MENU.FORBIDDEN権限不足403
MENU.VERSION_MISMATCH行 ETag 不一致409
MENU.COLLECTION_ETAG_MISMATCHreorder ETag 不一致409
MENU.DURATION_INVALIDduration 不正422
MENU.BUFFER_INVALIDbuffer 不正422
MENU.PRICE_INVALIDprice 不正422
MENU.EQUIPMENT_REQUIREMENT_INVALID設備要件不正422
MENU.CATEGORY_NOT_FOUNDcategory 不在422

カラム制約
iduuidPK
store_iduuidNOT NULL, FK
namevarchar(100)NOT NULL, CHECK btrim(name) <> ''
display_orderintegerNOT NULL, CHECK >= 1
versionintegerNOT NULL DEFAULT 1, CHECK >= 1
created_attimestamptzNOT NULL
updated_attimestamptzNOT NULL
制約UNIQUE(store_id, id)
制約UNIQUE(store_id, display_order)
カラム制約
iduuidPK
store_iduuidNOT NULL, FK
category_iduuidNOT NULL, FK (store_id, category_id) → menu_category(store_id, id)
namevarchar(200)NOT NULL, CHECK btrim(name) <> ''
duration_minutesintegerNOT NULL, CHECK 1..1440
buffer_minutes_aftersmallintNOT NULL DEFAULT 0, CHECK 0..240
price_excl_taxbigintNOT NULL, CHECK >= 0
tax_rate_pctsmallintNOT NULL(DB CHECK は親 §7.16 に従い無し、zod でガード)
tax_amountbigintNOT NULL, CHECK >= 0
price_incl_taxbigintNOT NULL, CHECK >= 0
staff_assignment_policyvarchar(20)NOT NULL DEFAULT 'any', CHECK IN ('any','whitelist')
display_orderintegerNOT NULL, CHECK >= 1
terminated_attimestamptzNULL
versionintegerNOT NULL DEFAULT 1, CHECK >= 1
created_attimestamptzNOT NULL
updated_attimestamptzNOT NULL
制約UNIQUE(store_id, id)
制約UNIQUE(store_id, category_id, display_order) WHERE terminated_at IS NULL
制約CHECK(price_incl_tax = price_excl_tax + tax_amount)
カラム制約
store_iduuidPK, FK
menu_iduuidPK, 複合 FK → menu(store_id, id)
staff_iduuidPK, 複合 FK → staff(store_id, id)

独立 ID なし。親 §7.14 / §7.2.2 に従う。

カラム制約
store_iduuidPK, FK
menu_iduuidPK, 複合 FK → menu(store_id, id)
equipment_group_iduuidPK, 複合 FK → equipment_group(store_id, id)
quantitysmallintNOT NULL, CHECK 1..99

行 1 件 = 「equipment_group_id の設備が quantity 台必要」。 複数行は AND(全て必要)と解釈する。

0014_menu_master_and_eligibility.sql で以下を 1 ファイルにまとめる。

  1. 0006_reservation.sql の placeholder menu を本番 schema へ昇格(category_id, duration_minutes, tax_*, staff_assignment_policy, display_order, terminated_at, version 追加)
  2. menu_category 新規作成
  3. menu_staff_eligibility 新規作成
  4. menu_equipment_requirement 新規作成(equipment_group_id 参照前提だが、equipment_group の作成は 0017 で行う。本 migration では先行 FK を pending 状態にするか、0017 後に dependant FK を遅延適用する)
  5. RLS ENABLE + FORCE
  6. app ロールへ SELECT/INSERT/UPDATE を GRANT(DELETE なし、§7.5 物理削除なし)
  7. permission backfill 実行

permission backfill (admin:menu:* 4 keys) は 0014 内で同時実施する。

migration 順序の現実的整理:

  • 0014 (menu, menu_category, menu_staff_eligibility, menu_equipment_requirement の RLS / GRANT のみ、equipment_group への FK は 0017 で追加)
  • 0017 (equipment_group + equipment.group_id) で menu_equipment_requirement.equipment_group_id への FK を有効化

  • Phase 1 のメニュー有効 / 無効は terminated_at のみ
  • active boolean は導入しない
  • 退役メニューは新規予約候補に出さない
  • 既存 reservation_menu_lines.menu_id 参照は保持する

7.2 価格スナップショット契約(RES-002 提供)

Section titled “7.2 価格スナップショット契約(RES-002 提供)”

予約作成時、RES-002 は menu から以下を 必ず reservation_menu_lines にコピーする。

  • namemenu_name_snapshot(RES-002 v0.5 で追加)
  • duration_minutes
  • price_excl_tax
  • tax_amount
  • price_incl_tax
  • tax_rate_pct
  • buffer_minutes_after(予約レベル reservation.buffer_minutes_after の最大値計算用)

メニュー改定後も既存予約の金額・時間・名前は不変。

意味予約作成時
any制限なし全 staff 可。menu_staff_eligibility 行は無視
whitelistホワイトリストmenu_staff_eligibility に存在する staff のみ可

whitelist かつ 0 件は 設定上は許可。ただし RES-002 は RESERVATION.MENU_NOT_ELIGIBLE_FOR_STAFF でブロックする。

7.4 複数メニュー時の staff eligibility

Section titled “7.4 複数メニュー時の staff eligibility”

1 予約に複数 menu がある場合、指定 staff はすべての menuを満たさなければならない。

for all selected menus m: m.staff_assignment_policy='any' OR exists(menu_staff_eligibility where menu_id=m.id and staff_id=reservation.staff_id)

7.5 menu_equipment_requirement の AND ロジック + 自動割当

Section titled “7.5 menu_equipment_requirement の AND ロジック + 自動割当”
  • 1 menu に requirement 行が N 件ある場合、N 件すべてが必要
  • 各行は (equipment_group_id, quantity) 単位で「この group から quantity 台が必要」を意味する
  • RES-002 は予約保存前に「各要件の group 内に空き concrete equipment を quantity 台確保できるか」を判定する
  • 自動割当アルゴリズム:
    1. 各 requirement について、対象 group の active equipment(bookable=true AND terminated_at IS NULL)を display_order ASC, id ASC で列挙
    2. 候補時間帯に EXCLUDE 制約と衝突しない equipment を quantity 台選ぶ
    3. 不足なら RESERVATION.NO_EQUIPMENT_AVAILABLE 422
  • RES-005 の EXCLUDE 制約は concrete equipment_id 単位で機能するため、group 概念とは独立
  • カテゴリ reorder は store 全体 collection ETag
  • menu reorder は同一 category_id 内 collection ETag
  • no-op reorder は version / updated_at / audit を動かさない
  • menu.create / update / retire / restore / reorder
  • menu_category.create / update / reorder
  • menu_staff_eligibility.replace
  • menu_equipment_requirement.replace

  • GET /api/admin/menus は cursor-based。limit 既定 50、上限 100
  • reorder と bulk replace は 1 transaction
  • メニュー件数上限は Phase 1 で 1000 を目安。それ以上は cursor で十分対応

key用途初期付与
admin:menu:readmenu / category 読取全 role
admin:menu:createmenu / category 作成owner / manager
admin:menu:updatemenu / category 更新、eligibility / requirement 更新、reorderowner / manager
admin:menu:retiremenu 退役・復帰owner / manager

RLS / FORCE / 複合 FK / GRANT 明示は全 4 テーブルで必須。


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

Section titled “10. 受け入れ基準(Given-When-Then)”
  1. Given category が存在 / When menu を作成 / Then category 末尾に display_order 付きで追加される
  2. Given active menu が多数存在 / When 一覧取得 / Then cursor pagination で返る
  3. Given price_excl_tax = 0 / When 保存 / Then 作成成功する
  4. Given buffer_minutes_after = 240 / When 保存 / Then 作成成功する
  5. Given buffer_minutes_after = 241 / When 保存 / Then MENU.BUFFER_INVALID
  6. Given duration_minutes = 0 / When 保存 / Then MENU.DURATION_INVALID
  7. Given row version=3 / When If-Match: 3 で更新 / Then version=4 で更新される
  8. Given row version=3 / When If-Match: 2 で更新 / Then 409 MENU.VERSION_MISMATCH
  9. Given category A の active menu 並び / When reorder / Then 同一 category 内で 1-based 連番に再採番される
  10. Given category collection ETag stale / When category reorder / Then 409 MENU.COLLECTION_ETAG_MISMATCH
  11. Given menu を退役 / When active list 取得 / Then 候補に出ない
  12. Given retired menu / When restore / Then category 末尾へ戻る
  13. Given staff_assignment_policy='any' かつ eligibility 行あり / When RES-002 が staff 判定 / Then eligibility 行は無視される
  14. Given staff_assignment_policy='whitelist' かつ staff A 行あり / When staff A で予約作成 / Then eligibility check を通る
  15. Given staff_assignment_policy='whitelist' かつ staff A 行なし / When staff A で予約作成 / Then RESERVATION.MENU_NOT_ELIGIBLE_FOR_STAFF
  16. Given eligibility 更新 API / When staff_ids を差し替え / Then junction table が payload と一致する
  17. Given requirement 更新 API / When {group_id, quantity} を差し替え / Then junction table が payload と一致する
  18. Given group A から quantity=2 要件 / When group A に空き 2 台あり / Then 予約成功し 2 台が自動割当される
  19. Given group A から quantity=2 要件 / When 空き 1 台のみ / Then RESERVATION.NO_EQUIPMENT_AVAILABLE
  20. Given menu で予約作成済 / When menu の価格を後日更新 / Then 既存 reservation_menu_lines.price_* は変わらず、menu_name_snapshot も変わらない
  21. Given 店舗 X セッション / When 店舗 Y menu を取得 / Then 404 or 0 件
  22. Given no-op reorder / When 同じ順序を送信 / Then version も audit も増えない

  • Unit: price calculation, policy evaluation, reorder payload validation, equipment auto-allocation
  • Integration: CRUD, retire/restore, eligibility replace, requirement replace, RLS, optimistic lock
  • Cross-SRS integration: RES-002 での price snapshot / eligibility check / equipment 自動割当
  • Contract: OpenAPI / zod fixture

  • なし

#内容扱い
OQ-MST-002-01スタッフ指名料(親 OQ-02)Phase 2
OQ-MST-002-02category delete を Phase 2 で提供するかPhase 2
OQ-MST-002-03duration=0 のオプションメニューPhase 2
OQ-MST-002-04カテゴリ階層 (1 段 → 多段) の拡張Phase 2 以降

VersionDateAuthorChange
0.12026-05-05Codex / yudai初版ドラフト
0.22026-05-05yudai (with Codex co-design)Round 2 反映: migration 番号 0014 確定、menu_equipment_requirementequipment_group_id + quantity 参照へ変更、menu_name_snapshot の RES-002 提供契約を §7.2 に明記、tax_rate_pct DB CHECK 撤去(親 §7.16 連動)、staff_assignment_policy enum 確定、自動割当アルゴリズムを §7.5 に明記