# مدل منبع‌محور: منبع، سرویس، گزینه

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

## سه موجودیت

| مفهوم | کجاست | نکته |
|---|---|---|
| **منبع** | `ClinicResource` | تقویم، استثنا، مهارت، ظرفیت و اشغال دارد. پزشک/پرسنل/اتاق با `ResourceLinker` به منبع وصل می‌شوند |
| **سرویس** | `ServiceItem` | مدت (solo/additional)، قیمت، دستهٔ کاتالوگ |
| **گزینهٔ سرویس** | باز هم `ServiceItem` | عضو یک `ItemGroup` با بازهٔ انتخاب (`select_min`/`select_max`) |

**چرا گزینه جدول جدا ندارد:** «لیزر» و «لیزر پا» هر دو یک چیزند — چیزی که قیمت و مدت
دارد و می‌شود رزروش کرد. جدول سوم یعنی `PriceListItem`، `Tariff`،
`appointment_service_items`، `SegmentTemplate` و کل مسیر `appointment-service-slots`
باید دو نوع ورودی بشناسند؛ یعنی دو منبع حقیقت برای یک مفهوم.

## رابطهٔ منبع↔سرویس

`ResourceServiceOffering` — چند‌به‌چند با تنظیمات اختصاصی:

```
resource_service_offerings(resource_id, service_item_id, duration_minutes, price_rials, active)
```

- ردیف با **آیتم والد** = «منبع + سرویس»؛ ردیف با **آیتم عضو گروه** = «منبع + گزینه».
  یک جدول، هر دو سطحِ سند.
- `null` یعنی «ارث از سطح بالاتر»، **نه صفر**. صفرِ صریح یک مقدار واقعی است (سرویس رایگان).
- `active = false` یعنی «فعلاً این منبع این را نمی‌دهد» — ردیف می‌ماند تا تنظیماتش با
  خاموش/روشن کردن از دست نرود.
- فرزند aggregate منبع است: ستون محیط ندارد چون منبع خودش دارد، و سازنده اجبار می‌کند
  منبع و سرویس در یک محیط باشند.

## زنجیرهٔ حل مدت و قیمت

`ResourceServiceResolver` — از خاص به عام:

```
۱. منبع + گزینه   → ResourceServiceOffering(resource, option)     source: resource_option
۲. منبع + سرویس   → ResourceServiceOffering(resource, parent)     source: resource_service
۳. شعبه + آیتم    → ServiceBranchOverride(item, address)          source: branch
۴. پیش‌فرض آیتم   → ServiceItem                                    source: service_default
```

دو قاعده که سکوت‌شان باگ می‌سازد:

- **مدت و قیمت جدا حل می‌شوند.** منبعی که فقط مدتش فرق دارد نباید قیمتش هم از همان سطح
  بیاید، وگرنه اولین override تعرفهٔ شعبه را بی‌صدا می‌بلعد.
- **منبعِ هر مقدار برگردانده می‌شود** (`durationSource` / `priceSource`). بدون آن، پنل
  نمی‌تواند کنار عدد بنویسد «از شعبه»، و «چرا این عدد؟» می‌شود جست‌وجو در چهار جدول.

## انتخاب منبع

`ClinicResourceRepository::findEligible($address, $type, $skillIds, $service)`:

1. شعبه + نوع + فعال
2. مهارت‌های لازم (همه، نه یکی)
3. **رابطهٔ سرویس** — فقط اگر برای آن سرویس و **همان نوع منبع** ردیفی ثبت شده باشد
4. ترتیب: منابعی که دستهٔ سرویس را پوشش می‌دهند اول

بند ۳ دو قید دارد که هر کدام یک شکست واقعی را جلو گرفته‌اند:

- **مشروط بودن:** محیطی که هنوز رابطه‌ها را پر نکرده باید مثل قبل کار کند، وگرنه با
  اولین deploy بی‌وقت می‌شود.
