シフト管理
Document ID: SRS-MST-004 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-RES-002 v0.5, SRS-RES-003, SRS-RES-005 v0.4
本書は SRS-ROOT-001 v0.5 に従う。
スタッフごとの勤務可能時間を日単位で管理し、予約作成・編集時の「シフト外警告」の canonical source を提供する。Phase 1 は勤務可否の警告までであり、保存ブロックはしない。
breaks_jsonb の整合性は DB 関数 validate_shift_breaks_jsonb() で強制する。
2. ユーザーストーリー
Section titled “2. ユーザーストーリー”- As a 店長, I want to 月単位でシフトをまとめて差し替えたい, so that 月次調整を速く終えられる
- As a 店長, I want to 1 日単位でも修正したい, so that 欠勤や早退に対応できる
- As a 受付, I want to シフト外予約を警告表示したい, so that リスクを見た上で保存できる
- As a 店長, I want to スタッフ退職時に未来シフトを消したい, so that 死んだ予定を残さない
3. ユースケース
Section titled “3. ユースケース”3.1 主シナリオ(月一括 replace)
Section titled “3.1 主シナリオ(月一括 replace)”- オペレーターが対象月のシフト一覧を取得する
collection_etagを受け取る- 月内の行を UI 上で編集する
PUT /api/admin/shifts/months/:yearMonthに collection 全体を送る- サーバが ETag と payload 整合を検証する
- 差分更新する
- 既存予約と不整合があれば warnings を返す
- 保存自体は成功する
3.2 代替フロー
Section titled “3.2 代替フロー”- AF-1 単日作成:
POST /api/admin/shifts - AF-2 単日更新:
PATCH /api/admin/shifts/:shiftId - AF-3 単日削除:
DELETE /api/admin/shifts/:shiftId(work_starts_at > now()のみ許可、親 §7.5 例外) - AF-4 staff 退職: retire listener が
work_starts_at >= retired_atの future shift を物理削除する
3.3 例外フロー
Section titled “3.3 例外フロー”- EF-1 同一 staff 同一
work_date重複: 409SHIFT.DATE_CONFLICT - EF-2
If-Match不一致: 409 - EF-3
breaks_jsonb不正(DB 関数で reject): 422SHIFT.BREAKS_INVALID - EF-4
work_starts_at >= work_ends_at: 422 - EF-5
work_dateと JST date 不整合: 422SHIFT.WORK_DATE_MISMATCH - EF-6 stale month ETag: 409
- EF-7 過去シフト delete: 422
SHIFT.PAST_DELETE_FORBIDDEN
4. UI仕様
Section titled “4. UI仕様”4.1 画面の目的
Section titled “4.1 画面の目的”月単位の staff schedule を一覧編集し、必要なら日単位微修正する。
4.2 主要要素
Section titled “4.2 主要要素”- 月切替
- スタッフ × 日付グリッド
- 単日編集ダイアログ
- 月一括保存ボタン
- 予約衝突 warnings 表示領域
4.3 バリデーション
Section titled “4.3 バリデーション”work_date:YYYY-MM-DDwork_starts_at,work_ends_at: ISO8601breaks_jsonb: 配列、最大 3 件- 各 break は
[start, end)、勤務時間内、互いに非重複(DB 関数で強制)
5. API仕様
Section titled “5. API仕様”5.1 エンドポイント一覧
Section titled “5.1 エンドポイント一覧”| Method | Path | 用途 | permission | 楽観ロック |
|---|---|---|---|---|
| GET | /api/admin/shifts?month=YYYY-MM | 月一覧 + collection ETag | admin:shift:write(read 統合) | - |
| GET | /api/admin/shifts/:shiftId | 単一詳細 | admin:shift:write | - |
| POST | /api/admin/shifts | 単日作成 | admin:shift:write | - |
| PATCH | /api/admin/shifts/:shiftId | 単日更新 | admin:shift:write | If-Match: <version> |
| DELETE | /api/admin/shifts/:shiftId | 単日削除(future のみ) | admin:shift:write | If-Match: <version> |
| PUT | /api/admin/shifts/months/:yearMonth | 月一括 replace | admin:shift:write | If-Match: <collection_etag> |
Phase 1 では read 専用 permission を切らず、admin:shift:write 1 個で運用。read-only 運用は Phase 2 で admin:shift:read 分離 OQ。
5.2 checkShiftCoverage 契約
Section titled “5.2 checkShiftCoverage 契約”export type ShiftBreak = { startsAt: Temporal.Instant; endsAt: Temporal.Instant;};
export type ShiftRow = { staffId: string; workDate: string; workStartsAt: Temporal.Instant; workEndsAt: Temporal.Instant; breaks: ShiftBreak[];};
export type CheckShiftCoverageInput = { reservationStartsAt: Temporal.Instant; reservationEndsAt: Temporal.Instant; shift: ShiftRow | null;};
export type CheckShiftCoverageOutput = { covered: boolean; reason: 'covered' | 'no_shift' | 'outside_shift' | 'on_break' | 'cross_day';};5.3 collection_etag
Section titled “5.3 collection_etag”月一覧の ETag は MD5 of ordered JSON_AGG。並び work_date ASC, staff_id ASC, id ASC で正規化。空集合は md5('[]')。
5.4 エラーコード
Section titled “5.4 エラーコード”| code | 意味 | HTTP |
|---|---|---|
SHIFT.NOT_FOUND | 行なし | 404 |
SHIFT.VERSION_MISMATCH | 行 ETag 不一致 | 409 |
SHIFT.COLLECTION_ETAG_MISMATCH | 月 ETag 不一致 | 409 |
SHIFT.DATE_CONFLICT | 同一 staff/date 重複 | 409 |
SHIFT.TIME_RANGE_INVALID | 開始終了不正 | 422 |
SHIFT.WORK_DATE_MISMATCH | work_date と JST date 不整合 | 422 |
SHIFT.BREAKS_INVALID | breaks 不正 | 422 |
SHIFT.PAST_DELETE_FORBIDDEN | 過去 shift delete 試行 | 422 |
6. データモデル影響
Section titled “6. データモデル影響”6.1 validate_shift_breaks_jsonb() DB 関数
Section titled “6.1 validate_shift_breaks_jsonb() DB 関数”CREATE OR REPLACE FUNCTION validate_shift_breaks_jsonb( breaks jsonb, work_starts_at timestamptz, work_ends_at timestamptz) RETURNS boolean AS $$DECLARE arr_len int; i int; br jsonb; br_start timestamptz; br_end timestamptz;BEGIN -- 配列であること IF jsonb_typeof(breaks) <> 'array' THEN RETURN false; END IF;
arr_len := jsonb_array_length(breaks);
-- 最大 3 件 IF arr_len > 3 THEN RETURN false; END IF;
FOR i IN 0 .. arr_len - 1 LOOP br := breaks -> i; IF jsonb_typeof(br) <> 'object' THEN RETURN false; END IF; IF NOT (br ? 'starts_at' AND br ? 'ends_at') THEN RETURN false; END IF;
br_start := (br ->> 'starts_at')::timestamptz; br_end := (br ->> 'ends_at')::timestamptz;
-- start < end IF br_start >= br_end THEN RETURN false; END IF;
-- 勤務時間内 IF br_start < work_starts_at OR br_end > work_ends_at THEN RETURN false; END IF;
-- 非重複(前 break と比較) IF i > 0 THEN DECLARE prev_end timestamptz := ((breaks -> (i - 1)) ->> 'ends_at')::timestamptz; BEGIN IF br_start < prev_end THEN RETURN false; END IF; END; END IF; END LOOP;
RETURN true;END;$$ LANGUAGE plpgsql IMMUTABLE;6.2 shift schema
Section titled “6.2 shift schema”| カラム | 型 | 制約 |
|---|---|---|
id | uuid | PK |
store_id | uuid | NOT NULL, FK |
staff_id | uuid | NOT NULL, 複合 FK → staff(store_id, id) |
work_date | date | NOT NULL |
work_starts_at | timestamptz | NOT NULL |
work_ends_at | timestamptz | NOT NULL |
breaks_jsonb | jsonb | NOT NULL DEFAULT '[]' |
version | integer | NOT NULL DEFAULT 1, CHECK >=1 |
created_at | timestamptz | NOT NULL |
updated_at | timestamptz | NOT NULL |
| 制約 | UNIQUE(store_id, id) | |
| 制約 | UNIQUE(store_id, staff_id, work_date) | |
| 制約 | CHECK(work_starts_at < work_ends_at) | |
| 制約 | CHECK((work_starts_at AT TIME ZONE 'Asia/Tokyo')::date = work_date) | |
| 制約 | CHECK((work_ends_at AT TIME ZONE 'Asia/Tokyo')::date = work_date) | |
| 制約 | CHECK(validate_shift_breaks_jsonb(breaks_jsonb, work_starts_at, work_ends_at)) |
6.3 マイグレーション計画
Section titled “6.3 マイグレーション計画”0016_shift_management.sql で:
validate_shift_breaks_jsonb()定義shiftテーブル作成- RLS ENABLE + FORCE
appロールへSELECT/INSERT/UPDATE/DELETEGRANT(DELETEは親 §7.5 例外、future 行のみ application 層で制限)admin:shift:writepermission backfill
7. 業務ルール
Section titled “7. 業務ルール”7.1 シフト外判定は warning only
Section titled “7.1 シフト外判定は warning only”checkShiftCoverage.covered=falseでも RES-002 / RES-003 は保存可能- ただし API response に warnings を返す
- warning code は
RESERVATION.STAFF_OFF_SHIFT
7.2 月一括 replace の実装意味論
Section titled “7.2 月一括 replace の実装意味論”外部契約は replace だが、実装は次の差分適用を推奨する。
- 既存 row を
(staff_id, work_date)で index 化 - payload に同キー row があれば update in place
- payload にのみある row は insert
- 既存にのみある row は delete(delete は future shift のみ許可)
これにより無用な id churn を避ける。
7.3 既存予約との不整合
Section titled “7.3 既存予約との不整合”- shift 変更で既存予約が勤務外になっても保存をブロックしない
- response
warningsに impacted reservation ids を返す - 運用上は human resolution に委ねる
7.4 staff retire と future shift 同期(親 §7.5 例外)
Section titled “7.4 staff retire と future shift 同期(親 §7.5 例外)”staff.retire実行時、retire listener がwork_starts_at >= retired_atの future shift を 物理削除 する- 過去 shift は触らない
- 物理削除する代わりに削除ログ
operator_action_log(action='shift.delete_by_staff_retire')を残す - 予約自体は消さない
7.5 future shift delete の制限
Section titled “7.5 future shift delete の制限”DELETE /api/admin/shifts/:shiftIdはwork_starts_at > now()のみ許可- 過去 shift は API でも削除不可(
SHIFT.PAST_DELETE_FORBIDDEN422)
7.6 監査
Section titled “7.6 監査”shift.create / update / delete / bulk_replaceshift.delete_by_staff_retire
8. 非機能要件
Section titled “8. 非機能要件”GET /api/admin/shifts?month=YYYY-MMは当該月全件返却- 月一括 replace は 1 transaction
- month payload は staff 数 × 日数で大きくなるため、1 request 上限 2MB を目安にする
9. セキュリティ・認可
Section titled “9. セキュリティ・認可”| key | 用途 | 初期付与 |
|---|---|---|
admin:shift:write | shift 全操作(read も含む Phase 1 暫定) | owner / manager |
RLS / FORCE / 複合 FK / GRANT 明示は必須。
admin:shift:read の分離は Phase 2 OQ。
10. 受け入れ基準(Given-When-Then)
Section titled “10. 受け入れ基準(Given-When-Then)”- Given staff A / When 2026-05-10 の shift を作成 / Then row が作成される
- Given 同一 staff/date の row あり / When 同日作成 / Then 409
SHIFT.DATE_CONFLICT - Given row version=4 / When
If-Match: 4で更新 / Then version=5 - Given row version=4 / When
If-Match: 3で更新 / Then 409SHIFT.VERSION_MISMATCH - Given
work_date=2026-05-10とwork_starts_at=2026-05-11T00:30+09:00/ When 保存 / Then 422SHIFT.WORK_DATE_MISMATCH - Given breaks が 4 件 / When 保存 / Then 422
SHIFT.BREAKS_INVALID(DB 関数で reject) - Given breaks が勤務外時間に重なる / When 保存 / Then 422
SHIFT.BREAKS_INVALID - Given 月一覧取得済み ETag / When stale ETag で bulk replace / Then 409
SHIFT.COLLECTION_ETAG_MISMATCH - Given 月一覧取得済み ETag / When 正しい ETag で bulk replace / Then 差分更新される
- Given 予約 13:00-14:00 と shift 12:00-18:00 / When
checkShiftCoverage/ Thencovered=true - Given 予約 13:00-14:00 と shift 14:00-18:00 / When
checkShiftCoverage/ Thencovered=false,reason='outside_shift' - Given 予約 13:00-14:00 と shift 12:00-18:00, break 13:00-13:30 / When
checkShiftCoverage/ Thencovered=false,reason='on_break' - Given 保存時に impacted reservations あり / When shift 更新 / Then 保存成功し warnings を返す
- Given staff retire at 2026-05-20T09:00+09:00 / When retire listener 実行 / Then
work_starts_at >= retired_atの future shift が削除される - Given 過去 shift / When DELETE / Then 422
SHIFT.PAST_DELETE_FORBIDDEN - Given 店舗 X セッション / When 店舗 Y shift 取得 / Then 0 件または 404
11. テスト計画
Section titled “11. テスト計画”- Unit: shift coverage, break overlap validation, month diffing
- Integration: CRUD, bulk replace ETag, RLS, retire listener, breaks DB 関数
- Cross-SRS: RES-002 warning path
12. 関連ジョブ(graphile-worker)
Section titled “12. 関連ジョブ(graphile-worker)”- なし(future shift cleanup は staff retire handler の同期 listener で処理)
13. Open Questions
Section titled “13. Open Questions”| # | 内容 | 扱い |
|---|---|---|
| OQ-MST-004-01 | admin:shift:read の分離 | Phase 2 |
| OQ-MST-004-02 | impacted reservations への明示 ack を必須にするか | Phase 2 検討 |
| OQ-MST-004-03 | シフトテンプレ(週パターン適用) | Phase 2 |
14. 変更履歴
Section titled “14. 変更履歴”| Version | Date | Author | Change |
|---|---|---|---|
| 0.1 | 2026-05-05 | Codex / yudai | 初版ドラフト |
| 0.2 | 2026-05-05 | yudai (with Codex co-design) | Round 2 反映: migration 番号 0016 確定、validate_shift_breaks_jsonb() DB 関数導入、shift を UUIDv7 へ(親 §7.2.2 例外から削除)、future shift 物理削除を親 §7.5 例外として明記、permission 1 個(admin:shift:write)に集約 |