コンテンツにスキップ

シフト管理

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() で強制する。


  • 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.1 主シナリオ(月一括 replace)

Section titled “3.1 主シナリオ(月一括 replace)”
  1. オペレーターが対象月のシフト一覧を取得する
  2. collection_etag を受け取る
  3. 月内の行を UI 上で編集する
  4. PUT /api/admin/shifts/months/:yearMonth に collection 全体を送る
  5. サーバが ETag と payload 整合を検証する
  6. 差分更新する
  7. 既存予約と不整合があれば warnings を返す
  8. 保存自体は成功する
  • AF-1 単日作成: POST /api/admin/shifts
  • AF-2 単日更新: PATCH /api/admin/shifts/:shiftId
  • AF-3 単日削除: DELETE /api/admin/shifts/:shiftIdwork_starts_at > now() のみ許可、親 §7.5 例外)
  • AF-4 staff 退職: retire listener が work_starts_at >= retired_at の future shift を物理削除する
  • EF-1 同一 staff 同一 work_date 重複: 409 SHIFT.DATE_CONFLICT
  • EF-2 If-Match 不一致: 409
  • EF-3 breaks_jsonb 不正(DB 関数で reject): 422 SHIFT.BREAKS_INVALID
  • EF-4 work_starts_at >= work_ends_at: 422
  • EF-5 work_date と JST date 不整合: 422 SHIFT.WORK_DATE_MISMATCH
  • EF-6 stale month ETag: 409
  • EF-7 過去シフト delete: 422 SHIFT.PAST_DELETE_FORBIDDEN

月単位の staff schedule を一覧編集し、必要なら日単位微修正する。

  • 月切替
  • スタッフ × 日付グリッド
  • 単日編集ダイアログ
  • 月一括保存ボタン
  • 予約衝突 warnings 表示領域
  • work_date: YYYY-MM-DD
  • work_starts_at, work_ends_at: ISO8601
  • breaks_jsonb: 配列、最大 3 件
  • 各 break は [start, end)、勤務時間内、互いに非重複(DB 関数で強制)

MethodPath用途permission楽観ロック
GET/api/admin/shifts?month=YYYY-MM月一覧 + collection ETagadmin: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:writeIf-Match: <version>
DELETE/api/admin/shifts/:shiftId単日削除(future のみ)admin:shift:writeIf-Match: <version>
PUT/api/admin/shifts/months/:yearMonth月一括 replaceadmin:shift:writeIf-Match: <collection_etag>

Phase 1 では read 専用 permission を切らず、admin:shift:write 1 個で運用。read-only 運用は Phase 2 で admin:shift:read 分離 OQ。

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';
};

月一覧の ETag は MD5 of ordered JSON_AGG。並び work_date ASC, staff_id ASC, id ASC で正規化。空集合は md5('[]')

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_MISMATCHwork_date と JST date 不整合422
SHIFT.BREAKS_INVALIDbreaks 不正422
SHIFT.PAST_DELETE_FORBIDDEN過去 shift delete 試行422

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;
カラム制約
iduuidPK
store_iduuidNOT NULL, FK
staff_iduuidNOT NULL, 複合 FK → staff(store_id, id)
work_datedateNOT NULL
work_starts_attimestamptzNOT NULL
work_ends_attimestamptzNOT NULL
breaks_jsonbjsonbNOT NULL DEFAULT '[]'
versionintegerNOT NULL DEFAULT 1, CHECK >=1
created_attimestamptzNOT NULL
updated_attimestamptzNOT 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))

0016_shift_management.sql で:

  1. validate_shift_breaks_jsonb() 定義
  2. shift テーブル作成
  3. RLS ENABLE + FORCE
  4. app ロールへ SELECT/INSERT/UPDATE/DELETE GRANT(DELETE は親 §7.5 例外、future 行のみ application 層で制限)
  5. admin:shift:write permission backfill

  • checkShiftCoverage.covered=false でも RES-002 / RES-003 は保存可能
  • ただし API response に warnings を返す
  • warning code は RESERVATION.STAFF_OFF_SHIFT

外部契約は replace だが、実装は次の差分適用を推奨する。

  1. 既存 row を (staff_id, work_date) で index 化
  2. payload に同キー row があれば update in place
  3. payload にのみある row は insert
  4. 既存にのみある row は delete(delete は future shift のみ許可

これにより無用な id churn を避ける。

  • 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') を残す
  • 予約自体は消さない
  • DELETE /api/admin/shifts/:shiftIdwork_starts_at > now() のみ許可
  • 過去 shift は API でも削除不可(SHIFT.PAST_DELETE_FORBIDDEN 422)
  • shift.create / update / delete / bulk_replace
  • shift.delete_by_staff_retire

  • GET /api/admin/shifts?month=YYYY-MM は当該月全件返却
  • 月一括 replace は 1 transaction
  • month payload は staff 数 × 日数で大きくなるため、1 request 上限 2MB を目安にする

key用途初期付与
admin:shift:writeshift 全操作(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)”
  1. Given staff A / When 2026-05-10 の shift を作成 / Then row が作成される
  2. Given 同一 staff/date の row あり / When 同日作成 / Then 409 SHIFT.DATE_CONFLICT
  3. Given row version=4 / When If-Match: 4 で更新 / Then version=5
  4. Given row version=4 / When If-Match: 3 で更新 / Then 409 SHIFT.VERSION_MISMATCH
  5. Given work_date=2026-05-10work_starts_at=2026-05-11T00:30+09:00 / When 保存 / Then 422 SHIFT.WORK_DATE_MISMATCH
  6. Given breaks が 4 件 / When 保存 / Then 422 SHIFT.BREAKS_INVALID(DB 関数で reject)
  7. Given breaks が勤務外時間に重なる / When 保存 / Then 422 SHIFT.BREAKS_INVALID
  8. Given 月一覧取得済み ETag / When stale ETag で bulk replace / Then 409 SHIFT.COLLECTION_ETAG_MISMATCH
  9. Given 月一覧取得済み ETag / When 正しい ETag で bulk replace / Then 差分更新される
  10. Given 予約 13:00-14:00 と shift 12:00-18:00 / When checkShiftCoverage / Then covered=true
  11. Given 予約 13:00-14:00 と shift 14:00-18:00 / When checkShiftCoverage / Then covered=false, reason='outside_shift'
  12. Given 予約 13:00-14:00 と shift 12:00-18:00, break 13:00-13:30 / When checkShiftCoverage / Then covered=false, reason='on_break'
  13. Given 保存時に impacted reservations あり / When shift 更新 / Then 保存成功し warnings を返す
  14. Given staff retire at 2026-05-20T09:00+09:00 / When retire listener 実行 / Then work_starts_at >= retired_at の future shift が削除される
  15. Given 過去 shift / When DELETE / Then 422 SHIFT.PAST_DELETE_FORBIDDEN
  16. Given 店舗 X セッション / When 店舗 Y shift 取得 / Then 0 件または 404

  • Unit: shift coverage, break overlap validation, month diffing
  • Integration: CRUD, bulk replace ETag, RLS, retire listener, breaks DB 関数
  • Cross-SRS: RES-002 warning path

  • なし(future shift cleanup は staff retire handler の同期 listener で処理)

#内容扱い
OQ-MST-004-01admin:shift:read の分離Phase 2
OQ-MST-004-02impacted reservations への明示 ack を必須にするかPhase 2 検討
OQ-MST-004-03シフトテンプレ(週パターン適用)Phase 2

VersionDateAuthorChange
0.12026-05-05Codex / yudai初版ドラフト
0.22026-05-05yudai (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)に集約