# رفع باگ: نوبت‌های رزروشده در سایت عمومی «آزاد» نمایش داده می‌شوند

## پروژه

`clinicpro` (backend — منبع واحد محاسبه‌ی آزاد/رزرو).
یک بررسی ثانویه‌ی کوچک هم در `nobat724_front` لازم است (پارامتر تاریخِ درخواست) — در وظیفه‌ی ۳ توضیح داده شده.

## زمینه

سایت عمومی نوبت‌دهی روی صفحه‌ی `/appointment/<doctor-uuid>` تایم‌اسلات‌های یک پزشک را از
`GET /api/v1/appointment-slots?doctor_uuid=...&date=YYYY-MM-DD` می‌گیرد. بک‌اند برای هر اسلات یک فلگ
`is_available` برمی‌گرداند و فرانت فقط همان فلگ را رعایت می‌کند (`components`/`List.js`: اسلات فقط وقتی قابل
انتخاب است که `is_available === true`). پس **درست/غلط بودنِ آزاد نمایش‌دادن کاملاً به همین فلگِ بک‌اند وابسته است.**

## مشکل / هدف

برای پزشک `ab747d75-2114-42b8-9e6d-abdaa338edbe` در تاریخ `1405-04-27` (میلادی: **2026-07-18**) همه‌ی اسلات‌ها
«آزاد» نمایش داده می‌شوند، در حالی که برخی نوبت‌ها قبلاً ثبت/رزرو شده‌اند.

ریشه‌ی محتمل (اثبات‌شده در کد): **رزروهای روز-محور (`is_reserve`) با `slot_end == slot_start` ذخیره می‌شوند
(بازه‌ی صفر)**، و منطق «اشغال‌بودن» بر پایه‌ی هم‌پوشانی بازه است؛ رکوردِ بازه‌صفر عملاً هیچ اسلاتی را اشغال
نمی‌کند → همه آزاد. هدف: این ناسازگاری برطرف شود و رزروها/نوبت‌ها به‌درستی اسلات‌ها را ببندند.

## فایل‌های مرتبط

| فایل | نقش |
|------|-----|
| `src/Appointment/Controller/AppointmentController.php` (متد `slots()`، خط ۱۱۵–۱۳۷) | endpoint `GET /api/v1/appointment-slots` |
| `src/Appointment/Service/SlotCalculatorService.php` (`getAllSlotsWithAvailability`، خط ۶۰–۷۲) | تولید اسلات‌ها + تگ `is_available` |
| `src/Appointment/Repository/AppointmentRepository.php` (`isSlotTaken`، خط ۹۱–۱۱۳) | بررسی اشغال‌بودن (هم‌پوشانی بازه) |
| `src/Appointment/Controller/MyAppointmentsController.php` (خط ۶۴–۷۲، ۹۸) | مسیر ثبتِ رزرو روز-محور که `slot_end = slot_start` می‌گذارد |
| `nobat724_front/app/component/date/dateTime/index.js` (خط ۳۲) | ساخت پارامتر `date` برای درخواست اسلات‌ها (بررسی ثانویه) |

## وضعیت فعلی

### ۱) بررسی اشغال‌بودن — هم‌پوشانی بازه (منبع باگ)

`AppointmentRepository::isSlotTaken()` برای هر اسلات یک بار صدا زده می‌شود:

```php
// AppointmentRepository.php:91
public function isSlotTaken(Doctor $doctor, int $slotStart, int $slotEnd, ?int $excludeId = null): bool
{
    $qb = $this->createQueryBuilder('a')
        ->select('COUNT(a.id)')
        ->where('a.doctor = :doctor')
        ->andWhere('a.slotStart < :slotEnd')   // هم‌پوشانی
        ->andWhere('a.slotEnd   > :slotStart') // هم‌پوشانی
        ->andWhere('a.status = :confirmed OR (a.status = :pending AND (a.expiresAt IS NULL OR a.expiresAt > :now))')
        ->setParameter('doctor', $doctor)
        ->setParameter('confirmed', Appointment::STATUS_CONFIRMED)
        ->setParameter('pending', Appointment::STATUS_PENDING)
        ->setParameter('now', time())
        ->setParameter('slotStart', $slotStart)
        ->setParameter('slotEnd', $slotEnd);
    // ...
    return (int) $qb->getQuery()->getSingleScalarResult() > 0;
}
```

