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:
@@ -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 نهایی کن.
|
||||
@@ -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 }
|
||||
|
||||
@@ -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` کنار بیاید.
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user