# 駐車場の時間帯・祝日・日付オーバーライド統合 (データモデル変更 ADR)

**作成日**: 2026-04-24
**関連 migration**:
- `parky/infra/supabase/migrations/20260424020000_parking_lot_hours_window_type.sql`
- `parky/infra/supabase/migrations/20260424020001_jp_holidays_and_day_type_codes.sql`
- `parky/infra/supabase/migrations/20260424020002_parking_lot_date_overrides.sql`
- `parky/infra/supabase/migrations/20260424020003_parking_lot_hours_rpc_unified.sql`

---

## 1. 背景 / 問題

現行スキーマ (`parking_lot_hours` + `parking_lot_pricing_rules`) は「営業時間を曜日別に1行で持つ」シンプルな構造で、以下の実運用要件を表現できなかった。

| 要件 | 既存スキーマの限界 |
|---|---|
| **入庫時間と出庫時間を別管理したい** (夜間入庫不可だが出庫はいつでも可) | `parking_lot_hours` は「営業時間」1 種別しか持てない |
| **営業時間外でも「出庫は可能」を曜日別に設定したい** | 1 行 = 営業時間。許可フラグの持ち場がない |
| **祝日は平日と別運用にしたい** | `day_type='holiday'` は書けたが、**どの日が祝日か** を判定する術が無い |
| **花火大会・元旦など特定日だけルールを上書きしたい** | スキーマに一切対応無し。擬似的に `effective_from=effective_to=当日` の特別行を増やすしか無かった |
| **RPC `is_parking_lot_open_now` の判定ロジックが曖昧** | どの行が優先かが実装任せ、祝日判定なし、特定日オーバーライドなし |

2026-04-24 の 4 migration でこれらを一括解消した。

---

## 2. 変更の詳細

### 2.1 `parking_lot_hours` の窓口種別化 (`window_type`)

- 新カラム `window_type text NOT NULL DEFAULT 'business'` + CHECK 制約
- 4 種別: `business` / `entry` / `exit` / `after_hours_exit`
- 既存行はすべて `'business'` として後方互換
- 旧 UNIQUE `(parking_lot_id, day_type)` を破棄し、`COALESCE` で NULL を正規化した部分 UNIQUE INDEX `uq_parking_lot_hours_window_key` に置換 (同一 lot × window_type × day_type × day_of_week × effective 期間の重複防止)
- `codes` マスタに `category_id='parking_hours_window_type'` を追加 (`business` / `entry` / `exit` / `after_hours_exit`)
- 判定用 partial index `idx_parking_lot_hours_lot_dow_business` と汎用 `idx_parking_lot_hours_lot_window_dow`

「1 行 = 1 種別の時間窓」に正規化し、`entry` 行と `exit` 行を独立させたのが肝。

### 2.2 `jp_holidays` + `day_type` コード化

- 新テーブル `public.jp_holidays (date PK, name, kind, created_at, updated_at)`
- `kind ∈ {national, substitute, national_holiday_bridge}` CHECK
- RLS: `anon/authenticated` 共に SELECT 可、書込は admin のみ
- **2026 / 2027 の国民の祝日 34 行**を seed (振替休日含む、`ON CONFLICT (date) DO UPDATE` で冪等)
- `public.is_jp_holiday(date)` SECURITY DEFINER 関数
- `codes` に `category_id='day_type'` 追加 (`weekday` / `saturday` / `sunday` / `holiday` / `holiday_eve` / `all`)
- `parking_lot_hours.day_type` / `parking_lot_pricing_rules.day_type` に 6 値の CHECK。非準拠値は `'all'` に寄せて正規化

### 2.3 `parking_lot_date_overrides` (特定日ルール上書き)

- 新テーブル。同一日は 1 行 (`UNIQUE parking_lot_id + override_date`)
- `label` (例: "花火大会" / "元旦特別営業"), `note` (社内メモ)
- `hours jsonb` — キー `business` / `entry` / `exit` / `after_hours_exit`。省略したキーは通常ルールへフォールバック
- `pricing jsonb` — `parking_lot_pricing_rules` 相当の配列。`NULL`=通常通り / `[]`=当日無料 (明示)
- CHECK: `hours` は object / `pricing` は array
- RLS: SELECT 公開 / 書込は admin もしくは当該 lot の owner (`parking_lot_owners + current_owner_id()`)
- `public.get_parking_lot_date_override(lot_id, date)` helper

