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>
152 lines
6.5 KiB
PHP
152 lines
6.5 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Appointment;
|
|
|
|
use App\Appointment\Entity\Appointment;
|
|
use App\Appointment\Entity\WeeklySchedule;
|
|
use App\Appointment\Repository\AppointmentRepository;
|
|
use App\Appointment\Service\SlotCalculatorService;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Tests\ApiTestCase;
|
|
|
|
/**
|
|
* جابهجایی خودِ یک نوبت: بازهٔ همان نوبت نباید اشغال حساب شود، وگرنه زمان فعلیاش
|
|
* هرگز در فهرست زمانهای ممکن نمیآید و کاربر نمیتواند «همان ساعت، سرویس متفاوت» را
|
|
* ثبت کند.
|
|
*/
|
|
class ServiceSlotExcludeSelfTest extends ApiTestCase
|
|
{
|
|
/** @return array{0:Doctor,1:string,2:int} */
|
|
private function serviceDoctor(): array
|
|
{
|
|
$owner = $this->createUser(['ROLE_DOCTOR']);
|
|
$doctor = new Doctor($owner, 'دکتر جابهجایی');
|
|
$this->em->persist($doctor);
|
|
|
|
$date = date('Y-m-d', strtotime('+2 days'));
|
|
$dayKey = (string) (((int) date('w', strtotime($date)) + 1) % 7);
|
|
|
|
$schedule = $this->newWeeklySchedule($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' => 0]);
|
|
$this->em->persist($schedule);
|
|
$this->em->flush();
|
|
|
|
return [$doctor, $date, (int) strtotime($date . ' 15:00')];
|
|
}
|
|
|
|
private function confirmedAppointment(Doctor $doctor, int $start, int $minutes): Appointment
|
|
{
|
|
$patient = $this->createUser(['ROLE_USER']);
|
|
$appt = $this->newAppointment($doctor, $patient, $start, $start + $minutes * 60);
|
|
$appt->transitionTo(Appointment::STATUS_CONFIRMED);
|
|
$this->em->persist($appt);
|
|
$this->em->flush();
|
|
|
|
return $appt;
|
|
}
|
|
|
|
private function calc(): SlotCalculatorService
|
|
{
|
|
return static::getContainer()->get(SlotCalculatorService::class);
|
|
}
|
|
|
|
// ── ✅ موفق ──────────────────────────────────────────────────────────────
|
|
|
|
public function testOwnIntervalReappearsWhenExcluded(): void
|
|
{
|
|
[$doctor, $date, $start] = $this->serviceDoctor();
|
|
$appt = $this->confirmedAppointment($doctor, $start, 30);
|
|
|
|
$without = array_column($this->calc()->getServiceStartTimes($doctor, $date, 30), 'start_time');
|
|
self::assertNotContains('15:00', $without, 'بدون exclude، نوبت خودش را اشغال میبیند');
|
|
|
|
$with = array_column(
|
|
$this->calc()->getServiceStartTimes($doctor, $date, 30, null, false, $appt->getId()),
|
|
'start_time',
|
|
);
|
|
self::assertContains('15:00', $with, 'با exclude، زمان فعلیِ خود نوبت در فهرست میآید');
|
|
}
|
|
|
|
public function testExcludingAllowsALongerDurationAtTheSameStart(): void
|
|
{
|
|
[$doctor, $date, $start] = $this->serviceDoctor();
|
|
$appt = $this->confirmedAppointment($doctor, $start, 30);
|
|
|
|
// همان ساعت ۱۵:۰۰ ولی سرویس بلندتر (۶۰ دقیقه) — سناریوی «سرویس را عوض کردم».
|
|
$times = array_column(
|
|
$this->calc()->getServiceStartTimes($doctor, $date, 60, null, false, $appt->getId()),
|
|
'start_time',
|
|
);
|
|
|
|
self::assertContains('15:00', $times);
|
|
}
|
|
|
|
// ── ❌ خطا / مسیر نادرست ─────────────────────────────────────────────────
|
|
|
|
public function testOtherAppointmentsStayBlockingWhenOneIsExcluded(): void
|
|
{
|
|
[$doctor, $date, $start] = $this->serviceDoctor();
|
|
$mine = $this->confirmedAppointment($doctor, $start, 30);
|
|
$others = $this->confirmedAppointment($doctor, $start + 30 * 60, 30); // ۱۵:۳۰
|
|
|
|
$times = array_column(
|
|
$this->calc()->getServiceStartTimes($doctor, $date, 30, null, false, $mine->getId()),
|
|
'start_time',
|
|
);
|
|
|
|
self::assertContains('15:00', $times, 'نوبت خودم آزاد شد');
|
|
self::assertNotContains('15:30', $times, 'نوبت دیگری همچنان اشغال است');
|
|
self::assertNotNull($others->getId());
|
|
}
|
|
|
|
// ── ⚠️ مرزی ──────────────────────────────────────────────────────────────
|
|
|
|
public function testNullExcludeIdBehavesExactlyLikeBefore(): void
|
|
{
|
|
[$doctor, $date, $start] = $this->serviceDoctor();
|
|
$this->confirmedAppointment($doctor, $start, 30);
|
|
|
|
$implicit = $this->calc()->getServiceStartTimes($doctor, $date, 30);
|
|
$explicit = $this->calc()->getServiceStartTimes($doctor, $date, 30, null, false, null);
|
|
|
|
self::assertSame($implicit, $explicit, 'پیشفرض null نباید رفتار موجود را عوض کند');
|
|
}
|
|
|
|
public function testRepositoryExcludeIdFiltersOnlyThatRow(): void
|
|
{
|
|
[$doctor, , $start] = $this->serviceDoctor();
|
|
$appt = $this->confirmedAppointment($doctor, $start, 30);
|
|
|
|
$repo = static::getContainer()->get(AppointmentRepository::class);
|
|
$from = $start - 3600;
|
|
$to = $start + 7200;
|
|
|
|
self::assertNotEmpty($repo->findBusyIntervals($doctor, $from, $to));
|
|
self::assertSame([], $repo->findBusyIntervals($doctor, $from, $to, $appt->getId()));
|
|
}
|
|
|
|
public function testExcludingAReserveEntryChangesNothing(): void
|
|
{
|
|
[$doctor, $date, $start] = $this->serviceDoctor();
|
|
|
|
// نوبت رزرو هیچ بازهای اشغال نمیکند، پس exclude کردنش هم بیاثر است.
|
|
$patient = $this->createUser(['ROLE_USER']);
|
|
$dayMid = (int) strtotime($date . ' 00:00:00');
|
|
$reserve = $this->newAppointment($doctor, $patient, $dayMid, $dayMid);
|
|
$reserve->rescheduleTo($dayMid, $dayMid, true);
|
|
$this->em->persist($reserve);
|
|
$this->em->flush();
|
|
|
|
$before = $this->calc()->getServiceStartTimes($doctor, $date, 30);
|
|
$after = $this->calc()->getServiceStartTimes($doctor, $date, 30, null, false, $reserve->getId());
|
|
|
|
self::assertSame($before, $after);
|
|
self::assertSame($start, (int) strtotime($date . ' 15:00'));
|
|
}
|
|
}
|