Files
clinicpro/.claude/prompt/fix-booking-context-slots-and-phantom-locations.md
hamedandClaude Opus 4.8 7a0654f8ba fix(booking): carry the clinic context through the panel and drop phantom locations
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>
2026-07-18 14:35:31 +03:30

17 KiB
Raw Permalink Blame History

اصلاح تشخیص روز کاری در پنل + حذف محل‌های جعلی از 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-28
  • ServiceSlotPicker.tsx:58-59
  • NewAppointmentDrawer.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() فقط محلی را برگرداند که هر دو شرط را دارد:

  1. حداقل یک آدرس در آن context ثبت شده باشد.
  2. حداقل یک شیفت فعال داشته باشد که 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/:

  1. اسلات‌های پزشکِ عضو کلینیک با clinic_uuid → غیرخالی؛ بدون آن → خالی با empty_reason = 'no_schedule'.
  2. booking_locations محلی که آدرس ندارد را برنمی‌گرداند.
  3. booking_locations محلی که آدرس دارد ولی هیچ شیفتی روی آن آدرس نیست را برنمی‌گرداند.
  4. ?date= روزی که فقط کلینیک باز است → فقط یک محل.
  5. 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 (uuid bcabb3a8-cae3-45ec-876c-548f9c1e1569) در کلینیک 41e325c4-e825-4067-8438-5d828ecaee09. مدیر کلینیک برای بازتولید مشکل دوم.
  • پاسخ‌ها طبق BaseController با $this->success() / $this->error()؛ تاریخ‌ها timestamp صحیح.