feat: Implement resource booking functionality

- Add service timeline builder for appointments to manage available slots.
- Create a hook to fetch resource booking services with effective durations.
- Develop ResourceBookingSlotController to handle API requests for resource booking slots.
- Implement ResourceBookingSlotService to calculate available time slots based on resource occupancy and service durations.
- Add tests for resource appointment creation and booking slot functionality to ensure correct behavior and edge cases.
This commit is contained in:
hamed
2026-08-03 14:34:23 +03:30
parent 981261ed3a
commit 4f69bc9044
21 changed files with 2045 additions and 514 deletions
+33
View File
@@ -1118,6 +1118,39 @@ New query param `reserve=1` → returns only reserve-list entries; without it on
منبع تحت `TenantFilter` است: `resource_uuid`ِ محیط دیگر هیچ ردیفی برنمی‌گرداند (۲۰۰ با
فهرست خالی، نه ۴۰۳).
### ثبت نوبت برای یک منبع — `resource_uuid` روی `POST /api/v1/my/appointment` (2026-08)
نوبت‌دهی منبع **سرویسی** است: مودالِ منبع همان فرمِ نوبت‌دهی سرویسیِ پزشک است و همین
اندپوینت را صدا می‌زند، فقط با `resource_uuid`.
| فیلد | نوع | توضیح |
|---|---|---|
| `resource_uuid` | string | منبعِ نوبت. غیرفعال یا ناموجود ⇒ `422` |
قواعدی که فقط وقتی این فیلد بیاید اعمال می‌شوند:
- **پزشک از ناظرِ منبع می‌آید.** `doctor_uuid` اختیاری می‌شود؛ منبعِ بی‌ناظر ⇒ `422`
(رابطهٔ پزشک↔منبع یک جا تعریف شده است و پرسیدن دوباره‌اش یعنی دو منبعِ حقیقت).
- **مدت از زنجیرهٔ حلِ همان منبع** (`ResourceServiceResolver`) می‌آید نه از
`duration_minutes` خامِ سرویس: همان «RF فرکشنال» روی یک دستگاه ۵۰ دقیقه است و روی
دیگری ۴۰. `service_durations` همچنان همین نوبت را جابه‌جا می‌کند.
- **گیتِ سرویس، `ResourceServiceOffering` فعال است** نه پرچم `bookable`: سرویسی که
این منبع ارائه نمی‌دهد ⇒ `422` با فیلد `service_item_uuids`.
- **تداخل روی خودِ منبع جدا سنجیده می‌شود** ⇒ `409`. `bookAtomically` فقط اسلاتِ پزشک
را قفل می‌کند و دو پزشک می‌توانند یک دستگاه را هم‌زمان بگیرند. اشغال از دو جا خوانده
می‌شود: نوبت‌های `appointments.resource_id` و ردیف‌های `resource_occupancy` (رزرو
موقت، مسدودسازیِ دستی، نوبت‌های موتور منبع‌محور). ظرفیت منبع رعایت می‌شود: اتاق
دوتخته با یک نوبت پر نمی‌شود.
- **منبعِ محیط دیگر رد می‌شود** ⇒ `422`. منبع با uuid از بدنه می‌آید و `TenantFilter`
پوششش نمی‌دهد.
> **محدودیت شناخته‌شده:** این مسیر ردیف `resource_occupancy` نمی‌سازد (مثل
> `POST /api/v1/appointment` عمومی که از قبل همین‌طور بود). پس نوبتِ پنلی برای موتور
> منبع‌محور (`appointment-availability`) نامرئی است؛ در جهت عکس — پنل هر دو منبعِ
> اشغال را می‌خواند — مشکلی نیست.
تست: `tests/Appointment/ResourceAppointmentCreateTest.php`.
خروجی واقعی `GET /api/v1/my/appointments?limit=1&resource_uuid=ce070910-…`:
```json
+56 -1
View File
@@ -451,6 +451,58 @@
> تعداد کوئری ثابت است: یک کوئری اشغال، یک کوئری شیفت، یک کوئری نوبت — نه یکی به‌ازای
> هر منبع.
### `GET /api/v1/resource/{uuid}/day-slots` (2026-08)
مجوز: `appointment_settings.view`. پارامتر: `date=Y-m-d` (الزامی).
بازه‌های **کاری** منبع در یک روز — ورودیِ تایم‌لاینِ سرویسیِ صفحهٔ نوبت‌ها. معادلِ
`appointment-slots` پزشک، ولی از تقویم خودِ منبع: ساعت شعبه ∩ شیفت منبع − تعطیلات −
استثناها.
نوبت‌ها اینجا **کسر نمی‌شوند**: تایم‌لاین نوبت‌های همان روز را جدا دارد و کارت‌ها را
داخل همین بازه‌ها می‌چیند؛ کسرشان یعنی نوبتِ ثبت‌شده جایی برای نشستن ندارد.
```json
{ "success": true, "data": {
"resource_uuid": "ce07…", "date": "2026-08-04", "timezone": "Asia/Tehran",
"windows": [{ "start": 1785220200, "end": 1785249000, "start_time": "09:00", "end_time": "17:00" }],
"empty_reason": null
} }
```
`empty_reason` وقتی `windows` خالی است می‌گوید چرا: `no_shift`، `national_holiday`،
`tenant_holiday`، `exception`، `resource_inactive`، `address_inactive`. خالی‌بودن خطا
نیست و کلاینت نباید همه را «تعطیل» بنامد.
### `GET /api/v1/resource/{uuid}/service-slots` (2026-08)
مجوز: `appointment_settings.view`.
| پارامتر | توضیح |
|---|---|
| `date` | `Y-m-d`، الزامی |
| `service_item_uuids[]` | یک یا چند سرویس؛ خالی ⇒ `422` |
| `durations[<uuid>]` | override مدت، فقط برای همین محاسبه |
زمان‌های خالیِ کافی برای مجموعِ مدتِ سرویس‌های انتخاب‌شده — معادلِ
`appointment-service-slots` پزشک. مدت هر سرویس از زنجیرهٔ حلِ همان منبع می‌آید
(`ResourceServiceResolver`)، اشغال از نوبت‌های `resource_id` **و** ردیف‌های
`resource_occupancy` خوانده می‌شود، و ظرفیت منبع رعایت می‌شود. زمان‌ها پشت‌سرهم
چیده می‌شوند (بدون بافر) و زمانِ گذشته پیشنهاد نمی‌شود.
```json
{ "success": true, "data": {
"resource_uuid": "ce07…", "date": "2026-08-04", "total_duration_minutes": 60,
"start_times": [{ "start": 1785220200, "end": 1785223800, "start_time": "09:00", "end_time": "10:00" }]
} }
```
**۴۲۲:** سرویسِ ناموجود · سرویسی که این منبع ارائه نمی‌دهد (یا offeringش غیرفعال است)
· سرویسِ بی‌مدت · تاریخ بدفرم. **۴۰۴:** منبعِ محیط دیگر.
ثبتِ خودِ نوبت با همین زمان‌ها از `POST /api/v1/my/appointment` با `resource_uuid`
انجام می‌شود ([`appointment.md`](appointment.md)).
### `PUT /api/v1/resource/{uuid}/categories`
مجوز: `appointment_settings.update`.
@@ -581,9 +633,12 @@ idempotent است: تکیه‌گاهش وجود یا نبودِ منبعِ مت
## تست‌ها
```bash
ddev exec php bin/phpunit tests/Resource # ۵۲ تست / ۱۲۹ assertion
ddev exec php bin/phpunit tests/Resource # ۱۳۱ تست / ۳۴۲ assertion
ddev exec php vendor/bin/phpstan analyse src/Resource
npx vitest run assets/admin/pages/ResourcesPage.test.tsx
npx vitest run assets/admin/components/appointments/ResourceDayPanel.test.tsx \
assets/admin/components/appointments/ResourceBookingModal.test.tsx \
assets/admin/components/appointments/serviceTimeline.test.ts
```
---