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
@@ -0,0 +1,412 @@
# نوبت‌دهی آنلاین منبع‌محور — اندپوینت‌های عمومی
## پروژه
`clinicpro` (Backend).
پرامپت همتا در سایت عمومی: `nobat724_front/.claude/prompt/public-resource-booking-ui.md`.
**اول این را اجرا کن، بعد آن را.** قرارداد API که اینجا ساخته می‌شود مصرف‌کنندهٔ مستقیم دارد و
تغییرش در build سایت خطا نمی‌دهد.
## زمینه
منبع (`ClinicResource`) هر چیزی است که ممکن است اشغال باشد: پزشک، اپراتور، دستگاه، اتاق، یونیت.
هر منبع تقویم خودش را دارد و سرویس‌هایی که ارائه می‌دهد در `resource_service_offerings` ثبت شده‌اند
(`ResourceServiceOffering`)، با مدت و قیمتِ اختصاصیِ همان جفتِ منبع↔سرویس.
روی `ServiceItem` یک توگل به نام `bookable` هست که در پنل با برچسب «نمایش در نوبت‌دهی آنلاین»
دیده می‌شود:
```php
// src/ClinicService/Entity/ServiceItem.php:107
/** نمایش این سرویس در نوبت‌دهی (پزشک ممکن است همهٔ سرویس‌ها را ارائه ندهد). */
#[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $bookable = false;
```
امروز این توگل فقط جریانِ **پزشک‌محورِ** سایت را تغذیه می‌کند
(`AppointmentController::bookableServices()``ServiceItemRepository::findBookableByEntity()`).
هیچ مسیر عمومی‌ای منابع را نمی‌بیند.
## مشکل / هدف
اسلات‌های منبع فقط از داخل پنل قابل خواندن‌اند:
```php
// src/Resource/Controller/ResourceBookingSlotController.php:26
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class ResourceBookingSlotController extends BaseController
{
use ResourcePermissionTrait;
// هر اکشن با denyUnlessGrantedForBooking($user) گیت می‌شود
```
`ResourceContext::resource($user, $uuid)` هم منبع را در محیطِ کاربرِ احرازشده حل می‌کند، پس برای
بازدیدکنندهٔ ناشناسِ سایت اصلاً قابل استفاده نیست.
هدف: سه اندپوینت عمومیِ جدید تا سایت بتواند
۱) منابعِ قابلِ رزروِ یک پزشک را ببیند، ۲) وقت‌های خالیِ یک منبع برای سرویس‌های انتخاب‌شده را
بگیرد، ۳) روزهای فعالِ ماه را برای تقویم بگیرد.
به‌علاوه رفع یک ناسازگاریِ موجود در مسیر عمومیِ ثبت نوبت (وظیفهٔ ۴).
**گیتِ عمومی‌شدن دقیقاً همان توگل است:** منبع وقتی در سایت دیده می‌شود که دست‌کم یک
`ResourceServiceOffering` فعال به یک `ServiceItem` با `bookable = true` و `active = true` داشته باشد.
**تصمیم دامنه (تأییدشده):** روی صفحهٔ یک پزشک فقط منابعی می‌آیند که پزشکِ ناظرشان
(`supervisor`) همان پزشک است، یا خودشان پلِ همان پزشک‌اند (`doctor_id`). فهرست کلینیک‌محور
خارج از این تسک است.
## معیار پذیرش
- ✅ موفق:
- `GET /api/v1/appointment-booking-resources/{doctorUuid}` بدون هیچ توکنی → ۲۰۰ با
`{ success: true, data: { doctor_uuid, clinic_uuid, resources: [...] } }`؛ هر منبع فیلد
`services[]` دارد و در آن فقط سرویس‌های `bookable=true` و `active=true` با
`duration_minutes` و `price_rials`ِ حل‌شده از `ResourceServiceResolver` هستند.
- `GET /api/v1/appointment-resource-slots?resource_uuid=..&date=..&service_item_uuids[]=..`
بدون توکن → ۲۰۰ با `start_times[]` که هر عضوش `{start, end, start_time, end_time}` است، و
`total_duration_minutes` برابرِ مجموعِ مدتِ حل‌شدهٔ همان منبع.
- `GET /api/v1/appointment-resource-month-availability/{resourceUuid}?year=&month=&service_item_uuids[]=`
بدون توکن → ۲۰۰ با `enabled_dates[]` و `disabled_dates[]` که مجموعشان همهٔ روزهای آن ماه است.
- `POST /api/v1/appointment` با `resource_uuid` + `service_item_uuids[]` + یکی از
`start_times`ِ بالا → ۲۰۱، و `slot_end` دقیقاً برابرِ `end`ِ همان start_time.
- ❌ خطا:
- منبعی که هیچ سرویسِ `bookable` ندارد → در پاسخِ فهرست **نمی‌آید**؛ و
`appointment-resource-slots` رویش → ۴۲۲ با کد `ERR_VALIDATION_001` و
`field: service_item_uuids`.
- سرویسی که `bookable=false` است ولی روی منبع offering فعال دارد → `resource-slots` → ۴۲۲
(پیام: «این سرویس برای نوبت‌دهی آنلاین فعال نیست»). یعنی مسیر عمومی سخت‌گیرتر از پنل است.
- `date` با فرمت نادرست → ۴۲۲ با `field: date`. `resource_uuid` ناموجود یا `active=false`
۴۲۲ با `field: resource_uuid` (نه ۴۰۴؛ همان الگوی موجود در `AppointmentController`).
- `POST /api/v1/appointment` روی بازه‌ای که منبع در آن پر است → ۴۰۹ با
`field: resource_uuid` (رفتار موجودِ `ResourceOccupier`؛ نباید بشکند).
- ⚠️ مرزی:
- پزشکی که هیچ منبعی ندارد → ۲۰۰ با `resources: []`، نه ۴۰۴.
- منبعِ با `capacity > 1`: وقتی یک نوبت روی آن نشسته، همان بازه هنوز باید در `start_times` بیاید
(ظرفیت هنوز پر نشده). `ResourceBookingSlotService::freeIntervals()` این را می‌داند؛ فقط با
داده تست شود.
- روزی که منبع شیفت ندارد → `start_times: []` و همان روز در `disabled_dates` ماه.
- `service_item_uuids` خالی → ۴۲۲ با پیام «انتخاب حداقل یک سرویس الزامی است» (رفتار موجودِ
`resolveDuration`).
- آخرین روز ماه شمسی/میلادی: `month-availability` بر پایهٔ سال/ماهِ **میلادی** است — همان
قرارداد `appointment-settings/month-availability` که سایت از قبل با آن کار می‌کند. تغییرش نده.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Resource/Entity/ClinicResource.php` | منبع؛ `address`, `type`, `supervisor`, `doctor`, `capacity`, `active` |
| `src/Resource/Entity/ResourceServiceOffering.php` | جفتِ منبع↔سرویس با مدت/قیمت/`active` |
| `src/Resource/Repository/ClinicResourceRepository.php` | کوئری‌های منبع؛ متد جدید اینجا |
| `src/Resource/Repository/ResourceServiceOfferingRepository.php` | `findForResource`, `bookableResourceIds`, `activeResourceIdsFor` |
| `src/Resource/Service/ResourceBookingSlotService.php` | `resolveDuration`, `startTimes`, `freeIntervals`, `assertOffered` |
| `src/ClinicService/Service/ResourceServiceResolver.php` | زنجیرهٔ حلِ مدت و قیمت |
| `src/Resource/Controller/ResourceBookingSlotController.php` | نسخهٔ پنلیِ همین اسلات‌ها — الگوی مرجع |
| `src/Appointment/Controller/AppointmentController.php` | اندپوینت‌های عمومی موجود + `POST /api/v1/appointment` |
| `config/packages/security.yaml` | `access_control` |
| `docs/api/resource.md`، `docs/api/appointment.md` | مستندات |
## وضعیت فعلی
منبعِ حقیقتِ مدت و قیمت، این زنجیره است:
```php
// src/ClinicService/Service/ResourceServiceResolver.php:41
public function resolve(
ClinicResource $resource,
ServiceItem $item,
DoctorAddress $address,
?ServiceItem $parentService = null,
): ResolvedServiceSpec
// ۱. منبع+گزینه → ۲. منبع+سرویس → ۳. شعبه → ۴. پیش‌فرض سرویس
```
و اسلات‌ها:
```php
// src/Resource/Service/ResourceBookingSlotService.php:87
public function startTimes(
ClinicResource $resource,
string $date,
int $totalMinutes,
?int $excludeAppointmentId = null,
): array
```
مسیر عمومیِ ثبت نوبت، مدت را از calculatorِ پزشک‌محور می‌گیرد — حتی وقتی منبع دارد:
```php
// src/Appointment/Controller/AppointmentController.php:528-538
$duration = null;
if ($hasServices) {
$duration = $this->serviceCalculator->calculate($doctor, $bookingClinic, $serviceUuids);
$slotEnd = $duration->endFor($slotStart);
}
if ($resource !== null) {
[$bookingType, $bookingId] = EntityContext::forBooking($doctor, $bookingClinic)->toEntityPair();
// ...
```
مسیر پنل همین را درست انجام می‌دهد و باید الگو باشد:
```php
// src/Appointment/Controller/MyAppointmentsController.php:188-197
if (!empty($serviceUuids) && !$isReserve && $resource !== null) {
// نوبتِ منبع: مدت از زنجیرهٔ حلِ همان منبع می‌آید (هر دستگاه مدت خودش را
// دارد) و گیتِ «این سرویس روی این منبع فعال است؟» جای `bookable` می‌نشیند.
['minutes' => $resourceMinutes, 'items' => $serviceItems] =
$this->resourceSlots->resolveDuration($resource, $serviceUuids, $durationOverrides);
```
## وظایف
### ۱. کوئری منابعِ قابلِ رزروِ عمومی
در `src/Resource/Repository/ClinicResourceRepository.php` متد جدید:
```php
/**
* منابعِ یک پزشک در یک محیط که در سایت عمومی قابل رزروند.
*
* سه شرط با هم: منبع فعال، دست‌کم یک offering فعال، و سرویسِ آن offering هم
* `bookable` و هم `active`. توگلِ «نمایش در نوبت‌دهی آنلاین» تنها گیتِ عمومی‌شدن است؛
* منبعی که سرویسِ روشنی ندارد اصلاً نباید در پاسخ دیده شود.
*
* @return ClinicResource[]
*/
public function findPublicBookableForDoctor(string $entityType, int $entityId, Doctor $doctor): array
{
return $this->createQueryBuilder('r')
->join('r.type', 't')
->addSelect('t')
->join(ResourceServiceOffering::class, 'o', 'WITH', 'o.resource = r')
->join('o.serviceItem', 'i')
->where('r.entityType = :type')
->andWhere('r.entityId = :id')
->andWhere('r.active = true')
->andWhere('o.active = true')
->andWhere('i.bookable = true')
->andWhere('i.active = true')
// فقط منابع همین پزشک: یا خودش پلِ پزشک است، یا پزشک ناظرش همین است.
->andWhere('r.doctor = :doctor OR r.supervisor = :doctor')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->setParameter('doctor', $doctor)
->distinct()
->orderBy('r.name', 'ASC')
->getQuery()
->getResult();
}
```
**نحوه تست:** یک unit/functional تست که سه سناریو بسازد — منبع با سرویسِ bookable (باید بیاید)،
منبع با سرویسِ `bookable=false` (نباید بیاید)، منبع با offering غیرفعال (نباید بیاید) — و منبعی
که ناظرش پزشک دیگری است (نباید بیاید).
### ۲. سرویسِ ساختِ payload عمومی
فایل جدید `src/Resource/Service/PublicResourceBookingService.php`.
کنترلر نازک می‌ماند؛ این کلاس تنها مسئولیتش «منبع → آرایهٔ عمومی» است (SRP).
```php
/**
* نمای عمومیِ منبع برای سایت نوبت‌دهی.
*
* از نمای پنل جداست چون سؤالِ دیگری جواب می‌دهد: پنل همهٔ سرویس‌های منبع را
* می‌خواهد، سایت فقط آن‌هایی را که مالک روشن کرده. ادغامشان یعنی یک `if ($public)`
* در دلِ کد پنل و یک راهِ تازه برای نشتِ سرویسِ خاموش.
*/
final class PublicResourceBookingService
{
public function __construct(
private readonly ClinicResourceRepository $resources,
private readonly ResourceServiceOfferingRepository $offerings,
private readonly ResourceServiceResolver $resolver,
) {}
/** @return array<int, array<string, mixed>> */
public function resourcesFor(Doctor $doctor, ?Clinic $clinic): array;
/**
* سرویس‌های عمومیِ یک منبع — همان گیتِ فهرست، تا اسلات و فهرست از هم واگرا نشوند.
*
* @return array<int, array<string, mixed>>
*/
public function publicServices(ClinicResource $resource): array;
/** @throws AppException ۴۲۲ روی سرویسی که در سایت روشن نیست */
public function assertPublicService(ClinicResource $resource, ServiceItem $item): void;
}
```
شکل هر منبع در پاسخ:
```php
[
'uuid' => $resource->getUuid(),
'name' => $resource->getName(),
'type' => ['code' => $type->getCode(), 'name' => $type->getName()],
'capacity' => $resource->getCapacity(),
'location' => [
'uuid' => $address->getUuid(),
'title' => $address->getName() ?: 'مطب شخصی',
'address' => $address->getAddress(),
],
'supervisor' => ['uuid' => ..., 'full_name' => ...] | null,
'services' => [
[
'uuid' => $item->getUuid(),
'name' => $item->getName(),
'duration_minutes' => $spec->durationMinutes, // از resolver، نه از پیش‌فرض خام
'price_rials' => $spec->priceRials,
'service_section' => ['uuid' => ..., 'name' => ...],
],
],
]
```
نکتهٔ کارایی: `publicServices()` نباید به ازای هر سرویس یک `findOneFor` جدا بزند وقتی
`findForResource($resource)` همه را یک‌جا می‌دهد. offeringهای همان منبع را یک‌بار بخوان و
سرویس‌های `bookable && active` را از رویش فیلتر کن؛ `resolver->resolve()` را فقط برای همان‌ها صدا بزن.
**نحوه تست:** `ddev exec php bin/phpunit` روی یک تستِ سرویس با دو سرویس (یکی روشن، یکی خاموش)
و یک offering با `duration_minutes` اختصاصی؛ ادعا: خروجی یک عضو دارد و مدتش عددِ offering است،
نه `solo_duration_minutes`ِ سرویس.
### ۳. کنترلر عمومی + مسیرها
فایل جدید `src/Resource/Controller/PublicResourceBookingController.php``extends BaseController`،
**بدون** `IsGranted` و بدون `ResourcePermissionTrait`.
```php
/**
* نوبت‌دهی منبع‌محور برای سایت عمومی.
*
* از {@see ResourceBookingSlotController} جداست و نه یک پرچمِ `public` روی آن: آنجا منبع
* از محیطِ کاربرِ احرازشده حل می‌شود (`ResourceContext::resource($user, $uuid)`) و اینجا
* کاربری وجود ندارد. یک کنترلر با دو مدلِ اعتماد، همان‌جایی است که نشت اتفاق می‌افتد.
*/
#[OA\Tag(name: 'Resource')]
class PublicResourceBookingController extends BaseController
{
// GET /api/v1/appointment-booking-resources/{doctorUuid}?clinic_uuid=
// GET /api/v1/appointment-resource-slots?resource_uuid=&date=&service_item_uuids[]=
// GET /api/v1/appointment-resource-month-availability/{resourceUuid}?year=&month=&service_item_uuids[]=
}
```
قواعدی که باید رعایت شوند:
- محیط با همان الگوی موجود حل شود:
`[$type, $id] = EntityContext::forBooking($doctor, $clinic)->toEntityPair();`
و `$clinic` از `AppointmentController::bookingClinic()` — اگر متد `private` است، منطقش را
کپی نکن؛ یا در یک سرویس مشترک بگذار یا از `BookingContextResolver` استفاده کن. تصمیم و دلیلش
را در کامنت بنویس.
- در `resource-slots` و `month-availability` منبع با `ClinicResourceRepository::findByUuid()`
گرفته می‌شود و **باید** بررسی شود: `isActive()` و اینکه دست‌کم یک سرویس عمومی دارد. منبعِ
ناموجود یا خاموش → ۴۲۲ با `field: resource_uuid`.
- برای هر uuid در `service_item_uuids` اول `assertPublicService()` صدا زده شود (گیتِ `bookable`
بعد `ResourceBookingSlotService::resolveDuration()` (گیتِ offering + مدت). ترتیب مهم است:
پیامِ «در سایت فعال نیست» گویاتر از «این منبع این سرویس را ارائه نمی‌دهد» است.
- `durations[]` که نسخهٔ پنلی می‌پذیرد **در مسیر عمومی پذیرفته نشود** — override مدت ابزار منشی
است؛ در دست بازدیدکننده یعنی ساختن ظرفیتِ جعلی. یعنی `resolveDuration($resource, $uuids)`
بدون آرگومان سوم.
- `month-availability` روی روزهای ماه حلقه بزند و برای هر روز `startTimes(...) !== []` را
بسنجد — همان الگوی `AppointmentController::monthAvailability()` که با `hasAnyAvailability`
کار می‌کند. اگر روی ۳۱ روز کند بود، به‌جای `startTimes` از `freeIntervals` استفاده کن و فقط
وجودِ یک بازهٔ به‌اندازهٔ کافی بلند را چک کن.
سپس در `config/packages/security.yaml`، بخش `access_control`، کنار همتاهای موجود:
```yaml
- { path: ^/api/v1/appointment-booking-resources/, roles: PUBLIC_ACCESS }
- { path: ^/api/v1/appointment-resource-slots, roles: PUBLIC_ACCESS }
- { path: ^/api/v1/appointment-resource-month-availability/, roles: PUBLIC_ACCESS }
```
مسیرها روی firewallِ `api` می‌مانند (به `public_endpoints` **اضافه نشوند**) — دقیقاً به همان
دلیلی که در کامنت بالای `public_endpoints` نوشته شده: توکن اختیاری بماند.
**نحوه تست:**
```bash
ddev exec php bin/console debug:router | grep resource
# سه مسیر جدید باید دیده شوند
curl -s "https://clinic-pro.ddev.site/api/v1/appointment-booking-resources/<DOCTOR_UUID>" | jq
curl -s "https://clinic-pro.ddev.site/api/v1/appointment-resource-slots?resource_uuid=<R>&date=2026-08-15&service_item_uuids[]=<S>" | jq
curl -s "https://clinic-pro.ddev.site/api/v1/appointment-resource-month-availability/<R>?year=2026&month=8&service_item_uuids[]=<S>" | jq
```
هر سه بدون هدر `Authorization` و همه باید ۲۰۰ بدهند. سپس همان `curl`ها را روی سرویسی بزن که
`bookable` را در پنل خاموش کرده‌ای و ۴۲۲ بگیر.
اکانت تست پنل برای ساختن داده: `09390039833` / `09390039833`.
### ۴. رفعِ مدتِ نوبتِ منبع‌دار در مسیر عمومی
در `src/Appointment/Controller/AppointmentController.php::book()`، شاخهٔ محاسبهٔ مدت باید مثل
مسیر پنل، وقتی منبع هست از `ResourceBookingSlotService::resolveDuration()` استفاده کند:
```php
$duration = null;
$resourceItems = [];
if ($hasServices && $resource !== null) {
// مدت از زنجیرهٔ حلِ همان منبع می‌آید، وگرنه `slot_end` با start_timesِ
// appointment-resource-slots یکی نمی‌شود و بیمار وقتی را می‌گیرد که سرور
// جای دیگری آزاد حساب کرده بود.
foreach ($serviceUuids as $u) { /* assertPublicService(...) */ }
['minutes' => $minutes, 'items' => $resourceItems] =
$this->resourceSlots->resolveDuration($resource, $serviceUuids);
$slotEnd = $slotStart + $minutes * 60;
} elseif ($hasServices) {
$duration = $this->serviceCalculator->calculate($doctor, $bookingClinic, $serviceUuids);
$slotEnd = $duration->endFor($slotStart);
}
```
سه قید:
- ترازِ سطلِ اشغال (`OccupancyBucket::alignWindow`) در خط ۵۱۵ **قبل از** محاسبهٔ مدت اجرا
می‌شود و در آنجا `$slotEnd` هنوز مقدارِ کلاینت است. بعد از بازنویسیِ `$slotEnd`، تراز باید
دوباره اعمال شود؛ وگرنه بازهٔ نهایی ناتراز می‌ماند.
- ذخیرهٔ سرویس‌ها روی نوبت (خط ~۶۰۵، `replaceServiceItems` + `setServiceDuration`) نباید بشکند.
در شاخهٔ منبع، `$duration` تهی است، پس این بلوک باید `items` و `minutes`ِ منبع را هم بپذیرد.
- بررسیِ موجودِ «این منبع این سرویس را ارائه می‌دهد؟» (خط ۵۵۸ با `hasAnyFor`/`activeResourceIdsFor`)
حالا با `resolveDuration` تکراری می‌شود. یکی را نگه دار — `resolveDuration` سخت‌گیرتر است چون
`offering.active` را هم می‌بیند. حذفِ کدِ تکراری را در همان commit توضیح بده.
**نحوه تست:** یک تست functional که:
۱) از `appointment-resource-slots` یک `start_time` بگیرد،
۲) با همان `start` و همان `service_item_uuids` روی `POST /api/v1/appointment` بزند،
۳) ادعا کند `slot_end` پاسخ برابر `end`ِ همان start_time است.
قبل از این تغییر باید قرمز شود.
### ۵. مستندات
- `docs/api/resource.md`: سه اندپوینت عمومی جدید با نمونهٔ درخواست/پاسخ و جدول خطاها.
- `docs/api/appointment.md`: رفتار `resource_uuid` در `POST /api/v1/appointment` — که مدت از
منبع می‌آید و در مسیر عمومی سرویس باید `bookable` باشد.
- اگر فایل `docs/api/appointment-booking.md` جریان عمومی را توصیف می‌کند، مرحلهٔ منبع‌محور را
هم آنجا اضافه کن.
## نکات مهم
- **گیتِ عمومی یک جا تعریف شود.** «سرویس در سایت دیده می‌شود» = `bookable && active` + offering
فعال. این شرط در سه جا لازم است (فهرست، اسلات، ثبت نوبت). یک متد در
`PublicResourceBookingService` و صدا زدنش از هر سه — نه سه‌بار نوشتنِ همان `if`.
- **جداسازی محیط:** منبع با uuid از بیرون می‌آید و `TenantFilter` پوششش نمی‌دهد
(`ResourceServiceOffering` فرزندِ aggregate است). در هر سه اندپوینت، تطابقِ
`(entity_type, entity_id)`ِ منبع با محیطِ رزروِ همان پزشک/کلینیک باید صریح بررسی شود — همان
کاری که `AppointmentController` در خط ۵۴۹ می‌کند.
- **fail-safe عمومی:** پارامتر `management=1` در این کنترلر معنا ندارد و پیاده نشود. اگر روزی
پنل بخواهد همین دید را داشته باشد، مسیر پنلیِ خودش را دارد.
- **`durations[]` در مسیر عمومی ممنوع** — دلیلش بالا آمده.
- ظرفیت و اشغال را خودت حساب نکن؛ `ResourceBookingSlotService` هر دو منبعِ اشغال را می‌بیند
(`appointments.resource_id` و `resource_occupancy`). دور زدنش یعنی نوبتِ نامرئی.
- تاریخ‌ها Unix timestamp صحیح‌اند؛ `start_time`/`end_time` رشتهٔ `H:i` به وقتِ محلیِ شعبهٔ منبع
است (`ResourceBookingSlotService::dayStart()` این را از `address->getTimezone()` می‌گیرد).
- **مصرف‌کنندهٔ cross-repo:** این قرارداد را `nobat724_front/services/response.js` مصرف می‌کند.
هر تغییر در نام فیلدها بعد از این، در build سایت خطا **نمی‌دهد** — پس شکل پاسخ را قبل از
merge نهایی کن.