feat: add service-based booking mode to appointment scheduling

- Introduced a new booking mode in WeeklySchedule to support service-based appointments.
- Updated SlotCalculatorService to calculate available start times based on selected service durations and buffer times.
- Enhanced AppointmentController to handle service items during booking, calculating slot_end on the server side.
- Implemented validation to ensure at least one bookable service exists for doctors in service mode.
- Added new API endpoint to retrieve available appointment slots based on selected services.
- Updated MyAppointmentsController to accept service items during appointment creation.
- Modified ServiceItem entity to include a bookable flag, allowing services to be marked for scheduling.
- Created migration to add bookable column to service_items table.
- Added tests for service-based slot calculations and validation logic.
This commit is contained in:
hamed
2026-07-15 23:15:45 +03:30
parent 6904361e32
commit 5937f7e176
16 changed files with 817 additions and 26 deletions
@@ -136,6 +136,63 @@ class AppointmentController extends BaseController
]);
}
/**
* حالت نوبت‌دهی سرویسی: زمان‌های خالیِ کافی برای مجموعِ مدت سرویس‌های انتخاب‌شده.
* فقط سرویس‌های «نمایش در نوبت‌دهی» (bookable) و دارای مدت پذیرفته می‌شوند.
*
* GET /api/v1/appointment-service-slots?doctor_uuid=..&date=Y-m-d&service_item_uuids[]=..
*/
#[Route('/api/v1/appointment-service-slots', methods: ['GET'])]
public function serviceSlots(Request $request): JsonResponse
{
$doctorUuid = trim($request->query->get('doctor_uuid', ''));
$date = trim($request->query->get('date', ''));
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
}
$schedule = $this->scheduleRepo->findByDoctor($doctor);
$mode = ($schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META)['booking_mode'] ?? WeeklySchedule::MODE_SLOT;
if ($mode !== WeeklySchedule::MODE_SERVICE) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این پزشک در حالت نوبت‌دهی سرویسی نیست', 422);
}
$uuids = array_values(array_filter(array_map('trim', (array) $request->query->all('service_item_uuids'))));
if (empty($uuids)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'انتخاب حداقل یک سرویس الزامی است', 422, 'service_item_uuids');
}
$totalMinutes = 0;
foreach ($uuids as $u) {
$item = $this->itemRepo->findByUuid($u);
if ($item === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids');
}
if (!$item->isBookable()) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس برای نوبت‌دهی فعال نیست', 422, 'service_item_uuids');
}
if (($item->getDurationMinutes() ?? 0) <= 0) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
}
$totalMinutes += (int) $item->getDurationMinutes();
}
$meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
return $this->success([
'doctor_uuid' => $doctorUuid,
'date' => $date,
'total_duration_minutes' => $totalMinutes,
'buffer_minutes' => (int) $meta['buffer_minutes'],
'start_times' => $this->slotCalculator->getServiceStartTimes($doctor, $date, $totalMinutes),
]);
}
#[Route('/api/v1/appointment-settings/month-availability/{doctorUuid}', methods: ['GET'])]
public function monthAvailability(string $doctorUuid, Request $request): JsonResponse
{
@@ -224,6 +281,29 @@ class AppointmentController extends BaseController
$slotStart = (int) ($data['slot_start'] ?? 0);
$slotEnd = (int) ($data['slot_end'] ?? 0);
// حالت نوبت‌دهی سرویسی: مدت نوبت = مجموع مدت سرویس‌های bookableِ انتخاب‌شده،
// و slot_end سمت سرور محاسبه می‌شود (به مقدار کلاینت اعتماد نمی‌شود).
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
$serviceItem = null;
if (!empty($serviceUuids)) {
$totalMinutes = 0;
foreach ($serviceUuids as $u) {
$item = $this->itemRepo->findByUuid($u);
if ($item === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids');
}
if (!$item->isBookable()) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس برای نوبت‌دهی فعال نیست', 422, 'service_item_uuids');
}
if (($item->getDurationMinutes() ?? 0) <= 0) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
}
$totalMinutes += (int) $item->getDurationMinutes();
$serviceItem ??= $item;
}
$slotEnd = $slotStart + $totalMinutes * 60;
}
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid، slot_start و slot_end الزامی است', 422);
}
@@ -261,6 +341,7 @@ class AppointmentController extends BaseController
$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
$appointment->setPatientNationalCode($nationalCode);
$appointment->setPatientGender($gender);
if ($serviceItem !== null) $appointment->setServiceItem($serviceItem);
if (isset($data['note'])) $appointment->setNote($data['note']);
// نماینده‌ی دامنه‌ی مبدأ رزرو (از Origin مرورگر)؛ گاردِ نهایی پورسانت در لحظه‌ی
@@ -34,8 +34,19 @@ class AppointmentSettingsController extends BaseController
private readonly DoctorRepository $doctorRepo,
private readonly DoctorAddressRepository $addressRepo,
private readonly ClinicRepository $clinicRepo,
private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo,
) {}
/**
* در حالت نوبت‌دهی سرویسی، پزشک باید حداقل یک سرویسِ «نمایش در نوبت‌دهی»
* (bookable) داشته باشد؛ وگرنه هیچ نوبتی قابل‌محاسبه نیست.
*/
private function serviceModeHasNoBookable(array $meta, \App\Doctor\Entity\Doctor $doctor): bool
{
return ($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) === WeeklySchedule::MODE_SERVICE
&& $this->itemRepo->countBookableByEntity('doctor', $doctor->getId()) === 0;
}
// ── Weekly Schedule ───────────────────────────────────────────────────────
#[Route('/api/v1/appointment-settings/weekly-schedule', methods: ['POST'])]
@@ -69,6 +80,10 @@ class AppointmentSettingsController extends BaseController
$schedule->setMeta($data['meta']);
}
if ($this->serviceModeHasNoBookable($schedule->getMeta(), $doctor)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است', 422, 'booking_mode');
}
$this->scheduleRepo->save($schedule);
return $this->success(['data' => $schedule->toArray()], 201);
@@ -103,6 +118,10 @@ class AppointmentSettingsController extends BaseController
$schedule->setMeta($data['meta']);
}
if ($this->serviceModeHasNoBookable($schedule->getMeta(), $schedule->getDoctor())) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است', 422, 'booking_mode');
}
$this->scheduleRepo->save($schedule);
return $this->success(['data' => $schedule->toArray()]);
@@ -69,6 +69,29 @@ class MyAppointmentsController extends BaseController
$slotEnd = $slotStart;
}
// حالت نوبت‌دهی سرویسی: مدت نوبت از مجموعِ مدت سرویس‌های انتخاب‌شده تعیین
// و slot_end سمت سرور محاسبه می‌شود (به مقدار کلاینت اعتماد نمی‌شود).
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
$serviceItems = [];
if (!empty($serviceUuids) && !$isReserve) {
$totalMinutes = 0;
foreach ($serviceUuids as $u) {
$item = $this->itemRepo->findByUuid($u);
if ($item === null) {
return $this->error(ErrorCodes::VALIDATION, 'سرویس یافت نشد', 422, 'service_item_uuids');
}
if (!$item->isBookable()) {
return $this->error(ErrorCodes::VALIDATION, 'این سرویس برای نوبت‌دهی فعال نیست', 422, 'service_item_uuids');
}
if (($item->getDurationMinutes() ?? 0) <= 0) {
return $this->error(ErrorCodes::VALIDATION, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
}
$totalMinutes += (int) $item->getDurationMinutes();
$serviceItems[] = $item;
}
$slotEnd = $slotStart + $totalMinutes * 60;
}
if (empty($doctorUuid) || $slotStart <= 0 || (!$isReserve && $slotEnd <= $slotStart) || empty($mobile) || empty($patientName)) {
return $this->error(ErrorCodes::VALIDATION, 'همه فیلدها الزامی است', 422);
}
@@ -117,6 +140,10 @@ class MyAppointmentsController extends BaseController
}
$appointment->$setter($entity);
}
// در حالت سرویسی، سرویسِ اصلیِ نوبت = اولین سرویسِ انتخاب‌شده.
if (!empty($serviceItems)) {
$appointment->setServiceItem($serviceItems[0]);
}
if (!empty($data['deposit_required'])) {
$appointment->setDepositRequired(true);
}
+10
View File
@@ -15,10 +15,16 @@ class WeeklySchedule
public const DAYS = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday'];
public const META_KEY = 'meta';
public const MODE_SLOT = 'slot'; // نوبت‌دهی اسلاتی (رفتار پیش‌فرض)
public const MODE_SERVICE = 'service'; // نوبت‌دهی بر اساس مدت سرویس
public const DEFAULT_META = [
'online_booking_enabled' => true,
'booking_window_value' => 1,
'booking_window_unit' => 'month',
'booking_mode' => self::MODE_SLOT,
'buffer_minutes' => 0,
];
#[ORM\Id]
@@ -82,6 +88,10 @@ class WeeklySchedule
'booking_window_unit' => in_array($meta['booking_window_unit'] ?? null, ['week', 'month'], true)
? $meta['booking_window_unit']
: $current['booking_window_unit'],
'booking_mode' => in_array($meta['booking_mode'] ?? null, [self::MODE_SLOT, self::MODE_SERVICE], true)
? $meta['booking_mode']
: $current['booking_mode'],
'buffer_minutes' => max(0, (int)($meta['buffer_minutes'] ?? $current['buffer_minutes'])),
];
$this->updatedAt = time();
return $this;
@@ -7,6 +7,7 @@ use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\OptimisticLockException;
use Doctrine\Persistence\ManagerRegistry;
@@ -37,6 +38,13 @@ class AppointmentRepository extends ServiceEntityRepository
$start = $appointment->getSlotStart();
$end = $appointment->getSlotEnd();
// قفلِ per-doctor (SELECT ... FOR UPDATE روی ردیف پزشک): رزروهای
// هم‌زمانِ یک پزشک را سریالایز می‌کند. در حالت نوبت‌دهی سرویسی که
// نوبت‌ها طول متغیر و شروعِ متفاوت دارند، unique-keyِ (doctor,slot_start)
// تداخلِ بازه‌ایِ دو رزروِ هم‌زمان را نمی‌گیرد؛ این قفل تضمین می‌کند
// بررسیِ isSlotTaken و insert به‌صورت اتمیک نسبت به سایر رزروها انجام شود.
$em->lock($doctor, LockMode::PESSIMISTIC_WRITE);
if ($this->isSlotTaken($doctor, $start, $end)) {
throw new SlotTakenException();
}
@@ -87,6 +95,37 @@ class AppointmentRepository extends ServiceEntityRepository
$this->getEntityManager()->flush();
}
/**
* بازه‌های اشغال‌شدهٔ یک پزشک در پنجرهٔ [$from, $to) — برای محاسبهٔ زمانِ خالی
* در حالت نوبت‌دهی سرویسی. همان معیارِ isSlotTaken (blocking یا pendingِ زنده)،
* ولی نوبت‌های «آزاد» (is_reserve) هیچ بازه‌ای اشغال نمی‌کنند.
*
* @return array<array{start:int,end:int}> مرتب‌شده بر اساس start
*/
public function findBusyIntervals(Doctor $doctor, int $from, int $to): array
{
$rows = $this->createQueryBuilder('a')
->select('a.slotStart AS start, a.slotEnd AS end')
->where('a.doctor = :doctor')
->andWhere('a.isReserve = false')
->andWhere('a.slotStart < :to')
->andWhere('a.slotEnd > :from')
->andWhere(
'a.status IN (:blocking) OR (a.status = :pending AND (a.expiresAt IS NULL OR a.expiresAt > :now))'
)
->setParameter('doctor', $doctor)
->setParameter('blocking', Appointment::SLOT_BLOCKING_STATUSES)
->setParameter('pending', Appointment::STATUS_PENDING)
->setParameter('now', time())
->setParameter('from', $from)
->setParameter('to', $to)
->orderBy('a.slotStart', 'ASC')
->getQuery()
->getScalarResult();
return array_map(fn($r) => ['start' => (int) $r['start'], 'end' => (int) $r['end']], $rows);
}
/** Check if a slot is already taken (confirmed or pending) */
public function isSlotTaken(Doctor $doctor, int $slotStart, int $slotEnd, ?int $excludeId = null): bool
{
@@ -80,6 +80,74 @@ class SlotCalculatorService
return !empty($this->buildAllSessions($doctor, $date));
}
/**
* حالت نوبت‌دهی سرویسی: زمان‌های شروعِ ممکن برای نوبتی به طول $durationMinutes
* در یک روز. برخلاف اسلاتِ ثابت، فضای خالی داخل هر session را با توجه به مدت
* سرویس (+ بافر) پُر می‌کند: از ابتدای window شروع، بازه‌های اشغال‌شده را رد
* می‌کند و اولین جای پیوستهٔ کافی را برمی‌گرداند، سپس نوبت‌های بعدی را پشت‌سرهم
* (با فاصلهٔ بافر) می‌چیند.
*
* زمان پایانِ ذخیره‌شدهٔ نوبت = start + duration (بدون بافر)؛ بافر فقط فاصلهٔ
* بین دو نوبت است، پس candidate بعدی از start + duration + buffer شروع می‌شود.
*
* @return array<array{start:int,end:int,start_time:string,end_time:string,location_id:?int}>
*/
public function getServiceStartTimes(Doctor $doctor, string $date, int $durationMinutes): array
{
if ($durationMinutes <= 0) return [];
$buffer = (int)($this->getBookingMeta($doctor)['buffer_minutes'] ?? 0);
$durSec = $durationMinutes * 60;
$needSec = $durSec + $buffer * 60; // فضای لازم شامل بافر
$sessions = $this->buildAllSessions($doctor, $date); // window/holiday/override/booking-window رعایت می‌شود
if (empty($sessions)) return [];
$dayStart = (int) strtotime($date . ' 00:00:00');
$busy = $this->appointmentRepo->findBusyIntervals($doctor, $dayStart, $dayStart + 86400);
$now = time();
$result = [];
foreach ($sessions as $session) {
$winStart = $dayStart + $this->parseTime($session['start_time'] ?? '00:00');
$winEnd = $dayStart + $this->parseTime($session['end_time'] ?? '00:00');
$locationId = $session['slots'][0]['location_id'] ?? null;
$t = max($winStart, $now);
while ($t + $durSec <= $winEnd) {
$end = $t + $durSec;
$conflict = $this->firstOverlap($t, $t + $needSec, $busy);
if ($conflict !== null) {
$t = $conflict; // به انتهای بازهٔ اشغال‌شدهٔ متداخل بپر
continue;
}
$result[] = [
'start' => $t,
'end' => $end,
'start_time' => date('H:i', $t),
'end_time' => date('H:i', $end),
'location_id' => $locationId !== null ? (int) $locationId : null,
];
$t += $needSec; // نوبت بعدی پس از این نوبت + بافر
}
}
return $result;
}
/**
* انتهای اولین بازهٔ اشغال‌شده‌ای که با [$start, $end) تداخل دارد، یا null.
* @param array<array{start:int,end:int}> $busy
*/
private function firstOverlap(int $start, int $end, array $busy): ?int
{
foreach ($busy as $b) {
if ($b['start'] < $end && $b['end'] > $start) {
return $b['end'];
}
}
return null;
}
/**
* Booking is allowed only when online booking is enabled and the date is
* today..(today + window). Past dates are always rejected.
@@ -176,6 +176,9 @@ class ClinicServiceController extends BaseController
$dm = $data['duration_minutes'];
$item->setDurationMinutes(($dm === null || $dm === '') ? null : (int) $dm);
}
if (array_key_exists('bookable', $data)) {
$item->setBookable((bool) $data['bookable']);
}
$this->itemRepo->save($item);
@@ -217,6 +220,9 @@ class ClinicServiceController extends BaseController
$dm = $data['duration_minutes'];
$item->setDurationMinutes(($dm === null || $dm === '') ? null : (int) $dm);
}
if (array_key_exists('bookable', $data)) {
$item->setBookable((bool) $data['bookable']);
}
$this->itemRepo->save($item);
+7
View File
@@ -58,6 +58,10 @@ class ServiceItem
#[ORM\Column(name: 'duration_minutes', type: 'integer', nullable: true)]
private ?int $durationMinutes = null;
/** نمایش این سرویس در نوبت‌دهی (پزشک ممکن است همهٔ سرویس‌ها را ارائه ندهد). */
#[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $bookable = false;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
@@ -85,6 +89,7 @@ class ServiceItem
public function isInsuranceCovered(): bool { return $this->insuranceCovered; }
public function getInsurancePriceRials(): ?int { return $this->insurancePriceRials; }
public function getDurationMinutes(): ?int { return $this->durationMinutes; }
public function isBookable(): bool { return $this->bookable; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
@@ -122,6 +127,7 @@ class ServiceItem
public function setInsuranceCovered(bool $v): self { $this->insuranceCovered = $v; $this->updatedAt = time(); return $this; }
public function setInsurancePriceRials(?int $v): self { $this->insurancePriceRials = $v; $this->updatedAt = time(); return $this; }
public function setDurationMinutes(?int $v): self { $this->durationMinutes = $v; $this->updatedAt = time(); return $this; }
public function setBookable(bool $v): self { $this->bookable = $v; $this->updatedAt = time(); return $this; }
public function toArray(): array
{
@@ -151,6 +157,7 @@ class ServiceItem
'insurance_covered' => $this->insuranceCovered,
'insurance_price_rials' => $this->insurancePriceRials,
'duration_minutes' => $this->durationMinutes,
'bookable' => $this->bookable,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
@@ -57,6 +57,25 @@ class ServiceItemRepository extends ServiceEntityRepository
return $counts;
}
/**
* تعداد سرویس‌های فعالِ «نمایش در نوبت‌دهی» (bookable) متعلق به یک entity
* (پزشک/کلینیک) — از طریق section.entityType/entityId. برای اجبارِ حالت سرویس.
*/
public function countBookableByEntity(string $entityType, int $entityId): int
{
return (int) $this->createQueryBuilder('i')
->select('COUNT(i.id)')
->join('i.section', 's')
->where('s.entityType = :type')
->andWhere('s.entityId = :id')
->andWhere('i.bookable = true')
->andWhere('i.active = true')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->getQuery()
->getSingleScalarResult();
}
public function save(ServiceItem $item): void
{
$this->getEntityManager()->persist($item);