Files
clinicpro/docs/architecture/online-resource-booking.md

16 KiB
Raw Permalink Blame History

نوبت‌دهی آنلاین منبع‌محور در nobat724

وضعیت: طرح پیشنهادی — هنوز پیاده نشده. مرجعِ تصمیم‌گیری پیش از شروع کار. دامنه: clinicpro (بک‌اند) + nobat724_front (سایت عمومی). پیش‌نیاز خواندن: resource-first-model.md · docs/api/appointment.md · docs/api/resource.md


۱. صورت مسئله

نوبت‌دهی منبع‌محور امروز فقط از پنل کار می‌کند. بیماری که وارد سایت می‌شود، برای کلینیکی که در حالت resource است یا چیزی نمی‌بیند، یا یک تقویمِ اسلاتیِ بی‌ربط.

سه حالت نوبت‌دهی در سیستم هست (WeeklySchedule.php):

حالت یعنی سایت امروز؟
slot شبکهٔ اسلات ثابت پزشک تاریخ → ساعت
service طول نوبت از مدت سرویس‌ها سرویس → تاریخ → ساعت
resource برنامهٔ چندبخشی روی تقویم منابع هیچ

booking_mode برای هر محل جدا می‌آید (GET /api/v1/appointment-booking-locations/{doctorUuid}) و سایت آن را از محل انتخاب‌شده می‌خواند، نه از پزشک — این قرارداد سرِ جایش می‌ماند و resource فقط حالت سوم همان سوییچ است.

چرا نمی‌شود همین موتور را به سایت وصل کرد

موتور منبع‌محور (appointment-availabilityappointment-holdappointment-confirm) ذاتاً پنلی است، نه فقط «احراز هویت لازم دارد»:

// AvailabilityController::search() و BookingController::create()
$address = $this->branches->resolve($user, $data['branch_uuid']);   // ← محیطِ خودِ کاربر
[$entityType, $entityId] = $this->branches->pair($user);            // ← محیطِ خودِ کاربر

AddressResolver::pair() محیط را از کاربرِ درخواست‌دهنده می‌گیرد. بیمار هیچ محیطی ندارد، پس همین حالا هم اگر توکنِ بیمار بفرستیم، آدرس کلینیک «یافت نشد» می‌شود. یعنی مسئله یک خط security.yaml نیست؛ جهتِ resolve باید برعکس شود: محیط باید از مقصدِ رزرو (پزشک/کلینیک) بیاید، نه از فرستنده.

سه گاردِ دیگر هم که مسیرهای عمومی از قبل دارند، در این موتور اصلاً وجود ندارند — چون تا امروز فقط کارمند صدایش می‌زده:

گارد مسیر عمومیِ اسلاتی/سرویسی موتور منبع‌محور
online_booking_enabled SlotCalculatorService.php:197
پنجرهٔ رزرو (booking_window_*)
«فقط سرویس‌های bookable» جزئی
عدم افشای منابع پاسخ، نام دستگاه و اپراتور را می‌دهد

۲. تصمیم معماری

سه راه روی میز بود:

گزینه کار چرا نه / چرا آری
الف. مسیر عمومیِ موازی روی همان موتور سه اندپوینت عمومی که محیط را از مقصد resolve می‌کنند و گاردهای عمومی را دارند پیشنهادی. موتور تخصیص، ظرفیت و اتمیک‌بودن یکی می‌ماند؛ فقط لایهٔ ورودی/گارد جدا می‌شود
ب. بیمار خودش منبع را انتخاب کند (مثل مودال پنل) استفادهٔ مستقیم از resource/{uuid}/service-slots بیمار نباید بین «لیزر CO2 شمارهٔ ۲» و «۳» انتخاب کند؛ این تصمیمِ کلینیک است و افشای ظرفیت داخلی هم هست
ج. تنزل resource به service برای سایت نادیده گرفتن منابع در مسیر عمومی نوبتِ ثبت‌شده منبع نمی‌گیرد، پس پنل و سایت دو حقیقت متفاوت از ظرفیت می‌سازند و دابل‌بوکینگ قطعی است

تصمیم: گزینهٔ الف. هستهٔ AvailabilityEngine / HoldService / BookingService دست نمی‌خورد؛ فقط سه کنترلر نازکِ عمومی روی آن می‌نشیند.


۳. قرارداد API پیشنهادی

همه زیر /api/v1/public/... تا از مسیر پنلی جدا بماند و در security.yaml یک‌جا whitelist شود. احراز هویتِ بیمار برای دو مرحلهٔ آخر لازم است (مثل POST /api/v1/appointment).

۳.۱ GET /api/v1/appointment-booking-services/{doctorUuid} — بدون تغییر

از قبل عمومی است و booking_mode را می‌دهد. سایت با دیدن "resource" جریان تازه را شروع می‌کند. تنها افزودهٔ لازم: در این حالت هم services[] باید سرویس‌های قابل رزرو آنلاین باشند (ServiceItem.bookable).

۳.۲ GET /api/v1/public/resource-availability/month — تقویم ماه