### ۲) ثبت رزرو روز-محور با بازه‌ی صفر

```php
// MyAppointmentsController.php:64
$isReserve = (bool) ($data['is_reserve'] ?? false);

// Reserve entries are day-level: only a date is picked in the UI, so
// slot_end may equal slot_start and the past-slot rule does not apply.
if ($isReserve && $slotEnd < $slotStart) {
    $slotEnd = $slotStart;                       // ← بازه‌ی صفر
}
// ...
$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd); // slotStart == slotEnd
```

**چرا باگ:** برای رکوردِ `slotStart == slotEnd == R`، شرط `a.slotStart < :slotEnd AND a.slotEnd > :slotStart`
فقط وقتی برقرار است که `R` اکیداً داخل بازه‌ی اسلات کاندید `(S, E)` باشد. اگر `R` برابر ابتدای روز (۰۰:۰۰) یا
هر لحظه‌ای بیرونِ اسلات‌ها باشد، هیچ اسلاتی اشغال نمی‌شود → همه آزاد. رزروِ «کل روز» عملاً هیچ‌چیز را نمی‌بندد.

### ۳) پارامتر تاریخ در فرانت (بررسی ثانویه)

```js
// nobat724_front/app/component/date/dateTime/index.js:32
const dateStr = moment.unix(date).format("YYYY-MM-DD");
```

`date` یک timestamp است که با `dateToTimestamp` روی `Asia/Tehran` (`startOf("day")`) ساخته شده، ولی
`moment.unix(date)` در منطقه‌زمانیِ سیستم/مرورگر فرمت می‌شود. اگر TZ اجرا Tehran نباشد، ممکن است `date=`
یک روز جابه‌جا شود و روزِ اشتباه کوئری گردد.

## وظایف

### ۱. تشخیص قطعی با داده‌ی واقعی (اول این)

داخل ddev، رکوردهای واقعیِ همان پزشک و روز را ببین (بازه‌ی timestamp تهرانِ 2026-07-18):

```bash
ddev exec php bin/console dbal:run-sql "
  SELECT a.id, a.status, a.slot_start, a.slot_end,
         FROM_UNIXTIME(a.slot_start) AS s, FROM_UNIXTIME(a.slot_end) AS e, a.expires_at
  FROM appointments a
  JOIN doctors d ON d.id = a.doctor_id
  WHERE d.uuid = 'ab747d75-2114-42b8-9e6d-abdaa338edbe'
    AND a.slot_start >= UNIX_TIMESTAMP('2026-07-18 00:00:00')
    AND a.slot_start <  UNIX_TIMESTAMP('2026-07-19 00:00:00')
  ORDER BY a.slot_start
"
```

با خروجی مشخص کن کدام حالت است و بر همان اساس ادامه بده:
- **`slot_end == slot_start`** روی رکوردها → باگِ بازه‌صفرِ رزرو (وظیفه‌ی ۲). محتمل‌ترین.
- `slot_end > slot_start` ولی `is_available` باز هم `true` → مشکل تاریخ/منطقه‌زمانی (وظیفه‌ی ۳) یا `status`.
- `status` مقداری غیر از `confirmed`/`pending`ِ معتبر → رکوردها عمداً شمرده نمی‌شوند؛ منطق `isSlotTaken` را بازبینی کن.

### ۲. رفع باگِ رزروِ بازه‌صفر (فیکس اصلی)

دو راه؛ **راه A ترجیح داده می‌شود** چون داده را در همان لحظه‌ی ثبت درست می‌کند و به منطقِ کوئری دست نمی‌زند:

**راه A — رزروِ روز-محور را به بازه‌ی کل روز تبدیل کن** در `MyAppointmentsController.php` (خط ۶۶–۷۰):

```php
if ($isReserve && $slotEnd < $slotStart) {
    // رزرو روز-محور: کل روز را ببند تا با منطق هم‌پوشانی، همه‌ی اسلات‌های آن روز اشغال شوند.
    $dayStart  = strtotime(date('Y-m-d', $slotStart) . ' 00:00:00');
    $slotStart = $dayStart;
    $slotEnd   = $dayStart + 86400;
}
```

