Resources never needed a branch: devices and rooms belong to the clinic itself, and the picker always had exactly one option — a mandatory click that decided nothing. - `address_uuid` is now optional on resource and pool creation; when it is missing the environment's own address is used. Clients still sending it keep working. - The panel no longer asks for or displays a branch anywhere: resource form, list column and filter, pool form and column, detail row, and the resource-first booking page. - Availability no longer gates on `doctor_addresses.active`. That gate shut down every device of a clinic whose address row happened to be inactive, with a message no page in the panel could act on — no endpoint writes that column at all. `address_id` stays on the resource: the timezone and the tenant pair are derived from it. It is simply no longer the user's decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
300 lines
14 KiB
Markdown
300 lines
14 KiB
Markdown
# 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
|
||
```
|