12 KiB
رفع باگ: نوبتهای رزروشده در سایت عمومی «آزاد» نمایش داده میشوند
پروژه
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() برای هر اسلات یک بار صدا زده میشود:
// 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;
}
۲) ثبت رزرو روز-محور با بازهی صفر
// 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 برابر ابتدای روز (۰۰:۰۰) یا
هر لحظهای بیرونِ اسلاتها باشد، هیچ اسلاتی اشغال نمیشود → همه آزاد. رزروِ «کل روز» عملاً هیچچیز را نمیبندد.
۳) پارامتر تاریخ در فرانت (بررسی ثانویه)
// 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):
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 (خط ۶۶–۷۰):
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 رخ ندهد:
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).