diff --git a/.claude/prompt/public-resource-booking-api.md b/.claude/prompt/public-resource-booking-api.md new file mode 100644 index 00000000..5cac301e --- /dev/null +++ b/.claude/prompt/public-resource-booking-api.md @@ -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> */ + public function resourcesFor(Doctor $doctor, ?Clinic $clinic): array; + + /** + * سرویس‌های عمومیِ یک منبع — همان گیتِ فهرست، تا اسلات و فهرست از هم واگرا نشوند. + * + * @return array> + */ + 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/" | jq +curl -s "https://clinic-pro.ddev.site/api/v1/appointment-resource-slots?resource_uuid=&date=2026-08-15&service_item_uuids[]=" | jq +curl -s "https://clinic-pro.ddev.site/api/v1/appointment-resource-month-availability/?year=2026&month=8&service_item_uuids[]=" | 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 نهایی کن. diff --git a/config/packages/security.yaml b/config/packages/security.yaml index cd5f7e04..45c2a4e5 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -68,6 +68,9 @@ security: - { path: ^/api/v1/appointment-booking-services/, roles: PUBLIC_ACCESS } - { path: ^/api/v1/appointment-booking-locations/, roles: PUBLIC_ACCESS } - { path: ^/api/v1/appointment-settings/month-availability/, roles: PUBLIC_ACCESS } + - { 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 } - { path: ^/api/v1/comments/, roles: PUBLIC_ACCESS } - { path: ^/api/v1/site-context$, methods: [GET], roles: PUBLIC_ACCESS } - { path: ^/api/v1/specialties, methods: [GET], roles: PUBLIC_ACCESS } diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 87071b75..25b1c6b8 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -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` کنار بیاید. diff --git a/docs/api/resource.md b/docs/api/resource.md index fb41ef54..710a7d1a 100644 --- a/docs/api/resource.md +++ b/docs/api/resource.md @@ -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`. diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index 767a4af9..b518a33f 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -52,6 +52,8 @@ class AppointmentController extends BaseController private readonly \App\Appointment\Service\ServiceBookingCalculator $serviceCalculator, private readonly \App\Appointment\Service\ServiceRescheduleService $rescheduleService, private readonly \App\Appointment\Availability\Service\ResourceOccupier $occupier, + private readonly \App\Resource\Service\ResourceBookingSlotService $resourceSlots, + private readonly \App\Resource\Service\PublicResourceBookingService $publicResources, private readonly \App\Treatment\Service\SessionBookingLink $sessionLink, private readonly \Psr\Log\LoggerInterface $logger, ) {} @@ -523,6 +525,22 @@ class AppointmentController extends BaseController $bookingClinic = $this->bookingClinic($doctor, $clinicUuid); + if ($resource !== null) { + /** + * منبع با uuid از بدنهٔ درخواست می‌آید و `TenantFilter` پوششش نمی‌دهد، پس + * بدون این بررسی بیمار می‌توانست دستگاه کلینیک دیگری را روی نوبت این کلینیک + * بنشاند. + * + * پیش از محاسبهٔ مدت می‌آید تا منبعِ بیگانه همان «منبع یافت نشد» را بگیرد، نه + * خطای ریزترِ سرویس را. + */ + [$bookingType, $bookingId] = EntityContext::forBooking($doctor, $bookingClinic)->toEntityPair(); + + if ($resource->getEntityType() !== $bookingType || $resource->getEntityId() !== $bookingId) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'منبع یافت نشد', 422, 'resource_uuid'); + } + } + /** * مدت از {@see ServiceBookingCalculator} می‌آید، نه از جمعِ دستیِ `duration_minutes`. * @@ -531,25 +549,25 @@ class AppointmentController extends BaseController * می‌کرد که سرور جای دیگری آزاد حساب کرده بود. calculator خودش هم مالکیت محیط را * می‌سنجد (همان بررسی‌ای که قبلاً جداگانه صدا زده می‌شد) و خطاهایش همان کد و پیام * قبلی را دارند. + * + * **استثنای منبع:** وقتی منبع همهٔ این سرویس‌ها را عمومی ارائه می‌دهد، مدت از + * زنجیرهٔ حلِ خودِ منبع می‌آید ({@see ResourceServiceResolver}) — همان عددی که + * `appointment-resource-slots` با آن زمان‌ها را ساخته. وگرنه `slot_end` با اسلاتی + * که به بیمار نشان داده شد یکی نمی‌شد. + * + * شرطی است و نه همیشگی: محیطی که هنوز رابطه‌های منبع↔سرویس را پر نکرده باید مثل + * قبل کار کند. */ $duration = null; if ($hasServices) { - $duration = $this->serviceCalculator->calculate($doctor, $bookingClinic, $serviceUuids); - $slotEnd = $duration->endFor($slotStart); + $duration = $resource !== null && $this->publicResources->offersAllPublicly($resource, $serviceUuids) + ? $this->resourceDuration($resource, $serviceUuids) + : $this->serviceCalculator->calculate($doctor, $bookingClinic, $serviceUuids); + + $slotEnd = $duration->endFor($slotStart); } if ($resource !== null) { - /** - * منبع با uuid از بدنهٔ درخواست می‌آید و `TenantFilter` پوششش نمی‌دهد، پس - * بدون این بررسی بیمار می‌توانست دستگاه کلینیک دیگری را روی نوبت این کلینیک - * بنشاند. - */ - [$bookingType, $bookingId] = EntityContext::forBooking($doctor, $bookingClinic)->toEntityPair(); - - if ($resource->getEntityType() !== $bookingType || $resource->getEntityId() !== $bookingId) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'منبع یافت نشد', 422, 'resource_uuid'); - } - /** * منبعی که این سرویس را نمی‌دهد، همین‌جا رد می‌شود نه وقتی بیمار سرِ قرار * حاضر شده. روی `serviceItems`ِ خروجی calculator کار می‌کند نه uuidهای خام: @@ -898,6 +916,27 @@ class AppointmentController extends BaseController return $this->bookingContext->resolve($doctor, $clinicUuid); } + /** + * مدتِ نوبت روی یک منبع، در قالبِ همان VOیی که مسیر پزشک‌محور تولید می‌کند. + * + * هم‌شکل‌کردنشان عمدی است: پایین‌دستِ این نقطه (`replaceServiceItems`، + * `setServiceDuration`، `endFor`) دیگر نمی‌داند نوبت منبع‌دار است یا نه، و شاخهٔ + * دومی برای نگهداری ندارد. + * + * بافر صفر است چون `ResourceBookingSlotService::startTimes()` زمان‌ها را پشت‌سرهم و + * بدون بافر می‌چیند؛ عددِ دیگری اینجا یعنی واگرایی با همان اسلات‌ها. + * + * @param list $serviceUuids + */ + private function resourceDuration( + \App\Resource\Entity\ClinicResource $resource, + array $serviceUuids, + ): \App\Appointment\ValueObject\ServiceBookingDuration { + ['minutes' => $minutes, 'items' => $items] = $this->resourceSlots->resolveDuration($resource, $serviceUuids); + + return new \App\Appointment\ValueObject\ServiceBookingDuration($minutes, 0, $items); + } + /** * آیا این درخواستِ اسلات از پنل مدیریت است (پزشک/منشی/ادمینِ دارای دسترسی)؟ * اندپوینت‌های اسلات عمومی‌اند؛ فقط با management=1 + کاربرِ احرازشده و مجاز، diff --git a/src/Resource/Controller/PublicResourceBookingController.php b/src/Resource/Controller/PublicResourceBookingController.php new file mode 100644 index 00000000..d6654c4b --- /dev/null +++ b/src/Resource/Controller/PublicResourceBookingController.php @@ -0,0 +1,243 @@ +doctors->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + + $clinic = $this->bookingContext->resolve($doctor, $request->query->get('clinic_uuid')); + + return $this->success([ + 'doctor_uuid' => $doctor->getUuid(), + 'clinic_uuid' => $clinic?->getUuid(), + 'resources' => $this->public->resourcesFor($doctor, $clinic), + ]); + } + + /** + * عمومی: زمان‌های خالیِ کافی برای مجموعِ مدتِ سرویس‌های انتخاب‌شده روی این منبع. + * + * GET /api/v1/appointment-resource-slots?resource_uuid=..&date=Y-m-d&service_item_uuids[]=.. + * + * `durations[]` که نسخهٔ پنلی می‌پذیرد اینجا عمداً پشتیبانی نمی‌شود: override مدت + * ابزار منشی است و در دست بازدیدکننده یعنی ساختنِ ظرفیتِ ساختگی. + */ + #[OA\Get( + path: '/api/v1/appointment-resource-slots', + summary: 'Public: free start times of a resource for the selected online-enabled services', + parameters: [ + new OA\Parameter(name: 'resource_uuid', in: 'query', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'date', in: 'query', required: true, schema: new OA\Schema(type: 'string', format: 'date')), + new OA\Parameter(name: 'service_item_uuids[]', in: 'query', required: true, schema: new OA\Schema(type: 'array', items: new OA\Items(type: 'string'))), + ], + responses: [ + new OA\Response(response: 200, description: 'Start times (may be empty)'), + new OA\Response(response: 422, description: 'Unknown/closed resource, service not enabled online, bad date'), + ], + )] + #[Route('/api/v1/appointment-resource-slots', name: 'public_resource_slots', methods: ['GET'])] + public function slots(Request $request): JsonResponse + { + $resource = $this->requireResource((string) $request->query->get('resource_uuid', '')); + $date = $this->requireDate($request); + $uuids = $this->requirePublicServices($resource, $request); + + ['minutes' => $minutes] = $this->slots->resolveDuration($resource, $uuids); + + return $this->success([ + 'resource_uuid' => $resource->getUuid(), + 'date' => $date, + 'timezone' => $resource->getAddress()->getTimezone(), + 'total_duration_minutes' => $minutes, + 'start_times' => $this->slots->startTimes($resource, $date, $minutes), + ]); + } + + /** + * عمومی: روزهای فعال و غیرفعالِ یک ماه برای همان ترکیبِ سرویس‌ها. + * + * GET /api/v1/appointment-resource-month-availability/{resourceUuid}?year=&month=&service_item_uuids[]= + * + * سرویس‌ها الزامی‌اند چون منبع اسلاتِ ثابت ندارد: «روز فعال» یعنی دست‌کم یک بازهٔ + * خالی به اندازهٔ مجموعِ مدتِ همین سرویس‌ها. بدون آن، تقویم روزی را سبز نشان می‌داد + * که برای سرویسِ ۹۰ دقیقه‌ای جا ندارد. + * + * سال و ماه **میلادی**‌اند — همان قرارداد `appointment-settings/month-availability` + * که سایت از قبل با آن کار می‌کند. + */ + #[OA\Get( + path: '/api/v1/appointment-resource-month-availability/{resourceUuid}', + summary: 'Public: which days of a Gregorian month can host the selected services on this resource', + parameters: [ + new OA\Parameter(name: 'resourceUuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'year', in: 'query', required: true, schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'month', in: 'query', required: true, schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'service_item_uuids[]', in: 'query', required: true, schema: new OA\Schema(type: 'array', items: new OA\Items(type: 'string'))), + ], + responses: [ + new OA\Response(response: 200, description: 'Enabled and disabled dates'), + new OA\Response(response: 422, description: 'Unknown/closed resource, service not enabled online, bad year/month'), + ], + )] + #[Route('/api/v1/appointment-resource-month-availability/{resourceUuid}', name: 'public_resource_month_availability', methods: ['GET'])] + public function monthAvailability(string $resourceUuid, Request $request): JsonResponse + { + $resource = $this->requireResource($resourceUuid); + $uuids = $this->requirePublicServices($resource, $request); + + $year = $request->query->getInt('year'); + $month = $request->query->getInt('month'); + if ($year < 1970 || $month < 1 || $month > 12) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'سال یا ماه نامعتبر است', 422, 'month'); + } + + ['minutes' => $minutes] = $this->slots->resolveDuration($resource, $uuids); + + $daysInMonth = (int) date('t', (int) strtotime(sprintf('%04d-%02d-01', $year, $month))); + $enabled = []; + $disabled = []; + + for ($day = 1; $day <= $daysInMonth; $day++) { + $date = sprintf('%04d-%02d-%02d', $year, $month, $day); + + if ($this->slots->startTimes($resource, $date, $minutes) !== []) { + $enabled[] = $date; + continue; + } + + $disabled[] = $date; + } + + return $this->success([ + 'resource_uuid' => $resource->getUuid(), + 'year' => $year, + 'month' => $month, + 'total_duration_minutes' => $minutes, + 'enabled_dates' => $enabled, + 'disabled_dates' => $disabled, + ]); + } + + /** + * منبعی که در سایت قابل رزرو است — وگرنه ۴۲۲. + * + * ۴۲۲ و نه ۴۰۴، چون همان کدی است که `POST /api/v1/appointment` برای منبعِ ناموجود + * برمی‌گرداند و سایت یک مسیر خطا بیشتر ندارد. + * + * @throws AppException + */ + private function requireResource(string $uuid): ClinicResource + { + $resource = $uuid === '' ? null : $this->resources->findByUuid($uuid); + + if ($resource === null || !$this->public->isPubliclyBookable($resource)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'منبع یافت نشد', 422, 'resource_uuid'); + } + + return $resource; + } + + /** @throws AppException */ + private function requireDate(Request $request): string + { + $date = trim((string) $request->query->get('date', '')); + $parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $date); + + // regex تنها کافی نیست: «2026-13-99» الگو را پاس می‌کند ولی روزی نیست. + if ($parsed === false || $parsed->format('Y-m-d') !== $date) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date'); + } + + return $date; + } + + /** + * uuidهای سرویس، پس از گیتِ «در سایت روشن است؟». + * + * ترتیب عمدی است: این گیت قبل از `resolveDuration` می‌آید تا پیام «برای نوبت‌دهی + * آنلاین فعال نیست» جای پیامِ گمراه‌کنندهٔ «این منبع این سرویس را ارائه نمی‌دهد» + * بنشیند — سرویسی که offering دارد ولی توگلش خاموش است، دقیقاً همین حالت است. + * + * @return list + * @throws AppException + */ + private function requirePublicServices(ClinicResource $resource, Request $request): array + { + $uuids = array_values(array_filter(array_map('trim', (array) $request->query->all('service_item_uuids')))); + + if ($uuids === []) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'انتخاب حداقل یک سرویس الزامی است', 422, 'service_item_uuids'); + } + + foreach ($uuids as $uuid) { + $item = $this->items->findByUuid($uuid); + + if ($item === null) { + throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids'); + } + + $this->public->assertPublicService($resource, $item); + } + + return $uuids; + } +} diff --git a/src/Resource/Repository/ClinicResourceRepository.php b/src/Resource/Repository/ClinicResourceRepository.php index 662be2c7..38e31dce 100644 --- a/src/Resource/Repository/ClinicResourceRepository.php +++ b/src/Resource/Repository/ClinicResourceRepository.php @@ -115,6 +115,41 @@ class ClinicResourceRepository extends ServiceEntityRepository ->getResult(); } + /** + * منابعِ یک پزشک در یک محیط که در **سایت عمومی** قابل رزروند. + * + * سه شرط با هم: منبع فعال، دست‌کم یک ردیفِ ارائهٔ فعال، و سرویسِ آن ردیف هم + * `bookable` و هم `active`. توگلِ «نمایش در نوبت‌دهی آنلاین» تنها گیتِ عمومی‌شدن + * است؛ منبعی که هیچ سرویسِ روشنی ندارد نباید اصلاً در پاسخ دیده شود. + * + * فیلترِ پزشک دو شاخه دارد چون رابطهٔ منبع↔پزشک دو شکل است: منبع یا **خودِ** پزشک + * است (ستون پل `doctor`)، یا دستگاهی است که زیر نظر او کار می‌کند (`supervisor`). + * + * @return ClinicResource[] + */ + public function findPublicBookableForDoctor(string $entityType, int $entityId, Doctor $doctor): array + { + return $this->createQueryBuilder('r') + ->addSelect('t') + ->join('r.type', 't') + ->join(\App\Resource\Entity\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(); + } + /** * پرس‌وجوی داغِ تسک ۰۶: «منابع فعالِ این شعبه از این نوع که **همهٔ** این مهارت‌ها * را دارند». diff --git a/src/Resource/Service/PublicResourceBookingService.php b/src/Resource/Service/PublicResourceBookingService.php new file mode 100644 index 00000000..d15a0497 --- /dev/null +++ b/src/Resource/Service/PublicResourceBookingService.php @@ -0,0 +1,208 @@ +> + */ + private array $offeringCache = []; + + public function __construct( + private readonly ClinicResourceRepository $resources, + private readonly ResourceServiceOfferingRepository $offerings, + private readonly ResourceServiceResolver $resolver, + private readonly ClinicRepository $clinics, + ) {} + + /** + * منابعِ قابلِ رزروِ یک پزشک، آمادهٔ پاسخ. + * + * `$clinic === null` یعنی «همهٔ محیط‌های این پزشک» — مطب شخصی به‌علاوهٔ هر کلینیکی + * که عضوش است — نه فقط مطب شخصی. + * + * دلیلش این است که منبع تقویم و شعبهٔ خودش را دارد و به برنامهٔ هفتگیِ پزشک وابسته + * نیست: پزشکی که خودش نوبت آنلاین نمی‌دهد ولی دستگاهش می‌دهد، هیچ «محل نوبت‌دهی» + * ندارد که سایت بتواند `clinic_uuid` را از آن بردارد. اگر اینجا فقط مطب شخصی را + * می‌دیدیم، آن دستگاه هرگز در سایت پیدا نمی‌شد. + * + * @return list> + */ + public function resourcesFor(Doctor $doctor, ?Clinic $clinic): array + { + $contexts = $clinic !== null ? [$clinic] : [null, ...$this->clinics->findByDoctor($doctor)]; + + $out = []; + foreach ($contexts as $context) { + [$entityType, $entityId] = EntityContext::forBooking($doctor, $context)->toEntityPair(); + + if ($entityId === null) { + continue; + } + + foreach ($this->resources->findPublicBookableForDoctor($entityType, $entityId, $doctor) as $resource) { + $out[] = $this->toArray($resource, $context); + } + } + + return $out; + } + + /** + * @param Clinic|null $clinic محیطی که این منبع در آن پیدا شد — سایت با همین + * `clinic_uuid` نوبت را ثبت می‌کند + * + * @return array + */ + public function toArray(ClinicResource $resource, ?Clinic $clinic = null): array + { + $address = $resource->getAddress(); + $supervisor = $resource->getSupervisor(); + + return [ + 'uuid' => $resource->getUuid(), + 'name' => $resource->getName(), + 'clinic_uuid' => $clinic?->getUuid(), + 'type' => [ + 'code' => $resource->getType()->getCode(), + 'name' => $resource->getType()->getName(), + ], + 'location' => [ + 'uuid' => $address->getUuid(), + 'title' => $address->getName() ?: 'مطب شخصی', + 'address' => $address->getAddress(), + ], + 'supervisor' => $supervisor === null + ? null + : ['uuid' => $supervisor->getUuid(), 'full_name' => $supervisor->getName()], + 'services' => $this->publicServices($resource), + ]; + } + + /** + * سرویس‌های عمومیِ یک منبع، با مدت و قیمتِ **حل‌شدهٔ همان منبع**. + * + * عددها از {@see ResourceServiceResolver} می‌آیند نه از پیش‌فرضِ خامِ سرویس: همان + * «RF فرکشنال» روی یک دستگاه ۵۰ دقیقه است و روی دیگری ۴۰، و بیمار باید عددی را + * ببیند که واقعاً برایش وقت گرفته می‌شود. + * + * @return list> + */ + public function publicServices(ClinicResource $resource): array + { + $out = []; + + foreach ($this->publicOfferings($resource) as $offering) { + $item = $offering->getServiceItem(); + $spec = $this->resolver->resolve($resource, $item, $resource->getAddress()); + $section = $item->getSection(); + + $out[] = [ + 'uuid' => $item->getUuid(), + 'name' => $item->getName(), + 'duration_minutes' => $spec->durationMinutes, + 'price_rials' => $spec->priceRials, + 'service_section' => ['uuid' => $section->getUuid(), 'name' => $section->getName()], + ]; + } + + return $out; + } + + /** آیا این منبع اصلاً چیزی برای نمایش در سایت دارد؟ */ + public function isPubliclyBookable(ClinicResource $resource): bool + { + return $resource->isActive() && $this->publicOfferings($resource) !== []; + } + + /** + * آیا **همهٔ** این سرویس‌ها روی این منبع عمومی‌اند؟ + * + * پرسشِ شاخه‌گیری است، نه گیت: مسیر ثبت نوبت با پاسخ `true` مدت را از منبع می‌گیرد و + * با `false` به محاسبهٔ پزشک‌محور برمی‌گردد. محیطی که هنوز رابطه‌های منبع↔سرویس را پر + * نکرده نباید یک‌شبه نوبت‌دهی‌اش قطع شود — همان سازگاری عقب‌روی `findEligible`. + * + * @param list $serviceUuids + */ + public function offersAllPublicly(ClinicResource $resource, array $serviceUuids): bool + { + if ($serviceUuids === []) { + return false; + } + + $public = []; + foreach ($this->publicOfferings($resource) as $offering) { + $public[$offering->getServiceItem()->getUuid()] = true; + } + + foreach ($serviceUuids as $uuid) { + if (!isset($public[$uuid])) { + return false; + } + } + + return true; + } + + /** + * @throws AppException ۴۲۲ روی سرویسی که در سایت روشن نیست + */ + public function assertPublicService(ClinicResource $resource, ServiceItem $item): void + { + foreach ($this->publicOfferings($resource) as $offering) { + if ($offering->getServiceItem()->getId() === $item->getId()) { + return; + } + } + + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + 'این سرویس برای نوبت‌دهی آنلاین فعال نیست', + 422, + 'service_item_uuids', + ); + } + + /** @return list */ + private function publicOfferings(ClinicResource $resource): array + { + $key = (int) $resource->getId(); + + return $this->offeringCache[$key] ??= array_values(array_filter( + $this->offerings->findForResource($resource), + static fn (ResourceServiceOffering $o): bool => $o->isActive() + && $o->getServiceItem()->isBookable() + && $o->getServiceItem()->isActive(), + )); + } +} diff --git a/tests/Appointment/BookForResourceTest.php b/tests/Appointment/BookForResourceTest.php index 2b68786d..a27a29f5 100644 --- a/tests/Appointment/BookForResourceTest.php +++ b/tests/Appointment/BookForResourceTest.php @@ -174,6 +174,114 @@ class BookForResourceTest extends ApiTestCase self::assertSame('این منبع این سرویس را ارائه نمی‌دهد', $res['errors'][0]['message']); } + /** + * مدتِ نوبت باید همان عددی باشد که `appointment-resource-slots` با آن زمان‌ها را + * ساخته — وگرنه بیمار وقتی را می‌گیرد که سرور جای دیگری آزاد حساب کرده بود. + */ + public function testTheLengthComesFromTheResourceOfferingNotTheServiceDefault(): void + { + [$clinic, $doctor, $address, $section] = $this->clinic(); + + $resource = $this->resource($clinic, $address, 'دستگاه لیزر', $doctor); + $service = $this->service($section, 'لیزر پا', 30); // پیش‌فرضِ سرویس + + $offering = new ResourceServiceOffering($resource, $service); + $offering->setDurationMinutes(45); // مدتِ همین دستگاه + $this->em->persist($offering); + $this->em->flush(); + + [$res] = $this->book([ + 'doctor_uuid' => $doctor->getUuid(), + 'clinic_uuid' => $clinic->getUuid(), + 'resource_uuid' => $resource->getUuid(), + 'service_item_uuids' => [$service->getUuid()], + ]); + + self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); + + $row = $res['data']['data']; + self::assertSame(45 * 60, $row['slot_end'] - $row['slot_start']); + } + + /** + * سازگاری عقب‌رو: محیطی که هنوز هیچ ردیفِ منبع↔سرویس نساخته باید مثل قبل کار کند — + * مدت از پیش‌فرضِ خودِ سرویس می‌آید و نوبت‌دهی‌اش قطع نمی‌شود. + */ + public function testWithoutAnyOfferingRowTheLengthStaysTheServiceDefault(): void + { + [$clinic, $doctor, $address, $section] = $this->clinic(); + + $resource = $this->resource($clinic, $address, 'دستگاه بدون رابطه', $doctor); + $service = $this->service($section, 'سرویس بدون رابطه', 30); + + [$res] = $this->book([ + 'doctor_uuid' => $doctor->getUuid(), + 'clinic_uuid' => $clinic->getUuid(), + 'resource_uuid' => $resource->getUuid(), + 'service_item_uuids' => [$service->getUuid()], + ]); + + self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); + + $row = $res['data']['data']; + self::assertSame(30 * 60, $row['slot_end'] - $row['slot_start']); + } + + /** + * ردیفِ **غیرفعال** فرق دارد و همان ۴۲۲ قبلی را می‌گیرد: «فعلاً این را نمی‌دهم» + * حرفِ صریحِ مالک است، نه سکوتِ محیطی که هنوز رابطه‌ها را پر نکرده. + */ + public function testAnInactiveOfferingStillRejectsTheBooking(): void + { + [$clinic, $doctor, $address, $section] = $this->clinic(); + + $resource = $this->resource($clinic, $address, 'دستگاه لیزر', $doctor); + $service = $this->service($section, 'لیزر پا', 30); + + $offering = new ResourceServiceOffering($resource, $service); + $offering->setDurationMinutes(45)->setActive(false); + $this->em->persist($offering); + $this->em->flush(); + + [$res] = $this->book([ + 'doctor_uuid' => $doctor->getUuid(), + 'clinic_uuid' => $clinic->getUuid(), + 'resource_uuid' => $resource->getUuid(), + 'service_item_uuids' => [$service->getUuid()], + ]); + + self::assertSame(422, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); + self::assertSame('این منبع این سرویس را ارائه نمی‌دهد', $res['errors'][0]['message']); + } + + /** + * گیتِ «نمایش در نوبت‌دهی آنلاین» با منبع هم دور زده نمی‌شود: شاخهٔ منبع فقط وقتی + * فعال است که سرویس عمومی باشد، و در غیر این‌صورت همان ۴۲۲ مسیر پزشک‌محور می‌آید. + */ + public function testAServiceWithTheOnlineToggleOffIsStillRejectedOnAResource(): void + { + [$clinic, $doctor, $address, $section] = $this->clinic(); + + $resource = $this->resource($clinic, $address, 'دستگاه لیزر', $doctor); + $service = $this->service($section, 'لیزر پا', 30); + $service->setBookable(false); + + $offering = new ResourceServiceOffering($resource, $service); + $offering->setDurationMinutes(45); + $this->em->persist($offering); + $this->em->flush(); + + [$res] = $this->book([ + 'doctor_uuid' => $doctor->getUuid(), + 'clinic_uuid' => $clinic->getUuid(), + 'resource_uuid' => $resource->getUuid(), + 'service_item_uuids' => [$service->getUuid()], + ]); + + self::assertSame(422, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); + self::assertSame('این سرویس برای نوبت‌دهی فعال نیست', $res['errors'][0]['message']); + } + public function testAnUnknownResourceIsRejected(): void { $this->book(['resource_uuid' => '00000000-0000-4000-8000-000000000000']); diff --git a/tests/Resource/PublicResourceBookingTest.php b/tests/Resource/PublicResourceBookingTest.php new file mode 100644 index 00000000..c431e4e7 --- /dev/null +++ b/tests/Resource/PublicResourceBookingTest.php @@ -0,0 +1,388 @@ +doctorWithAddress(); + + $resource = new ClinicResource($address, $this->resourceType($address), 'لیزر CO2'); + $resource->setSupervisor($doctor); + $this->em->persist($resource); + $this->em->flush(); + + return [$doctor, $address, $resource]; + } + + private function service(DoctorAddress $address, string $name, bool $bookable = true): ServiceItem + { + $section = new ServiceSection($address->tenantEntityType(), $address->tenantEntityId(), 'بخش ' . $name); + $this->em->persist($section); + + $item = new ServiceItem($section, $name, 5_000_000); + $item->setDurationMinutes(30)->setBookable($bookable); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + private function offer(ClinicResource $resource, ServiceItem $item, ?int $minutes = null, bool $active = true): ResourceServiceOffering + { + $offering = new ResourceServiceOffering($resource, $item); + $offering->setDurationMinutes($minutes)->setActive($active); + $this->em->persist($offering); + $this->em->flush(); + + return $offering; + } + + /** @return array درخواستِ ناشناس، دقیقاً مثل بازدیدکنندهٔ سایت */ + private function publicJson(string $uri): array + { + $this->client->request('GET', $uri); + + return json_decode($this->client->getResponse()->getContent(), true) ?? []; + } + + private function resourcesUri(Doctor $doctor): string + { + return '/api/v1/appointment-booking-resources/' . $doctor->getUuid(); + } + + /** شیفت ۰۹:۰۰ تا ۱۷:۰۰ روی همان روزِ هفته‌ای که تست رویش نوبت می‌گیرد. */ + private function shift(ClinicResource $resource): void + { + $this->em->persist(new ResourceCalendar($resource, $this->dayOfWeek($this->midnight()), 540, 1020)); + $this->em->flush(); + } + + /** فردا انتخاب می‌شود تا «زمانِ گذشته» نتیجه را کوتاه نکند. */ + private function midnight(): int + { + return (int) strtotime('tomorrow midnight'); + } + + private function date(): string + { + return date('Y-m-d', $this->midnight()); + } + + /** ۰ = شنبه، همان قرارداد تقویم منبع. */ + private function dayOfWeek(int $timestamp): int + { + return ((int) date('w', $timestamp) + 1) % 7; + } + + /** @param list $serviceUuids */ + private function slotsUri(string $resourceUuid, string $date, array $serviceUuids): string + { + return '/api/v1/appointment-resource-slots?' . http_build_query([ + 'resource_uuid' => $resourceUuid, + 'date' => $date, + 'service_item_uuids' => $serviceUuids, + ]); + } + + // ── ✅ موفق ────────────────────────────────────────────────────────────── + + public function testAResourceWithAnOnlineEnabledServiceIsListedWithResolvedNumbers(): void + { + [$doctor, $address, $resource] = $this->doctorWithResource(); + $item = $this->service($address, 'لیزر پا'); + $this->offer($resource, $item, 45); // مدتِ اختصاصیِ همین دستگاه + + $body = $this->publicJson($this->resourcesUri($doctor)); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertCount(1, $body['data']['resources']); + + $row = $body['data']['resources'][0]; + self::assertSame($resource->getUuid(), $row['uuid']); + self::assertSame('لیزر CO2', $row['name']); + self::assertSame($doctor->getUuid(), $row['supervisor']['uuid']); + self::assertCount(1, $row['services']); + // مدت از ردیف ارائه می‌آید، نه از پیش‌فرضِ ۳۰ دقیقه‌ایِ خودِ سرویس. + self::assertSame(45, $row['services'][0]['duration_minutes']); + self::assertSame(5_000_000, $row['services'][0]['price_rials']); + // ظرفیت عددِ عملیاتیِ داخل کلینیک است و نباید به سایت نشت کند. + self::assertArrayNotHasKey('capacity', $row); + } + + // ── ❌ خطا ─────────────────────────────────────────────────────────────── + + public function testAServiceWithTheOnlineToggleOffHidesTheResource(): void + { + [$doctor, $address, $resource] = $this->doctorWithResource(); + $this->offer($resource, $this->service($address, 'سرویس خاموش', bookable: false)); + + $body = $this->publicJson($this->resourcesUri($doctor)); + + self::assertSame(200, $this->responseCode()); + self::assertSame([], $body['data']['resources']); + } + + public function testAnInactiveOfferingHidesTheResource(): void + { + [$doctor, $address, $resource] = $this->doctorWithResource(); + $this->offer($resource, $this->service($address, 'لیزر پا'), active: false); + + $body = $this->publicJson($this->resourcesUri($doctor)); + + self::assertSame(200, $this->responseCode()); + self::assertSame([], $body['data']['resources']); + } + + public function testAnUnknownDoctorIsRejected(): void + { + $this->publicJson('/api/v1/appointment-booking-resources/00000000-0000-4000-8000-000000000000'); + + self::assertSame(404, $this->responseCode()); + } + + // ── ⚠️ مرزی ────────────────────────────────────────────────────────────── + + public function testADoctorWithoutAnyResourceGetsAnEmptyListNotAnError(): void + { + [, $doctor] = $this->doctorWithAddress(); + + $body = $this->publicJson($this->resourcesUri($doctor)); + + self::assertSame(200, $this->responseCode()); + self::assertSame([], $body['data']['resources']); + } + + /** + * پزشکی که هیچ برنامهٔ هفتگی ندارد، هیچ «محل نوبت‌دهی» هم ندارد که سایت بتواند + * `clinic_uuid` را از آن بردارد. منبع اما تقویم و شعبهٔ خودش را دارد و باید بدون + * آن پارامتر هم پیدا شود، وگرنه دستگاهِ قابلِ رزرو هرگز در سایت دیده نمی‌شود. + */ + public function testWithoutAClinicParamTheResourcesOfEveryEnvironmentComeBack(): void + { + [$user, $clinic, $address] = $this->clinicWithAddress(); + + $doctor = $this->supervisorFor($address); + + $resource = new ClinicResource($address, $this->resourceType($address), 'کندلا'); + $resource->setSupervisor($doctor); + $this->em->persist($resource); + $this->em->flush(); + + $this->offer($resource, $this->service($address, 'لیزر دست'), 20); + + // بدون clinic_uuid — دقیقاً همان چیزی که سایت برای پزشکِ بی‌برنامه می‌فرستد. + $body = $this->publicJson($this->resourcesUri($doctor)); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertCount(1, $body['data']['resources']); + // محیطِ منبع همراهش می‌آید تا سایت بتواند نوبت را در همان کلینیک ثبت کند. + self::assertSame($clinic->getUuid(), $body['data']['resources'][0]['clinic_uuid']); + } + + public function testAResourceSupervisedByAnotherDoctorIsNotListed(): void + { + [, $address, $resource] = $this->doctorWithResource(); + $this->offer($resource, $this->service($address, 'لیزر پا')); + + $other = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), 'پزشک دیگر'); + $this->em->persist($other); + $this->em->flush(); + + $body = $this->publicJson($this->resourcesUri($other)); + + self::assertSame(200, $this->responseCode()); + self::assertSame([], $body['data']['resources']); + } + + public function testAnInactiveResourceIsNotListed(): void + { + [$doctor, $address, $resource] = $this->doctorWithResource(); + $this->offer($resource, $this->service($address, 'لیزر پا')); + + $resource->setActive(false); + $this->em->flush(); + + $body = $this->publicJson($this->resourcesUri($doctor)); + + self::assertSame(200, $this->responseCode()); + self::assertSame([], $body['data']['resources']); + } + + // ── اسلات‌های عمومیِ منبع ──────────────────────────────────────────────── + + public function testTheFreeStartTimesOfAResourceComeBackWithoutAToken(): void + { + [, $address, $resource] = $this->doctorWithResource(); + $item = $this->service($address, 'لیزر پا'); + $this->offer($resource, $item, 45); + $this->shift($resource); + + $body = $this->publicJson($this->slotsUri($resource->getUuid(), $this->date(), [$item->getUuid()])); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + // مدت از ردیف ارائه می‌آید، نه از پیش‌فرضِ ۳۰ دقیقه‌ایِ سرویس. + self::assertSame(45, $body['data']['total_duration_minutes']); + self::assertNotEmpty($body['data']['start_times']); + self::assertSame('09:00', $body['data']['start_times'][0]['start_time']); + self::assertSame('09:45', $body['data']['start_times'][0]['end_time']); + } + + public function testAServiceWithTheOnlineToggleOffIsRejectedEvenWhenTheResourceOffersIt(): void + { + [, $address, $resource] = $this->doctorWithResource(); + // یکی روشن تا خودِ منبع عمومی بماند، یکی خاموش تا گیتِ سرویس سنجیده شود. + $this->offer($resource, $this->service($address, 'لیزر پا'), 45); + $off = $this->service($address, 'سرویس خاموش', bookable: false); + $this->offer($resource, $off, 20); + $this->shift($resource); + + $body = $this->publicJson($this->slotsUri($resource->getUuid(), $this->date(), [$off->getUuid()])); + + self::assertSame(422, $this->responseCode()); + self::assertSame('این سرویس برای نوبت‌دهی آنلاین فعال نیست', $body['errors'][0]['message']); + self::assertSame('service_item_uuids', $body['errors'][0]['field']); + } + + public function testAResourceWithoutAnyOnlineServiceIsNotReachable(): void + { + [, $address, $resource] = $this->doctorWithResource(); + $item = $this->service($address, 'سرویس خاموش', bookable: false); + $this->offer($resource, $item, 20); + $this->shift($resource); + + $body = $this->publicJson($this->slotsUri($resource->getUuid(), $this->date(), [$item->getUuid()])); + + self::assertSame(422, $this->responseCode()); + self::assertSame('resource_uuid', $body['errors'][0]['field']); + } + + public function testAMalformedDateIsRejected(): void + { + [, $address, $resource] = $this->doctorWithResource(); + $item = $this->service($address, 'لیزر پا'); + $this->offer($resource, $item, 45); + + $body = $this->publicJson($this->slotsUri($resource->getUuid(), '2026-13-99', [$item->getUuid()])); + + self::assertSame(422, $this->responseCode()); + self::assertSame('date', $body['errors'][0]['field']); + } + + public function testAnEmptyServiceSelectionIsRejected(): void + { + [, $address, $resource] = $this->doctorWithResource(); + $this->offer($resource, $this->service($address, 'لیزر پا'), 45); + + $body = $this->publicJson($this->slotsUri($resource->getUuid(), $this->date(), [])); + + self::assertSame(422, $this->responseCode()); + self::assertSame('service_item_uuids', $body['errors'][0]['field']); + } + + // ── تقویم ماهِ منبع ────────────────────────────────────────────────────── + + /** ماهِ آینده انتخاب می‌شود تا «زمانِ گذشته» همهٔ روزها را خالی نکند. */ + private function nextMonth(): array + { + $first = (int) strtotime('first day of next month midnight'); + + return [(int) date('Y', $first), (int) date('n', $first)]; + } + + /** @param list $serviceUuids */ + private function monthUri(string $resourceUuid, int $year, int $month, array $serviceUuids): string + { + return '/api/v1/appointment-resource-month-availability/' . $resourceUuid . '?' . http_build_query([ + 'year' => $year, + 'month' => $month, + 'service_item_uuids' => $serviceUuids, + ]); + } + + public function testOnlyTheWeekdaysWithAShiftAreEnabled(): void + { + [, $address, $resource] = $this->doctorWithResource(); + $item = $this->service($address, 'لیزر پا'); + $this->offer($resource, $item, 45); + + // تنها شیفتِ منبع: شنبه‌ها (۰ در قرارداد تقویم منبع). + $this->em->persist(new ResourceCalendar($resource, 0, 540, 1020)); + $this->em->flush(); + + [$year, $month] = $this->nextMonth(); + $body = $this->publicJson($this->monthUri($resource->getUuid(), $year, $month, [$item->getUuid()])); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertNotEmpty($body['data']['enabled_dates']); + + foreach ($body['data']['enabled_dates'] as $date) { + self::assertSame('Saturday', date('l', (int) strtotime($date)), $date); + } + + // هیچ روزی از قلم نمی‌افتد — تقویم سایت روی همین دو فهرست تصمیم می‌گیرد. + $daysInMonth = (int) date('t', (int) strtotime(sprintf('%04d-%02d-01', $year, $month))); + self::assertCount( + $daysInMonth, + array_merge($body['data']['enabled_dates'], $body['data']['disabled_dates']), + ); + } + + public function testAnInvalidMonthIsRejected(): void + { + [, $address, $resource] = $this->doctorWithResource(); + $item = $this->service($address, 'لیزر پا'); + $this->offer($resource, $item, 45); + + $body = $this->publicJson($this->monthUri($resource->getUuid(), 2026, 13, [$item->getUuid()])); + + self::assertSame(422, $this->responseCode()); + self::assertSame('month', $body['errors'][0]['field']); + } + + public function testAPastMonthHasNoEnabledDay(): void + { + [, $address, $resource] = $this->doctorWithResource(); + $item = $this->service($address, 'لیزر پا'); + $this->offer($resource, $item, 45); + $this->em->persist(new ResourceCalendar($resource, 0, 540, 1020)); + $this->em->flush(); + + $past = (int) strtotime('first day of last month midnight'); + $body = $this->publicJson( + $this->monthUri($resource->getUuid(), (int) date('Y', $past), (int) date('n', $past), [$item->getUuid()]), + ); + + self::assertSame(200, $this->responseCode()); + self::assertSame([], $body['data']['enabled_dates']); + } + + public function testADayWithoutAShiftReturnsAnEmptyListNotAnError(): void + { + [, $address, $resource] = $this->doctorWithResource(); + $item = $this->service($address, 'لیزر پا'); + $this->offer($resource, $item, 45); + // بدون هیچ ResourceCalendar — منبع آن روز باز نیست. + + $body = $this->publicJson($this->slotsUri($resource->getUuid(), $this->date(), [$item->getUuid()])); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame([], $body['data']['start_times']); + } +}