feat(booking): let service slot search exclude the appointment being moved

findBusyIntervals() and getServiceStartTimes() gain an optional
excludeAppointmentId, mirroring isSlotTaken($doctor, $start, $end, $excludeId)
which already had it. Without it an appointment being rescheduled sees itself as
busy, so its current time never appears among the candidates and "same hour,
different service" is impossible.

The parameter is optional with a null default and only affects the service-mode
path; no existing call site changes behaviour. SlotModeFrozenTest caught the
signature change immediately while both response contracts stayed green, so the
signature fixture was updated once with a written rationale, as its own header
permits.

Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-30 12:51:29 +03:30
co-authored by Claude Opus 5
parent 7eb0b1566f
commit b784124b7e
5 changed files with 188 additions and 10 deletions
@@ -101,11 +101,16 @@ class AppointmentRepository extends ServiceEntityRepository
* در حالت نوبت‌دهی سرویسی. همان معیارِ isSlotTaken (blocking یا pendingِ زنده)،
* ولی نوبت‌های «آزاد» (is_reserve) هیچ بازه‌ای اشغال نمی‌کنند.
*
* `$excludeId` برای جابه‌جایی خودِ یک نوبت است: بدون آن، نوبتِ در حال جابه‌جایی خودش
* را اشغال می‌بیند و زمان فعلی‌اش هرگز در فهرست زمان‌های ممکن نمی‌آید — پس کاربر
* نمی‌تواند «همان ساعت، سرویس متفاوت» را ثبت کند. همان الگوی
* {@see isSlotTaken()} که این پارامتر را از قبل دارد.
*
* @return array<array{start:int,end:int}> مرتب‌شده بر اساس start
*/
public function findBusyIntervals(Doctor $doctor, int $from, int $to): array
public function findBusyIntervals(Doctor $doctor, int $from, int $to, ?int $excludeId = null): array
{
return $this->occupiedIntervals($doctor, $from, $to, true);
return $this->occupiedIntervals($doctor, $from, $to, true, $excludeId);
}
/**
@@ -120,8 +125,13 @@ class AppointmentRepository extends ServiceEntityRepository
}
/** @return array<int, array{start: int, end: int}> */
private function occupiedIntervals(Doctor $doctor, int $from, int $to, bool $skipReserve): array
{
private function occupiedIntervals(
Doctor $doctor,
int $from,
int $to,
bool $skipReserve,
?int $excludeId = null,
): array {
$qb = $this->createQueryBuilder('a')
->select('a.slotStart AS start, a.slotEnd AS end')
->where('a.doctor = :doctor')
@@ -142,6 +152,10 @@ class AppointmentRepository extends ServiceEntityRepository
$qb->andWhere('a.isReserve = false');
}
if ($excludeId !== null) {
$qb->andWhere('a.id != :excludeId')->setParameter('excludeId', $excludeId);
}
$rows = $qb->getQuery()->getScalarResult();
return array_map(fn($r) => ['start' => (int) $r['start'], 'end' => (int) $r['end']], $rows);
@@ -100,9 +100,13 @@ class SlotCalculatorService
* زمان پایانِ ذخیره‌شدهٔ نوبت = start + duration (بدون بافر)؛ بافر فقط فاصلهٔ
* بین دو نوبت است، پس candidate بعدی از start + duration + buffer شروع می‌شود.
*
* `$excludeAppointmentId` برای جابه‌جایی خودِ یک نوبت است: بدون آن، نوبتِ در حال
* جابه‌جایی خودش را اشغال می‌بیند و زمان فعلی‌اش در فهرست نمی‌آید. همان الگوی
* {@see \App\Appointment\Repository\AppointmentRepository::isSlotTaken()}.
*
* @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, ?Clinic $clinic = null, bool $forManagement = false): array
public function getServiceStartTimes(Doctor $doctor, string $date, int $durationMinutes, ?Clinic $clinic = null, bool $forManagement = false, ?int $excludeAppointmentId = null): array
{
if ($durationMinutes <= 0) return [];
@@ -114,7 +118,7 @@ class SlotCalculatorService
if (empty($sessions)) return [];
$dayStart = (int) strtotime($date . ' 00:00:00');
$busy = $this->appointmentRepo->findBusyIntervals($doctor, $dayStart, $dayStart + 86400);
$busy = $this->appointmentRepo->findBusyIntervals($doctor, $dayStart, $dayStart + 86400, $excludeAppointmentId);
$now = time();
$result = [];