|
|
|
@@ -0,0 +1,173 @@
|
|
|
|
|
# رفع باگ: نوبتهای رزروشده در سایت عمومی «آزاد» نمایش داده میشوند
|
|
|
|
|
|
|
|
|
|
## پروژه
|
|
|
|
|
|
|
|
|
|
`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).
|