Two faults, one root: the per-context booking work updated ScheduleSection but left the rest of the panel calling slot endpoints without clinic_uuid. Absent clinic_uuid means the personal practice, so the panel asked about a schedule the doctor barely uses and got nothing back. - useClinicContext() resolves the current environment once and is used by the appointments page, useDoctorBookingServices, ServiceSlotPicker and both queries in NewAppointmentDrawer (a fifth call site a sweep turned up). It returns null in a doctor's personal environment so the mirror-image bug — a doctor seeing the clinic's schedule at their own practice — cannot appear. clinicUuid is part of every query key; without it the cache leaks across environments. - appointment-slots returns empty_reason (no_schedule | holiday | day_off | outside_window). TurnsTimeline rendered «این روز تعطیل است» for any empty day, which is what the bug report actually saw; it now says which of the four it is. - booking-locations lists a location only when the context has an address and an active shift points at it. The dev data had three "personal" schedules whose shifts referenced the clinic's address, so the public site advertised a personal practice that could never be booked. - ?date= adds available_on_date per location, validated as a real calendar date. - MyAppointmentsController and AdminApiController resolved the appointment address with no context and could store the wrong one. Both now go through the new BookingContextResolver, which also replaces AppointmentController's private copy of the same membership check. - app:schedule:audit-locations reports shifts pointing at a missing or foreign address; --fix deactivates them rather than deleting. Verified against the reported doctor: same date, no clinic_uuid -> 0 sessions, with it -> 1 session; a full week matches the configured Sat/Tue/Wed/Thu. Suite: 417 tests, 2 failures — both pre-existing and unrelated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
17 KiB
اصلاح تشخیص روز کاری در پنل + حذف محلهای جعلی از API عمومی
پروژه
clinicpro (Backend + پنل ادمین React)
پرامپت همتا در سایت عمومی: nobat724_front/.claude/prompt/booking-locations-day-aware.md
(backend اول اجرا شود — قرارداد API تغییر میکند.)
زمینه
در تغییرات قبلی، تنظیمات نوبتدهی per-context شد: هر پزشک یک برنامه برای مطب شخصی و یکی به ازای
هر کلینیک دارد (weekly_schedules.clinic_id، NULL = شخصی). همهٔ endpointهای اسلات پارامتر
اختیاری clinic_uuid گرفتند و نبودِ آن یعنی «مطب شخصی» — نه «هر برنامهای که پیدا شد».
ScheduleSection (تنظیمات نوبتدهی) بهدرستی clinic_uuid را میفرستد، اما بقیهٔ پنل ادمین
بهروزرسانی نشد. این یک رگرسیون است، نه یک قابلیت ناقص.
مشکل / هدف
مشکل ۱ — «این روز تعطیل است» در پنل
پزشک 09100652121 در محیط کلینیک 41e325c4-e825-4067-8438-5d828ecaee09، و مدیر همان کلینیک در
/admin/appointments، هر دو پیام «این روز تعطیل است» میبینند در حالی که برنامهٔ آن روز در محیط
کلینیک فعال است.
علت: صفحهٔ نوبتها اسلاتها را بدون clinic_uuid میگیرد، پس backend برنامهٔ مطب شخصی را
میخواند. آن پزشک برنامهٔ شخصیِ تقریباً خالی دارد → صفر اسلات → پیام تعطیلی.
پیام هم گمراهکننده است: TurnsTimeline هیچوقت تعطیلی را بررسی نمیکند، فقط
slots.length === 0 را به «تعطیل» ترجمه میکند.
مشکل ۲ — «مطب شخصی» جعلی در API عمومی
GET /api/v1/appointment-booking-locations/{doctorUuid} برای «دکتر تست» یک محل
type: "personal" برمیگرداند، در حالی که در دیتابیس:
-- برنامههای این پزشک: (id, clinic_id, تعداد روز)
2505 NULL 1 -- برنامهٔ شخصی
2509 1003 8 -- برنامهٔ کلینیک
-- آدرسهای شخصی این پزشک:
(هیچ ردیفی)
-- location_id شیفتهای برنامهٔ شخصی:
NULL
یعنی محل «مطب شخصی» هیچ آدرسی ندارد و شیفتش هم به هیچ آدرسی وصل نیست، ولی در سایت نمایش داده
میشود و حتی opening_hours تولید میکند. openingHours() فقط active را چک میکند و
location_id را نادیده میگیرد.
قاعدهٔ درست: محل نوبتدهی فقط وقتی وجود دارد که هم آدرس ثبت شده باشد، هم آن آدرس در شیفتهای همان برنامه انتخاب شده باشد.
فایلهای مرتبط
| فایل | نقش |
|---|---|
assets/admin/pages/AppointmentsPage.tsx:326 |
صفحهٔ /admin/appointments |
assets/admin/pages/AppointmentsPage.tsx:422-428 |
فراخوانی اسلاتها — بدون clinic_uuid |
assets/admin/components/appointments/TurnsTimeline.tsx:166-171 |
پیام «این روز تعطیل است» |
assets/admin/hooks/useDoctorBookingServices.ts:24-28 |
حالت نوبتدهی — بدون clinic_uuid |
assets/admin/components/appointments/ServiceSlotPicker.tsx:58-59 |
اسلات سرویسی — بدون clinic_uuid |
assets/admin/components/NewAppointmentDrawer.tsx:61 |
برنامهٔ هفتگی — بدون clinic_uuid |
assets/admin/pages/ClinicAppointmentSettingsPage.tsx:20-27 |
الگوی درستِ استخراج clinicUuid |
src/Appointment/Controller/AppointmentController.php:270-305 |
bookingLocations() |
src/Appointment/Controller/AppointmentController.php:~700 |
openingHours() |
src/Appointment/Controller/MyAppointmentsController.php:144 |
resolveSlotLocationId بدون context |
src/Admin/Controller/AdminApiController.php:933 |
resolveSlotLocationId بدون context |
src/Appointment/Service/SlotCalculatorService.php |
منبع واحد تولید اسلات |
وضعیت فعلی
پنل: context حمل نمیشود
assets/admin/pages/AppointmentsPage.tsx:422-428:
const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate];
const slotsQuery = useQuery<ApiResponse<any>>({
queryKey: slotsQueryKey,
queryFn: () => api.get(`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}`),
enabled: viewMode === 'timeline' && !!selectedDoctorUuid,
});
dbUuid (uuid کلینیک) فقط برای گرفتن فهرست پزشکان استفاده میشود
(/api/v1/clinic/doctor-list/${dbUuid} در :384) و هرگز بهعنوان clinic_uuid ارسال نمیشود.
پیام گمراهکننده
assets/admin/components/appointments/TurnsTimeline.tsx:166-171:
if (!slots.length) return (
<div style={{ padding: 40, textAlign: 'center' }}>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)' }}>این روز تعطیل است</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>هیچ برنامه زمانبندی برای این روز تنظیم نشده است</div>
</div>
);
محل بدون آدرس فیلتر نمیشود
src/Appointment/Controller/AppointmentController.php:277-296:
$locations = [];
foreach ($this->scheduleRepo->findAllByDoctor($doctor) as $schedule) {
$clinic = $schedule->getClinic();
$meta = $schedule->getMeta();
$address = $this->addressRepo->findForContext($doctor, $clinic?->getId())[0] ?? null;
$locations[] = [
'location_uuid' => $address?->getUuid(),
'type' => $clinic === null ? 'personal' : 'clinic',
'title' => $clinic?->getName() ?? ($address?->getName() ?: 'مطب شخصی'),
...
$address میتواند null باشد و همچنان محل ساخته میشود.
وظایف
۱. حمل context در پنل ادمین
یک hook مشترک بساز تا منطق در چهار جا تکرار نشود — assets/admin/hooks/useClinicContext.ts:
/**
* uuid کلینیکِ محیط جاری، یا null برای محیط شخصی پزشک. همان قاعدهای که
* ClinicAppointmentSettingsPage استفاده میکند.
*/
export function useClinicContext(): string | null {
const dbUuid = useAuthStore(s => s.dbUuid);
const context = useAuthStore(s => s.context);
const availableContexts = useAuthStore(s => s.availableContexts);
return useMemo(() => {
if (context?.type === 'clinic') return dbUuid;
return availableContexts.find(c => c.type === 'clinic')?.db_uuid ?? null;
}, [context, dbUuid, availableContexts]);
}
سپس در این چهار جا مصرفش کن و clinic_uuid را به query اضافه کن:
AppointmentsPage.tsx:422-428(اسلاتها) — حتماًclinicUuidرا درqueryKeyهم بگذار، وگرنه cache بین دو محیط نشت میکند.useDoctorBookingServices.ts:24-28ServiceSlotPicker.tsx:58-59NewAppointmentDrawer.tsx:61
نمونه:
const clinicUuid = useClinicContext();
const slotsQuery = useQuery<ApiResponse<any>>({
queryKey: ['appt-slots', selectedDoctorUuid, selectedDate, clinicUuid],
queryFn: () => api.get(
`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}` +
(clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
),
enabled: viewMode === 'timeline' && !!selectedDoctorUuid,
});
نکتهٔ مهم: پزشکی که هم مطب شخصی دارد هم در کلینیک است، در محیط شخصی نباید clinic_uuid
بفرستد. useClinicContext وقتی context.type === 'doctor' است باید null برگرداند — قاعدهٔ
fallback به availableContexts فقط برای مدیر کلینیک است. اگر این تفکیک را رعایت نکنی، پزشک در
محیط شخصی برنامهٔ کلینیک را میبیند و مشکل قبلی وارونه تکرار میشود.
۲. پیام دقیق بهجای «تعطیل»
TurnsTimeline.tsx:166-171 نمیداند چرا اسلاتی نیست. GET /api/v1/appointment-slots را طوری
تغییر بده که دلیل خالیبودن را برگرداند:
return $this->success([
'doctor_uuid' => $doctorUuid,
'clinic_uuid' => $clinic?->getUuid(),
'date' => $date,
'sessions' => $sessions,
// چرا خالی است — تا پنل پیام درست بدهد
'empty_reason' => $sessions === [] ? $this->emptySlotsReason($doctor, $date, $clinic) : null,
]);
emptySlotsReason() یکی از اینها را برگرداند:
| مقدار | معنی | پیام پنل |
|---|---|---|
no_schedule |
برنامهای برای این context ثبت نشده | «برای این محل برنامهٔ نوبتدهی ثبت نشده است» |
holiday |
تعطیلی فعال این روز را پوشش میدهد | «این روز تعطیل است» |
day_off |
برنامه هست ولی این روز شیفت فعال ندارد | «این روز در برنامهٔ کاری تعریف نشده است» |
outside_window |
خارج از بازهٔ نوبتدهی یا نوبتدهی آنلاین خاموش | «این تاریخ خارج از بازهٔ نوبتدهی است» |
منطقش از همان دادهٔ SlotCalculatorService بیرون میآید؛ متد کمکی عمومی به آن اضافه کن تا کنترلر
دوباره کوئری نزند.
۳. فیلترکردن محلهای بدون آدرس در API عمومی
bookingLocations() فقط محلی را برگرداند که هر دو شرط را دارد:
- حداقل یک آدرس در آن context ثبت شده باشد.
- حداقل یک شیفت فعال داشته باشد که
location_idاش یکی از همان آدرسها باشد.
foreach ($this->scheduleRepo->findAllByDoctor($doctor) as $schedule) {
$clinic = $schedule->getClinic();
$addresses = $this->addressRepo->findForContext($doctor, $clinic?->getId());
if ($addresses === []) {
continue; // محلی که آدرس ندارد، محل نیست
}
$byId = [];
foreach ($addresses as $a) { $byId[(int) $a->getId()] = $a; }
$hours = $this->openingHours($schedule, $byId);
if ($hours === []) {
continue; // هیچ شیفت فعالی روی آدرسهای این محل نشسته
}
$address = $byId[$hours[0]['location_id']] ?? $addresses[0];
...
}
و openingHours() باید location_id را هم بررسی و برگرداند:
private function openingHours(WeeklySchedule $schedule, array $allowedAddressIds): array
{
...
$locationId = (int) ($session['location_id'] ?? 0);
if ($locationId === 0 || !isset($allowedAddressIds[$locationId])) {
continue; // شیفت بدون آدرس معتبر = قابل رزرو نیست
}
...
$hours[] = [
'day' => ucfirst($dayName),
'day_index' => (int) $dayIndex,
'location_id' => $locationId,
'opens' => $opens,
'closes' => $closes,
];
}
این تغییر رفتار عمومی است و باید در docs/api/appointment.md صریح ثبت شود: محلی که آدرس ندارد
یا شیفتی روی آدرسش تعریف نشده، دیگر در booking_locations نمیآید.
۴. فیلتر بر اساس روز — پارامتر date
سایت باید بتواند بپرسد «در این تاریخ کدام محلها باز است». به bookingLocations پارامتر اختیاری
?date=Y-m-d اضافه کن:
- بدون
date→ رفتار فعلی (همهٔ محلهای معتبر). - با
date→ فقط محلهایی که در آن روز حداقل یک اسلات آزاد دارند ($this->slotCalculator->getAvailableSlots($doctor, $date, $clinic)غیرخالی).
هر آیتم یک فیلد available_on_date (بولین) هم بگیرد تا کلاینت بتواند بهجای حذف، آن را غیرفعال
نشان دهد.
پاسخ در حالت date باید date را echo کند.
۵. دو call site بدون context
src/Appointment/Controller/MyAppointmentsController.php:144 و
src/Admin/Controller/AdminApiController.php:933 هر دو
resolveSlotLocationId($doctor, $slotStart) را بدون کلینیک صدا میزنند، پس آدرس نوبتِ ثبتشده در
کلینیک را null یا اشتباه حل میکنند.
هر دو باید context را از همان مسیری که نوبت ساخته میشود بگیرند (بدنهٔ درخواست یا
EntityContextResolver). اگر context در دسترس نبود، بهجای حدسزدن، آدرس را null بگذار و در
لاگ ثبت کن — حدسزدن یعنی ثبت نوبت با آدرس اشتباه.
۶. پاکسازی دادهٔ ناسازگار
برنامهٔ شخصیِ id=2505 شیفت فعال با location_id = NULL دارد؛ چنین ردیفی امروز از طریق API
ساخته نمیشود چون validateSessions() جلویش را میگیرد، ولی ردیفهای قدیمی ماندهاند.
یک console command بنویس — app:schedule:audit-locations:
- برنامههایی که شیفت فعال با
location_idتهی یا اشاره به آدرسی خارج از context دارند را فهرست کند. - با
--fixآن شیفتها راactive = falseکند (حذف نکن — دادهٔ کاربر است). - خروجی: uuid پزشک، context، روز، و
location_idمشکلدار.
۷. تست و مستندات
تستهای لازم در tests/Appointment/:
- اسلاتهای پزشکِ عضو کلینیک با
clinic_uuid→ غیرخالی؛ بدون آن → خالی باempty_reason = 'no_schedule'. booking_locationsمحلی که آدرس ندارد را برنمیگرداند.booking_locationsمحلی که آدرس دارد ولی هیچ شیفتی روی آن آدرس نیست را برنمیگرداند.?date=روزی که فقط کلینیک باز است → فقط یک محل.empty_reasonبرای هر چهار حالت (no_schedule/holiday/day_off/outside_window).
مستندات: docs/api/appointment.md — پارامتر date، فیلدهای available_on_date، location_id
داخل opening_hours، فیلد empty_reason روی appointment-slots، و قاعدهٔ جدید فیلترشدن محلها.
نکات مهم
- این رگرسیون از تغییر per-context قبلی آمده. هر جای دیگری از پنل که اسلات یا برنامه میخواند
و در فهرست بالا نیست را هم بگرد:
grep -rn "appointment-slots\|appointment-service-slots\|month-availability\|weekly-schedule" assets/admin. SlotCalculatorServiceتنها منبع تولید اسلات است و درست کار میکند — مشکل در ورودیاش است، نه در خودش. هیچ منطق موازی تولید اسلات نساز.- Timezone: همهٔ محاسبات با
strtotime/dateو timestamp صحیح انجام میشود وSlotCalculatorفرض میکند تاریخY-m-dمحلی است. اگر مشکل روزِ اشتباه دیدی، اولdate_default_timezoneکانتینر را باAsia/Tehranبسنج؛ ولی علتِ گزارششدهٔ فعلی timezone نیست — نبودِclinic_uuidاست. - تعطیلی سراسری پزشک (
holidays.clinic_id IS NULL) عمداً روی همهٔ محیطها اثر میگذارد؛ این رفتار درست است و نباید تغییر کند. - کاربر تست: پزشک
09100652121(uuidbcabb3a8-cae3-45ec-876c-548f9c1e1569) در کلینیک41e325c4-e825-4067-8438-5d828ecaee09. مدیر کلینیک برای بازتولید مشکل دوم. - پاسخها طبق
BaseControllerبا$this->success()/$this->error()؛ تاریخها timestamp صحیح.