# Resource Calendar API — تقویم منبع، استثنا و تعطیلات

> **Base:** `/api/v1` · **Auth:** JWT · **مجوز:** `appointment_settings`
> مکمل [resource.md](resource.md) — منبع آنجا ساخته می‌شود، تقویمش اینجا.
>
> **پنل:** این اندپوینت‌ها در تب‌های «ساعات کاری» و «تعطیلات و استثنا»ی صفحهٔ
> `/admin/resources/{uuid}` مصرف می‌شوند. مسیر قدیمی `/admin/resources/{uuid}/calendar`
> با redirect به `?tab=hours` می‌رود.

---

## کسرِ لایه‌ها

بند ۹ مستند ساعت آزاد را از کسر هفت لایه می‌سازد. آنچه این بخش می‌دهد **سه لایهٔ اول** است
(لایهٔ «ساعت کاری شعبه» با حذف دامنهٔ شعبه برداشته شد — تنها مرجع ساعت، شیفت خودِ منبع است):

```
شیفت منبع  −  تعطیلات رسمی  −  استثناهای منبع
```

**نوبت‌های ثبت‌شده و رزروهای موقت اینجا کسر نمی‌شوند.** خروجی این اندپوینت «وقت قابل
رزرو» **نیست**؛ تقاطع چند منبع و کسر اشغال کارِ تسک‌های بعدی است. متد داخلی‌اش عمداً
`rawAvailability()` نام دارد.

مسیر اسلاتیِ موجود (`SlotCalculatorService`، `WeeklySchedule`، `DateOverride`، `Holiday`)
در این فاز **دست‌نخورده** است؛ این یک مسیر موازی روی منبع است.

---

## `GET /api/v1/resource/{uuid}/calendar`

```json
{
  "success": true,
  "data": {
    "resource_uuid": "38bdd1e9-d982-4cef-9419-3d42ee6b85f2",
    "timezone": "Asia/Tehran",
    "defined": true,
    "days": {
      "0": [{ "sequence": 0, "start_minute": 540, "end_minute": 1020, "start_time": "09:00", "end_time": "17:00", "active": true }],
      "1": [{ "sequence": 0, "start_minute": 540, "end_minute": 1020, "start_time": "09:00", "end_time": "17:00", "active": true }],
      "2": [], "3": [], "4": [], "5": [], "6": []
    }
  }
}
```

`days` همیشه **شیء** با هر هفت کلید `"0".."6"` است؛ ۰ = شنبه. `timezone` از محل نوبت‌دهی منبع
می‌آید و مبنای «روز» در محاسبهٔ ساعت آزاد است.

## `PUT /api/v1/resource/{uuid}/calendar`

جایگزینی کامل هفت روز — روزی که نفرستید خالی می‌شود. قواعد و خطاها دقیقاً مثل ساعت
کاری: دقیقه از نیمه‌شب `0..1440`، `end > start`، بدون
هم‌پوشانی در یک روز، `sequence` را سرور می‌دهد، و **اعتبارسنجی کامل پیش از هر حذفی**.

```json
{ "days": { "0": [{ "start_minute": 540, "end_minute": 1020 }] } }
```

### خطاهای اعتبارسنجی (۴۲۲)

| شرط | `field` | پیام |
|---|---|---|
| `start_minute`/`end_minute` نبود یا عدد نبود | همان فیلد | `در روز شنبه مقدار start_minute الزامی است` |
| خارج از `0..1440` | همان فیلد | `در روز شنبه مقدار end_minute باید بین ۰ و ۱۴۴۰ باشد` |
| `end <= start` | `end_minute` | `در روز شنبه، پایان شیفت باید بعد از شروع آن باشد` |
| دو بازهٔ متداخل در یک روز | `start_minute` | `شیفت‌های روز شنبه با هم تداخل دارند — بازه‌ها نباید هم‌پوشانی داشته باشند` |

پیام‌ها **نام روز** را می‌گویند نه اندیس عددی را، چون مستقیم به کاربر نشان داده می‌شوند.

هم‌پوشانی پس از مرتب‌سازی بازه‌ها بررسی می‌شود، پس ترتیب ارسال مهم نیست و تداخل بین
بازهٔ اول و سوم هم گرفته می‌شود. مرزِ چسبیده هم‌پوشانی **نیست**: `09:00–13:00` کنار
`13:00–17:00` معتبر است (شیفت صبح و عصر). پنل ادمین همین قواعد را حین ویرایش هم اجرا
می‌کند و تا رفع تداخل، دکمهٔ ذخیره را غیرفعال نگه می‌دارد؛ ولی مرجع نهایی سرور است.

---

## استثناها — مرخصی، غیبت، سرویس، تعطیلی موردی

چهار نوع، **یک جدول**: هر چهار «یک بازهٔ کسرشونده از تقویم منبع»اند و جدا کردنشان یعنی
چهار کوئری در هر محاسبه به‌جای یکی.

| `type` | برچسب |
|---|---|
| `leave` | مرخصی |
| `absence` | غیبت |
| `maintenance` | سرویس دوره‌ای |
| `closure` | تعطیلی موردی |