- توجه: پس از این تغییر `resolveSlotLocationId($doctor, $slotStart)` (خط ۱۰۱) با `slot_start = 00:00`
  دیگر اسلاتی پیدا نمی‌کند و `null` می‌دهد؛ همین رفتار قابل‌قبول است (رزرو روزانه آدرس اسلات ندارد) ولی مطمئن شو
  خطایی تولید نمی‌شود.
- اگر رزروهای قدیمیِ بازه‌صفر در دیتابیس هست، یک migration/Command یک‌باره برای گسترش آن‌ها به بازه‌ی روز بنویس
  وگرنه رکوردهای موجود همچنان اسلات‌ها را نمی‌بندند.

**راه B — به‌جای تغییر داده، منطقِ اشغال را برای بازه‌ی روز-محور اصلاح کن** (اگر نمی‌خواهی معنای داده عوض شود):
در `isSlotTaken` رکوردهای `slotEnd <= slotStart` را به‌عنوان «قفلِ کل روزِ `slotStart`» در نظر بگیر (شرط اضافه: هم‌پوشانی
عادی **یا** رکوردِ بازه‌صفری که در همان روزِ اسلات کاندید است). این راه پیچیده‌تر و مستعد خطا است؛ فقط اگر راه A ممکن نبود.

### ۳. بررسی ثانویه‌ی پارامتر تاریخ در فرانت

در `nobat724_front/app/component/date/dateTime/index.js` خط ۳۲، فرمت تاریخ را صریحاً روی تهران کن تا با
`dateToTimestamp` (که تهران است) هم‌تراز شود و off-by-one رخ ندهد:

```js
import moment from "moment-jalaali"; // موجود است
const dateStr = moment.unix(date).tz("Asia/Tehran").format("YYYY-MM-DD");
```

اگر `moment-jalaali` متد `.tz` ندارد، از همان helperِ تهران (`moment-timezone`) که `dateToTimestamp` استفاده می‌کند
بهره بگیر. این فقط وقتی اثر دارد که TZ اجرا تهران نباشد؛ در ddev (TZ=Asia/Tehran) بی‌اثر است ولی درستی را تضمین می‌کند.

### ۴. تست

- **موفق:** یک رزرو روز-محور (`is_reserve=true`) برای پزشک تست ثبت کن؛ سپس `GET /api/v1/appointment-slots` همان روز
  باید همه‌ی اسلات‌ها را `is_available=false` بدهد.
- **موفق:** یک نوبت عادی (`slot_end > slot_start`) روی یک اسلات مشخص؛ فقط همان اسلات باید بسته شود، بقیه آزاد.
- **مرزی:** نوبت `pending` منقضی‌شده (`expires_at < now`) → اسلات باید دوباره آزاد شود.
- **خطا/رگرسیون:** روزِ بدون هیچ رزرو → همه‌ی اسلات‌های آینده آزاد بمانند (بازه‌ی روز اشتباهاً چیزی نبندد).
- تستِ واحد برای `AppointmentRepository::isSlotTaken` با رکوردِ بازه‌ی کل‌روز اضافه کن.
- اجرا: `ddev exec php bin/phpunit --filter Appointment`.

## نکات مهم

- تایم‌استمپ‌ها **int یونیکس** هستند؛ TZِ ddev = `Asia/Tehran` (تأییدشده در `.ddev/*compose*`). ثبت نوبت و
  تولید اسلات هر دو در تهران‌اند و هم‌تراز؛ پس ریشه، منطقِ بازه است نه منطقه‌زمانی.
- `isSlotTaken` فقط `STATUS_CONFIRMED` و `STATUS_PENDING`ِ منقضی‌نشده را می‌شمارد؛ رفتار درست است — دست نزن مگر
  در وظیفه‌ی ۱ خلافش ثابت شود.
- مسیر عمومی `book()` در `AppointmentController` بازه‌ی درست (`slot_end > slot_start`) دارد و اسلات را درست می‌بندد؛
  باگ فقط در مسیر رزروِ `MyAppointmentsController` است.
- هیچ فیلترِ `clinic` در `isSlotTaken` نیست؛ اگر لازم شد جدا بررسی کن، ولی خارج از دامنه‌ی این باگ است.
- اگر route/response خروجی endpoint تغییر کرد، `docs/api/appointment.md` را همان session به‌روزرسانی کن (قاعده‌ی مستندات).
- بعد از تغییر کد، `graphify update .` را اجرا کن (پس از commit).