?doctor_uuid=…&clinic_uuid=…&service_uuid=…&item_uuids[]=…&year=1405&month=5
→ { enabled_dates: [...], disabled_dates: [...], online_booking_enabled: true,
    booking_window: { value: 3, unit: "month" } }

عمداً سبک: فقط «این روز ظرفیت دارد یا نه»، بدون ساختِ تخصیص. معادلِ موجودِ پنلی‌اش appointment-availability/month است.

۳.۳ POST /api/v1/public/resource-availability — زمان‌های یک روز

{ "doctor_uuid": "…", "clinic_uuid": "…", "service_uuid": "…",
  "item_uuids": ["…"], "date": "2026-08-04", "patient_gender": "woman" }

پاسخ بدون assignment:

{ "plan": { "total_minutes": 75, "segments": [ { "name": "لیزر", "duration_minutes": 50 } ] },
  "slots": [ { "start": 1785220200, "end": 1785224700 } ],
  "reason": null }

افشای منابع ممنوع. پاسخ پنلی assignment (نام دستگاه و اپراتور) دارد؛ نسخهٔ عمومی فقط زمان می‌دهد. تخصیص سمت سرور در hold نگه داشته می‌شود.

۳.۴ POST /api/v1/public/resource-hold — نگه‌داشتن موقت (احراز شده)

ورودی: همان کلیدها + start. خروجی: { hold_uuid, starts_at, ends_at, expires_at }.

تخصیص منبع را سرور انتخاب می‌کند (resource_strategy محیط: first_available / least_loaded / …) و بیمار در آن نقشی ندارد. hold اجباری است: بین دیدن وقت و پرداخت، صندلی باید قفل شود وگرنه دو بیمار هم‌زمان یک دستگاه را می‌خرند.

۳.۵ POST /api/v1/public/resource-confirm — ثبت نهایی (احراز شده)

ورودی { hold_uuid, patient_national_code, patient_gender, … }. خروجی همان appointment_uuid + price_snapshot. نوبت pending متولد می‌شود و مسیر پرداخت/بیعانهٔ موجود دست‌نخورده می‌ماند.

۳.۶ گاردهای مشترکِ هر چهار اندپوینت

  1. online_booking_enabled برای همان (پزشک، محیط) — خاموش ⇒ 403 صریح، نه فهرست خالی.
  2. پنجرهٔ رزرو booking_window_value/unit — تاریخ خارج از پنجره ⇒ 422.
  3. booking_mode === 'resource' — در غیر این‌صورت 422 با اشاره به مسیر درست.
  4. سرویس باید bookable و مالِ همان محیط باشد.
  5. محیط از مقصد resolve می‌شود: EntityContext::forBooking($doctor, $clinic) — نه از کاربر.
  6. Rate limit روی hold (بیمار می‌تواند با چند hold ظرفیت را قفل کند).

۴. کلید روشن/خاموش برای مدیر کلینیک

خواستهٔ صریح: مدیر کلینیک باید بتواند نوبت‌دهی آنلاین را داشته باشد یا نه. این کلید از قبل وجود دارد و لازم نیست چیز تازه‌ای اختراع شود — فقط باید در مسیر منبع‌محور هم خوانده شود.

وضع موجود

لایه کجا
ذخیره WeeklySchedule.meta.online_booking_enabled per (پزشک، محیط) — WeeklySchedule.php:50
کنترل در پنل سوییچ «نوبت‌دهی آنلاین» در ScheduleSection.tsx:886
اعمال در مسیر اسلاتی/سرویسی SlotCalculatorService.php:197 و :315 (فقط وقتی forManagement نباشد)
اثر روی فهرست‌ها پزشکِ همه‌خاموش از فهرست عمومی حذف می‌شود — DoctorRepository.php:98

نکتهٔ مهم: خاموش‌بودن آنلاین هرگز جلوی ثبت نوبت از پنل را نمی‌گیرد — منشی همچنان نوبت می‌دهد. همین رفتار باید در حالت منبع‌محور هم عیناً حفظ شود.

آنچه باید اضافه شود

  1. خواندن همان کلید در موتور منبع‌محور. هر چهار اندپوینت عمومی بند ۳ اول این را بسنجند. بدون این، عمومی‌کردن موتور یعنی کلیدِ خاموشِ کلینیک بی‌اثر می‌شود — یعنی نشت رفتار، نه یک نقص کوچک.

  2. سه سطحِ کنترل، از درشت به ریز:

    سطح کلید وضعیت
    کل محیط/پزشک online_booking_enabled هست
    هر سرویس ServiceItem.bookable («نمایش در نوبت‌دهی») هست
    هر منبع ClinicResource.online_bookable پیشنهاد فاز ۲

    سطح سوم برای دستگاهی است که باید در گردش کار داخلی بماند ولی مستقیم آنلاین فروخته نشود. تا وقتی نیست، همان active تنها اهرم است — و خاموش‌کردنش نوبت‌دهی پنلی را هم می‌کُشد، که همان چیزی نیست که مدیر می‌خواهد.

  3. پاسخِ «خاموش است» باید صریح باشد. فهرست خالی، هم بیمار را گیج می‌کند هم پشتیبانی را. یک 403 با پیام «نوبت‌دهی آنلاین این کلینیک فعال نیست» + پنهان‌کردن دکمهٔ رزرو در سایت.


