feat: add PublicResourceBookingController and PublicResourceBookingService for public booking functionality

- Implemented PublicResourceBookingController to handle public resource booking requests.
- Added methods for retrieving bookable resources, available slots, and month availability.
- Created PublicResourceBookingService to manage public resource offerings and service visibility.
- Developed tests for public resource booking to ensure correct functionality and error handling.
This commit is contained in:
hamed
2026-08-09 10:45:51 +03:30
parent cd793489ef
commit cfeb447645
10 changed files with 1645 additions and 13 deletions
@@ -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(),
));
}
}