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