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

251 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# نوبت‌دهی آنلاین منبع‌محور در nobat724
> وضعیت: **طرح پیشنهادی** — هنوز پیاده نشده. مرجعِ تصمیم‌گیری پیش از شروع کار.
> دامنه: `clinicpro` (بک‌اند) + `nobat724_front` (سایت عمومی).
> پیش‌نیاز خواندن: [resource-first-model.md](resource-first-model.md) · [docs/api/appointment.md](../api/appointment.md) · [docs/api/resource.md](../api/resource.md)
---
## ۱. صورت مسئله
نوبت‌دهی منبع‌محور امروز فقط از **پنل** کار می‌کند. بیماری که وارد سایت می‌شود، برای
کلینیکی که در حالت `resource` است یا چیزی نمی‌بیند، یا یک تقویمِ اسلاتیِ بی‌ربط.
سه حالت نوبت‌دهی در سیستم هست ([WeeklySchedule.php](../../src/Appointment/Entity/WeeklySchedule.php)):
| حالت | یعنی | سایت امروز؟ |
|---|---|---|
| `slot` | شبکهٔ اسلات ثابت پزشک | ✅ تاریخ → ساعت |
| `service` | طول نوبت از مدت سرویس‌ها | ✅ سرویس → تاریخ → ساعت |
| `resource` | برنامهٔ چندبخشی روی **تقویم منابع** | ❌ هیچ |
`booking_mode` برای هر محل جدا می‌آید (`GET /api/v1/appointment-booking-locations/{doctorUuid}`)
و سایت آن را از **محل انتخاب‌شده** می‌خواند، نه از پزشک — این قرارداد سرِ جایش می‌ماند و
`resource` فقط حالت سوم همان سوییچ است.
### چرا نمی‌شود همین موتور را به سایت وصل کرد
موتور منبع‌محور (`appointment-availability``appointment-hold``appointment-confirm`)
**ذاتاً پنلی** است، نه فقط «احراز هویت لازم دارد»:
```php
// 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](../../src/Appointment/Service/SlotCalculatorService.php#L197) | ❌ |
| پنجرهٔ رزرو (`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` — زمان‌های یک روز
```json
{ "doctor_uuid": "…", "clinic_uuid": "…", "service_uuid": "…",
"item_uuids": ["…"], "date": "2026-08-04", "patient_gender": "woman" }
```
پاسخ **بدون** `assignment`:
```json
{ "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](../../src/Appointment/Entity/WeeklySchedule.php#L50) |
| کنترل در پنل | سوییچ «نوبت‌دهی آنلاین» در [ScheduleSection.tsx:886](../../assets/admin/components/schedule/ScheduleSection.tsx#L886) |
| اعمال در مسیر اسلاتی/سرویسی | [SlotCalculatorService.php:197](../../src/Appointment/Service/SlotCalculatorService.php#L197) و `:315` (فقط وقتی `forManagement` نباشد) |
| اثر روی فهرست‌ها | پزشکِ همه‌خاموش از فهرست عمومی حذف می‌شود — [DoctorRepository.php:98](../../src/Doctor/Repository/DoctorRepository.php#L98) |
نکتهٔ مهم: خاموش‌بودن آنلاین **هرگز** جلوی ثبت نوبت از پنل را نمی‌گیرد — منشی همچنان
نوبت می‌دهد. همین رفتار باید در حالت منبع‌محور هم عیناً حفظ شود.
### آنچه باید اضافه شود
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](../api/appointment.md)). تا وقتی رزرو آنلاین منبع‌محور روشن
نشده این فقط یک ناهماهنگی است؛ **بعد از آن، منبعِ دابل‌بوکینگ می‌شود.** بستنش
پیش‌نیازِ فاز ۱ است، نه کارِ بعدی.
---
## ۷. فازبندی
| فاز | کار | خروجی |
|---|---|---|
| ۰ | نوشتن occupancy برای نوبت‌های منبعِ پنلی + آزادسازی روی لغو | یک منبعِ حقیقت برای اشغال |
| ۱ | چهار اندپوینت عمومی + گاردها + تست (موفق/خطا/مرزی) + `docs/api/*` | API آمادهٔ مصرف |
| ۲ | سایت: آداپتور، مراحل، شمارش معکوس، حالت‌های لبه | رزرو آنلاین قابل استفاده |
| ۳ | `ClinicResource.online_bookable` + نمایش «اتاق/پزشک» در تأیید نهایی | کنترل ریزتر |
---
## ۸. تصمیم‌های باز
1. **بیعانه/پرداخت آنلاین:** آیا رزرو منبع‌محور مثل بقیه بیعانه می‌گیرد؟ اگر بله، مهلت
hold باید از مهلت درگاه بیشتر باشد وگرنه بیمار پول می‌دهد و وقت را از دست می‌دهد.
2. **مهلت hold:** مقدار فعلی پنلی برای بیمارِ در حال تایپ کوتاه است. عدد جدا برای مسیر
عمومی؟
3. **بیمه:** مسیر عمومی امروز بیمه نمی‌گیرد؛ نوبت منبع‌محور فاکتور لحظه‌ای دارد
(`PriceSnapshot`). تکلیف سهم بیمار در سایت باید روشن شود.
4. **چند سرویس در یک نوبت آنلاین:** پنل اجازه می‌دهد؛ سایت هم؟ اگر بله، سقفِ مدت لازم است.
---
## ۹. چک‌لیست پذیرش
- [ ] کلینیک با `booking_mode = resource` و `online_booking_enabled = true` در سایت قابل رزرو است.
- [ ] خاموش‌کردن سوییچ، بلافاصله رزرو آنلاین را می‌بندد و **پنل دست‌نخورده** کار می‌کند.
- [ ] پاسخ عمومی هیچ نام منبعی افشا نمی‌کند.
- [ ] دو رزرو هم‌زمان روی آخرین ظرفیت ⇒ یکی `409` می‌گیرد، نه هر دو موفق.
- [ ] نوبتِ ثبت‌شده از سایت، در تایم‌لاین منبعِ پنل دیده می‌شود و بالعکس.
- [ ] تاریخ خارج از پنجرهٔ رزرو، در تقویم غیرفعال است.