feat: implement cancellation policy, no-show tracking, and waitlist management

- Add implementation notes for cancellation and waitlist features.
- Create task documentation outlining goals, current status, and acceptance criteria for cancellation policy and resource utilization reporting.
- Establish architecture for domain events and outbox pattern to ensure reliable event publishing.
- Define database schema for domain events and necessary queries for resource utilization and plan accuracy reports.
- Implement detailed implementation notes covering edge cases, testing strategies, and documentation requirements.
This commit is contained in:
hamed
2026-07-30 11:43:58 +03:30
parent 1d338503c8
commit 021d0eb6b2
62 changed files with 8098 additions and 0 deletions
@@ -0,0 +1,139 @@
# معماری — تسک ۰۳
## ساختار فایل
```
src/Resource/
├── Entity/
│ ├── ResourceCalendar.php # شیفت تکرارشوندهٔ هفتگی
│ └── ResourceException.php # مرخصی/غیبت/سرویس دستگاه/تعطیلی موردی
├── Controller/
│ ├── ResourceCalendarController.php
│ └── ResourceExceptionController.php
├── Service/
│ ├── ResourceCalendarService.php
│ └── ResourceAvailabilityService.php ← قلب این تسک
└── Repository/…
src/Holiday/
├── Entity/
│ ├── NationalHoliday.php
│ └── TenantHolidayOverride.php
├── Controller/HolidayController.php
├── Service/HolidayResolver.php
└── Command/ImportNationalHolidaysCommand.php
```
## `ResourceAvailabilityService` — قرارداد
```php
final class ResourceAvailabilityService
{
/**
* بازه‌های آزادِ خام یک منبع (بدون در نظر گرفتن نوبت‌ها).
* خروجی: بازه‌های مرتب و ادغام‌شده، بر حسب Unix timestamp.
*
* @return array<array{start:int, end:int}>
*/
public function rawWindows(ClinicResource $resource, int $from, int $to): array;
/**
* چرا این روز خالی است. null یعنی خالی نیست.
* همان قرارداد SlotCalculatorService::explainEmptyDay — پنل به دلیل نیاز دارد.
*/
public function explainEmptyDay(ClinicResource $resource, int $dayStart): ?string;
public const EMPTY_NO_CALENDAR = 'no_calendar';
public const EMPTY_NATIONAL_HOLIDAY = 'national_holiday';
public const EMPTY_RESOURCE_EXCEPTION = 'resource_exception';
public const EMPTY_OUTSIDE_BRANCH_HOURS = 'outside_branch_hours';
public const EMPTY_DAY_OFF = 'day_off';
}
```
## الگوریتم `rawWindows`
```
ورودی: منبع، [from, to)
۱. یک بار برای کل بازه واکشی کن (نه per-day):
- branch_working_hours شعبهٔ منبع (۱ کوئری)
- resource_calendars منبع (۱ کوئری)
- resource_exceptions متداخل با بازه (۱ کوئری)
- national_holidays متداخل با بازه (۱ کوئری)
- tenant_holiday_overrides محیط (۱ کوئری)
۲. برای هر روز از from تا to:
الف) اگر تعطیل رسمی است و override با is_working=true ندارد → روز را رد کن
ب) بازه‌های شیفت منبع آن روزِ هفته را بگیر
ج) اگر شعبه ساعت کاری تعریف‌شده دارد → تقاطع بگیر
اگر ندارد → بازهٔ منبع دست‌نخورده می‌ماند
د) استثناهای متداخل را کسر کن (اتحاد استثناها، بعد تفاضل)
ه) بازه‌های حاصل را به لیست اضافه کن
۳. ادغام بازه‌های مجاور و مرتب‌سازی
```
**پنج کوئری ثابت برای هر بازه، نه رشد خطی با تعداد روز.** این دقیقاً همان کاری است که
`SlotCalculatorService::findNextAvailableStart()` امروز برای پزشک می‌کند و باید حفظ شود؛
تسک ۰۶ روی همین حساب می‌کند که بتواند زیر نیم ثانیه بماند.
## عملیات بازه — یک جای واحد
تقاطع، اتحاد، تفاضل و ادغامِ بازه‌ها در سه تسک بعدی هم لازم است. یک کلاس بدون وابستگی:
```php
// src/Shared/Time/IntervalSet.php
final class IntervalSet
{
/** @param array<array{start:int,end:int}> $intervals */
public static function normalize(array $intervals): array; // مرتب + ادغام مجاور
public static function intersect(array $a, array $b): array;
public static function subtract(array $from, array $minus): array;
public static function union(array $a, array $b): array;
public static function totalSeconds(array $intervals): int;
}
```
خالص و بدون I/O → تست واحد سریع و بدون دیتابیس. هر جای دیگری که بازه جمع/کم می‌کند
باید از این استفاده کند، وگرنه سه پیاده‌سازی با سه باگ مرزی متفاوت خواهیم داشت.
## تعطیلات رسمی
```php
class NationalHoliday // سراسری — GlobalTables::ENTITIES
{
private int $date; // نیمه‌شب روز، Unix
private string $title; // «عید فطر»
private bool $isOfficial; // تعطیل رسمی یا مناسبت غیرتعطیل
}
class TenantHolidayOverride // per محیط
{
use TenantOwnedTrait;
private int $date;
private bool $isWorking; // true = این تعطیل رسمی برای ما کاری است
private ?string $note;
}
```
`HolidayResolver::isClosedFor(EntityContext $ctx, int $dayStart): bool` تنها نقطهٔ ترکیب
این دو است. `Holiday` موجود (per پزشک) دست‌نخورده می‌ماند و در حالت `slot`/`service`
همچنان مرجع است؛ در حالت `resource` هر دو منبع اعمال می‌شوند (اتحاد).
## import تعطیلات
```bash
ddev exec php bin/console app:holiday:import --year=1405 --file=var/holidays-1405.json
```
فایل JSON با تاریخ شمسی؛ تبدیل با `jalaali-js` معادل PHP در `src/Shared/Time/JalaliDate.php`
(اگر نبود، بساز). عمداً از سرویس آنلاین نمی‌خوانیم: تعطیلات ایران سالانه با مصوبه تغییر
می‌کنند و وابستگی به یک API خارجی یعنی جستجوی وقت به آن گره می‌خورد.
## پنل ادمین
- `ResourceCalendarPage.tsx` — گرید هفت‌روزه، هر روز چند بازه، درگ ندارد (فرم ساده)
- `ResourceExceptionsPage.tsx` — لیست + `PersianDatePicker` برای بازه
- `HolidaysSettingsPage.tsx` — تعطیلات رسمی سال با تیک «ما این روز کار می‌کنیم»
- هر سه زیرصفحه‌اند → `backTo` اجباری
@@ -0,0 +1,112 @@
# دیتابیس — تسک ۰۳
## `resource_calendars`
شیفت تکرارشوندهٔ هفتگی یک منبع.
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `resource_id` | INT NOT NULL | FK → `clinic_resources.id` ON DELETE CASCADE |
| `day_of_week` | TINYINT NOT NULL | ۰=شنبه … ۶=جمعه |
| `sequence` | TINYINT NOT NULL DEFAULT 0 | |
| `start_minute` | SMALLINT NOT NULL | |
| `end_minute` | SMALLINT NOT NULL | |
| `valid_from` | INT NULL | شیفت فصلی؛ NULL = از همیشه |
| `valid_to` | INT NULL | NULL = تا همیشه |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
```sql
UNIQUE KEY uniq_rc_resource_day_seq (resource_id, day_of_week, sequence)
KEY idx_rc_resource_day (resource_id, day_of_week, active)
```
`valid_from/valid_to` از روز اول: شیفت تابستانی/زمستانی حالت رایج کلینیک است و
افزودنش بعداً یعنی یا حذف و ثبت دوبارهٔ شیفت‌ها یا یک جدول موازی.
فرزند aggregate با ریشهٔ `ClinicResource`.
## `resource_exceptions`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | **از request می‌آید** → جفت tenant خودش را دارد |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | از منبع مشتق می‌شود |
| `resource_id` | INT NOT NULL | FK ON DELETE CASCADE |
| `type` | VARCHAR(20) NOT NULL | `leave` \| `absence` \| `maintenance` \| `blocked` |
| `start_at` | INT NOT NULL | Unix |
| `end_at` | INT NOT NULL | Unix، `> start_at` |
| `reason` | VARCHAR(255) NULL | |
| `created_by` | INT NULL | FK → `users.id` ON DELETE SET NULL |
| `created_at` | INT NOT NULL | |
```sql
KEY idx_rex_tenant (entity_type, entity_id, start_at)
KEY idx_rex_resource_range (resource_id, start_at, end_at)
```
`idx_rex_resource_range` کوئری داغ است: «استثناهای این منبع که با [from, to) تداخل دارند».
> نوع `blocked` عمداً هست: مسدود کردن دستیِ یک بازه توسط منشی («امروز عصر کسی را نگذار»)
> با مرخصی یکی نیست و گزارش بهره‌وری (تسک ۱۴) باید تفکیکشان کند.
## `national_holidays`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `date` | INT NOT NULL UNIQUE | نیمه‌شب روز به وقت `Asia/Tehran`، Unix |
| `jalali_date` | VARCHAR(10) NOT NULL | `1405-01-13` — برای import و نمایش |
| `title` | VARCHAR(150) NOT NULL | |
| `is_official` | TINYINT(1) NOT NULL DEFAULT 1 | مناسبت غیرتعطیل هم ثبت می‌شود |
| `created_at` | INT NOT NULL | |
```sql
UNIQUE KEY uniq_national_holiday_date (date)
KEY idx_national_holiday_jalali (jalali_date)
```
سراسری — در `GlobalTables::ENTITIES` با دلیل: «تقویم رسمی کشور، مال هیچ محیطی نیست».
## `tenant_holiday_overrides`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `date` | INT NOT NULL | نیمه‌شب روز |
| `is_working` | TINYINT(1) NOT NULL | true = روز رسمیِ تعطیل، برای ما کاری است |
| `note` | VARCHAR(255) NULL | |
| `created_at` | INT NOT NULL | |
```sql
UNIQUE KEY uniq_tho_tenant_date (entity_type, entity_id, date)
KEY idx_tho_tenant (entity_type, entity_id, date)
```
`is_working=false` هم معنی دارد: روزی که رسمی نیست ولی این محیط تعطیل است (مثلاً
تعطیلی سالانهٔ کلینیک). پس این جدول هر دو جهت را می‌پوشاند و `Holiday` موجود فقط برای
سازگاری عقب‌رو در حالت `slot`/`service` می‌ماند.
## Migration
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
ddev exec php bin/console app:holiday:import --year=1405 --file=var/holidays-1405.json
```
`app:resource:calendar:backfill` (اختیاری، در همین تسک): برای هر منبعِ `type=doctor` که از
`WeeklySchedule` ساخته شده، شیفت‌های همان برنامه را به `resource_calendars` کپی می‌کند تا
تقویم منبع از روز اول خالی نباشد. dry-run پیش‌فرض.
## طبقه‌بندی tenant
| جدول | وضعیت | دلیل |
|---|---|---|
| `resource_calendars` | `AGGREGATE_CHILDREN` | ریشه `ClinicResource`؛ uuid از request نمی‌گیرد |
| `resource_exceptions` | جفت tenant | uuid از request می‌آید (`PATCH /resource-exception/{uuid}`) |
| `national_holidays` | `ENTITIES` | تقویم کشوری |
| `tenant_holiday_overrides` | جفت tenant | |
@@ -0,0 +1,133 @@
# نکات پیاده‌سازی — تسک ۰۳
## ۱. `IntervalSet` اول، بقیه بعد
اولین چیزی که می‌نویسی `src/Shared/Time/IntervalSet.php` و تستش است — قبل از هر entity.
سه تسک بعدی روی درستی‌اش حساب می‌کنند و باگ مرزی در تفاضل بازه، در تسک ۰۶ به شکل
«یک اسلات عجیب» ظاهر می‌شود که دیباگش ساعت‌ها می‌برد.
مرزهایی که تست واحد باید بپوشاند:
```php
subtract([[0,100]], [[0,100]]) === [] // کامل
subtract([[0,100]], [[20,30]]) === [[0,20],[30,100]] // وسط
subtract([[0,100]], [[100,200]]) === [[0,100]] // مجاور، نه متداخل
subtract([[0,100]], [[-10,10]]) === [[10,100]] // از چپ بیرون‌زده
intersect([[0,100]], []) === [] // خالی = هیچ، نه همه‌چیز
normalize([[0,50],[50,100]]) === [[0,100]] // ادغام مجاور
normalize([[10,20],[0,5]]) === [[0,5],[10,20]] // مرتب‌سازی
```
قرارداد بازه‌ها: **نیم‌باز `[start, end)`**. همه‌جا. `end == start` یعنی بازهٔ تهی و حذف می‌شود.
## ۲. شعبهٔ بدون ساعت کاری = بی‌قید، نه بسته
```php
$branchHours = $this->branchHoursRepo->forDay($branch, $dow);
$windows = $branchHours === []
? $resourceShifts // بی‌قید
: IntervalSet::intersect($resourceShifts, $branchHours);
```
اگر برعکسش را بنویسی، همهٔ داده‌های موجود (که هیچ شعبه‌ای ساعت کاری ندارد) یک‌شبه
هیچ وقتی نمی‌دهند. این تصمیم در تسک ۰۱ هم نوشته شده — هر دو جا باید یکی باشد.
## ۳. تعطیلات: ملی، محیطی، منبعی — ترتیب
```
روز تعطیل است اگر:
(در national_holidays با is_official=true باشد
و tenant_holiday_override با is_working=true نداشته باشد)
یا
(tenant_holiday_override با is_working=false داشته باشد)
```
منبع می‌تواند با `resource_exception` روزِ باز را ببندد، ولی **نمی‌تواند** روز تعطیل را باز
کند. باز کردن فقط در سطح محیط معنی دارد (کل کلینیک آن روز کار می‌کند یا نه).
## ۴. زمان و منطقهٔ زمانی
ذخیره‌سازی همیشه Unix timestamp (UTC ذاتی). `date('w', $ts)` و `strtotime('today')` به
منطقهٔ زمانی PHP وابسته‌اند. `SlotCalculatorService` امروز روی همین فرض کار می‌کند و
`php.ini` پروژه روی `Asia/Tehran` است.
قاعده: **هیچ‌جا `date()` بدون منطقهٔ زمانی صریح ننویس** وقتی شعبه `timezone` دارد.
```php
$tz = new \DateTimeZone($branch->getTimezone());
$day = (new \DateTimeImmutable("@{$ts}"))->setTimezone($tz);
$dow = ((int) $day->format('w') + 1) % 7; // ← همان تبدیل SlotCalculatorService
```
تبدیل `(w + 1) % 7` عمداً همان است که در `SlotCalculatorService:359` هست. دو قرارداد
شمارش روز هفته در یک کدبیس = باگ قطعی.
## ۵. کارایی — پنج کوئری، نه پنج × تعداد روز
```php
// ❌ اشتباه
foreach ($days as $day) { $exceptions = $repo->findForDay($resource, $day); }
// ✅ درست — یک بار برای کل بازه، بعد در حافظه
$exceptions = $repo->findOverlapping($resource, $from, $to);
$byDay = $this->bucketByDay($exceptions, $from, $to);
```
معیار: `rawWindows()` برای یک منبع در ۹۰ روز باید **دقیقاً ۵ کوئری** بزند. یک تست با
`ProfilerStack` یا شمارندهٔ `SQLLogger` این را قفل کند — وگرنه اولین refactor آن را می‌شکند.
## ۶. سازگاری با نوبت‌دهی موجود
این تسک هیچ‌چیز از `SlotCalculatorService` را تغییر نمی‌دهد. `ResourceAvailabilityService`
یک سرویس **موازی** است که فقط در حالت `booking_mode=resource` (تسک ۰۶) صدا زده می‌شود.
تنها نقطهٔ اتصال: `HolidayResolver` می‌تواند از تسک بعد در `SlotCalculatorService` هم
استفاده شود تا پزشک‌های حالت `slot` هم تعطیلات رسمی را بگیرند. آن یک تغییر رفتاری است
(روزهایی که امروز باز بودند بسته می‌شوند) — پس **در این تسک انجام نده**، به‌عنوان یک
تغییر جدا با تأیید محصول.
## ۷. edge case ها
| حالت | رفتار درست |
|---|---|
| منبع بدون هیچ `resource_calendar` | `rawWindows` خالی، `explainEmptyDay` = `no_calendar` |
| استثنا که کل روز را می‌پوشاند | آن روز خالی |
| استثنا نیم‌روزه | فقط همان بازه کسر شود |
| دو استثنای هم‌پوشان | `union` بعد `subtract` — نه کسر پشت‌سرهم (دوبار کسر بازهٔ مشترک) |
| شیفت با `valid_to` گذشته | نادیده گرفته شود |
| شیفت شبانه (۲۲:۰۰ تا ۰۲:۰۰) | **پشتیبانی نمی‌شود در این تسک**`422` با پیام روشن. دو ردیف بنویسند |
| `from > to` | `422` |
| بازهٔ بزرگ‌تر از ۹۰ روز | `422` — سقف مستند بند ۱۰ |
| تعطیل رسمی + override محیط + استثنای منبع، هر سه روی یک روز | ترتیب بند ۳ بالا |
شیفت شبانه عمداً بیرون است: پشتیبانی‌اش یعنی هر بازهٔ روزانه ممکن است به روز بعد سرریز
کند و کل منطق bucket-by-day باید بازنویسی شود. اگر کلینیکی لازم داشت، تسک جدا.
## ۸. تست
```
tests/Shared/Time/IntervalSetTest.php ← اول این، واحد و بدون DB
tests/Resource/ResourceCalendarTest.php
- PUT شیفت‌ها، بازخوانی یکسان
- end <= start → 422 · هم‌پوشانی در یک روز → 422
- شیفت شبانه → 422
tests/Resource/ResourceAvailabilityTest.php
- منبع بدون تقویم → خالی + دلیل no_calendar
- تقاطع با ساعت شعبه
- شعبهٔ بدون ساعت → بی‌قید
- کسر استثنای نیم‌روزه
- دو استثنای هم‌پوشان → یک بار کسر
tests/Resource/AvailabilityQueryCountTest.php
- ۹۰ روز → دقیقاً ۵ کوئری
tests/Holiday/HolidayResolverTest.php
- تعطیل رسمی برای همه
- override is_working=true فقط برای همان محیط
- override is_working=false روی روز غیررسمی
tests/Holiday/ImportNationalHolidaysTest.php
- idempotent
```
## ۹. مستندات
`docs/api/resource-calendar.md` بساز. در `docs/api/appointment-settings.md` یک بخش
«تفاوت با تقویم منبع» اضافه کن تا کسی دو سیستم را قاطی نکند.
@@ -0,0 +1,76 @@
# تسک ۰۳ — تقویم منبع، استثنا، تعطیلات ملی
**فاز:** ۱ (هسته) · **وابستگی:** ۰۱، ۰۲ · **زمان:** ۱۲-۱۴ ساعت
---
## هدف
مستند بند ۹ ساعت آزاد را از کسر هفت لایه می‌سازد. امروز چهار لایه داریم و همه روی
**پزشک** سوارند. این تسک لایه‌های غایب را اضافه می‌کند و آن‌ها را روی **منبع** می‌نشاند:
```
ساعت کاری شعبه ← تسک ۰۱ ساخت، اینجا وارد محاسبه می‌شود
– شیفت منبع ← این تسک
– تعطیلات رسمی کشور ← این تسک
– مرخصی و غیبت ← این تسک
– سرویس دوره‌ای دستگاه ← این تسک (همان جدول استثنا با نوع دیگر)
– نوبت‌های ثبت‌شده ← موجود (تسک ۰۷ به resource_occupancy منتقل می‌کند)
– رزروهای موقت ← موجود
– آماده‌سازی و تمیزکاری ← تسک ۰۲ ستون‌ها را ساخت، تسک ۰۶ اعمال می‌کند
```
## وضعیت فعلی
- `WeeklySchedule` — JSON هفتگی per `(doctor, clinic)` با `sessions[]`
- `DateOverride` — تنظیم یک روز خاص per `(doctor, clinic)`
- `Holiday` — بازهٔ تعطیلی per `(doctor, clinic)`، دستی
- جدول تعطیلات رسمی کشور **وجود ندارد**؛ هر پزشک باید دستی ثبت کند
## دامنه
**هست:** `ResourceCalendar` (شیفت تکرارشوندهٔ منبع)، `ResourceException` (مرخصی، غیبت،
سرویس دوره‌ای، تعطیلی موردی)، `NationalHoliday` (جدول کشوری با import شمسی) و
`TenantHolidayOverride` (کلینیکی که پنجشنبه کار می‌کند)، و یک سرویس واحد
`ResourceAvailabilityService` که «ساعت آزاد یک منبع در یک بازه» را می‌دهد.
**نیست:** تقاطع چند منبع و برنامهٔ چندبخشی (تسک ۰۶)، ثبت اشغال (تسک ۰۷).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| GET | `/api/v1/resource/{uuid}/calendar` | شیفت‌های هفتگی منبع |
| PUT | `/api/v1/resource/{uuid}/calendar` | جایگزینی کامل شیفت‌ها |
| GET | `/api/v1/resource/{uuid}/exceptions` | مرخصی/سرویس، با فیلتر بازه |
| POST | `/api/v1/resource/{uuid}/exception` | ثبت استثنا |
| PATCH/DELETE | `/api/v1/resource-exception/{uuid}` | |
| GET | `/api/v1/resource/{uuid}/availability?from&to` | ساعت آزاد خام (بدون نوبت) — برای پنل |
| GET | `/api/v1/national-holidays?year=1405` | تعطیلات رسمی سال |
| POST | `/api/v1/holiday-overrides` | تغییر تعطیلی رسمی توسط محیط |
| DELETE | `/api/v1/holiday-override/{uuid}` | |
## معیار پذیرش
- ✅ موفق: منبع «اپراتور مریم» شیفت شنبه تا چهارشنبه ۹:۰۰-۱۷:۰۰ می‌گیرد →
`GET /resource/{uuid}/availability?from=…&to=…` پنج بازه برمی‌گرداند و جمعه خالی است.
- ✅ موفق: مرخصی سه‌شنبه ثبت می‌شود → همان endpoint سه‌شنبه را خالی می‌دهد.
- ✅ موفق: تعطیل رسمی ۱۳ فروردین در `national_holidays` هست → آن روز برای همهٔ منابع
همهٔ محیط‌ها خالی است، بدون هیچ ثبت دستی.
- ✅ موفق: کلینیکی که پنجشنبه کار می‌کند یک `holiday_override` با `is_working=true` ثبت
می‌کند → فقط منابع همان محیط پنجشنبه باز می‌شوند.
- ❌ خطا: شیفت با `end_minute <= start_minute``422`.
- ❌ خطا: استثنا با `end < start``422`؛ استثنا روی منبع محیط دیگر → `404`.
- ⚠️ مرزی: شیفت منبع بیرون از ساعت کاری شعبه → **تقاطع** گرفته می‌شود، نه رد. اگر تقاطع
خالی شد، پاسخ `availability` آن روز را خالی می‌دهد و دلیل `outside_branch_hours` را
برمی‌گرداند.
- ⚠️ مرزی: شعبهٔ بدون ساعت کاری → شیفت منبع بی‌قید اعمال می‌شود (سازگاری با داده‌های موجود).
- ⚠️ مرزی: استثنای نیم‌روزه (۱۴:۰۰ تا ۱۸:۰۰) → فقط همان بازه کسر می‌شود، نه کل روز.
- ⚠️ مرزی: دو استثنای هم‌پوشان → مجاز، اتحاد گرفته می‌شود.
## خروجی
- `src/Resource/Calendar/` + `src/Resource/Service/ResourceAvailabilityService.php`
- صفحات: `ResourceCalendarPage.tsx`, `ResourceExceptionsPage.tsx`, `HolidaysSettingsPage.tsx`
- `docs/api/resource-calendar.md`
- دستور `app:holiday:import --year=1405` برای بارگذاری تعطیلات رسمی