コンテンツにスキップ

スタッフ管理

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

親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 の値は仮置き
  • 店舗あたり現役スタッフ件数の上限:200 名terminated_at IS NULL の件数)
  • create / restore で上限超過なら 422 STAFF.LIMIT_EXCEEDED で拒否(§7.5)
  • 上限ガード値は store_settings には置かず、本SRSの定数で固定(packages/domain/src/staff/constants.tsMAX_ACTIVE_STAFF = 200)。将来店舗ごとに変えたくなったら store_settings.max_active_staff への移管を検討(OQ-MST-001-04)
  • 退職者は無制限(過去履歴は永続)

  • 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.1 主シナリオ(スタッフ作成)

Section titled “3.1 主シナリオ(スタッフ作成)”
  1. 店長が管理画面「設定 > スタッフ」へ移動
  2. 「新規追加」ボタンをクリック → ダイアログが開く
  3. 氏名(name)を入力(必須)
  4. 「保存」をクリック
  5. システムがトランザクション内で店舗単位 advisory lock を取得し、現役件数が 200 未満であることを確認 → display_order = MAX(現役の display_order) + 1(現役が 0 件なら 1)を採番
  6. staff 行を作成(is_bookable = trueterminated_at = NULLversion = 1
  7. 一覧の末尾に新スタッフが表示される
  8. operator_action_logstaff.create を記録
  • 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/restoreterminated_at = NULL)。同一 staff_id で過去予約・履歴と連続する
  • 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 欠落:428 STAFF.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 は親SRS §7.12 に従い最低限の粒度で記述する。デザインシステム導入SRS(仮 SRS-UI-001)リリース時に一括改訂される前提。

