# Resource Calendar API — تقویم منبع، استثنا و تعطیلات > **Base:** `/api/v1` · **Auth:** JWT · **مجوز:** `appointment_settings` > مکمل [resource.md](resource.md) — منبع آنجا ساخته می‌شود، تقویمش اینجا. --- ## کسرِ لایه‌ها بند ۹ مستند ساعت آزاد را از کسر هفت لایه می‌سازد. آنچه این بخش می‌دهد **چهار لایهٔ اول** است: ``` ساعت کاری شعبه ∩ شیفت منبع − تعطیلات رسمی − استثناهای منبع ``` **نوبت‌های ثبت‌شده و رزروهای موقت اینجا کسر نمی‌شوند.** خروجی این اندپوینت «وقت قابل رزرو» **نیست**؛ تقاطع چند منبع و کسر اشغال کارِ تسک‌های بعدی است. متد داخلی‌اش عمداً `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` جایگزینی کامل هفت روز — روزی که نفرستید خالی می‌شود. قواعد و خطاها دقیقاً مثل ساعت کاری شعبه ([branch.md](branch.md)): دقیقه از نیمه‌شب `0..1440`، `end > start`، بدون هم‌پوشانی در یک روز، `sequence` را سرور می‌دهد، و **اعتبارسنجی کامل پیش از هر حذفی**. ```json { "days": { "0": [{ "start_minute": 540, "end_minute": 1020 }] } } ``` --- ## استثناها — مرخصی، غیبت، سرویس، تعطیلی موردی چهار نوع، **یک جدول**: هر چهار «یک بازهٔ کسرشونده از تقویم منبع»اند و جدا کردنشان یعنی چهار کوئری در هر محاسبه به‌جای یکی. | `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` | منبع آن روز شیفتی ندارد | | `branch_closed` | شعبه آن روز ساعت کاری ندارد | | `outside_branch_hours` | شیفت هست ولی تقاطعش با ساعت شعبه خالی شد | | `national_holiday` | تعطیل رسمی کشور | | `tenant_holiday` | این محیط آن روز را تعطیل اعلام کرده | | `exception` | مرخصی/غیبت/سرویس بخشی یا تمام روز را بریده | | `resource_inactive` / `branch_inactive` | منبع یا شعبه غیرفعال است | **۴۲۲:** نبودِ `from`/`to` · `to < from` · بازهٔ بیش از ۹۲ روز (خروجی واقعی): ```json {"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"بازهٔ درخواستی حداکثر 92 روز است","field":"to"}]} ``` ### دو قرارداد مهم **شعبهٔ بدون ساعت کاری = «تعریف‌نشده»، نه «بسته».** شیفت منبع بی‌قید اعمال می‌شود تا دادهٔ موجود دقیقاً مثل امروز کار کند. این با `branch_closed` — که یعنی ساعت تعریف شده ولی آن روز خالی است — فرق دارد. **شیفت بیرون از ساعت شعبه رد نمی‌شود، تقاطع گرفته می‌شود.** شیفت ۹–۱۷ روی شعبه‌ای که ۱۰–۱۲ باز است، ۱۲۰ دقیقه می‌دهد. --- ## تعطیلات رسمی ### `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` یعنی آن روز باز است. ### `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 ```