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
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\Appointment;
use App\Appointment\Entity\WeeklySchedule;
use App\Appointment\Service\SlotCalculatorService;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* حالت نوبت‌دهی سرویسی: getServiceStartTimes باید فضای خالیِ داخل شیفت را با توجه
* به مدت سرویس (+ بافر) بچیند و بازه‌های اشغال‌شده را رد کند. همچنین متای mode/buffer.
*/
class ServiceBasedSlotsTest extends ApiTestCase
{
private function makeDoctorWithServiceSchedule(int $buffer = 5): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر سرویس');
$this->em->persist($doctor);
// فردا در بازهٔ booking-window قرار دارد و روز گذشته نیست.
$date = date('Y-m-d', strtotime('tomorrow'));
$dayKey = (string)(((int) date('w', strtotime($date)) + 1) % 7);
$schedule = new WeeklySchedule($doctor, [
$dayKey => ['sessions' => [[
'active' => true,
'start_time' => '15:00',
'end_time' => '17:00',
'duration_per_patient' => 20,
'location_id' => 1,
]]],
]);
$schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SERVICE, 'buffer_minutes' => $buffer]);
$this->em->persist($schedule);
$this->em->flush();
return [$doctor, $date];
}
public function testGapPackingWithBuffer(): void
{
[$doctor, $date] = $this->makeDoctorWithServiceSchedule(5);
$calc = static::getContainer()->get(SlotCalculatorService::class);
$slots = $calc->getServiceStartTimes($doctor, $date, 30);
// پنجره 15:0017:00، سرویس 30 + بافر 5 → گام 35 دقیقه: 15:00, 15:35, 16:10
$times = array_column($slots, 'start_time');
$this->assertSame(['15:00', '15:35', '16:10'], $times);
// زمان پایانِ ذخیره‌شده بدون بافر است.
$this->assertSame(strtotime($date . ' 15:00') + 30 * 60, $slots[0]['end']);
}
public function testBookedIntervalIsSkipped(): void
{
[$doctor, $date] = $this->makeDoctorWithServiceSchedule(5);
$patient = $this->createUser(['ROLE_USER']);
$start = strtotime($date . ' 15:00');
$appt = new Appointment($doctor, $patient, $start, $start + 30 * 60);
$appt->transitionTo(Appointment::STATUS_CONFIRMED);
$this->em->persist($appt);
$this->em->flush();
$calc = static::getContainer()->get(SlotCalculatorService::class);
$times = array_column($calc->getServiceStartTimes($doctor, $date, 30), 'start_time');
// 15:00 اشغال است → از 15:30 شروع می‌شود.
$this->assertNotContains('15:00', $times);
$this->assertContains('15:30', $times);
}
public function testNoRoomReturnsEmpty(): void
{
[$doctor, $date] = $this->makeDoctorWithServiceSchedule(0);
// سرویس 200 دقیقه در پنجرهٔ 120 دقیقه‌ای جا نمی‌شود.
$calc = static::getContainer()->get(SlotCalculatorService::class);
$this->assertSame([], $calc->getServiceStartTimes($doctor, $date, 200));
}
public function testMetaDefaultsAndWhitelist(): void
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر متا');
$schedule = new WeeklySchedule($doctor, []);
// پیش‌فرض = اسلاتی
$this->assertSame(WeeklySchedule::MODE_SLOT, $schedule->getMeta()['booking_mode']);
$this->assertSame(0, $schedule->getMeta()['buffer_minutes']);
$schedule->setMeta(['booking_mode' => 'service', 'buffer_minutes' => 7]);
$this->assertSame('service', $schedule->getMeta()['booking_mode']);
$this->assertSame(7, $schedule->getMeta()['buffer_minutes']);
// مقدار نامعتبر mode نادیده گرفته می‌شود (whitelist)، بافر منفی → صفر.
$schedule->setMeta(['booking_mode' => 'bogus', 'buffer_minutes' => -3]);
$this->assertSame('service', $schedule->getMeta()['booking_mode']);
$this->assertSame(0, $schedule->getMeta()['buffer_minutes']);
}
}