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 نهایی کن.
+3
View File
@@ -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 }
+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`.
@@ -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<string> $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 + کاربرِ احرازشده و مجاز،
@@ -0,0 +1,243 @@
<?php
namespace App\Resource\Controller;
use App\Appointment\Service\BookingContextResolver;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Resource\Entity\ClinicResource;
use App\Resource\Repository\ClinicResourceRepository;
use App\Resource\Service\PublicResourceBookingService;
use App\Resource\Service\ResourceBookingSlotService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
/**
* نوبت‌دهی منبع‌محور برای سایت عمومی.
*
* از {@see ResourceBookingSlotController} جداست و نه یک پرچمِ `public` روی آن: آنجا منبع
* از محیطِ کاربرِ احرازشده حل می‌شود (`ResourceContext::resource($user, $uuid)`) و اینجا
* کاربری وجود ندارد. یک کنترلر با دو مدلِ اعتماد، همان‌جایی است که نشت اتفاق می‌افتد.
*
* گیتِ عمومی‌شدن، توگلِ «نمایش در نوبت‌دهی آنلاین» روی سرویس است و در
* {@see PublicResourceBookingService} یک‌جا تعریف شده.
*/
#[OA\Tag(name: 'Resource')]
class PublicResourceBookingController extends BaseController
{
public function __construct(
private readonly DoctorRepository $doctors,
private readonly BookingContextResolver $bookingContext,
private readonly PublicResourceBookingService $public,
private readonly ClinicResourceRepository $resources,
private readonly ServiceItemRepository $items,
private readonly ResourceBookingSlotService $slots,
) {}
/**
* عمومی: منابعِ قابلِ رزروِ یک پزشک به‌همراه سرویس‌های روشنِ هرکدام.
*
* GET /api/v1/appointment-booking-resources/{doctorUuid}?clinic_uuid=
*/
#[OA\Get(
path: '/api/v1/appointment-booking-resources/{doctorUuid}',
summary: 'Public: bookable resources of a doctor with their online-enabled services',
parameters: [
new OA\Parameter(name: 'doctorUuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'clinic_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Resource list (may be empty)'),
new OA\Response(response: 404, description: 'Doctor or booking location not found'),
],
)]
#[Route('/api/v1/appointment-booking-resources/{doctorUuid}', name: 'public_booking_resources', methods: ['GET'])]
public function resources(string $doctorUuid, Request $request): JsonResponse
{
$doctor = $this->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<string>
* @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;
}
}
@@ -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();
}
/**
* پرس‌وجوی داغِ تسک ۰۶: «منابع فعالِ این شعبه از این نوع که **همهٔ** این مهارت‌ها
* را دارند».
@@ -0,0 +1,208 @@
<?php
namespace App\Resource\Service;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Service\ResourceServiceResolver;
use App\Doctor\Entity\Doctor;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceServiceOffering;
use App\Clinic\Repository\ClinicRepository;
use App\Resource\Repository\ClinicResourceRepository;
use App\Resource\Repository\ResourceServiceOfferingRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Context\EntityContext;
use App\Shared\Exception\AppException;
/**
* نمای عمومیِ منبع برای سایت نوبت‌دهی.
*
* از نمای پنل جداست چون سؤالِ دیگری جواب می‌دهد: پنل همهٔ سرویس‌های یک منبع را
* می‌خواهد، سایت فقط آن‌هایی را که مالک با توگلِ «نمایش در نوبت‌دهی آنلاین» روشن کرده.
* ادغامشان یعنی یک `if ($public)` در دلِ کد پنل و یک راهِ تازه برای نشتِ سرویسِ خاموش.
*
* همین کلاس تنها جایی است که «سرویس در سایت دیده می‌شود» تعریف می‌شود، و هر سه مسیرِ
* عمومی (فهرست، اسلات، ثبت نوبت) از آن می‌پرسند — وگرنه سه تعریفِ قابلِ واگرایی از یک
* قاعده داشتیم.
*/
final class PublicResourceBookingService
{
/**
* ردیف‌های ارائهٔ عمومیِ هر منبع، به ازای شناسهٔ منبع.
*
* ثبتِ نوبتِ چندسرویسی این را به ازای هر uuid صدا می‌زند؛ بدون این حافظه، همان
* کوئری به تعداد سرویس‌ها تکرار می‌شد.
*
* @var array<int, list<ResourceServiceOffering>>
*/
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<array<string, mixed>>
*/
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<string, mixed>
*/
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<array<string, mixed>>
*/
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<string> $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<ResourceServiceOffering> */
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(),
));
}
}
+108
View File
@@ -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']);
@@ -0,0 +1,388 @@
<?php
namespace App\Tests\Resource;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceCalendar;
use App\Resource\Entity\ResourceServiceOffering;
/**
* نوبت‌دهی منبع‌محور از **سایت عمومی** — بدون هیچ توکنی.
*
* تا پیش از این، منابع و سرویس‌هایشان فقط از داخل پنل دیده می‌شدند، پس تیکِ «نمایش در
* نوبت‌دهی آنلاین» روی سرویسِ یک دستگاه هیچ اثری در سایت نداشت.
*/
class PublicResourceBookingTest extends ResourceTestCase
{
/** @return array{0: Doctor, 1: DoctorAddress, 2: ClinicResource} */
private function doctorWithResource(): array
{
[, $doctor, $address] = $this->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<string, mixed> درخواستِ ناشناس، دقیقاً مثل بازدیدکنندهٔ سایت */
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<string> $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<string> $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']);
}
}