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:
@@ -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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user