### 2.4 判定 RPC の一本化

`public.is_parking_lot_window_active(p_lot_id uuid, p_at timestamptz, p_window_type text)` を新設し、既存 `is_parking_lot_open_now` をその `window_type='business'` ラッパに差し替え。追加で `can_enter_parking_lot_now` / `can_exit_parking_lot_now` を新設。

すべて JST (`Asia/Tokyo`) で日付・曜日・時刻を判定。

---

## 3. 優先順序ロジック (最重要)

判定対象日 **D**, 時刻 **T**, 窓口種別 **W** に対し、以下の順で「使用する 1 行」を決定する。

1. **日付オーバーライド**: `parking_lot_date_overrides` に `override_date=D` かつ `hours->W` が存在 → その jsonb ブロックで判定
   - `W='after_hours_exit'` のときは `allowed` ブール
   - それ以外は `is_closed` / `is_24h` / `open_time`+`close_time`
   - キーが足りず判定不能なら下段にフォールスルー
2. **祝日行**: `is_jp_holiday(D)=true` かつ `parking_lot_hours` に `window_type=W AND day_type='holiday'`
3. **曜日行**: `parking_lot_hours.day_of_week = EXTRACT(DOW FROM D)`
4. **day_type** (祝日以外の曜日区分): `weekday` (月〜金) / `saturday` / `sunday` / `holiday_eve` のマッチ
5. **フォールバック**: `day_type='all' OR day_type IS NULL` かつ `day_of_week IS NULL`
6. 該当なし → `false`

いずれの行も `effective_from / effective_to` の期間内である必要あり。

`after_hours_exit` は時刻範囲ではなく「当該曜日に営業時間外出庫を許可するか」のブールとして扱う (行が存在 & `is_closed=false` → true)。

### 3.1 `can_exit_parking_lot_now` の特殊性

出庫可否は **OR 合成**:
- `exit` 窓口が設定されていて該当時刻 true, or
- `business` 営業中, or
- `after_hours_exit` で許可

どれか一つでも true なら出庫可。`exit` 未設定時は business と同義 (ユーザー側 SRP)。

---

## 4. API / UI への影響

本 ADR は DB 層のみを変更する。上流 API / UI は別エージェントが以下の方針で対応する (このターンでは触らない):

- **Mobile BFF**: `/v1/mobile/parking-lots/:id` 系 aggregate が返す `derived` に `is_open_now` / `can_enter_now` / `can_exit_now` を RPC 結果で埋める。`hours` レスポンスは `windows[] { window_type, rows[] }` でネスト化、`date_overrides[]` を追加
- **Admin Portal**: 駐車場編集画面で「営業 / 入庫 / 出庫 / 時間外出庫」タブを分離。日付オーバーライドタブ新設
- **Owner Portal**: 日付オーバーライドのみ自 lot について CRUD 可 (RLS で担保)
- **Bundle 登録 API** (`POST /v1/admin/parking-lots` etc.): 既存 `hours[]` は全行 `window_type='business'` として受け付け続ける (`DEFAULT 'business'`)。`window_type` 指定がある body を受けた場合はそのまま保存、`date_overrides[]` は未対応 (別 endpoint 予定)

---

## 5. 将来: 車室別料金 (次 ADR)

本 ADR には含めない。`parking_lot_spaces` / `spaces_pricing_rules` テーブルで「区画 A/B ごとの料金」を扱う案を別 ADR で検討する。理由: 現状のユーザー要望は「時間帯」「祝日」「特定日」で十分カバーされ、車室別は需要が別軸 (主にタイムズ系列の屋内機械式) で影響範囲が大きいため。

---

## 6. SQL サンプル

### 6.1 `parking_lot_hours` に entry/exit 行を入れる

