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:
@@ -0,0 +1,151 @@
|
||||
<?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'));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,12 @@
|
||||
* افزودن پارامتر **اختیاری** جدید مجاز است و باید یک بار همینجا ثبت شود؛ تغییر نام،
|
||||
* تغییر نوع، تغییر ترتیب یا حذف پارامتر موجود ممنوع است.
|
||||
* رجوع: docs/new_feture/taskes/_shared/red-lines.md
|
||||
*
|
||||
* تاریخچهٔ بهروزرسانیهای مجاز:
|
||||
* - تسک ۰۰ / قابلیت ۴: `getServiceStartTimes(..., ?int $excludeAppointmentId = null)`.
|
||||
* پارامتر اختیاری با پیشفرض null و فقط روی مسیر **سرویسی**؛ هیچ فراخوانیِ موجودی
|
||||
* رفتارش عوض نشد. لازم بود چون بدون آن، نوبتِ در حال جابهجایی خودش را اشغال میبیند
|
||||
* و زمان فعلیاش در فهرست زمانهای ممکن نمیآید.
|
||||
*/
|
||||
return [
|
||||
'explainEmptyDay' => [
|
||||
@@ -52,6 +58,8 @@ return [
|
||||
'durationMinutes' => 'int',
|
||||
'clinic' => '?App\Clinic\Entity\Clinic = NULL',
|
||||
'forManagement' => 'bool = false',
|
||||
// تسک ۰۰ / قابلیت ۴ — تنها افزودهٔ مجاز؛ رجوع به docblock بالا
|
||||
'excludeAppointmentId' => '?int = NULL',
|
||||
],
|
||||
],
|
||||
'hasAnyAvailability' => [
|
||||
|
||||
Reference in New Issue
Block a user