スタッフ管理
スタッフ管理
Section titled “スタッフ管理”Document ID: SRS-MST-001 Parent: SRS-ROOT-001 v0.3 Version: 0.5 Status: Draft Last Updated: 2026-04-25 Depends on: SRS-TEN-001(店舗作成・初期設定), SRS-TEN-002(オペレーター登録・Passkey), SRS-TEN-003(ロールと店舗アサイン) Blocked by(マージ条件): SRS-RES-002 改訂PR / SRS-RES-005 改訂PR(§6.3 で詳述)
本書は SRS-ROOT-001 v0.3 に従う。
店舗(テナント)に所属する スタッフ(Staff) マスタの CRUD・並び順管理・退職化を提供する。スタッフは以下から参照される基盤マスタ:
- 予約カレンダー表示(SRS-RES-001)— カレンダー縦軸の「スタッフ列」
- 予約作成(SRS-RES-002)— 担当スタッフの選択
- 二重予約判定(SRS-RES-005)— 同一スタッフの並列予約上限判定
- メニュー × スタッフ対応(SRS-MST-002、
menu_staff_eligibility経由) - シフト管理(SRS-MST-004、
shift.staff_id)
1.1 用語の境界(重要)
Section titled “1.1 用語の境界(重要)”親SRS §2 で定義済みだが本SRSの前提として再掲する。
- スタッフ(Staff) = 施術担当者の業務マスタ。受付専業・指名対象だけ・退職者を含む
- オペレーター(Operator) = 管理画面を操作する認証ユーザー
- 両者は1対1ではない(受付専業スタッフは Operator 紐付けなしでも成立し、Operator が施術しないケースもある)
本SRSは Staff のみを扱い、Operator との実リンク(operator_staff_link)は Phase 1 では一切作らない(テーブルも置かない)。Phase 2 以降で SRS-RES-001(カレンダーのロール別表示)と同期して別 SRS(仮 SRS-TEN-004 等)で起票する。
1.2 Phase 1 で本SRSに入れないもの
Section titled “1.2 Phase 1 で本SRSに入れないもの”| 項目 | 委譲先 |
|---|---|
メニュー × スタッフ対応一式(menu_staff_eligibility テーブル / menu_equipment_requirement テーブル / メニュー側 restrict_staff_required フラグ) | SRS-MST-002(メニュー管理)が所有。親SRS §7.14 のリソース適格性マスタ規範に従う |
| 営業時間・定休日・祝日 | SRS-MST-003 |
| シフト | SRS-MST-004 |
| 設備マスタ | SRS-MST-005 |
| HPB 掲載用プロフィール(写真・肩書・コメント・経歴) | SRS-LST-006(Phase 4) |
| スタッフ別売上集計 | SRS-ANL-001(Phase 3) |
| 指名料の取扱 | 親SRS OQ-02 → SRS-MST-002 で判断 |
Operator-Staff リンク(operator_staff_link) | 別SRS(Phase 2 以降) |
| 性別(gender) | 持たない(必要時に追加) |
表示用ID(staff_no) | 持たない(業務上の必要性が現時点で薄いため) |
写真(photo_url) | 持たない(HPB 掲載写真は SRS-LST-006、運営内部用は別途検討) |
| permission キーのプリセット role 初期割当 | 列挙のみ本SRS。初期割当の seed 正本は SRS-TEN-002(PRESET_ROLE_PERMISSIONS)。本SRS §9.1 の値は仮置き |
1.3 件数上限(ハード制約)
Section titled “1.3 件数上限(ハード制約)”- 店舗あたり現役スタッフ件数の上限:200 名(
terminated_at IS NULLの件数) - create / restore で上限超過なら 422
STAFF.LIMIT_EXCEEDEDで拒否(§7.5) - 上限ガード値は
store_settingsには置かず、本SRSの定数で固定(packages/domain/src/staff/constants.tsのMAX_ACTIVE_STAFF = 200)。将来店舗ごとに変えたくなったらstore_settings.max_active_staffへの移管を検討(OQ-MST-001-04) - 退職者は無制限(過去履歴は永続)
2. ユーザーストーリー
Section titled “2. ユーザーストーリー”- 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 受付指名・受付別売上記録の余地を残せる
- As a スタイリスト, I want to 自分の表示名と並びが正しい状態で予約を受ける, so that 顧客対応時に違和感が出ない
3. ユースケース
Section titled “3. ユースケース”3.1 主シナリオ(スタッフ作成)
Section titled “3.1 主シナリオ(スタッフ作成)”- 店長が管理画面「設定 > スタッフ」へ移動
- 「新規追加」ボタンをクリック → ダイアログが開く
- 氏名(
name)を入力(必須) - 「保存」をクリック
- システムがトランザクション内で店舗単位 advisory lock を取得し、現役件数が 200 未満であることを確認 →
display_order = MAX(現役の display_order) + 1(現役が 0 件なら1)を採番 staff行を作成(is_bookable = true、terminated_at = NULL、version = 1)- 一覧の末尾に新スタッフが表示される
operator_action_logにstaff.createを記録
3.2 代替フロー
Section titled “3.2 代替フロー”- AF-1. 受付専業スタッフ作成:主シナリオと同じ。作成直後に
is_bookable = falseへ切替えれば指名候補に出ない - AF-2. 並び替え:一覧画面で DnD → 「並び順を保存」で
PUT /api/admin/staff/orderを 1 リクエスト発行、サーバ側で全件再採番 - AF-3. 編集:氏名・指名可否を変更 →
PATCH /api/admin/staff/:staffId - AF-4. 退職化:「退職にする」ボタン → 確認ダイアログ →
POST /api/admin/staff/:staffId/retire(物理削除ではなくterminated_at = now()を立てる lifecycle 操作) - AF-5. 復職:退職スタッフ一覧(
GET /api/admin/staff/terminated、cursor ページング)から「復職」→POST /api/admin/staff/:staffId/restore(terminated_at = NULL)。同一staff_idで過去予約・履歴と連続する
3.3 例外フロー
Section titled “3.3 例外フロー”- EF-1. 同一店舗で氏名衝突:氏名は UNIQUE 制約を張らない(同姓同名・複数の「斎藤」あり得る)。UI で確認ダイアログを出すのみで保存はブロックしない
- EF-2. 退職スタッフを再度退職にしようとする:
If-Match検証が優先。stale なら 409、最新値で投げ直して既に終端状態なら 200 を返し state を変えず監査ログも追加しない(§7.3.2) - EF-3. 復職スタッフを再度復職にしようとする:EF-2 と同じ優先順位(§7.4)
- EF-4. 並び替え時に他のオペレーターが同時編集:collection ETag による楽観ロックで競合検出。stale な ETag からの reorder は 409
STAFF.COLLECTION_ETAG_MISMATCHを返し、UI が再取得を促す(§7.7) - EF-5. 並び替え時の
If-Match欠落:428STAFF.PRECONDITION_REQUIRED - EF-6. 権限不足:403
STAFF.FORBIDDEN - EF-7. RLS 越境(他店舗のスタッフを操作試行):404(行が見えない)
- EF-8. 物理削除 API の試行:DELETE
/api/admin/staff/:staffIdというエンドポイント自体が存在しない(404)。さらに DB のappロールにはDELETE権限が GRANT されていないため、SQL 直接実行も拒否される - EF-9. 現役件数上限超過:create または restore で現役件数が 200 に達している → 422
STAFF.LIMIT_EXCEEDED
4. UI 仕様
Section titled “4. UI 仕様”注記:本書の §4 は親SRS §7.12 に従い最低限の粒度で記述する。デザインシステム導入SRS(仮 SRS-UI-001)リリース時に一括改訂される前提。
4.1 画面の目的
Section titled “4.1 画面の目的”「設定 > スタッフ」配下の スタッフマスタ管理画面。スタッフの一覧表示・新規作成・編集・退職化・並び替えを 1 画面で完結させる。
4.2 主要要素(列挙)
Section titled “4.2 主要要素(列挙)”- 一覧テーブル:氏名 / 指名可否 / 並び順番号 / 状態(現役 or 退職)
- フィルタトグル:「退職スタッフを表示」(既定 OFF、ON で退職一覧
GET /api/admin/staff/terminatedを cursor ページングで取得) - 「新規追加」ボタン → 作成ダイアログ
- 各行のアクション:編集 / 退職にする(現役行)/ 復職する(退職行)
- DnD ハンドル:現役行のみドラッグで並び替え可。確定タイミングで一括保存
- 作成・編集ダイアログ:氏名(必須)/ 指名可否トグル
- 検索・高度なフィルタは持たない(数十件規模で過剰、§1.2 と整合)
4.3 バリデーション
Section titled “4.3 バリデーション”- 氏名:1〜100 文字、トリム後の空文字列は不可
- 指名可否:boolean(既定 true)
- 並び順:数値直接編集は UI に出さない(DnD のみ)
4.4 操作フロー
Section titled “4.4 操作フロー”- 設定メニューから「スタッフ」を開く → 現役一覧(
GET /api/admin/staff) - 「新規追加」→ 氏名入力 → 保存 → 末尾に追加
- 行の「編集」→ ダイアログで変更 → 保存
- 行を DnD で並び替え → 「並び順を保存」→ 一括反映(コレクション楽観ロック)
- 「退職にする」→ 確認 → 退職一覧に移動。トグルで再表示可
- トグル ON → 退職一覧(
GET /api/admin/staff/terminated?cursor=...)→「復職する」→ 現役末尾に戻る
5. API 仕様
Section titled “5. API 仕様”5.1 エンドポイント一覧
Section titled “5.1 エンドポイント一覧”すべて親SRS §7.6 のパスプレフィクス /api/admin/* 配下、オペレーター認証必須。
| Method | Path | 用途 | 必要 permission | 楽観ロック |
|---|---|---|---|---|
| GET | /api/admin/staff | 現役スタッフ一覧(最大 200 件全件返却、親SRS §7.6 例外。truncate なし) | admin:staff:read | - |
| GET | /api/admin/staff/terminated?cursor=&limit= | 退職スタッフ一覧(cursor ページング、復職フロー専用) | admin:staff:read | - |
| GET | /api/admin/staff/all-active | reorder 用の現役全件取得(collection_etag を含む) | admin:staff:read | - |
| GET | /api/admin/staff/:staffId | スタッフ詳細(退職含む) | admin:staff:read | - |
| POST | /api/admin/staff | スタッフ作成(現役上限 200) | admin:staff:create | - |
| PATCH | /api/admin/staff/:staffId | スタッフ更新(氏名・指名可否) | admin:staff:update | If-Match: <version> 必須 |
| PUT | /api/admin/staff/order | 並び順一括更新 | admin:staff:update | If-Match: <collection_etag> 必須 |
| POST | /api/admin/staff/:staffId/retire | 退職化(terminated_at = now()、§7.3 で詳述) | admin:staff:retire | If-Match: <version> 必須 |
| POST | /api/admin/staff/:staffId/restore | 復職(terminated_at = NULL、現役上限 200) | admin:staff:retire | If-Match: <version> 必須 |
store_id はセッションから解決し body には含めない。
5.1.1 一覧 API の責務分割
Section titled “5.1.1 一覧 API の責務分割”現役と退職を別エンドポイントに分離することで、件数特性の違い(現役は上限 200 で全件返却が現実的、退職は無制限で cursor 必須)を契約に反映する。truncate で誤魔化さない。
-
GET /api/admin/staff— 現役のみ(terminated_at IS NULL)- 最大 200 件全件返却(親SRS §7.6 例外、現役上限 §1.3 で 200 にハード化されているため自然に収まる)
- ソート:
display_order ASC, id ASC - cursor / limit パラメータなし
truncatedフィールドなし(上限ガードで超過しないため)
-
GET /api/admin/staff/terminated— 退職のみ(terminated_at IS NOT NULL)- cursor ページング、
limit既定 50・上限 100 - ソート:
terminated_at DESC, id DESC(直近退職が先頭、復職フロー UX に整合) - 退職者は無制限に蓄積されるため、必ずページングで取る
- cursor ページング、
-
GET /api/admin/staff/all-active— reorder 用の現役全件 + collection_etagGET /api/admin/staffと同じデータを返すがcollection_etagを必ず含める- reorder UI に入る直前にクライアントが取得し、
If-Matchに使う - 通常一覧と分けることで、どこの ETag を使うかが contract 上明確になる
-
GET /api/admin/staff/:staffId— 詳細(退職含む)- 単一行取得、フィルタなし
5.1.2 物理削除エンドポイントの非存在
Section titled “5.1.2 物理削除エンドポイントの非存在”DELETE /api/admin/staff/:staffId は意図的に提供しない。退職は HTTP DELETE ではなく POST /api/admin/staff/:staffId/retire の lifecycle 操作として表現する(理由は §7.3)。
5.2 zod スキーマ(contract-first、packages/contracts/src/admin/staff.ts)
Section titled “5.2 zod スキーマ(contract-first、packages/contracts/src/admin/staff.ts)”import { z } from '@hono/zod-openapi'
export const StaffId = z.string().uuid()export const StoreId = z.string().uuid()export const Iso8601 = z.string().datetime({ offset: true })
export const Staff = z.object({ id: StaffId, store_id: StoreId, name: z.string().min(1).max(100), is_bookable: z.boolean(), display_order: z.number().int().positive(), // 1-based terminated_at: Iso8601.nullable(), created_at: Iso8601, updated_at: Iso8601, // 監査・表示用(ETag 役割は持たない) version: z.number().int().positive(), // 行レベル楽観ロックの ETag、INSERT 時 1、UPDATE 毎に +1}).openapi('Staff')
const trimmedName = z.string().min(1).max(100).transform((s) => s.trim()).pipe(z.string().min(1))
export const CreateStaffRequest = z.object({ name: trimmedName, is_bookable: z.boolean().default(true),})
export const UpdateStaffRequest = z.object({ name: trimmedName.optional(), is_bookable: z.boolean().optional(),}).refine((o) => Object.keys(o).length > 0, { message: '少なくとも1フィールド必須' })
export const ReorderRequest = z.object({ ordered_ids: z.array(StaffId).min(0).max(200),})
// --- Query schemas ---export const TerminatedListQuery = z.object({ cursor: z.string().optional(), // opaque cursor(サーバが (terminated_at, id) を base64 でエンコード) limit: z.coerce.number().int().min(1).max(100).default(50),})
// --- Header schemas ---// `If-Match` の値は **unquoted**。RFC 7232 の opaque-tag は採用しない(quoted "..." は受理しない)export const IfMatchVersionHeader = z.object({ 'if-match': z.string().regex(/^\d+$/), // 整数文字列、unquoted。例: 3})
export const IfMatchCollectionEtagHeader = z.object({ 'if-match': z.string().regex(/^[0-9a-f]{64}$/), // SHA-256 hex 64文字、unquoted})
// --- Response envelopes ---export const StaffEnvelope = z.object({ data: z.object({ staff: Staff }) })
export const StaffActiveListEnvelope = z.object({ data: z.object({ staff: z.array(Staff).max(200), // 現役上限 200、truncate しない }),})
export const StaffTerminatedListEnvelope = z.object({ data: z.object({ staff: z.array(Staff), next_cursor: z.string().nullable(), }),})
export const StaffReorderListEnvelope = z.object({ data: z.object({ staff: z.array(Staff).max(200), collection_etag: z.string(), // §5.3.2 の計算式 }),})
export const ErrorEnvelope = z.object({ error: z.object({ code: z.string(), // 例 'STAFF.NAME_INVALID', 'VALIDATION_ERROR' message: z.string(), details: z.record(z.unknown()).optional(), }),})契約境界の明文化:
GET /api/admin/staffのレスポンス:StaffActiveListEnvelope(query なし、現役のみ)GET /api/admin/staff/terminatedのレスポンス:StaffTerminatedListEnvelope、query:TerminatedListQueryGET /api/admin/staff/all-activeのレスポンス:StaffReorderListEnvelopePATCH /api/admin/staff/:staffId/POST /api/admin/staff/:staffId/retire/POST /api/admin/staff/:staffId/restoreの header:IfMatchVersionHeaderをrequirePrecondition()ミドルウェアで検証PUT /api/admin/staff/orderの header:IfMatchCollectionEtagHeaderをrequirePrecondition()ミドルウェアで検証
5.3 楽観ロック契約
Section titled “5.3 楽観ロック契約”5.3.1 行レベル(PATCH / retire / restore)
Section titled “5.3.1 行レベル(PATCH / retire / restore)”If-Match: <version>必須。値は unquoted の 10 進整数文字列(例:If-Match: 3、引用符"なし)- レスポンスの
data.staff.versionを次回のIf-Match値に使う - DB 比較は単純な整数等値で、タイムスタンプ精度問題は発生しない
- shape 不正(非数字、quoted、空文字等)→ 400
VALIDATION_ERROR(IfMatchVersionHeaderで弾く) - 不一致 → 409
STAFF.VERSION_MISMATCH - 欠落 → 428
STAFF.PRECONDITION_REQUIRED - UPDATE 文(アプリ層、
packages/db):影響行 0 件 → 409UPDATE staffSET ..., version = version + 1, updated_at = now()WHERE id = $1 AND store_id = $2 AND version = $3RETURNING *STAFF.VERSION_MISMATCH
5.3.2 コレクションレベル(PUT /order)
Section titled “5.3.2 コレクションレベル(PUT /order)”If-Match: <collection_etag>必須(フィールド名・ヘッダ値ともに snake_casecollection_etagで統一)。値は unquoted の SHA-256 hex 64文字(例:If-Match: abc123...、引用符"なし)- 値は
GET /api/admin/staff/all-activeのレスポンスに含まれるdata.collection_etag - 計算式:
SHA-256(JSON.stringify(現役 staff の { id, version } を display_order ASC, id ASC で並べた配列))(行 version も含めるため、個別 PATCH と reorder が絡むケースでも検出できる) - shape 不正(非 hex、quoted、桁数違い等)→ 400
VALIDATION_ERROR(IfMatchCollectionEtagHeaderで弾く) - 不一致 → 409
STAFF.COLLECTION_ETAG_MISMATCH、UI はall-activeを再取得 - 欠落 → 428
STAFF.PRECONDITION_REQUIRED
5.4 エラーコード
Section titled “5.4 エラーコード”5.4.1 エラー分類規約
Section titled “5.4.1 エラー分類規約”VALIDATION_ERROR400: zod パース失敗(型・shape 不正・require 欠落)。汎用バリデーションエラーで、detailsに zod issue を含めるSTAFF.*4xx: zod パスは通ったが domain の業務ルールで弾かれたエラー。HTTP は意味別(422 = unprocessable, 404 = not found, 409 = conflict, 428 = precondition required, 403 = forbidden)
5.4.2 エラーコード一覧
Section titled “5.4.2 エラーコード一覧”| code | 意味 | HTTP |
|---|---|---|
VALIDATION_ERROR | zod バリデーション失敗(shape 不正、型不正、必須欠落) | 400 |
STAFF.NAME_INVALID | domain 層の防御的エラー(DB CHECK staff_name_not_blank 違反相当)。通常 zod の trimmedName パイプで先に VALIDATION_ERROR 400 になるため UI 経路では到達しないが、API 直接攻撃や race による境界取りこぼしの fallback として 422 ルートを残す | 422 |
STAFF.ORDER_PAYLOAD_INVALID | ordered_ids が現役集合と意味的に不一致(漏れ・重複・退職者混入・他店舗 id 混入)。zod では shape のみ検証、集合一致は domain で検証 | 422 |
STAFF.LIMIT_EXCEEDED | 現役件数 200 上限超過(create / restore) | 422 |
STAFF.NOT_FOUND | 該当 ID なし(RLS 越境含む、行が見えないため一律 404) | 404 |
STAFF.FORBIDDEN | permission 不足 | 403 |
STAFF.VERSION_MISMATCH | 行レベル楽観ロック失敗(If-Match: <version> 不一致) | 409 |
STAFF.COLLECTION_ETAG_MISMATCH | コレクションレベル楽観ロック失敗(reorder の If-Match 不一致) | 409 |
STAFF.PRECONDITION_REQUIRED | If-Match ヘッダ欠落 | 428 |
6. データモデル影響
Section titled “6. データモデル影響”6.1 スキーマ
Section titled “6.1 スキーマ”6.1.1 staff(業務実体、UUIDv7)
Section titled “6.1.1 staff(業務実体、UUIDv7)”| カラム | 型 | 制約 | 説明 |
|---|---|---|---|
id | uuid | PK | UUIDv7、アプリ層で生成(親SRS §7.15: PK は単独) |
store_id | uuid | NOT NULL, FK → store(id) | テナント |
name | varchar(100) | NOT NULL, CHECK btrim(name) <> '' | 氏名(同店舗内重複可) |
is_bookable | boolean | NOT NULL, DEFAULT true | 指名候補に出すか |
display_order | integer | NOT NULL, CHECK >= 1 | 並び順、1-based 連番。一括 reorder で再採番 |
terminated_at | timestamptz | NULL | 非NULL = 退職済み。lifecycle 状態の表現(§7.3) |
version | integer | NOT NULL, DEFAULT 1, CHECK >= 1 | 行レベル楽観ロックの ETag。INSERT 時 1、UPDATE 毎にアプリ層で +1 |
created_at | timestamptz | NOT NULL, DEFAULT now() | |
updated_at | timestamptz | NOT NULL, DEFAULT now() | 監査・表示用。アプリ層が UPDATE 文に明示的に書き込む |
| 制約 | UNIQUE (store_id, id) | 親SRS §7.15 に従い複合FK の参照先 | |
| 制約 | UNIQUE INDEX (store_id, display_order) WHERE terminated_at IS NULL | 現役内で並び順の DB レベル一意性を保証(§7.2) | |
| 制約 | INDEX (store_id) WHERE is_bookable = true AND terminated_at IS NULL | 予約 UI の候補取得高速化 |
updated_at の更新責務(単一責務に統一):
- DB トリガは置かない。アプリ層(
packages/dbの staff リポジトリ)が UPDATE 文にupdated_at = now()を明示的に書く(§5.3.1 の SQL 例) - これにより「If-Match で ETag を厳密にしたいが DB トリガが内部で自動更新する」という二重化を回避(過去議論ではトリガ案も検討したが、
version採用でupdated_atの ETag 役割が消えたため不要) - 監査・表示用のため、アプリ実装ミスで
updated_atが古いまま残っても致命傷ではない(version とは独立)
terminated_at の位置付け(重要、親SRS §7.5 との関係):
- 親SRS §7.5 の
deleted_atは 論理削除(誤登録の取消、データそのものの取下げ) のための列 - 一方、本SRS の
terminated_atは 業務 lifecycle のステータス変化(退職) を表す列 - 退職者は「削除されたデータ」ではなく「現在の予約候補から外れた現実存在」であり、過去予約・売上集計から参照され続ける
- 親SRS v0.3 ERD(DOC-ERD-001 §2 BC-MST)でも STAFF は
terminated_atで表現されており、本SRSはそれに準拠する - 親SRS §7.5 と命名衝突を起こさないために
deleted_atではなくterminated_atを採用する - 物理削除の禁止(親SRS §7.5)には準拠する:DELETE エンドポイントなし、
appロールに DELETE GRANT なし
is_bookable との関係(§7.1 で詳述):
terminated_at IS NOT NULLのときis_bookableの値は無視される(一覧 UI とサーバ側 reservation 作成チェックの両方で)terminated_at IS NULLでもis_bookable = falseの状態は存在しうる(休職・受付専業)
休職・異動など terminated_at の二値で表せない状態が将来必要になれば staff_status enum への移行を検討する(OQ-MST-001-03)。
6.2 マイグレーション計画
Section titled “6.2 マイグレーション計画”BC-MST 初回マイグレーション。
CREATE TABLE staff ( id uuid PRIMARY KEY, store_id uuid NOT NULL REFERENCES store(id), name varchar(100) NOT NULL, is_bookable boolean NOT NULL DEFAULT true, display_order integer NOT NULL, terminated_at timestamptz NULL, version integer NOT NULL DEFAULT 1, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT staff_name_not_blank CHECK (btrim(name) <> ''), CONSTRAINT staff_display_order_positive CHECK (display_order >= 1), CONSTRAINT staff_version_positive CHECK (version >= 1), CONSTRAINT staff_store_id_unique UNIQUE (store_id, id));
CREATE UNIQUE INDEX staff_store_order_active_uidx ON staff(store_id, display_order) WHERE terminated_at IS NULL;
CREATE INDEX staff_store_bookable_idx ON staff(store_id) WHERE is_bookable = true AND terminated_at IS NULL;
ALTER TABLE staff ENABLE ROW LEVEL SECURITY;ALTER TABLE staff FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON staff USING (store_id = current_setting('app.current_store_id')::uuid) WITH CHECK (store_id = current_setting('app.current_store_id')::uuid);
GRANT SELECT, INSERT, UPDATE ON staff TO app;-- DELETE は付与しない(物理削除の二重防御、親SRS §7.5)-- updated_at の自動更新トリガは置かない(§6.1.1 単一責務方針)6.3 SRS-RES-002 / SRS-RES-005 との整合(本SRSの規範要求 / blocked-by)
Section titled “6.3 SRS-RES-002 / SRS-RES-005 との整合(本SRSの規範要求 / blocked-by)”本SRSは予約系SRSに対し以下を規範として要求する。サーバ側 reject 規範が整わないままマージすると、UI 候補リスト除外しか効かず、API を直接叩かれて非bookable/退職スタッフへの予約が成立する穴が残るため、SRS-RES-002 / SRS-RES-005 の改訂PRと同一マージウェーブで取り込む(blocked-by)。
6.3.1 SRS-RES-002(予約作成)への要求
Section titled “6.3.1 SRS-RES-002(予約作成)への要求”- 予約作成 API(
POST /api/admin/reservations)は、リクエストのstaff_idが以下のいずれかなら 422RESERVATION.STAFF_NOT_BOOKABLEを返してサーバ側で拒否する:staff.terminated_at IS NOT NULLstaff.is_bookable = false
- UI 候補リストでの除外だけでは不十分(API を直接叩かれるケースに対応するため)
- 予約更新(PATCH)でスタッフを変更する場合も同等にチェック
6.3.2 SRS-RES-005(二重予約判定)との整合
Section titled “6.3.2 SRS-RES-005(二重予約判定)との整合”- 二重予約判定は
SELECT ... FROM staff WHERE id = $1 AND store_id = $2 FOR UPDATEでスタッフ行ロックを取る - 本SRSの advisory lock(§7.2.1)は scope が異なる(並び替え・採番のシリアライズ)ため、両者は競合しない
- ただし「reorder 中に予約作成が来る」シナリオでは、reorder トランザクション内で
FOR UPDATEが効かない(reorder は advisory lock のみ)ため、reorder 中のdisplay_order値は予約判定に影響しない(reservation はstaff_idのみ参照、display_orderは参照しない)
6.3.3 sibling SRS の改訂タスク(blocked-by)
Section titled “6.3.3 sibling SRS の改訂タスク(blocked-by)”本SRS をマージする前に、以下の sibling SRS 改訂PRが必須:
- SRS-RES-002 改訂:
- §6.3.1 の
RESERVATION.STAFF_NOT_BOOKABLE422 を契約・GWT・テスト計画に反映 menu_eligibility旧称の撤去:親SRS v0.3 §7.14 でmenu_staff_eligibility/menu_equipment_requirementに分離済みのため、SRS-RES-002 内のmenu_eligibility参照(§3 / §4 / §7 等)を新モデル(menu_staff_eligibility経由のチェック、restrict_staff_requiredフラグ参照)へ置換する
- §6.3.1 の
- SRS-RES-005 改訂: §6.3.2 の advisory lock との非競合を §7.7 race condition 対策に明記
- 親SRS / ERD: 既に v0.3 で
terminated_at整合済み(追加改訂不要)
OQ には逃がさない。本SRSのマージ条件として上記2件の sibling 改訂PRを同一マージウェーブで要求する。
7. 業務ルール
Section titled “7. 業務ルール”7.1 is_bookable と terminated_at の意味分離
Section titled “7.1 is_bookable と terminated_at の意味分離”| 状態 | terminated_at | is_bookable | 意味 | 一覧表示 | 予約候補 |
|---|---|---|---|---|---|
| 通常稼働 | NULL | true | 現役・指名可 | あり | あり |
| 休職/受付専業 | NULL | false | 現役だが指名対象外 | あり | なし |
| 退職 | NOT NULL | (無視) | 退職済み | フィルタ ON 時のみ | なし |
ルール:
- 退職時(
POST /retire)はterminated_atを立てるのみ。is_bookableの値は変えない(復職時の意図が消えるため) - 復職時(
POST /restore)はterminated_at = NULLのみ戻す。is_bookableの値は退職前のまま - 予約系 API は
terminated_at IS NULL AND is_bookable = trueをサーバ側でチェック(§6.3.1)
7.2 display_order の採番・一意性・並列制御
Section titled “7.2 display_order の採番・一意性・並列制御”display_order の整合性は本SRSの設計上の急所。以下の3層で守る。
7.2.1 店舗単位 advisory lock による並列直列化
Section titled “7.2.1 店舗単位 advisory lock による並列直列化”- create / retire / restore / reorder の各トランザクション冒頭で店舗単位の advisory lock を取る:
(SELECT pg_advisory_xact_lock(hashtextextended('staff:' || $1::text, 0));
$1 = store_id、hashtextextendedで int8 化) - 効果: 同一店舗内の
staff採番・並び替え操作が直列化される - 範囲はトランザクション内のみ(COMMIT/ROLLBACK で自動解放)
- 通常の SELECT は影響を受けない(advisory lock は明示取得のみ)
7.2.2 採番ルール
Section titled “7.2.2 採番ルール”- 新規作成: advisory lock 取得後、件数確認(
SELECT COUNT(*) FROM staff WHERE store_id = $1 AND terminated_at IS NULLが 200 未満)→SELECT COALESCE(MAX(display_order), 0) + 1 FROM staff WHERE store_id = $1 AND terminated_at IS NULL(empty case は 1) - 復職(restore): 同様に件数確認 → 末尾追加
- reorder(PUT /order): 受信した
ordered_idsの順に1, 2, 3, ...で再採番(手順は §7.2.4)
7.2.3 一意性保証
Section titled “7.2.3 一意性保証”- DB 部分 UNIQUE インデックス
UNIQUE INDEX (store_id, display_order) WHERE terminated_at IS NULLで並列での重複を防ぐ - advisory lock があるため通常は競合しないが、万一 UNIQUE 違反が発生したらアプリ層で最大 3 回リトライ、それでも失敗なら 500
7.2.4 reorder の実行手順(DB UNIQUE と両立する一時退避方式)
Section titled “7.2.4 reorder の実行手順(DB UNIQUE と両立する一時退避方式)”UNIQUE INDEX (store_id, display_order) WHERE terminated_at IS NULL を遵守しつつ全行を一括更新するため、大きい正値オフセット に一時退避してから再採番する。
BEGIN;SELECT pg_advisory_xact_lock(hashtextextended('staff:' || $1::text, 0));
-- 1. 現在の現役順序と version を取得し collection_etag を再計算SELECT id, version FROM staff WHERE store_id = $1 AND terminated_at IS NULL ORDER BY display_order ASC, id ASC;-- → サーバ側で SHA-256 を算出、リクエストの If-Match と照合-- 不一致なら 409 STAFF.COLLECTION_ETAG_MISMATCH
-- 2. ordered_ids の意味検証(id 集合一致、重複なし、退職者非混入、他店舗 id 非混入)-- 不一致なら 422 STAFF.ORDER_PAYLOAD_INVALID
-- 3. **no-op short-circuit**: ordered_ids が現順序と完全一致なら何もせず COMMIT して 200 を返す-- (version も updated_at も動かさない、監査ログも追加しない)-- 別画面で持っている個別行 If-Match の不要な失効を防ぐ(§7.7 churn 回避)
-- 4. (現順序と異なる場合のみ)全現役を大きい正値オフセットへ一時退避(version も +1)UPDATE staff SET display_order = display_order + 1000000, version = version + 1, updated_at = now() WHERE store_id = $1 AND terminated_at IS NULL;
-- 5. ordered_ids の順で 1-based 連番に再採番(version はさらに +1 はしない、既に上で +1 済み)UPDATE staff SET display_order = 1, updated_at = now() WHERE id = $orderedIds[0] AND store_id = $1;UPDATE staff SET display_order = 2, updated_at = now() WHERE id = $orderedIds[1] AND store_id = $1;-- ...(実装は CASE WHEN で 1 クエリにまとめる)
COMMIT;オフセット 1000000 の根拠:
- 現役上限 200 を大きく上回る
- int4 max(≒ 21 億)まで余裕
display_order >= 1を常に満たす(CHECK 違反なし)- 全行が同じオフセット分シフトするため相対順序は保たれ、UNIQUE 違反なし
version の +1 は退避ステップで一括行うため、reorder 1 回で各行の version が +1 される(後続の個別 PATCH との見分けがつく)。
7.2.5 reorder 入力検証
Section titled “7.2.5 reorder 入力検証”ordered_idsの長さ === 現在の現役件数- 重複なし
- 全 id が当該店舗の現役(
terminated_at IS NULL)に存在 - 違反時は
STAFF.ORDER_PAYLOAD_INVALID(422)
shape 不正(UUID 形式違反等)は zod が VALIDATION_ERROR 400 で弾く。意味不正(集合不一致等)は domain が STAFF.ORDER_PAYLOAD_INVALID 422 で弾く。境界は §5.4.1。
7.3 退職化(terminated_at)の lifecycle 操作
Section titled “7.3 退職化(terminated_at)の lifecycle 操作”7.3.1 設計の意図
Section titled “7.3.1 設計の意図”- HTTP DELETE ではなく
POST /api/admin/staff/:staffId/retireという lifecycle 動詞を採用する - 理由:物理削除を意味しないため。HTTP DELETE は「リソースを取り除く」セマンティクスを持つが、退職スタッフはデータが取り除かれない(過去予約から参照され続ける)
- 親SRS §7.5 の
deleted_at規約(論理削除)とも区別されるべきステータス変化である
7.3.2 挙動と If-Match 優先順位
Section titled “7.3.2 挙動と If-Match 優先順位”- リクエスト到着時の処理順序(
If-Match検証が常に最優先):If-Matchヘッダ存在チェック → 欠落で 428- RLS で行存在チェック → 不在で 404
If-Match: <version>と DB 値の比較 → 不一致で 409- 既に
terminated_at IS NOT NULLなら冪等 200(state 変えず、監査ログも追加せず、レスポンスはそのまま現状のstaffを返す) - それ以外は
terminated_at = now()、version + 1、updated_at = now()で UPDATE
- 過去予約(
reservation.staff_id)は影響を受けない(複合FK は引き続き有効) - カレンダー・予約作成 UI は
terminated_at IS NULLでフィルタ - 予約系 API はサーバ側でも拒否(§6.3.1)
- 退職スタッフの
display_orderは変更しない(元の位置情報を残す)。ただし部分 UNIQUE インデックスはWHERE terminated_at IS NULLなので、退職後は他の現役とdisplay_orderが衝突してもよい
7.3.3 物理削除の禁止
Section titled “7.3.3 物理削除の禁止”- DELETE エンドポイントなし(API 層で 404)
appロールに DELETE GRANT なし(DB 層で拒否)- 親SRS §7.1.9
DISABLE ROW LEVEL SECURITY禁止と同列の不可侵原則
7.4 復職
Section titled “7.4 復職”POST /api/admin/staff/:staffId/restoreでterminated_at = NULL、version + 1、updated_at = now()- §7.3.2 と同じ
If-Match優先順位(428 → 404 → 409 → 冪等 200 → 更新) - 同一
staff_idを再利用するため、過去予約・売上集計・指名履歴と連続する - 復職時の
display_orderは現役末尾。「退職前の位置に戻す」は採用しない(運用混乱回避) - 別人として扱いたい場合は新規
staff行を作る(運用判断) - 現役件数が 200 に達している場合は 422
STAFF.LIMIT_EXCEEDED(advisory lock 取得後の件数確認で弾く)
7.5 件数上限ガード
Section titled “7.5 件数上限ガード”- create / restore のトランザクション冒頭、advisory lock 取得後に
SELECT COUNT(*) FROM staff WHERE store_id = $1 AND terminated_at IS NULLを発行 - カウント >= 200 なら 422
STAFF.LIMIT_EXCEEDEDで即座に ROLLBACK - 200 名は本SRSのハード上限。store_settings には置かず、
packages/domain/src/staff/constants.tsの定数で固定(OQ-MST-001-04 で店舗ごと可変への移管を将来検討)
7.6 監査
Section titled “7.6 監査”書込系すべてを operator_action_log に記録(親SRS §7.8)。
| action | target | diff(JSONB) |
|---|---|---|
staff.create | staff_id | 作成レコード全体 |
staff.update | staff_id | 変更前後の差分 |
staff.retire | staff_id | { terminated_at: ISO } |
staff.restore | staff_id | { terminated_at: null } |
staff.reorder | store_id | { before_ordered_ids: [...], after_ordered_ids: [...] } |
reorder は1リクエストで複数行を更新するため、target は store_id を取る(個別 staff 1件ずつログるとノイズが増える)。
冪等 200(既に終端状態だった retire / restore)は監査ログを追加しない。
7.7 collection_etag(reorder 楽観ロック)
Section titled “7.7 collection_etag(reorder 楽観ロック)”GET /api/admin/staff/all-activeのレスポンスdata.collection_etagを取得- 計算式:
SHA-256(JSON.stringify(現役 staff の { id, version } を display_order ASC, id ASC で並べた配列))- id だけでなく 行 version も含めることで、個別 PATCH と reorder の絡みでも検出できる(並び順は変わっていないが内容が変わったケース)
- reorder リクエスト時は
If-Match: <collection_etag>を必須 - サーバはトランザクション内で再計算 → 不一致なら 409
- ヘッダ値・JSON フィールド名ともに
collection_etag(snake_case)で統一
7.7.1 ETag churn の抑制(no-op reorder の short-circuit)
Section titled “7.7.1 ETag churn の抑制(no-op reorder の short-circuit)”- reorder リクエストの
ordered_idsが現順序と完全一致なら、サーバはversion/updated_at/ 監査ログを変更せずに 200 を返す(§7.2.4 step 3) - 理由:別画面で開いている個別行の
If-Match: <version>を不要に失効させないため。reorder UI の「保存」ボタンを並び替えなしで連打しても他のオペレーターの作業を壊さない - ETag は不変なので、no-op reorder 後に同じ
If-Match: <collection_etag>で再リクエストしても一貫して 200 を返す(冪等)
8. 非機能要件
Section titled “8. 非機能要件”親SRS §6 に従う。特記事項:
- API p95:
GET /api/admin/staff200ms 以下(マスタなのでキャッシュなしでも軽い、最大 200 件で全件返却) PUT /api/admin/staff/orderレイテンシ:p95 500ms 以下(advisory lock + 一時退避 + 再採番のトランザクション)- 店舗あたり現役スタッフ件数の上限:200 名(§1.3、§7.5 でハードガード)
9. セキュリティ・認可
Section titled “9. セキュリティ・認可”9.1 使用する permission キー
Section titled “9.1 使用する permission キー”親SRS §7.7.2 のキー命名規約 {scope}:{resource}:{action} 準拠。
| key | 用途 | 想定ロール(参考、正本は SRS-TEN-002 の PRESET_ROLE_PERMISSIONS) |
|---|---|---|
admin:staff:read | 一覧・詳細取得(退職含む) | 全プリセット |
admin:staff:create | 新規作成 | owner / manager |
admin:staff:update | 編集(氏名・指名可否)・並び替え | owner / manager |
admin:staff:retire | 退職化・復職 | owner / manager |
命名の補足:物理削除を意味しない lifecycle 操作のため delete ではなく retire を使う(§7.3.1 の動詞選定理由と整合)。retire / restore を 1 permission に集約しているのは、Phase 1 で粒度変更マイグレーション(親SRS §7.7.5)コストを抱えないため。将来分離が必要になれば split で対応可能。
初期割当の正本:本表は「想定」のみ。プリセット role への実際の付与は SRS-TEN-002 の PRESET_ROLE_PERMISSIONS 定数で確定する。本SRS実装時に SRS-TEN-002 と整合確認する(OQ-MST-001-06)。
チェックは packages/auth の requirePermission() 経由でルート定義に宣言的に貼る(親SRS §7.7.3)。
9.2 RLS
Section titled “9.2 RLS”staffはstore_idで RLS ENABLE + FORCEUSINGとWITH CHECKの双方を貼り、INSERT/UPDATE 時の越境も防止する- すべてのアクセスは Hono middleware で
SET LOCAL app.current_store_id後に実行 - リポジトリ IF は
storeIdを必須引数(親SRS §7.1.6)
9.3 PII
Section titled “9.3 PII”staff は氏名以外を持たない(性別・写真・連絡先なし)ため、PII リスクは最小。連絡先・性別・写真等の追加が必要になった場合は本SRSの拡張ではなく、別目的のSRS(HPB掲載=SRS-LST-006、もしくは労務管理用の別SRS)で扱う。
10. 受け入れ基準(Given-When-Then)
Section titled “10. 受け入れ基準(Given-When-Then)”10.1 正常系
Section titled “10.1 正常系”- GWT-1 主シナリオ作成:Given owner / When 氏名のみ入力して
POST /api/admin/staff/ Thenstaff行が作成されis_bookable=true,display_order=現役最大+1(empty なら 1),terminated_at=NULL,version=1で一覧末尾に表示される - GWT-2 受付専業作成:Given owner / When 氏名のみで作成 → 直後に
PATCH /api/admin/staff/:staffId(If-Match: 1)でis_bookable=false/ Then 予約作成 UI の担当候補に出ない、version=2 - GWT-3 退職化:Given 現役スタッフ A(version=1)/ When
POST /api/admin/staff/:staffId/retire+If-Match: 1/ Thenterminated_atが現在時刻、version=2、現役一覧から消える、過去予約のreservation.staff_id = A.idは引き続き参照可能 - GWT-4 復職:Given 退職済みスタッフ A(version=2)/ When
POST /api/admin/staff/:staffId/restore+If-Match: 2/ Thenterminated_at=NULL、version=3、display_orderが現役末尾、過去予約は同一staff_idで連続参照 - GWT-5 並び替え:Given 現役 [A, B, C] が
display_order=1,2,3、collection_etag=E/ WhenPUT /api/admin/staff/order { ordered_ids: [C, A, B] }+If-Match: E/ Thendisplay_order=1,2,3の順で C, A, B に再採番、各行 version +1 - GWT-6 現役一覧(ハード上限内):Given 現役 200、退職 50 / When
GET /api/admin/staff/ Then 現役 200 件のみ全件返却、truncated等のフィールドなし - GWT-7 退職一覧 cursor ページング(1ページ目):Given 退職 75 件 / When
GET /api/admin/staff/terminated?limit=50/ Then 50 件 +next_cursor非 null - GWT-8 退職一覧 cursor ページング(次ページ):Given GWT-7 の次ページ / When 同
cursorでGET /api/admin/staff/terminated/ Then 残り 25 件 +next_cursornull - GWT-9 reorder 用 all-active:Given 現役 3 / When
GET /api/admin/staff/all-active/ Then 現役 3 件 +data.collection_etagが SHA-256 hex で返る
10.2 異常系・契約遵守
Section titled “10.2 異常系・契約遵守”- GWT-10 並び替え不正ペイロード(漏れ):Given 現役 [A, B, C] / When
ordered_ids: [A, B]で reorder +If-Match: E/ Then 422STAFF.ORDER_PAYLOAD_INVALID、DB は不変 - GWT-11 並び替え不正ペイロード(重複):Given 現役 [A, B, C] / When
ordered_ids: [A, A, B]/ Then 422 - GWT-12 並び替え不正ペイロード(退職者混入):Given 現役 [A, B] + 退職 [C] / When
ordered_ids: [A, B, C]/ Then 422 - GWT-13 並び替え不正ペイロード(他店舗 id 混入):Given 店舗 X セッション / When 店舗 Y の id を含む reorder / Then 422
- GWT-14 並び替え shape 不正(zod):Given
ordered_ids: ["not-uuid"]/ When reorder / Then 400VALIDATION_ERROR(zod が UUID 形式違反で弾く、§5.4.1 境界) - GWT-15 reorder no-op short-circuit:Given 現役 [A, B, C] が
display_order=1,2,3、collection_etag=E0、各行version=v0/ WhenPUT /api/admin/staff/order { ordered_ids: [A, B, C] }+If-Match: E0/ Then 200 OK、各行のversion/updated_at据え置き、collection_etagもE0のまま、operator_action_logにstaff.reorder追加なし(§7.7.1) - GWT-16 RLS テナント分離:Given 店舗 X セッション / When 店舗 Y のスタッフ ID を指定して GET / Then 404
STAFF.NOT_FOUND - GWT-17 退職冪等(最新 If-Match):Given 既に退職済み A(version=2)/ When 再度
POST /api/admin/staff/:staffId/retire+If-Match: 2/ Then 200 OK、terminated_at更新なし、version 据え置き、監査ログ追加なし - GWT-18 退職冪等優先 If-Match(stale):Given 既に退職済み A(version=2)/ When 再度
POST /api/admin/staff/:staffId/retire+If-Match: 1(古い)/ Then 409STAFF.VERSION_MISMATCH(冪等より If-Match 検証が優先、§7.3.2) - GWT-19 復職冪等(最新 If-Match):Given 現役 A(version=1)/ When
POST /api/admin/staff/:staffId/restore+If-Match: 1/ Then 200 OK、変更なし、監査ログ追加なし - GWT-20 行レベル楽観ロック競合:Given スタッフ A の version=1 / When クライアント1 が
If-Match: 1で PATCH 成功(version=2 になる)→ クライアント2 がIf-Match: 1で PATCH / Then 409STAFF.VERSION_MISMATCH - GWT-21 行レベル
If-Match欠落:GivenPATCH /api/admin/staff/:staffIdリクエスト / WhenIf-Matchヘッダなし / Then 428STAFF.PRECONDITION_REQUIRED - GWT-22 行レベル
If-Matchshape 不正:GivenPATCH /api/admin/staff/:staffId/ WhenIf-Match: "abc"(非整数)/ Then 400VALIDATION_ERROR(zodIfMatchVersionHeaderで弾く) - GWT-23 コレクション楽観ロック競合:Given クライアント1 と 2 が同じ
collection_etag = E0を取得 / When クライアント1 が reorder 成功 → クライアント2 がIf-Match: E0で reorder / Then 409STAFF.COLLECTION_ETAG_MISMATCH - GWT-24 コレクション ETag は version も含む:Given 現役 [A, B] が並び順 [1,2]、E0 / When 並び順は変わらず A だけ PATCH(version 更新)→ クライアント2 が
If-Match: E0で reorder / Then 409STAFF.COLLECTION_ETAG_MISMATCH(並び順は変わっていなくても version 変更で ETag が変わる、§7.7) - GWT-25 reorder
If-Match欠落:GivenPUT /api/admin/staff/orderリクエスト / WhenIf-Matchヘッダなし / Then 428STAFF.PRECONDITION_REQUIRED - GWT-26 認可拒否(create):Given
staffロールの operator / WhenPOST /api/admin/staff/ Then 403STAFF.FORBIDDEN - GWT-27 認可拒否(retire):Given
staffロール / WhenPOST /api/admin/staff/:staffId/retire/ Then 403 - GWT-28 物理削除エンドポイントなし:Given 任意 / When
DELETE /api/admin/staff/:staffId/ Then 404 もしくは 405(エンドポイントが定義されていない) - GWT-29 DB DELETE GRANT なし:Given
appロールで DB 接続 / When 生 SQLDELETE FROM staff WHERE id = ...を実行 / Thenpermission denied for table staff - GWT-30 名前空白拒否(zod 経由):Given
name = " "(全角/半角空白のみ)/ WhenPOST /api/admin/staff/ Then 400VALIDATION_ERROR(zod のtrim().min(1)パイプで弾かれる、§5.4.1 境界) - GWT-31 名前空白拒否(DB CHECK):Given アプリのバリデーションを迂回 / When DB に直接 INSERT で
name = ' '/ Thenstaff_name_not_blankCHECK 違反(DB 二重防御) - GWT-32 並列作成 display_order 一意性:Given 同時 5 並列で create / Then 全件成功し
display_orderに重複なし、advisory lock + 部分 UNIQUE で保証 - GWT-33 並列 reorder/create 競合:Given クライアント1 が reorder 中、同時にクライアント2 が create / Then advisory lock により直列化、両方成功(create は reorder 後の末尾に並ぶ)
- GWT-34 件数上限超過(create):Given 現役 200 件 / When
POST /api/admin/staff/ Then 422STAFF.LIMIT_EXCEEDED - GWT-35 件数上限超過(restore):Given 現役 200 件、退職 1 件 / When 退職 1 件を
POST /api/admin/staff/:staffId/restore/ Then 422STAFF.LIMIT_EXCEEDED - GWT-36 監査記録:Given create / update / retire / restore / reorder を発火 / Then 各操作が
operator_action_logに対応 action 名で記録される - GWT-37 サーバ側 reservation 拒否(terminated):Given staff A が退職済み / When
POST /api/admin/reservations { staff_id: A.id, ... }/ Then 422RESERVATION.STAFF_NOT_BOOKABLE(SRS-RES-002 の改訂で実装、本SRSの blocked-by §6.3.3) - GWT-38 サーバ側 reservation 拒否(is_bookable=false):Given staff A が
is_bookable=false(現役)/ When 予約作成 / Then 422RESERVATION.STAFF_NOT_BOOKABLE
11. テスト計画
Section titled “11. テスト計画”- Unit (
packages/domain):display_order採番ロジック(empty case = 1、max + 1)- reorder 入力検証(漏れ・重複・退職者混入・他店舗混入)
- reorder no-op short-circuit 判定(ordered_ids 完全一致時)
- 退職/復職の状態遷移(idempotency × If-Match 優先順位)
collection_etag計算(SHA-256 安定性、{ id, version }配列のシリアライズ)- 件数上限の境界条件(199 → 200 → 201)
- 退職一覧 cursor のエンコード/デコード(base64(
(terminated_at, id)) の安定性)
- Integration (
apps/api+ Testcontainers Postgres):- GWT-1〜36 を網羅
- RLS 効力テスト(
appロールでSET LOCAL app.current_store_id切替) - 物理削除の二重防御(GRANT なし + エンドポイントなし)
- 並列 create / reorder の競合(advisory lock 動作確認)
- 部分 UNIQUE インデックスの効力
- DB CHECK 制約の発火(直接 INSERT で確認)
- 楽観ロック(行レベル
version/ コレクションレベルcollection_etag)の正常・不一致・欠落・shape 不正 updated_atがアプリ層で常に書き込まれる(DB トリガなしを保証する meta テスト)- 退職一覧 cursor のページング整合性(行が追加・削除されても順序が崩れないこと)
- Contract:zod スキーマと OpenAPI 出力の整合(query / header / response envelope すべて)
- Cross-SRS:GWT-37 / GWT-38 は SRS-RES-002 改訂と合わせて実装。本SRS単独では検証しない(§6.3.3 blocked-by)
- E2E:Phase 1 では本SRS単独 E2E は不要。SRS-UI-001 導入時に再検討
12. 関連ジョブ(graphile-worker)
Section titled “12. 関連ジョブ(graphile-worker)”Phase 1 ではなし。
将来候補(本SRS範囲外):
- 退職後 N 年経過したスタッフの匿名化ジョブ(個人情報保護法対応、Phase 3 以降)
13. Open Questions
Section titled “13. Open Questions”| # | 内容 | 締切の目安 |
|---|---|---|
| OQ-MST-001-01 | 並び替え UI を DnD のみとするか、数値直接編集も併設するか(数十件規模での慣れ/アクセシビリティ) | 実装着手時 |
| OQ-MST-001-02 | operator_staff_link 導入のタイミング(SRS-RES-001 のロール別表示と一緒に出すのが自然か) | Phase 2 計画時、別 SRS 起票 |
| OQ-MST-001-03 | 休職/異動など terminated_at 二値で表せない状態が必要になった時、staff_status enum への拡張で扱う方針でよいか(その時の API 後方互換) | 実需が出た時 |
| OQ-MST-001-04 | 現役スタッフ件数のハード上限(200 名)を store_settings.max_active_staff に移管して店舗ごとに変えられるようにすべきか(大型店の運用観察後) | Phase 2 運用観察後 |
| OQ-MST-001-05 | スタッフ氏名の同店舗内 UNIQUE を将来導入するか、最終的に運用ガイドラインに任せるか | Phase 2 運用観察後 |
| OQ-MST-001-06 | permission の初期割当(プリセット role × 本SRS 4 キー)を SRS-TEN-002 の PRESET_ROLE_PERMISSIONS に取り込む際の整合確認 | SRS-TEN-002 と並行調整 |
親SRS昇格候補:なし(本SRSの議論で発見された親方針への影響は SRS-ROOT-001 v0.3 で吸収済み)。
14. 変更履歴
Section titled “14. 変更履歴”| Version | Date | Author | Change |
|---|---|---|---|
| 0.1 | 2026-04-25 | yudai | 初版起票(Draft)。最小スキーマ(name/is_bookable/display_order/terminated_at)、terminated_at 採用、operator_staff_link を Phase 1 はスキーマのみ、permission 4 キー(read/create/update/delete)、menu_staff_eligibility は SRS-MST-002 所有、HPB 掲載情報は SRS-LST-006 へ委譲、display_order の DB 部分一意制約は採用せず一括 PUT で整合担保 |
| 0.2 | 2026-04-25 | yudai | codex (gpt-5.4) 1st レビュー反映: ① permission delete → retire に改名、API も POST /retire lifecycle 動詞に変更(親SRS §7.5 deleted_at 規約との衝突回避)、② zod スキーマを prose から実コードへ、③ display_order 部分 UNIQUE インデックス + advisory lock + 一時退避方式の reorder で race-safe 化、④ 行レベル楽観ロック(If-Match: <updated_at>)+ コレクションレベル楽観ロック(collection_etag)導入、⑤ operator_staff_link を Phase 1 から完全に外す、⑥ §6.3 で予約系SRSへの規範要求(サーバ側 retired/non-bookable 拒否)を明文化、⑦ permission 初期割当の正本を SRS-TEN-002 に委譲、⑧ §7.14 委譲対象を menu_staff_eligibility + menu_equipment_requirement + restrict_staff_required フラグまで拡張、⑨ GWT を 27 本に拡充、⑩ API パスをフルパス /api/admin/staff/... に統一 |
| 0.3 | 2026-04-25 | yudai | codex (gpt-5.4) 2nd レビュー反映: ① 行レベル楽観ロックを If-Match: <updated_at> から If-Match: <version integer> へ変更(タイムスタンプ精度問題回避、staff.version カラム新設)、② updated_at の責務を「アプリ層で UPDATE 文に明示書き込み」に統一(DB トリガ削除)、③ 件数ハード上限 200 名を §1.3 / §7.5 で明文化、create / restore で STAFF.LIMIT_EXCEEDED 422、④ 一覧 query に include_terminated=false|true|only を追加、reorder 用に別エンドポイント GET /api/admin/staff/all-active を分離(contract 上の責務分割)、⑤ If-Match と冪等の優先順位を §7.3.2 / §7.4 で明文化(428→404→409→冪等200→更新の順序)、⑥ エラーコード分類規約 §5.4.1 を新設(zod は VALIDATION_ERROR 400、domain は STAFF.* 422 等)、⑦ sibling SRS(RES-002 / RES-005)改訂を OQ から blocked-by(マージ条件) に格上げ、⑧ collection_etag の casing を snake_case に統一、計算式に行 version を含めて並び順不変でも個別変更を検出可能に、⑨ §1.3 への壊れた参照を §7.3.1 に修正、⑩ GWT を 35 本に拡充(件数上限・退職者一覧取得・shape 不正 vs 意味不正・冪等優先 If-Match・version ベース ETag を追加) |
| 0.4 | 2026-04-25 | yudai | codex (gpt-5.4) 3rd レビュー反映: ① 一覧 API の責務分割を徹底。include_terminated query を廃止し、退職一覧を GET /api/admin/staff/terminated に独立(cursor ページング)、現役は GET /api/admin/staff 全件返却(truncate なし、§5.1.1)。silent truncate 問題解消、② query / header の zod 契約を実コード化(TerminatedListQuery, IfMatchVersionHeader, IfMatchCollectionEtagHeader)、③ reorder の no-op short-circuit を §7.2.4 step 3 と §7.7.1 で明文化(ordered_ids 完全一致なら version/updated_at/監査全部据え置き)。GWT-15 で検証、④ blocked-by に SRS-RES-002 の menu_eligibility 旧称撤去を追加(親SRS v0.3 §7.14 整合、§6.3.3)、⑤ STAFF.NAME_INVALID の説明を「DB CHECK 違反相当の防御的 fallback、UI 経路では到達しない」と整理、⑥ GWT 内の API パス短縮形(POST /A.id/retire 等)を全部フルパスに修正、If-Match shape 不正 GWT(GWT-22)を追加、⑦ GWT を 38 本に拡充(退職者 cursor ページング 2本・no-op reorder 1本・If-Match shape 不正 1本を追加) |
| 0.5 | 2026-04-25 | yudai | codex (gpt-5.4) 4th レビュー(Approve 判定)反映: Low 3 件のクリーンアップ。① §3.3 EF-2/3/4 の章参照を §7.6 から実在する §7.3.2 / §7.4 / §7.7 に修正、② If-Match の値表記を unquoted で統一(RFC 7232 opaque-tag は採用せず)。§5.3.1 / §5.3.2 に「unquoted、引用符 " なし」と明記し、shape 不正(quoted 等)は 400 VALIDATION_ERROR と契約化、③ §5.2「契約境界の明文化」に残っていた API パス短縮形(PATCH/POST :id/retire/...)をフルパスに統一 |