「設定 > スタッフ」配下の スタッフマスタ管理画面。スタッフの一覧表示・新規作成・編集・退職化・並び替えを 1 画面で完結させる。

  • 一覧テーブル:氏名 / 指名可否 / 並び順番号 / 状態(現役 or 退職)
  • フィルタトグル:「退職スタッフを表示」(既定 OFF、ON で退職一覧 GET /api/admin/staff/terminated を cursor ページングで取得)
  • 「新規追加」ボタン → 作成ダイアログ
  • 各行のアクション:編集 / 退職にする(現役行)/ 復職する(退職行)
  • DnD ハンドル:現役行のみドラッグで並び替え可。確定タイミングで一括保存
  • 作成・編集ダイアログ:氏名(必須)/ 指名可否トグル
  • 検索・高度なフィルタは持たない(数十件規模で過剰、§1.2 と整合)
  • 氏名:1〜100 文字、トリム後の空文字列は不可
  • 指名可否:boolean(既定 true)
  • 並び順:数値直接編集は UI に出さない(DnD のみ)
  1. 設定メニューから「スタッフ」を開く → 現役一覧(GET /api/admin/staff
  2. 「新規追加」→ 氏名入力 → 保存 → 末尾に追加
  3. 行の「編集」→ ダイアログで変更 → 保存
  4. 行を DnD で並び替え → 「並び順を保存」→ 一括反映(コレクション楽観ロック)
  5. 「退職にする」→ 確認 → 退職一覧に移動。トグルで再表示可
  6. トグル ON → 退職一覧(GET /api/admin/staff/terminated?cursor=...)→「復職する」→ 現役末尾に戻る

すべて親SRS §7.6 のパスプレフィクス /api/admin/* 配下、オペレーター認証必須。

MethodPath用途必要 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-activereorder 用の現役全件取得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:updateIf-Match: <version> 必須
PUT/api/admin/staff/order並び順一括更新admin:staff:updateIf-Match: <collection_etag> 必須
POST/api/admin/staff/:staffId/retire退職化(terminated_at = now()、§7.3 で詳述)admin:staff:retireIf-Match: <version> 必須
POST/api/admin/staff/:staffId/restore復職(terminated_at = NULL、現役上限 200)admin:staff:retireIf-Match: <version> 必須

store_id はセッションから解決し body には含めない。

現役と退職を別エンドポイントに分離することで、件数特性の違い(現役は上限 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 に整合)
    • 退職者は無制限に蓄積されるため、必ずページングで取る
  • GET /api/admin/staff/all-activereorder 用の現役全件 + collection_etag

    • GET /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: TerminatedListQuery
  • GET /api/admin/staff/all-active のレスポンス: StaffReorderListEnvelope
  • PATCH /api/admin/staff/:staffId / POST /api/admin/staff/:staffId/retire / POST /api/admin/staff/:staffId/restore の header: IfMatchVersionHeaderrequirePrecondition() ミドルウェアで検証
  • PUT /api/admin/staff/order の header: IfMatchCollectionEtagHeaderrequirePrecondition() ミドルウェアで検証

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_ERRORIfMatchVersionHeader で弾く)
  • 不一致 → 409 STAFF.VERSION_MISMATCH
  • 欠落 → 428 STAFF.PRECONDITION_REQUIRED
  • UPDATE 文(アプリ層、packages/db):
    UPDATE staff
    SET ..., version = version + 1, updated_at = now()
    WHERE id = $1 AND store_id = $2 AND version = $3
    RETURNING *
    影響行 0 件 → 409 STAFF.VERSION_MISMATCH

5.3.2 コレクションレベル(PUT /order)

Section titled “5.3.2 コレクションレベル(PUT /order)”
  • If-Match: <collection_etag> 必須(フィールド名・ヘッダ値ともに snake_case collection_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_ERRORIfMatchCollectionEtagHeader で弾く)
  • 不一致 → 409 STAFF.COLLECTION_ETAG_MISMATCH、UI は all-active を再取得
  • 欠落 → 428 STAFF.PRECONDITION_REQUIRED
  • VALIDATION_ERROR 400: zod パース失敗(型・shape 不正・require 欠落)。汎用バリデーションエラーで、details に zod issue を含める
  • STAFF.* 4xx: zod パスは通ったが domain の業務ルールで弾かれたエラー。HTTP は意味別(422 = unprocessable, 404 = not found, 409 = conflict, 428 = precondition required, 403 = forbidden)
code意味HTTP
VALIDATION_ERRORzod バリデーション失敗(shape 不正、型不正、必須欠落)400
STAFF.NAME_INVALIDdomain 層の防御的エラー(DB CHECK staff_name_not_blank 違反相当)。通常 zod の trimmedName パイプで先に VALIDATION_ERROR 400 になるため UI 経路では到達しないが、API 直接攻撃や race による境界取りこぼしの fallback として 422 ルートを残す422
STAFF.ORDER_PAYLOAD_INVALIDordered_ids が現役集合と意味的に不一致(漏れ・重複・退職者混入・他店舗 id 混入)。zod では shape のみ検証、集合一致は domain で検証422
STAFF.LIMIT_EXCEEDED現役件数 200 上限超過(create / restore)422
STAFF.NOT_FOUND該当 ID なし(RLS 越境含む、行が見えないため一律 404)404
STAFF.FORBIDDENpermission 不足403
STAFF.VERSION_MISMATCH行レベル楽観ロック失敗(If-Match: <version> 不一致)409
STAFF.COLLECTION_ETAG_MISMATCHコレクションレベル楽観ロック失敗(reorder の If-Match 不一致)409
STAFF.PRECONDITION_REQUIREDIf-Match ヘッダ欠落428

カラム制約説明
iduuidPKUUIDv7、アプリ層で生成(親SRS §7.15: PK は単独)
store_iduuidNOT NULL, FK → store(id)テナント
namevarchar(100)NOT NULL, CHECK btrim(name) <> ''氏名(同店舗内重複可)
is_bookablebooleanNOT NULL, DEFAULT true指名候補に出すか
display_orderintegerNOT NULL, CHECK >= 1並び順、1-based 連番。一括 reorder で再採番
terminated_attimestamptzNULL非NULL = 退職済み。lifecycle 状態の表現(§7.3)
versionintegerNOT NULL, DEFAULT 1, CHECK >= 1行レベル楽観ロックの ETag。INSERT 時 1、UPDATE 毎にアプリ層で +1
created_attimestamptzNOT NULL, DEFAULT now()
updated_attimestamptzNOT 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)。

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 が以下のいずれかなら 422 RESERVATION.STAFF_NOT_BOOKABLE を返してサーバ側で拒否する
    • staff.terminated_at IS NOT NULL
    • staff.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_BOOKABLE 422 を契約・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 フラグ参照)へ置換する
  • SRS-RES-005 改訂: §6.3.2 の advisory lock との非競合を §7.7 race condition 対策に明記
  • 親SRS / ERD: 既に v0.3 で terminated_at 整合済み(追加改訂不要)

OQ には逃がさない。本SRSのマージ条件として上記2件の sibling 改訂PRを同一マージウェーブで要求する。


7.1 is_bookableterminated_at の意味分離

Section titled “7.1 is_bookable と terminated_at の意味分離”
状態terminated_atis_bookable意味一覧表示予約候補
通常稼働NULLtrue現役・指名可ありあり
休職/受付専業NULLfalse現役だが指名対象外ありなし
退職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_idhashtextextended で int8 化)
  • 効果: 同一店舗内の staff 採番・並び替え操作が直列化される
  • 範囲はトランザクション内のみ(COMMIT/ROLLBACK で自動解放)
  • 通常の SELECT は影響を受けない(advisory lock は明示取得のみ)
  • 新規作成: 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)
  • 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 との見分けがつく)。

  1. ordered_ids の長さ === 現在の現役件数
  2. 重複なし
  3. 全 id が当該店舗の現役(terminated_at IS NULL)に存在
  4. 違反時は 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 操作”
  • HTTP DELETE ではなく POST /api/admin/staff/:staffId/retire という lifecycle 動詞を採用する
  • 理由:物理削除を意味しないため。HTTP DELETE は「リソースを取り除く」セマンティクスを持つが、退職スタッフはデータが取り除かれない(過去予約から参照され続ける)
  • 親SRS §7.5 の deleted_at 規約(論理削除)とも区別されるべきステータス変化である
  • リクエスト到着時の処理順序(If-Match 検証が常に最優先):
    1. If-Match ヘッダ存在チェック → 欠落で 428
    2. RLS で行存在チェック → 不在で 404
    3. If-Match: <version> と DB 値の比較 → 不一致で 409
    4. 既に terminated_at IS NOT NULL なら冪等 200(state 変えず、監査ログも追加せず、レスポンスはそのまま現状の staff を返す)
    5. それ以外は terminated_at = now()version + 1updated_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 が衝突してもよい
  • DELETE エンドポイントなし(API 層で 404)
  • app ロールに DELETE GRANT なし(DB 層で拒否)
  • 親SRS §7.1.9 DISABLE ROW LEVEL SECURITY 禁止と同列の不可侵原則
  • POST /api/admin/staff/:staffId/restoreterminated_at = NULLversion + 1updated_at = now()
  • §7.3.2 と同じ If-Match 優先順位(428 → 404 → 409 → 冪等 200 → 更新)
  • 同一 staff_id を再利用するため、過去予約・売上集計・指名履歴と連続する
  • 復職時の display_order は現役末尾。「退職前の位置に戻す」は採用しない(運用混乱回避)
  • 別人として扱いたい場合は新規 staff 行を作る(運用判断)
  • 現役件数が 200 に達している場合は 422 STAFF.LIMIT_EXCEEDED(advisory lock 取得後の件数確認で弾く)
  • 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 で店舗ごと可変への移管を将来検討)

書込系すべてを operator_action_log に記録(親SRS §7.8)。

actiontargetdiff(JSONB)
staff.createstaff_id作成レコード全体
staff.updatestaff_id変更前後の差分
staff.retirestaff_id{ terminated_at: ISO }
staff.restorestaff_id{ terminated_at: null }
staff.reorderstore_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 を返す(冪等)

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

  • API p95GET /api/admin/staff 200ms 以下(マスタなのでキャッシュなしでも軽い、最大 200 件で全件返却)
  • PUT /api/admin/staff/order レイテンシ:p95 500ms 以下(advisory lock + 一時退避 + 再採番のトランザクション)
  • 店舗あたり現役スタッフ件数の上限:200 名(§1.3、§7.5 でハードガード)

親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/authrequirePermission() 経由でルート定義に宣言的に貼る(親SRS §7.7.3)。

  • staffstore_id で RLS ENABLE + FORCE
  • USINGWITH CHECK の双方を貼り、INSERT/UPDATE 時の越境も防止する
  • すべてのアクセスは Hono middleware で SET LOCAL app.current_store_id 後に実行
  • リポジトリ IF は storeId を必須引数(親SRS §7.1.6)

staff は氏名以外を持たない(性別・写真・連絡先なし)ため、PII リスクは最小。連絡先・性別・写真等の追加が必要になった場合は本SRSの拡張ではなく、別目的のSRS(HPB掲載=SRS-LST-006、もしくは労務管理用の別SRS)で扱う。


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

Section titled “10. 受け入れ基準(Given-When-Then)”
  • GWT-1 主シナリオ作成:Given owner / When 氏名のみ入力して POST /api/admin/staff / Then staff 行が作成され is_bookable=true, display_order=現役最大+1(empty なら 1), terminated_at=NULL, version=1 で一覧末尾に表示される
  • GWT-2 受付専業作成:Given owner / When 氏名のみで作成 → 直後に PATCH /api/admin/staff/:staffIdIf-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 / Then terminated_at が現在時刻、version=2、現役一覧から消える、過去予約の reservation.staff_id = A.id は引き続き参照可能
  • GWT-4 復職:Given 退職済みスタッフ A(version=2)/ When POST /api/admin/staff/:staffId/restore + If-Match: 2 / Then terminated_at=NULLversion=3display_order が現役末尾、過去予約は同一 staff_id で連続参照
  • GWT-5 並び替え:Given 現役 [A, B, C] が display_order=1,2,3collection_etag=E / When PUT /api/admin/staff/order { ordered_ids: [C, A, B] } + If-Match: E / Then display_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 同 cursorGET /api/admin/staff/terminated / Then 残り 25 件 + next_cursor null
  • GWT-9 reorder 用 all-active:Given 現役 3 / When GET /api/admin/staff/all-active / Then 現役 3 件 + data.collection_etag が SHA-256 hex で返る
  • GWT-10 並び替え不正ペイロード(漏れ):Given 現役 [A, B, C] / When ordered_ids: [A, B] で reorder + If-Match: E / Then 422 STAFF.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 400 VALIDATION_ERROR(zod が UUID 形式違反で弾く、§5.4.1 境界)
  • GWT-15 reorder no-op short-circuit:Given 現役 [A, B, C] が display_order=1,2,3collection_etag=E0、各行 version=v0 / When PUT /api/admin/staff/order { ordered_ids: [A, B, C] } + If-Match: E0 / Then 200 OK、各行の version / updated_at 据え置き、collection_etagE0 のまま、operator_action_logstaff.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 409 STAFF.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 409 STAFF.VERSION_MISMATCH
  • GWT-21 行レベル If-Match 欠落:Given PATCH /api/admin/staff/:staffId リクエスト / When If-Match ヘッダなし / Then 428 STAFF.PRECONDITION_REQUIRED
  • GWT-22 行レベル If-Match shape 不正:Given PATCH /api/admin/staff/:staffId / When If-Match: "abc"(非整数)/ Then 400 VALIDATION_ERROR(zod IfMatchVersionHeader で弾く)
  • GWT-23 コレクション楽観ロック競合:Given クライアント1 と 2 が同じ collection_etag = E0 を取得 / When クライアント1 が reorder 成功 → クライアント2 が If-Match: E0 で reorder / Then 409 STAFF.COLLECTION_ETAG_MISMATCH
  • GWT-24 コレクション ETag は version も含む:Given 現役 [A, B] が並び順 [1,2]、E0 / When 並び順は変わらず A だけ PATCH(version 更新)→ クライアント2 が If-Match: E0 で reorder / Then 409 STAFF.COLLECTION_ETAG_MISMATCH(並び順は変わっていなくても version 変更で ETag が変わる、§7.7)
  • GWT-25 reorder If-Match 欠落:Given PUT /api/admin/staff/order リクエスト / When If-Match ヘッダなし / Then 428 STAFF.PRECONDITION_REQUIRED
  • GWT-26 認可拒否(create):Given staff ロールの operator / When POST /api/admin/staff / Then 403 STAFF.FORBIDDEN
  • GWT-27 認可拒否(retire):Given staff ロール / When POST /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 生 SQL DELETE FROM staff WHERE id = ... を実行 / Then permission denied for table staff
  • GWT-30 名前空白拒否(zod 経由):Given name = " "(全角/半角空白のみ)/ When POST /api/admin/staff / Then 400 VALIDATION_ERROR(zod の trim().min(1) パイプで弾かれる、§5.4.1 境界)
  • GWT-31 名前空白拒否(DB CHECK):Given アプリのバリデーションを迂回 / When DB に直接 INSERT で name = ' ' / Then staff_name_not_blank CHECK 違反(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 422 STAFF.LIMIT_EXCEEDED
  • GWT-35 件数上限超過(restore):Given 現役 200 件、退職 1 件 / When 退職 1 件を POST /api/admin/staff/:staffId/restore / Then 422 STAFF.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 422 RESERVATION.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 422 RESERVATION.STAFF_NOT_BOOKABLE

  • 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 導入時に再検討

Phase 1 ではなし

将来候補(本SRS範囲外):

  • 退職後 N 年経過したスタッフの匿名化ジョブ(個人情報保護法対応、Phase 3 以降)

#内容締切の目安
OQ-MST-001-01並び替え UI を DnD のみとするか、数値直接編集も併設するか(数十件規模での慣れ/アクセシビリティ)実装着手時
OQ-MST-001-02operator_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-06permission の初期割当(プリセット role × 本SRS 4 キー)を SRS-TEN-002 の PRESET_ROLE_PERMISSIONS に取り込む際の整合確認SRS-TEN-002 と並行調整

親SRS昇格候補:なし(本SRSの議論で発見された親方針への影響は SRS-ROOT-001 v0.3 で吸収済み)。


VersionDateAuthorChange
0.12026-04-25yudai初版起票(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.22026-04-25yudaicodex (gpt-5.4) 1st レビュー反映: ① permission deleteretire に改名、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.32026-04-25yudaicodex (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.42026-04-25yudaicodex (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.52026-04-25yudaicodex (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/...)をフルパスに統一