- **محدود به نوع:** «کدام منبع این سرویس را می‌دهد» دربارهٔ نقشِ انجام‌دهنده است. بدون این
  قید، ثبت رابطه برای دستگاه‌ها باعث می‌شد اتاقِ همان برنامه واجد شرایط نباشد و کل رزرو
  بشکند.

## دسته‌بندی

دسته سراسریِ محیط است و **هم سرویس‌ها هم منابع** از آن استفاده می‌کنند
(`ServiceItem::$catalogCategory` و `resource_catalog_categories`).

دو رابطهٔ متفاوت که نباید قاطی شوند:

| رابطه | جدول | معنا |
|---|---|---|
| سلسله‌مراتب نمایشی | `CatalogCategory::$parent` | چیدمان منو؛ درخت، تک‌والدی، `MAX_DEPTH = 4` |
| **شامل بودن** | `catalog_category_includes` | «تمام بدن شامل دست است»؛ گراف جهت‌دار بدون دور |

درخت برای «شامل بودن» کافی نیست: «دست» باید هم‌زمان زیر «تمام بدن» و «اندام فوقانی»
باشد و درخت تک‌والدی این را نمی‌تواند بگوید.

`CategoryClosureResolver` بستار گذرا را حساب می‌کند (تمام بدن → نیم‌تنه → پا ⇒ تمام بدن
شامل پا). همهٔ یال‌های محیط با یک کوئری خوانده و در حافظه پیمایش می‌شوند؛ مجموعهٔ
بازدیدشده هم‌زمان نتیجه و محافظ دور است، و `assertNoCycle` ساختِ حلقه را رد می‌کند.

**تعارض انتخاب:** انتخاب هم‌زمان دو آیتم که دستهٔ یکی دیگری را در بر می‌گیرد ⇒ `422` با
کد `category_overlap`. این جای رابطهٔ دستی `incompatible_with` را برای حالت ناحیه‌ای
می‌گیرد — یک بار روی دسته، نه به‌ازای هر جفت آیتم. آن رابطه برای ناسازگاری‌هایی که ربطی
به ناحیه ندارند سرِ جایش می‌ماند.

## نوبت

نوبت هم منبع را نگه می‌دارد هم گزینه را، و عددهایش را snapshot می‌کند:

| ستون | چرا |
|---|---|
| `resource_id` | نوبت **برای** کدام منبع است. تکرار `resource_occupancy` نیست: آن می‌گوید چه چیزی و کِی اشغال شد (شامل اتاقِ یک بخش)، این می‌گوید بیمار چه چیزی را انتخاب کرد |
| `service_option_item_id` | بدون دانستن گزینه، بازتولید عددِ ذخیره‌شده ممکن نیست |
| `service_total_minutes` | از خروجی resolver، نه از `ServiceItem` |
| `PriceSnapshot` | قیمتِ لحظهٔ رزرو؛ تغییر فردای تعرفه صورتحساب دیروز را تکان نمی‌دهد |

هر دو ستون تهی‌پذیرند: نوبت‌های پیش از این مدل منبع ندارند و migration نباید بشکندشان.

## رزرو

`POST /api/v1/appointment` هم `doctor_uuid` می‌پذیرد هم `resource_uuid`:

- با منبعِ پزشک، پزشک از خودِ منبع استنتاج می‌شود.
- محل نوبت از **شعبهٔ منبع** می‌آید؛ فرستادن جداگانهٔ `clinic_uuid` فقط راهی برای ناسازگار
  کردن این دو بود.
- منبع باید در همان محیط رزرو باشد. uuid از بدنهٔ درخواست می‌آید و `TenantFilter` پوششش
  نمی‌دهد، پس بدون این بررسی بیمار می‌توانست دستگاه کلینیک دیگری را روی نوبت این کلینیک
  بنشاند.
- منبعی که سرویس انتخاب‌شده را ارائه نمی‌دهد همان‌جا رد می‌شود، نه وقتی بیمار سرِ قرار
  حاضر شده.

جزئیات قرارداد: [`docs/api/appointment.md`](../api/appointment.md) و
[`docs/api/resource.md`](../api/resource.md).