```sql
-- 既存の business 行はそのまま (DEFAULT で window_type='business')
-- 入庫は夜 20:00 まで、出庫は翌 9:00 まで、時間外出庫は許可
INSERT INTO public.parking_lot_hours
  (parking_lot_id, window_type, day_type, is_24h, is_closed, open_time, close_time)
VALUES
  ('<lot_uuid>', 'business',         'all', false, false, '09:00', '22:00'),
  ('<lot_uuid>', 'entry',            'all', false, false, '09:00', '20:00'),
  ('<lot_uuid>', 'exit',             'all', false, false, '09:00', '24:00'),
  ('<lot_uuid>', 'after_hours_exit', 'all', false, false, null, null);
```

### 6.2 花火大会の日付オーバーライド

```sql
INSERT INTO public.parking_lot_date_overrides
  (parking_lot_id, override_date, label, hours, pricing, note)
VALUES (
  '<lot_uuid>',
  '2026-08-01',
  '隅田川花火大会',
  jsonb_build_object(
    'business', jsonb_build_object('is_closed', false, 'is_24h', false,
                                   'open_time', '12:00', 'close_time', '24:00'),
    'entry',    jsonb_build_object('is_closed', false,
                                   'open_time', '12:00', 'close_time', '20:00')
  ),
  jsonb_build_array(
    jsonb_build_object(
      'rule_order', 1, 'category', 'cap', 'day_type', 'all',
      'cap_type', 'duration', 'cap_duration_hours', 12,
      'cap_price_minor', 500000   -- 5000 円 (花火特別料金)
    )
  ),
  '観覧客のため 12:00 以降で入庫制限、当日は 12h cap ¥5000'
);
```

### 6.3 RPC 呼び出し

```sql
-- 今この瞬間に営業中か
SELECT public.is_parking_lot_open_now('<lot_uuid>');

-- 指定時刻で入庫できるか / 出庫できるか
SELECT
  public.can_enter_parking_lot_now('<lot_uuid>', '2026-08-01 19:30 JST'::timestamptz) AS can_enter,
  public.can_exit_parking_lot_now ('<lot_uuid>', '2026-08-01 23:45 JST'::timestamptz) AS can_exit;

-- 日付オーバーライド取得
SELECT * FROM public.get_parking_lot_date_override('<lot_uuid>', '2026-08-01');
```

### 6.4 API レスポンス例 (将来実装される Mobile BFF 形)

```json
{
  "parking_lot": { "id": "…", "name": "晴海西口第一駐車場" },
  "hours": {
    "windows": [
      {
        "window_type": "business",
        "rows": [
          { "day_type": "all", "open_time": "09:00", "close_time": "22:00" }
        ]
      },
      {
        "window_type": "entry",
        "rows": [
          { "day_type": "all", "open_time": "09:00", "close_time": "20:00" }
        ]
      },
      { "window_type": "after_hours_exit", "rows": [{ "day_type": "all" }] }
    ]
  },
  "date_overrides": [
    {
      "override_date": "2026-08-01",
      "label": "隅田川花火大会",
      "hours":   { "business": { "open_time": "12:00", "close_time": "24:00" } },
      "pricing": [{ "category": "cap", "cap_duration_hours": 12, "cap_price_minor": 500000 }]
    }
  ],
  "derived": {
    "is_open_now":    true,
    "can_enter_now":  false,
    "can_exit_now":   true,
    "is_today_holiday": false,
    "active_override": null
  }
}
```

---

## 7. チェックリスト (実装者向け)

- [ ] DB migration 4 本適用済み確認 (`SELECT * FROM supabase_migrations.schema_migrations WHERE version LIKE '2026042402%';`)
- [ ] `SELECT public.is_parking_lot_open_now('<lot>')` が既存 lot で従来と同じ結果を返す
- [ ] `codes` に `parking_hours_window_type` / `day_type` カテゴリが入っている
- [ ] `jp_holidays` に 2026+2027 年分 34 行が入っている
- [ ] Mobile BFF / Admin / Owner 側の対応 (別エージェント担当)
