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>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
# مدل منبعمحور: منبع، سرویس، گزینه
|
||||
|
||||
سیستم نوبتدهی روی **منبع** ساخته شده، نه روی پزشک. منبع هر چیزی است که زمانش رزرو
|
||||
میشود: پزشک، دستگاه لیزر، یونیت دندانپزشکی، اتاق عمل، مربی. همین باعث میشود از مطب یک
|
||||
پزشک تا مرکز لیزر و درمانگاه، همه با یک مدل داده کار کنند.
|
||||
|
||||
## سه موجودیت
|
||||
|
||||
| مفهوم | کجاست | نکته |
|
||||
|---|---|---|
|
||||
| **منبع** | `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).
|
||||
Reference in New Issue
Block a user