feat: Implement resource booking functionality

- Add service timeline builder for appointments to manage available slots.
- Create a hook to fetch resource booking services with effective durations.
- Develop ResourceBookingSlotController to handle API requests for resource booking slots.
- Implement ResourceBookingSlotService to calculate available time slots based on resource occupancy and service durations.
- Add tests for resource appointment creation and booking slot functionality to ensure correct behavior and edge cases.
This commit is contained in:
hamed
2026-08-03 14:34:23 +03:30
parent 981261ed3a
commit 4f69bc9044
21 changed files with 2045 additions and 514 deletions
@@ -47,6 +47,8 @@ class MyAppointmentsController extends BaseController
private readonly VisitPriceRequirementResolver $visitPriceResolver,
private readonly \App\Shared\Tenant\TenantOwnershipChecker $tenantOwnership,
private readonly \App\Appointment\Repository\WeeklyScheduleRepository $scheduleRepo,
private readonly \App\Resource\Repository\ClinicResourceRepository $resourceRepo,
private readonly \App\Resource\Service\ResourceBookingSlotService $resourceSlots,
) {}
/**
@@ -121,6 +123,31 @@ class MyAppointmentsController extends BaseController
$nationalCode = InputValidator::toEnglishDigits(trim((string) ($data['patient_national_code'] ?? '')));
$isReserve = (bool) ($data['is_reserve'] ?? false);
/**
* نوبتِ یک منبع (دستگاه/اتاق/پرسنل) — همان فرمِ نوبت، با هدفِ منبع.
*
* پزشک از ناظرِ منبع استنتاج می‌شود: رابطهٔ پزشک↔منبع یک جا تعریف شده (فرم
* منبع) و پرسیدن دوباره‌اش در فرم نوبت یعنی دو منبعِ حقیقت.
*/
$resourceUuid = trim((string) ($data['resource_uuid'] ?? ''));
$resource = null;
if ($resourceUuid !== '') {
$resource = $this->resourceRepo->findByUuid($resourceUuid);
if ($resource === null || !$resource->isActive()) {
return $this->error(ErrorCodes::VALIDATION, 'منبع یافت نشد', 422, 'resource_uuid');
}
if ($doctorUuid === '') {
$doctorUuid = $resource->getSupervisor()?->getUuid() ?? '';
}
if ($doctorUuid === '') {
return $this->error(ErrorCodes::VALIDATION, 'این منبع پزشک ناظر ندارد', 422, 'resource_uuid');
}
}
// Reserve entries are day-level: only a date is picked in the UI, so
// slot_end may equal slot_start and the past-slot rule does not apply.
if ($isReserve && $slotEnd < $slotStart) {
@@ -137,7 +164,16 @@ class MyAppointmentsController extends BaseController
// مدتِ override منشی برای همین نوبت (پیش‌فرض سرویس تغییر نمی‌کند). { uuid: minutes }
$durationOverrides = (array) ($data['service_durations'] ?? []);
$serviceItems = [];
if (!empty($serviceUuids) && !$isReserve) {
if (!empty($serviceUuids) && !$isReserve && $resource !== null) {
// نوبتِ منبع: مدت از زنجیرهٔ حلِ همان منبع می‌آید (هر دستگاه مدت خودش را
// دارد) و گیتِ «این سرویس روی این منبع فعال است؟» جای `bookable` می‌نشیند.
['minutes' => $resourceMinutes, 'items' => $serviceItems] =
$this->resourceSlots->resolveDuration($resource, $serviceUuids, $durationOverrides);
if ($computeDuration) {
$slotEnd = $slotStart + $resourceMinutes * 60;
}
} elseif (!empty($serviceUuids) && !$isReserve) {
$totalMinutes = 0;
foreach ($serviceUuids as $u) {
$item = $this->itemRepo->findByUuid($u);
@@ -231,6 +267,26 @@ class MyAppointmentsController extends BaseController
return $this->error(ErrorCodes::VALIDATION, 'سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', 422, 'service_item_uuids');
}
if ($resource !== null) {
/**
* منبع با uuid از بدنه می‌آید و `TenantFilter` پوششش نمی‌دهد؛ بدون این
* بررسی، دستگاهِ کلینیک دیگری روی نوبت این کلینیک می‌نشست.
*/
if (!$this->tenantOwnership->belongsTo($bookingContext, $resource)) {
return $this->error(ErrorCodes::VALIDATION, 'منبع یافت نشد', 422, 'resource_uuid');
}
/**
* تداخل روی خودِ منبع جدا سنجیده می‌شود: `bookAtomically` فقط اسلاتِ پزشک
* را قفل می‌کند و دو پزشکِ متفاوت می‌توانند یک دستگاه را هم‌زمان بگیرند.
*/
if (!$isReserve && !$this->resourceSlots->isFree($resource, $slotStart, $slotEnd)) {
return $this->error(ErrorCodes::SLOT_TAKEN, 'این منبع در این زمان آزاد نیست', 409, 'resource_uuid');
}
$appointment->setResource($resource);
}
// پیوستِ همهٔ سرویس‌های انتخاب‌شده؛ سرویسِ اصلی = اولین سرویس (addServiceItem).
foreach ($serviceItems as $si) {
$appointment->addServiceItem($si);
@@ -113,6 +113,46 @@ class AppointmentRepository extends ServiceEntityRepository
return $this->occupiedIntervals($doctor, $from, $to, true, $excludeId);
}
/**
* بازه‌های اشغال‌شدهٔ یک **منبع** در پنجرهٔ [$from, $to).
*
* همان معیارِ نوبتِ پزشک، ولی محورش `resource_id` است: منبع می‌تواند نوبت‌هایی از
* چند پزشک داشته باشد، پس فیلترِ پزشک اینجا هم غلط است و هم ناقص.
*
* ردیف‌های `resource_occupancy` اینجا نمی‌آیند — آن‌ها مسیرِ موتور منبع‌محورند و
* {@see \App\Resource\Service\ResourceBookingSlotService} هر دو را کنار هم می‌گذارد.
*
* @return list<array{start: int, end: int}> مرتب‌شده بر اساس start
*/
public function findResourceBusyIntervals(int $resourceId, int $from, int $to, ?int $excludeId = null): array
{
$qb = $this->createQueryBuilder('a')
->select('a.slotStart AS start, a.slotEnd AS end')
->where('IDENTITY(a.resource) = :resource')
->andWhere('a.slotStart < :to')
->andWhere('a.slotEnd > :from')
->andWhere('a.isReserve = false')
->andWhere(
'a.status IN (:blocking) OR (a.status = :pending AND (a.expiresAt IS NULL OR a.expiresAt > :now))'
)
->setParameter('resource', $resourceId)
->setParameter('blocking', Appointment::SLOT_BLOCKING_STATUSES)
->setParameter('pending', Appointment::STATUS_PENDING)
->setParameter('now', time())
->setParameter('from', $from)
->setParameter('to', $to)
->orderBy('a.slotStart', 'ASC');
if ($excludeId !== null) {
$qb->andWhere('a.id != :excludeId')->setParameter('excludeId', $excludeId);
}
return array_map(
static fn (array $r): array => ['start' => (int) $r['start'], 'end' => (int) $r['end']],
$qb->getQuery()->getScalarResult(),
);
}
/**
* همان مجموعه‌ای که isSlotTaken() یک اسلات را با آن می‌سنجد — شامل نوبت‌های
* رزروی. برای پیمایش چندروزه که نمی‌خواهد به ازای هر اسلات یک کوئری بزند.
@@ -0,0 +1,118 @@
<?php
namespace App\Resource\Controller;
use App\Auth\Entity\User;
use App\Resource\Service\ResourceBookingSlotService;
use App\Resource\Service\ResourceContext;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Time\TimeInterval;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* زمان‌های نوبت‌دهیِ یک منبع — معادلِ `appointment-slots` و `appointment-service-slots`
* پزشک، ولی از تقویم خودِ منبع.
*
* از {@see ResourceCalendarController} جدا است چون کارِ دیگری می‌کند: آنجا تقویم را
* **می‌سازد** (شیفت، استثنا)، اینجا از روی همان تقویم وقتِ قابلِ رزرو را می‌دهد.
*/
#[OA\Tag(name: 'Resource')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class ResourceBookingSlotController extends BaseController
{
use ResourcePermissionTrait;
public function __construct(
private readonly ResourceContext $context,
private readonly ResourceBookingSlotService $slots,
) {}
/**
* بازه‌های کاریِ منبع در یک روز — ورودیِ تایم‌لاینِ صفحهٔ نوبت‌ها.
*
* نوبت‌ها اینجا کسر **نمی‌شوند**: تایم‌لاین خودش نوبت‌های همان روز را دارد و کارت‌ها
* را داخل همین بازه‌ها می‌چیند؛ کسرشان یعنی نوبتِ ثبت‌شده جایی برای نشستن ندارد.
*
* GET /api/v1/resource/{uuid}/day-slots?date=Y-m-d
*/
#[Route('/api/v1/resource/{uuid}/day-slots', name: 'resource_day_slots', methods: ['GET'])]
public function daySlots(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
$resource = $this->context->resource($user, $uuid);
$date = $this->requireDate($request);
['windows' => $windows, 'reason' => $reason] = $this->slots->workingWindows($resource, $date);
return $this->success([
'resource_uuid' => $resource->getUuid(),
'date' => $date,
'timezone' => $resource->getAddress()->getTimezone(),
'windows' => array_map(
static fn (TimeInterval $i): array => [
'start' => $i->start,
'end' => $i->end,
'start_time' => date('H:i', $i->start),
'end_time' => date('H:i', $i->end),
],
$windows,
),
// خالی‌بودن دلایل مختلف دارد؛ کلاینت نباید همه را «تعطیل» بنامد.
'empty_reason' => $reason,
]);
}
/**
* زمان‌های خالیِ کافی برای مجموعِ مدتِ سرویس‌های انتخاب‌شده روی این منبع.
*
* GET /api/v1/resource/{uuid}/service-slots?date=Y-m-d&service_item_uuids[]=..&durations[uuid]=دقیقه
*/
#[Route('/api/v1/resource/{uuid}/service-slots', name: 'resource_service_slots', methods: ['GET'])]
public function serviceSlots(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
$resource = $this->context->resource($user, $uuid);
$date = $this->requireDate($request);
$uuids = array_values(array_filter(array_map('trim', (array) $request->query->all('service_item_uuids'))));
// وجود، ارائه‌شدن روی همین منبع و مدت — همه داخل resolveDuration و با
// AppException؛ ExceptionSubscriber همان envelope خطا را می‌سازد.
['minutes' => $minutes] = $this->slots->resolveDuration(
$resource,
$uuids,
(array) $request->query->all('durations'),
);
return $this->success([
'resource_uuid' => $resource->getUuid(),
'date' => $date,
'total_duration_minutes' => $minutes,
'start_times' => $this->slots->startTimes($resource, $date, $minutes),
]);
}
private function requireDate(Request $request): string
{
$date = trim((string) $request->query->get('date', ''));
if ($date === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
throw new \App\Shared\Exception\AppException(
ErrorCodes::ERR_VALIDATION_001,
'فرمت تاریخ نادرست است (Y-m-d)',
422,
'date',
);
}
return $date;
}
}
@@ -0,0 +1,272 @@
<?php
namespace App\Resource\Service;
use App\Appointment\Availability\Repository\ResourceOccupancyRepository;
use App\Appointment\Repository\AppointmentRepository;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceItemRepository;
use App\ClinicService\Service\ResourceServiceResolver;
use App\Resource\Entity\ClinicResource;
use App\Resource\Repository\ResourceServiceOfferingRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Shared\Time\TimeInterval;
/**
* وقتِ **قابلِ رزروِ** یک منبع در یک روز — همان چیزی که `SlotCalculatorService` برای
* پزشک می‌دهد، ولی از تقویم خودِ منبع.
*
* جدا از {@see ResourceAvailabilityService} است و آن را مصرف می‌کند: آنجا می‌گوید منبع
* کِی **باز** است (ساعت شعبه ∩ شیفت − تعطیلات − استثناها)، اینجا از همان بازه‌ها آنچه
* را گرفته شده کم می‌کند و می‌گوید نوبت کجا جا می‌شود.
*
* منبع اسلاتِ ثابت ندارد: زمان‌ها از مدتِ سرویس‌های انتخاب‌شده ساخته می‌شوند، پس این
* سرویس همیشه سرویسی (service-based) کار می‌کند و هرگز شبکهٔ اسلات نمی‌سازد.
*/
final class ResourceBookingSlotService
{
public function __construct(
private readonly ResourceAvailabilityService $availability,
private readonly ResourceOccupancyRepository $occupancy,
private readonly AppointmentRepository $appointments,
private readonly ResourceServiceOfferingRepository $offerings,
private readonly ResourceServiceResolver $resolver,
private readonly ServiceItemRepository $items,
) {}
/**
* بازه‌های کاریِ منبع در یک روز — بدون کسر نوبت‌ها.
*
* تایم‌لاین به این نیاز دارد نه به وقت آزاد: نوبتِ ثبت‌شده باید **داخل** بازهٔ کاری
* دیده شود، وگرنه کارت نوبت جایی برای نشستن ندارد.
*
* @return array{windows: list<TimeInterval>, reason: string|null, day_start: int}
*/
public function workingWindows(ClinicResource $resource, string $date): array
{
$dayStart = $this->dayStart($resource, $date);
$day = $this->availability->rawAvailability($resource, $dayStart, $dayStart)[0];
return [
'windows' => $day->intervals,
// اولین دلیل کافی است: پیام کاربر یک جمله است، نه فهرست.
'reason' => $day->isEmpty() ? ($day->reasons[0] ?? 'no_shift') : null,
'day_start' => $dayStart,
];
}
/**
* بازه‌های آزادِ منبع در یک روز: بازهٔ کاری منهای اشغال، با احتساب ظرفیت.
*
* ظرفیت مهم است: اتاقِ سه‌تخته با یک نوبت پر نمی‌شود. دقیقه‌ای اشغال است که تعداد
* بازه‌های هم‌پوشانش به ظرفیت رسیده باشد — همان قاعدهٔ {@see ResourceFreeTimeCalculator}.
*
* @return list<TimeInterval>
*/
public function freeIntervals(ClinicResource $resource, string $date, ?int $excludeAppointmentId = null): array
{
['windows' => $windows, 'day_start' => $dayStart] = $this->workingWindows($resource, $date);
if ($windows === []) {
return [];
}
$busy = $this->busyIntervals($resource, $dayStart, $dayStart + 86400, $excludeAppointmentId);
return TimeInterval::subtractAll($windows, $this->fullRanges($busy, $resource->getCapacity()));
}
/**
* زمان‌های شروعِ ممکن برای نوبتی به طول `$totalMinutes`.
*
* پشت‌سرهم چیده می‌شوند (بدون بافر): منبع بین دو بیمار برنامهٔ استراحت ندارد؛ اگر
* لازم باشد، «استثنای منبع» ابزارِ همان کار است.
*
* @return list<array{start: int, end: int, start_time: string, end_time: string}>
*/
public function startTimes(
ClinicResource $resource,
string $date,
int $totalMinutes,
?int $excludeAppointmentId = null,
): array {
if ($totalMinutes <= 0) {
return [];
}
$length = $totalMinutes * 60;
$now = time();
$out = [];
foreach ($this->freeIntervals($resource, $date, $excludeAppointmentId) as $free) {
// زمانِ گذشته پیشنهاد نمی‌شود؛ ثبتش هم سرِ POST رد می‌شود.
for ($t = max($free->start, $now); $t + $length <= $free->end; $t += $length) {
$out[] = [
'start' => $t,
'end' => $t + $length,
'start_time' => date('H:i', $t),
'end_time' => date('H:i', $t + $length),
];
}
}
return $out;
}
/**
* مجموع مدتِ سرویس‌های انتخاب‌شده روی این منبع.
*
* مدت از زنجیرهٔ حلِ منبع می‌آید ({@see ResourceServiceResolver})، نه از پیش‌فرضِ خامِ
* سرویس: همان دستگاه ممکن است «RF فرکشنال» را ۵۰ دقیقه بگیرد و دستگاه دیگر ۴۰.
* `$overrides` فقط همین نوبت را جابه‌جا می‌کند و پیش‌فرض را دست نمی‌زند.
*
* @param list<string> $serviceUuids
* @param array<string, mixed> $overrides uuid => دقیقه
* @return array{minutes: int, items: list<ServiceItem>}
*
* @throws AppException ۴۲۲ روی سرویسِ ناموجود، ارائه‌نشده یا بی‌مدت
*/
public function resolveDuration(ClinicResource $resource, array $serviceUuids, array $overrides = []): array
{
if ($serviceUuids === []) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'انتخاب حداقل یک سرویس الزامی است', 422, 'service_item_uuids');
}
$minutes = 0;
$items = [];
foreach ($serviceUuids as $uuid) {
$item = $this->items->findByUuid($uuid);
if ($item === null) {
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids');
}
$this->assertOffered($resource, $item);
$override = isset($overrides[$uuid]) && (int) $overrides[$uuid] > 0 ? (int) $overrides[$uuid] : null;
$duration = $override ?? $this->resolver->resolve($resource, $item, $resource->getAddress())->durationMinutes;
if ($duration === null || $duration <= 0) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
}
$minutes += $duration;
$items[] = $item;
}
return ['minutes' => $minutes, 'items' => $items];
}
/** آیا این منبع در بازهٔ [$start, $end) هنوز جا دارد؟ */
public function isFree(ClinicResource $resource, int $start, int $end, ?int $excludeAppointmentId = null): bool
{
$busy = $this->busyIntervals($resource, $start, $end, $excludeAppointmentId);
foreach ($this->fullRanges($busy, $resource->getCapacity()) as $full) {
if ($full->start < $end && $full->end > $start) {
return false;
}
}
return true;
}
/**
* سرویسی که این منبع ارائه نمی‌دهد، همین‌جا رد می‌شود.
*
* @throws AppException ۴۲۲
*/
public function assertOffered(ClinicResource $resource, ServiceItem $item): void
{
$offering = $this->offerings->findOneFor($resource, $item);
if ($offering === null || !$offering->isActive()) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
'این منبع این سرویس را ارائه نمی‌دهد',
422,
'service_item_uuids',
);
}
}
/**
* هرچه منبع را می‌گیرد: نوبت‌های مستقیمِ همین منبع + ردیف‌های اشغالِ موتور
* منبع‌محور (رزرو موقت، مسدودسازی دستی، نوبت‌های چندبخشی).
*
* دو منبعِ داده‌اند چون دو مسیرِ رزرو داریم و هیچ‌کدام ردیف دیگری نمی‌سازد: مسیر
* پنل روی `appointments.resource_id` می‌نشیند و مسیر موتور روی `resource_occupancy`.
* شمردنِ فقط یکی، آن یکی را نامرئی می‌کرد.
*
* @return list<array{start: int, end: int}>
*/
private function busyIntervals(ClinicResource $resource, int $from, int $to, ?int $excludeAppointmentId): array
{
$rows = $this->appointments->findResourceBusyIntervals(
(int) $resource->getId(),
$from,
$to,
$excludeAppointmentId,
);
foreach ($this->occupancy->busyByResource([(int) $resource->getId()], $from, $to)[(int) $resource->getId()] ?? [] as $row) {
$rows[] = $row;
}
return $rows;
}
/**
* بازه‌هایی که ظرفیت در آن‌ها تمام است — جاروی خطی روی مرزها.
*
* @param list<array{start: int, end: int}> $busy
* @return list<TimeInterval>
*/
private function fullRanges(array $busy, int $capacity): array
{
if ($busy === []) {
return [];
}
$capacity = max(1, $capacity);
$delta = [];
foreach ($busy as $row) {
$delta[$row['start']] = ($delta[$row['start']] ?? 0) + 1;
$delta[$row['end']] = ($delta[$row['end']] ?? 0) - 1;
}
ksort($delta);
$points = array_keys($delta);
$open = 0;
$out = [];
foreach ($points as $i => $point) {
$open += $delta[$point];
$next = $points[$i + 1] ?? null;
if ($next !== null && $open >= $capacity) {
$out[] = new TimeInterval($point, $next);
}
}
return TimeInterval::mergeAll($out);
}
/** نیمه‌شبِ روز، به وقتِ محلیِ شعبهٔ منبع. */
private function dayStart(ClinicResource $resource, string $date): int
{
$timezone = new \DateTimeZone($resource->getAddress()->getTimezone());
$midnight = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $date . ' 00:00:00', $timezone);
if ($midnight === false) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
}
return $midnight->getTimestamp();
}
}