زمان‌ها **timestamp مطلق**اند، نه دقیقه‌از-نیمه‌شب: یک مرخصی می‌تواند چندروزه باشد.

### `GET /api/v1/resource/{uuid}/exceptions?from&to`

بازه اختیاری است؛ استثناهایی که با آن **تداخل** دارند برمی‌گردند، نه فقط آن‌هایی که
کاملاً درونش‌اند — وگرنه مرخصیِ سه‌روزه‌ای که وسطش این بازه است دیده نمی‌شد.

### `POST /api/v1/resource/{uuid}/exception`

**۲۰۱** (خروجی واقعی):

```json
{
  "success": true,
  "data": {
    "uuid": "b387529b-c27c-4c6c-ab6c-047167ae51da",
    "resource_uuid": "38bdd1e9-d982-4cef-9419-3d42ee6b85f2",
    "resource_name": "لیزر آلکساندرایت ۱",
    "type": "maintenance",
    "type_label": "سرویس دوره‌ای",
    "starts_at": 1785509280,
    "ends_at": 1785512880,
    "reason": "سرویس دوره‌ای",
    "created_at": 1785422880,
    "updated_at": 1785422880
  }
}
```

**۴۲۲:** `ends_at <= starts_at` (field `ends_at`) · نوع ناشناخته (field `type`) ·
نبودِ `starts_at`/`ends_at`. **۴۰۴:** منبع یا استثنای محیط دیگر.

### `PATCH` / `DELETE /api/v1/resource-exception/{uuid}`

دو استثنای هم‌پوشان **مجازند** و در محاسبه اتحادشان گرفته می‌شود؛ خطا نیست.

---

## `GET /api/v1/resource/{uuid}/availability?from&to`

`from` و `to` هر دو timestamp‌اند و هر دو روز **شامل**اند. سقف بازه ۹۲ روز است.

**۲۰۰** (خروجی واقعی برای دو روز):

```json
{
  "success": true,
  "data": {
    "resource_uuid": "38bdd1e9-d982-4cef-9419-3d42ee6b85f2",
    "timezone": "Asia/Tehran",
    "days": [
      { "date": 1785529800, "day_of_week": 0, "intervals": [{ "start": 1785562200, "end": 1785591000 }], "total_minutes": 480, "reasons": [] },
      { "date": 1785616200, "day_of_week": 1, "intervals": [{ "start": 1785648600, "end": 1785677400 }], "total_minutes": 480, "reasons": [] }
    ]
  }
}
```

### `reasons` — چرا روز خالی است

بدون این، پاسخِ خالی از یک باگ قابل تشخیص نیست.

| کد | یعنی |
|---|---|
| `no_shift` | منبع آن روز شیفتی ندارد |
| `national_holiday` | تعطیل رسمی کشور |
| `tenant_holiday` | این محیط آن روز را تعطیل اعلام کرده |
| `exception` | مرخصی/غیبت/سرویس بخشی یا تمام روز را بریده |
| `resource_inactive` | خودِ منبع غیرفعال است (آدرس دیگر گیت نیست — منابع دامنهٔ شعبه ندارند) |

**۴۲۲:** نبودِ `from`/`to` · `to < from` · بازهٔ بیش از ۹۲ روز (خروجی واقعی):

```json
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"بازهٔ درخواستی حداکثر 92 روز است","field":"to"}]}
```

### یک قرارداد مهم

**تنها مرجع ساعت کاری، شیفت خودِ منبع است.** تا پیش از حذف دامنهٔ شعبه، این شیفت با
ساعت کاری شعبه تقاطع می‌گرفت و دلیل‌های `branch_closed` و `outside_branch_hours` را
می‌ساخت؛ آن لایه و آن دو دلیل دیگر وجود ندارند.

---

## تعطیلات رسمی

### `GET /api/v1/national-holidays?year=1405`

تعطیلات **سراسری**اند و به محیط تعلق ندارند؛ امروز هر پزشک باید ۱۳ فروردین را دستی
ثبت کند، که هم تکرار است و هم منبع خطا.

```json
{
  "success": true,
  "data": {
    "year": 1405,
    "holidays": [
      { "uuid": "f1af4e18-…", "date": 1774040400, "jalali_date": "1405-01-01", "jalali_year": 1405, "title": "نوروز", "overridden_working": null }
    ],
    "overrides": []
  }
}
```

`overridden_working` کنار خودِ تعطیلی می‌آید تا UI مجبور نباشد دو فهرست را تطبیق دهد:
`null` یعنی این محیط استثنایی ندارد، `true` یعنی آن روز باز است.

### مدیر سیستم — ساخت و ویرایش تقویم رسمی

`ROLE_ADMIN` فقط. تعطیل رسمی به هیچ محیطی تعلق ندارد، پس ساختنش هم کارِ هیچ کلینیکی
نیست؛ کلینیکی که آن روز باز است با `holiday-overrides` استثنا می‌زند.

| متد | مسیر | بدنه |
|---|---|---|
| POST | `/api/v1/admin/national-holidays` | `{ "jalali_date": "1405-01-13", "title": "سیزده‌بدر" }` |
| PATCH | `/api/v1/admin/national-holiday/{uuid}` | `{ "title": "…" }` |
| DELETE | `/api/v1/admin/national-holiday/{uuid}` | — |

