feat: add PublicResourceBookingController and PublicResourceBookingService for public booking functionality

- Implemented PublicResourceBookingController to handle public resource booking requests.
- Added methods for retrieving bookable resources, available slots, and month availability.
- Created PublicResourceBookingService to manage public resource offerings and service visibility.
- Developed tests for public resource booking to ensure correct functionality and error handling.
This commit is contained in:
hamed
2026-08-09 10:45:51 +03:30
parent cd793489ef
commit cfeb447645
10 changed files with 1645 additions and 13 deletions
+21
View File
@@ -291,6 +291,27 @@ Book an appointment slot.
>
> نوبت بدون منبع دقیقاً مثل قبل با کلید و قفل پزشک محافظت می‌شود.
>
> **مدت نوبت روی منبع (2026-08).** وقتی `resource_uuid` داده شده و منبع **همهٔ**
> سرویس‌های `service_item_uuids` را با ردیفِ ارائهٔ فعال و توگلِ «نمایش در نوبت‌دهی
> آنلاین» روشن ارائه می‌دهد، `slot_end` از زنجیرهٔ حلِ خودِ منبع محاسبه می‌شود
> (`ResourceServiceResolver`) — همان عددی که
> [`appointment-resource-slots`](resource.md) با آن زمان‌ها را ساخته. پیش از این مدت
> همیشه از `ServiceBookingCalculator` پزشک‌محور می‌آمد، پس نوبتِ ثبت‌شده با اسلاتی که به
> بیمار نشان داده شده بود یکی نمی‌شد.
>
> شرطی است و نه همیشگی — سه رفتار که با هم فرق دارند:
>
> - **ردیف ارائه هست و فعال + سرویس روشن** ⇒ مدت از منبع. مثلاً سرویسِ ۳۰ دقیقه‌ایِ
> پیش‌فرض با `duration_minutes = 45` روی این دستگاه، نوبتِ ۴۵ دقیقه‌ای می‌سازد.
> - **هیچ ردیفی نیست** ⇒ مثل قبل، مدت از پیش‌فرضِ خودِ سرویس. محیطی که هنوز رابطه‌های
> منبع↔سرویس را پر نکرده نباید یک‌شبه نوبت‌دهی‌اش قطع شود.
> - **ردیف هست ولی غیرفعال** ⇒ `422` با «این منبع این سرویس را ارائه نمی‌دهد». غیرفعال
> حرفِ صریحِ مالک است، نه سکوت.
>
> گیتِ `bookable` هم دور زده نمی‌شود: سرویسی که توگلش خاموش است حتی با ردیفِ ارائهٔ فعال
> `422` می‌گیرد («این سرویس برای نوبت‌دهی فعال نیست») — همان رفتار مسیر پزشک‌محور.
> مسیر پنل (`POST /api/v1/my/appointment`) عمداً سخت‌گیریِ `bookable` را ندارد.
>
> **پاسخ:** علاوه بر فیلدهای قبلی، `resource` (`uuid`, `name`, `type`) و `service_option`
> (`uuid`, `name`) برمی‌گردند. نوبت‌های پیش از مدل منبع‌محور هر دو را `null` دارند، پس
> کلاینت باید با `null` کنار بیاید.
+175
View File
@@ -604,6 +604,181 @@
ثبتِ خودِ نوبت با همین زمان‌ها از `POST /api/v1/my/appointment` با `resource_uuid`
انجام می‌شود ([`appointment.md`](appointment.md)).
## نوبت‌دهی عمومیِ منبع‌محور (سایت) (2026-08)
سه اندپوینتِ **بدون احراز هویت** که سایت عمومی (`nobat724_front`) با آن‌ها منبع را کشف
می‌کند و روی تقویم خودِ منبع نوبت می‌گیرد. کنترلرشان
`src/Resource/Controller/PublicResourceBookingController.php` است — عمداً جدا از
`ResourceBookingSlotController` که منبع را از محیطِ کاربرِ احرازشده حل می‌کند.
**گیتِ عمومی‌شدن** یک قاعده است و در `PublicResourceBookingService` یک‌جا تعریف شده:
منبع فعال باشد، ردیفِ ارائه (`resource_service_offerings`) فعال باشد، و سرویسِ آن ردیف
هم `bookable` (توگل «نمایش در نوبت‌دهی آنلاین») و هم `active` باشد. منبعی که هیچ سرویسِ
روشنی ندارد اصلاً در پاسخ نمی‌آید.
### `GET /api/v1/appointment-booking-resources/{doctorUuid}` (2026-08)
عمومی — بدون توکن.
| پارامتر | توضیح |
|---|---|
| `clinic_uuid` | اختیاری. **نبودش یعنی همهٔ محیط‌های این پزشک** — مطب شخصی به‌علاوهٔ هر کلینیکی که عضوش است |
فقط منابعی برمی‌گردند که **پزشکِ همین صفحه** یا ناظرشان است (`supervisor_id`) یا خودشان
پلِ همان پزشک‌اند (`doctor_id`). `duration_minutes` و `price_rials` هر سرویس از زنجیرهٔ
حلِ همان منبع می‌آیند (`ResourceServiceResolver`)، نه از پیش‌فرضِ خامِ سرویس.
هر منبع `clinic_uuid`ِ محیطِ خودش را همراه دارد (تهی = مطب شخصی) و سایت نوبت را با همان
ثبت می‌کند. این عمدی است: منبع تقویم و شعبهٔ خودش را دارد و به برنامهٔ هفتگیِ پزشک وابسته
نیست، پس پزشکی که خودش نوبت آنلاین نمی‌دهد هیچ «محل نوبت‌دهی»ای ندارد که سایت
`clinic_uuid` را از آن بردارد. اگر پاسخِ بدون پارامتر فقط مطب شخصی را می‌داد، دستگاهِ
قابلِ رزروِ چنین پزشکی هرگز در سایت پیدا نمی‌شد.
خروجی واقعی (اجرای محلی، بدون هدر `Authorization`):
```json
{
"success": true,
"data": {
"doctor_uuid": "f9c746ba-3b59-4e5f-a96a-986f5198c173",
"clinic_uuid": "279859e9-be78-4ce9-aebd-e68f6f12126c",
"resources": [
{
"uuid": "9bb129ac-6650-4078-8942-bef8d1ce844d",
"name": "کندلا2021",
"clinic_uuid": "279859e9-be78-4ce9-aebd-e68f6f12126c",
"type": { "code": "laser_device", "name": "دستگاه لیزر" },
"location": {
"uuid": "6cafca59-8261-47f6-93d2-2d6e16f6aeb3",
"title": "کلنیک مدیسا",
"address": ""
},
"supervisor": {
"uuid": "f9c746ba-3b59-4e5f-a96a-986f5198c173",
"full_name": "پزشک دعوت‌شده"
},
"services": [
{
"uuid": "f3e8f166-8ec0-479b-a82a-b5133bb06698",
"name": "لیزیر دست",
"duration_minutes": 20,
"price_rials": 2000000,
"service_section": {
"uuid": "fff74b7f-da59-4928-a549-e3ba96b43119",
"name": "لیزیر"
}
}
]
}
]
}
}
```
**۲۰۰ با `resources: []`** — پزشکِ بدون منبع، منبعِ غیرفعال، سرویسِ خاموش، یا ردیفِ ارائهٔ
غیرفعال. هیچ‌کدام خطا نیستند.
**۴۰۴:** پزشک یافت نشد (`ERR_VALIDATION_002`) · `clinic_uuid`ی که پزشک عضوش نیست
(«محل نوبت‌دهی یافت نشد»).
`capacity` عمداً در پاسخ نیست: عددِ عملیاتیِ داخلِ کلینیک است و سایت مصرفی برایش ندارد.
### `GET /api/v1/appointment-resource-slots` (2026-08)
عمومی — بدون توکن. نسخهٔ عمومیِ `GET /api/v1/resource/{uuid}/service-slots`.
| پارامتر | توضیح |
|---|---|
| `resource_uuid` | الزامی |
| `date` | `Y-m-d`، الزامی. تاریخِ تقویمیِ واقعی — «2026-13-99» رد می‌شود |
| `service_item_uuids[]` | یک یا چند سرویسِ روشن؛ خالی ⇒ `422` |
`durations[]` که نسخهٔ پنلی می‌پذیرد اینجا **پشتیبانی نمی‌شود**: override مدت ابزار منشی
است و در دست بازدیدکننده یعنی ساختنِ ظرفیتِ ساختگی.
مدت، اشغال، ظرفیت و چیدمانِ پشت‌سرهم دقیقاً مثل نسخهٔ پنلی است
(`ResourceBookingSlotService`).
خروجی واقعی (بدون هدر `Authorization`؛ کوتاه‌شده):
```json
{
"success": true,
"data": {
"resource_uuid": "9bb129ac-6650-4078-8942-bef8d1ce844d",
"date": "2026-08-10",
"timezone": "Asia/Tehran",
"total_duration_minutes": 20,
"start_times": [
{ "start": 1786339800, "end": 1786341000, "start_time": "09:00", "end_time": "09:20" },
{ "start": 1786341000, "end": 1786342200, "start_time": "09:20", "end_time": "09:40" }
]
}
}
```
**۴۲۲ — `field: resource_uuid`** (`ERR_VALIDATION_002`، «منبع یافت نشد»): منبعِ ناموجود،
غیرفعال، یا منبعی که هیچ سرویسِ روشنی ندارد. عمداً ۴۲۲ است نه ۴۰۴، چون همان کدی است که
`POST /api/v1/appointment` برای منبع برمی‌گرداند.
**۴۲۲ — `field: service_item_uuids`**: سرویسِ ناموجود (`ERR_VALIDATION_002`) · سرویسی که
توگلِ آنلاینش خاموش است یا این منبع ارائه‌اش نمی‌دهد · سرویسِ بی‌مدت · فهرست خالی.
```json
{
"success": false,
"data": null,
"errors": [
{ "code": "ERR_VALIDATION_001", "message": "این سرویس برای نوبت‌دهی آنلاین فعال نیست", "field": "service_item_uuids" }
]
}
```
**۴۲۲ — `field: date`**: فرمت یا تاریخِ ناموجود.
**۲۰۰ با `start_times: []`** — روزی که منبع شیفت ندارد یا کاملاً پر است. خطا نیست.
### `GET /api/v1/appointment-resource-month-availability/{resourceUuid}` (2026-08)
عمومی — بدون توکن. ورودیِ تقویمِ سایت؛ معادلِ منبع‌محورِ
`appointment-settings/month-availability/{doctorUuid}`.
| پارامتر | توضیح |
|---|---|
| `year` · `month` | **میلادی**، همان قرارداد نسخهٔ پزشک‌محور |
| `service_item_uuids[]` | الزامی |
سرویس‌ها الزامی‌اند چون منبع اسلاتِ ثابت ندارد: «روز فعال» یعنی دست‌کم یک بازهٔ خالی به
اندازهٔ مجموعِ مدتِ همین سرویس‌ها. بدون آن، تقویم روزی را سبز نشان می‌داد که برای سرویسِ
۹۰ دقیقه‌ای جا ندارد.
`enabled_dates` و `disabled_dates` با هم **همهٔ** روزهای ماه‌اند؛ سایت روی همین دو فهرست
تصمیم می‌گیرد. برخلاف نسخهٔ پزشک‌محور فیلد `online_booking_enabled` ندارد — آن پرچم روی
برنامهٔ هفتگیِ پزشک است و منبع همتایی برایش ندارد. مصرف‌کنندهٔ سایت نبودش را «روشن»
تفسیر می‌کند.
خروجی واقعی (بدون توکن؛ فهرست‌ها کوتاه‌شده):
```json
{
"success": true,
"data": {
"resource_uuid": "9bb129ac-6650-4078-8942-bef8d1ce844d",
"year": 2026,
"month": 9,
"total_duration_minutes": 20,
"enabled_dates": ["2026-09-01", "2026-09-02", "2026-09-05"],
"disabled_dates": ["2026-09-03", "2026-09-04", "2026-09-10"]
}
}
```
خطاها: همان `resource_uuid` و `service_item_uuids`ِ اندپوینت بالا، به‌علاوهٔ **۴۲۲ با
`field: month`** روی سال یا ماهِ نامعتبر.
**هزینه:** پیاده‌سازی همان الگوی حلقهٔ روزانهٔ نسخهٔ پزشک‌محور است. اندازه‌گیری محلی روی
ماهی با ۳۰ روز: حدود ۲۷ میلی‌ثانیه در فراخوانی گرم (اولین فراخوانی ۸۷ میلی‌ثانیه). بهینه‌سازی
بازه‌ای لازم نشد.
### `PUT /api/v1/resource/{uuid}/categories`
مجوز: `appointment_settings.update`.