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>
8.1 KiB
مدل منبعمحور: منبع، سرویس، گزینه
سیستم نوبتدهی روی منبع ساخته شده، نه روی پزشک. منبع هر چیزی است که زمانش رزرو میشود: پزشک، دستگاه لیزر، یونیت دندانپزشکی، اتاق عمل، مربی. همین باعث میشود از مطب یک پزشک تا مرکز لیزر و درمانگاه، همه با یک مدل داده کار کنند.
سه موجودیت
| مفهوم | کجاست | نکته |
|---|---|---|
| منبع | 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):
- شعبه + نوع + فعال
- مهارتهای لازم (همه، نه یکی)
- رابطهٔ سرویس — فقط اگر برای آن سرویس و همان نوع منبع ردیفی ثبت شده باشد
- ترتیب: منابعی که دستهٔ سرویس را پوشش میدهند اول
بند ۳ دو قید دارد که هر کدام یک شکست واقعی را جلو گرفتهاند:
- مشروط بودن: محیطی که هنوز رابطهها را پر نکرده باید مثل قبل کار کند، وگرنه با اولین 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 و
docs/api/resource.md.