۵. جریان کاربر در سایت

انتخاب محل (booking_mode از همان محل)
   └─ resource ─→ ۱. انتخاب سرویس (یک یا چند)   ← appointment-booking-services
                  ۲. تقویم ماه                   ← public/resource-availability/month
                  ۳. زمان‌های روز                ← public/resource-availability
                  ۴. نگه‌داشتن + شمارش معکوس     ← public/resource-hold
                  ۵. مشخصات بیمار و پرداخت       ← public/resource-confirm

نگاشت به کد موجود nobat724_front:

گام فایل کار
ارکستراسیون components/appointment/index.js شاخهٔ سوم برای booking_mode === 'resource'
انتخاب سرویس components/appointment/service/ تقریباً بدون تغییر؛ مدت کل از سرور می‌آید
تقویم/ساعت components/appointment/date/, lib/appointmentSlots.js آداپتور سوم adaptResourceSlots با همان خروجیِ { start_time, end_time, label, slots }
تماس‌ها services/response.js چهار متد تازه
نگه‌داشتن جدید شمارش معکوس تا expires_at؛ معادلِ HoldCountdown پنل

قواعدی که همین حالا در nobat724_front/CLAUDE.md هست و اینجا هم برقرارند: مدت دادهٔ سرور است و در فرانت جمع زده نمی‌شود؛ مرزهای شیفت در فرانت استنتاج نمی‌شوند.

رفتارهای لبه‌ای که باید در UI دیده شوند:

  • 409 روی hold ⇒ «این زمان همین لحظه گرفته شد» + رفرش خودکار زمان‌ها.
  • انقضای hold پیش از پرداخت ⇒ برگشت به گام ۳ با پیام روشن، نه خطای خام.
  • ترک صفحه ⇒ آزادسازی hold (beforeunload + TTL سمت سرور به‌عنوان تور ایمنی).

۶. داده و مهاجرت

فاز ۱ هیچ مهاجرتی ندارد: resource_occupancy، appointment_holds و price_snapshots از قبل هستند. تنها مهاجرتِ احتمالی، فیلد online_bookable روی clinic_resources در فاز ۲ است (پیش‌فرض true، عقب‌رو-سازگار).

یک بدهیِ شناخته‌شده که این کار آن را برجسته می‌کند: نوبت‌های مسیر پنلیِ منبع (appointments.resource_id) ردیف resource_occupancy نمی‌سازند، پس موتور آن‌ها را نمی‌بیند (appointment.md). تا وقتی رزرو آنلاین منبع‌محور روشن نشده این فقط یک ناهماهنگی است؛ بعد از آن، منبعِ دابل‌بوکینگ می‌شود. بستنش پیش‌نیازِ فاز ۱ است، نه کارِ بعدی.


۷. فازبندی

فاز کار خروجی
۰ نوشتن occupancy برای نوبت‌های منبعِ پنلی + آزادسازی روی لغو یک منبعِ حقیقت برای اشغال
۱ چهار اندپوینت عمومی + گاردها + تست (موفق/خطا/مرزی) + docs/api/* API آمادهٔ مصرف
۲ سایت: آداپتور، مراحل، شمارش معکوس، حالت‌های لبه رزرو آنلاین قابل استفاده
۳ ClinicResource.online_bookable + نمایش «اتاق/پزشک» در تأیید نهایی کنترل ریزتر

۸. تصمیم‌های باز

  1. بیعانه/پرداخت آنلاین: آیا رزرو منبع‌محور مثل بقیه بیعانه می‌گیرد؟ اگر بله، مهلت hold باید از مهلت درگاه بیشتر باشد وگرنه بیمار پول می‌دهد و وقت را از دست می‌دهد.
  2. مهلت hold: مقدار فعلی پنلی برای بیمارِ در حال تایپ کوتاه است. عدد جدا برای مسیر عمومی؟
  3. بیمه: مسیر عمومی امروز بیمه نمی‌گیرد؛ نوبت منبع‌محور فاکتور لحظه‌ای دارد (PriceSnapshot). تکلیف سهم بیمار در سایت باید روشن شود.
  4. چند سرویس در یک نوبت آنلاین: پنل اجازه می‌دهد؛ سایت هم؟ اگر بله، سقفِ مدت لازم است.

۹. چک‌لیست پذیرش

  • کلینیک با booking_mode = resource و online_booking_enabled = true در سایت قابل رزرو است.
  • خاموش‌کردن سوییچ، بلافاصله رزرو آنلاین را می‌بندد و پنل دست‌نخورده کار می‌کند.
  • پاسخ عمومی هیچ نام منبعی افشا نمی‌کند.
  • دو رزرو هم‌زمان روی آخرین ظرفیت ⇒ یکی 409 می‌گیرد، نه هر دو موفق.
  • نوبتِ ثبت‌شده از سایت، در تایم‌لاین منبعِ پنل دیده می‌شود و بالعکس.
  • تاریخ خارج از پنجرهٔ رزرو، در تقویم غیرفعال است.