# مدل منبع‌محور: منبع، سرویس، گزینه سیستم نوبت‌دهی روی **منبع** ساخته شده، نه روی پزشک. منبع هر چیزی است که زمانش رزرو می‌شود: پزشک، دستگاه لیزر، یونیت دندانپزشکی، اتاق عمل، مربی. همین باعث می‌شود از مطب یک پزشک تا مرکز لیزر و درمانگاه، همه با یک مدل داده کار کنند. ## سه موجودیت | مفهوم | کجاست | نکته | |---|---|---| | **منبع** | `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).