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

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):

  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 و docs/api/resource.md.