Files
clinicpro/docs/architecture/resource-first-model.md
hamedandClaude Opus 5 1972fdd20f Document the resource-first model and retire the deleted tasks' checklists
docs/architecture/resource-first-model.md describes the shape: the three
entities, why an option is a ServiceItem rather than a fourth table, the
four-level resolution chain, the two conditions on the eligibility filter and
what each of them prevented, and why containment is a graph beside the display
tree rather than the tree itself.

docs/api/resource.md gains both offering endpoints with the response captured
from a real call, including a row where the price comes from the branch and one
where it comes from the resource — the two cases the *_source fields exist for.
docs/api/appointment.md documents resource_uuid, the doctor inference, and the
nullable resource/service_option in the response.

The checklists for tasks 9 to 14 keep their rows but open with a banner saying
the task was removed, when, by whose decision, and which commit to revert. They
are history now; deleting them would erase the record of work that shipped and
was then withdrawn.

Verified end to end: 1304 tests, slot-mode-frozen green, phpstan at 14, tsc
clean, 648 panel tests, and app:seed-scenarios --reset builds all three
environments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 23:05:33 +03:30

123 lines
8.1 KiB
Markdown

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