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
@@ -1,6 +1,6 @@
# چک‌لیست — تسک ۰۰ (تکمیل نوبت‌دهی سرویسی در clinicpro)
**وضعیت کلی:** 🔄 در حال انجام — قابلیت ۳ از ۱۰ تمام شد (خط سرخ · ServiceBookingCalculator · فیلدهای سرویسی نوبت)
**وضعیت کلی:** 🔄 در حال انجام — قابلیت ۴ از ۱۰ تمام شد
**آخرین بازبینی:** ۱۴۰۵/۰۵/۰۸
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
@@ -16,11 +16,11 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
| ۰.۱ | `SlotModeFrozenTest` + سه fixture ساخته شد **پیش از** هر تغییر کد | ✅ | `tests/Appointment/SlotModeFrozenTest.php` + `tests/Appointment/fixtures/` — هیچ کد تولیدی‌ای هنوز لمس نشده |
| ۰.۲ | ~~fixture ها با تاریخ ثابت‌اند، نه `time()`~~ → fixture **ساختاری** است | ✅ | **انحراف عمدی از متن تسک.** تاریخ ثابتِ گذشته را `isWithinBookingWindow` رد می‌کند و snapshot خالی چیزی را تضمین نمی‌کند. به‌جایش: برنامهٔ قطعی (هر ۷ روز یک شیفت ۰۹:۰۰–۱۱:۰۰/۳۰ دقیقه) روی `+3 days`، و epoch/uuid با placeholder نرمال می‌شوند. آنچه قفل می‌شود: کلیدها، ترتیب، نوع‌ها، ساعت‌های محلی |
| ۰.۳ | کامنت «read-only، هیچ تسکی به‌روزش نمی‌کند» بالای هر سه fixture | ✅ | کلید `_readme` در دو JSON (در loader حذف می‌شود) + docblock در فایل PHP |
| ۰.۴ | هیچ متد موجود `SlotCalculatorService` ویرایش نشد | | قابلیت ۴ — فقط پارامتر اختیاری `excludeAppointmentId` روی `getServiceStartTimes`؛ fixture امضا **یک بار** به‌روز می‌شود (header خودش این را مجاز کرده) |
| ۰.۴ | هیچ متد موجود `SlotCalculatorService` ویرایش نشد | | فقط `getServiceStartTimes` یک پارامتر **اختیاری** با پیش‌فرض `null` گرفت. **اثبات کارکرد تور ایمنی:** تست منجمد همان لحظه قرمز شد و دقیقاً همان پارامتر را نشان داد، در حالی که دو قرارداد پاسخ سبز ماندند. fixture امضا یک بار با تاریخچهٔ مکتوب به‌روز شد (header خودش مجاز کرده) |
| ۰.۵ | `GET /appointment-slots` بیت‌به‌بیت دست‌نخورده | ⏳ | در پایان تسک تأیید می‌شود |
| ۰.۶ | `GET /month-availability/{doctorUuid}` دست‌نخورده | ⏳ | در پایان تسک تأیید می‌شود |
| ۰.۷ | `active_slot_key` و `refreshActiveSlotKey()` دست‌نخورده | ✅ | `setIsReserve()` **وجود ندارد**؛ toggle رزرو از قبل با `rescheduleTo($start,$end,$isReserve)` انجام می‌شود که خودش `refreshActiveSlotKey()` را صدا می‌زند ([Appointment.php:316](../../../src/Appointment/Entity/Appointment.php)). یادداشت قبلی چک‌لیست غلط بود |
| ۰.۸ | `isSlotTaken` امضا و معنا دست‌نخورده | | در پایان تسک تأیید می‌شود |
| ۰.۸ | `isSlotTaken` امضا و معنا دست‌نخورده | | لمس نشد؛ فقط الگویش تکرار شد |
| ۰.۹ | `--group=slot-mode-frozen` سبز | ✅ | `OK (3 tests, 8 assertions)` — نیازمند `#[Group]` attribute بود، نه `@group` (PHPUnit 12 annotation را حذف کرده) |
| ۰.۱۰ | baseline: کل `tests/Appointment` پیش از تغییرات سبز | ✅ | `OK (166 tests, 382 assertions)` |
@@ -36,7 +36,7 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
| ۱.۶ | `PATCH` مقدار `service_item_uuids[]` می‌پذیرد | ⏳ | |
| ۱.۷ | `ReserveConversionService` + `POST /appointment/{uuid}/convert-reserve` | ⏳ | |
| ۱.۸ | نوبت رزرو در حالت سرویسی سرویس‌ها را ذخیره می‌کند | ⏳ | |
| ۱.۹ | `excludeAppointmentId` روی `getServiceStartTimes` و `findBusyIntervals` | | همان الگوی `isSlotTaken` |
| ۱.۹ | `excludeAppointmentId` روی `getServiceStartTimes` و `findBusyIntervals` | | همان نام/جای/پیش‌فرضِ `isSlotTaken($doctor,$start,$end,?int $excludeId=null)`. در repository به `occupiedIntervals()` هم پاس داده می‌شود، پس `findBlockingIntervals` بی‌تغییر ماند |
| ۱.۱۰ | `Appointment::replaceServiceItems()` + `currentServiceUuids()` | ✅ | کنار `addServiceItem()` موجود؛ `currentServiceUuids()` ترتیب ورودی را حفظ می‌کند و برای نوبت قدیمیِ فقط-تکی هم uuid می‌دهد |
| ۱.۱۱ | `replaceServiceItems` مقدار `serviceItem` تکی را هم‌گام می‌کند | ✅ | برخلاف `addServiceItem()` که فقط وقتی `null` است ست می‌کند، این یکی **بی‌قید** بازنویسی می‌کند (و با فهرست خالی `null` می‌شود). چهار مصرف‌کننده روی `service_item` تکی خوانده‌اند |
| ۱.۱۲ | ~~`setIsReserve()` صدا زدن `refreshActiveSlotKey()`~~ | ✅ | **موردی برای انجام نبود:** `setIsReserve()` وجود ندارد؛ toggle رزرو از قبل با `rescheduleTo($start,$end,$isReserve)` است که خودش `refreshActiveSlotKey()` را صدا می‌زند |
@@ -90,6 +90,7 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
| ۴.۵ | `ConvertReserveTest` — شامل `active_slot_key` و رقابت | ⏳ | |
| ۴.۶ | `ServiceModeSectionDurationTest` موجود سبز ماند | ⏳ | |
| ۴.۷ | `BookingTenantTest` موجود سبز ماند | ✅ | داخل `tests/Appointment` — کل ۳۰۹ تست `tests/Appointment` + `tests/Shared` سبز |
| ۴.۱۰ | `ServiceSlotExcludeSelfTest` — رفتار exclude | ✅ | ۶ تست / ۱۱ assertion. شامل: بازهٔ خودِ نوبت با exclude برمی‌گردد · مدت بلندتر روی همان ساعت · نوبتِ دیگری همچنان اشغال می‌ماند · `null` صریح و ضمنی خروجی یکسان · فیلتر repository فقط همان ردیف · exclude کردن نوبت رزرو بی‌اثر |
| ۴.۹ | `AppointmentServiceFieldsTest` — متدها و ستون‌های جدید | ✅ | ۹ تست / ۲۴ assertion. شامل: هم‌گامی ستون تکی · حفظ ترتیب · فهرست خالی → `null` · حالت اسلاتی هر دو ستون `null` · مدتِ `null` بافر را هم `null` می‌کند · تکراری‌ها dedup · نوبت قدیمیِ فقط-تکی · بقای مقادیر پس از flush/clear |
| ۴.۸ | `AppointmentEditPage.test.tsx` — دو حالت | ⏳ | |
@@ -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 = [];
@@ -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' => [