**۲۰۱** (خروجی واقعی تست):

```json
{
  "success": true,
  "data": {
    "uuid": "…", "date": 1774472400, "jalali_date": "1405-01-13",
    "jalali_year": 1405, "title": "سیزده‌بدر"
  }
}
```

`POST` روی روزی که از قبل هست **upsert** است و عنوان را عوض می‌کند — `date` کلید یکتا
دارد و خطای خام دیتابیس رفتار درستی نیست. `PATCH` فقط عنوان را می‌گیرد: جابه‌جا کردن
تاریخ یعنی یک تعطیلِ دیگر، پس حذف و ثبت دوباره.

**۴۰۳:** هر نقشی جز ادمین (تأیید زنده: کاربر کلینیک روی همین مسیر ۴۰۳ گرفت).
**۴۲۲:** `jalali_date` بدقالب · نبودِ `title`. **۴۰۴:** uuid ناشناخته.

> `GET /api/v1/national-holidays` برای ادمین هم کار می‌کند: او محیط کاری ندارد، پس
> بخش `overrides` برایش خالی برمی‌گردد و فقط تقویم را می‌بیند.

> **پنل:** صفحهٔ `/admin/national-holidays` (منو ← سیستم ← تعطیلات رسمی) تنها جای
> ساخت و حذف است و فقط `ROLE_ADMIN` می‌بیندش. `/admin/holidays` مالِ محیط است و آنجا
> فقط استثنا زده می‌شود، نه خودِ تعطیلی.

### `POST /api/v1/holiday-overrides`

| فیلد | نوع | توضیح |
|---|---|---|
| `date` | int | هر لحظه از آن روز؛ سرور به نیمه‌شب تهران گرد می‌کند |
| `is_working` | bool | جهت استثنا |
| `note` | string\|null | |

**دو جهت دارد و هر دو لازم‌اند:**

- `true` → کلینیکی که آن روزِ تعطیلِ رسمی **باز** است
- `false` → روزی که رسمی نیست ولی این محیط **بسته** است

جهت دوم با «استثنای منبع» فرق دارد: آن روی **یک منبع** است و این روی **کل محیط**.

فرستادن دوباره روی همان تاریخ، همان ردیف را عوض می‌کند (کلید یکتا per محیط و تاریخ).

### `DELETE /api/v1/holiday-override/{uuid}`

---

## `app:holiday:import`

```bash
ddev exec php bin/console app:holiday:import --year=1405                 # dry-run
ddev exec php bin/console app:holiday:import --year=1405 --force
ddev exec php bin/console app:holiday:import --year=1405 --force --replace
ddev exec php bin/console app:holiday:import --year=1405 --force --extra=10/12/عید فطر
```

مناسبت‌های **ثابتِ شمسی** درون خودِ دستورند چون هر سال تکرار می‌شوند. مناسبت‌های
**قمری** (عید فطر، عاشورا، …) هر سال جابه‌جا می‌شوند و عمداً نیامده‌اند: حدس زدنشان
بدتر از نداشتنشان است — با `--extra` یا از پنل اضافه می‌شوند.

⚠️ **`--replace` کِی لازم است:** ثبت روی `date` کلید می‌خورد، پس ردیفی که با تاریخ
**غلط** نوشته شده هرگز خودش را تصحیح نمی‌کند؛ اجرای دوباره ردیف درست را کنارش می‌سازد و
غلط سرِ جایش می‌ماند. دقیقاً همین پس از اصلاح باگ تبدیل شمسی پیش آمد.

> **باگ تبدیل شمسی:** `JalaliDateService::gregorianToJalali()` غلط بود و برای
> ۲۰۲۶-۰۷-۳۰ مقدار `[3006, 7, 3]` می‌داد به‌جای `[1405, 5, 8]`. `jalaliYear()`،
> `jalaliMonthRange()` و `jalaliYearRange()` — که گزارش‌های نمایندگی رویشان ساخته
> شده‌اند — همه همین را به ارث می‌بردند. حالا هر دو تبدیل از `IntlDateFormatter`
> می‌آیند (همان چیزی که `formatDateTime()` همین کلاس از قبل درست استفاده می‌کرد) و
> `tests/Representation/JalaliDateServiceTest.php` قفلش می‌کند.

---

## طبقه‌بندی محیط

| جدول | وضعیت |
|---|---|
| `resource_calendars` | `AGGREGATE_CHILDREN` — ریشه `ClinicResource` |
| `resource_exceptions` | جفت محیط (uuidش از request می‌آید) |
| `tenant_holiday_overrides` | جفت محیط |
| `national_holidays` | `GlobalTables::ENTITIES` — کشوری است، نه محیطی |

---

## تست‌ها

```bash
ddev exec php bin/phpunit tests/Resource/ResourceAvailabilityTest.php   # ۱۳ تست
ddev exec php bin/phpunit tests/Representation/JalaliDateServiceTest.php
npx vitest run assets/admin/pages/ResourceCalendarPage.test.tsx
```
