Files
clinicpro/.claude/prompt/context-separation-clinic-vs-doctor-booking.md
hamed f1258d206d feat(migrations): add clinic_id context to weekly_schedules, date_overrides, and holidays
- Introduced clinic_id to weekly_schedules, date_overrides, and holidays to differentiate between personal and clinic schedules.
- Updated unique constraints and indexes to accommodate the new clinic context.

feat(command): create AssignScheduleClinicCommand to move schedules

- Added a command to move a doctor's personal weekly schedule into a clinic context.
- Implemented checks to ensure sessions align with the target clinic.

feat(context): implement EntityContext and EntityContextResolver

- Created EntityContext to represent the effective working environment of a request (doctor or clinic).
- Developed EntityContextResolver to determine the execution context based on user roles and active contexts.

test: add ServiceModeContextTest for appointment scheduling

- Implemented tests to ensure service booking respects clinic and personal contexts.
- Verified that financial data is omitted in clinic contexts in InvitedDoctorDashboardScopeTest.
2026-07-18 13:32:56 +03:30

30 KiB
Raw Permalink Blame History

جداسازی Context کلینیک و محیط شخصی پزشک در تنظیمات نوبت‌دهی

پروژه

clinicpro (Backend Symfony + پنل ادمین React)

زمینه

در مسیر admin/doctors/{doctorUuid} (پنل کلینیک) هنگام ذخیرهٔ تنظیمات نوبت‌دهی برای پزشک عضو کلینیک (دکتر تست، موبایل 09100652121، کلینیک 41e325c4-e825-4067-8438-5d828ecaee09) با انتخاب حالت «نوبت‌دهی سرویسی» خطای زیر برمی‌گردد:

برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است

در حالی که کلینیک سرویس‌های bookable دارد. علت: شمارش سرویس‌ها همیشه با entity_type='doctor' انجام می‌شود و هیچ‌وقت سرویس‌های کلینیک را نمی‌بیند.

اما این فقط علامتِ یک مشکل معماری بزرگ‌تر است: کل مدل تنظیمات نوبت‌دهی، context ندارد. WeeklySchedule یک رابطهٔ OneToOne با doctor دارد و یک unique constraint روی doctor_id؛ یعنی یک پزشک که هم مطب شخصی دارد و هم عضو یک یا چند کلینیک است، فقط یک برنامهٔ نوبت‌دهی در کل سیستم دارد. سرویس‌ها ولی polymorphic هستند (service_sections.entity_type = doctor|clinic) و کاملاً از هم جدا.

مشکل / هدف

دو Context باید کاملاً از هم جدا شوند:

Context مالک تنظیمات سرویس‌های قابل استفاده آدرس‌های قابل انتخاب
محیط شخصی پزشک doctor فقط entity_type='doctor', entity_id=doctor.id فقط DoctorAddress با type=personal (یا clinic_id IS NULL)
محیط مدیریت کلینیک (doctor, clinic) فقط entity_type='clinic', entity_id=clinic.id فقط آدرس‌های همان کلینیک

قوانین:

  1. پزشک در محیط شخصی نباید به سرویس‌ها، آدرس‌ها یا تنظیمات کلینیک دسترسی داشته باشد.
  2. کلینیک در محیط خودش برای پزشک عضو، باید بتواند از سرویس‌های کلینیک استفاده کند.
  3. یک پزشک باید بتواند برای مطب شخصی و برای هر کلینیک، برنامهٔ نوبت‌دهی مستقل داشته باشد.
  4. booking_mode (slot/service) در هر context مستقل قفل می‌شود، نه سراسری.

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

فایل نقش
src/Appointment/Entity/WeeklySchedule.php Entity تنظیمات نوبت‌دهی — OneToOne با doctor، بدون clinic
src/Appointment/Controller/AppointmentSettingsController.php همهٔ endpointهای تنظیمات؛ محل خطا و محل authorization
src/ClinicService/Repository/ServiceItemRepository.php countBookableByEntity() / findBookableByEntity()
src/ClinicService/Entity/ServiceSection.php مالکیت polymorphic سرویس (entityType/entityId)
src/ClinicService/Entity/ServiceItem.php فلگ bookable
src/ClinicService/Controller/ClinicServiceController.php resolveEntity() — تشخیص context از روی role
src/Doctor/Entity/DoctorAddress.php آدرس با clinicId و type
src/Appointment/Controller/AppointmentController.php:234-262 لیست عمومی سرویس‌های bookable پزشک
src/Auth/Entity/UserActiveContext.php context فعال کاربر (فقط db_uuid)
assets/admin/pages/AppointmentSettingsPage.tsx صفحهٔ شخصی پزشک
assets/admin/pages/ClinicAppointmentSettingsPage.tsx صفحهٔ کلینیک، تب به ازای هر پزشک
assets/admin/components/schedule/ScheduleSection.tsx کامپوننت مشترک هر دو صفحه
assets/admin/stores/authStore.ts `context: {type: 'doctor'

وضعیت فعلی

۱. شمارش سرویس با doctor هاردکد

src/Appointment/Controller/AppointmentSettingsController.php:57-61:

    private function serviceModeHasNoBookable(array $meta, \App\Doctor\Entity\Doctor $doctor): bool
    {
        return ($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) === WeeklySchedule::MODE_SERVICE
            && $this->itemRepo->countBookableByEntity('doctor', $doctor->getId()) === 0;
    }

فراخوانی در :101-103 (POST) و :144-146 (PATCH):

        if ($this->serviceModeHasNoBookable($schedule->getMeta(), $doctor)) {
            return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است', 422, 'booking_mode');
        }

۲. Entity بدون clinic

src/Appointment/Entity/WeeklySchedule.php:13-49:

#[ORM\Entity(repositoryClass: WeeklyScheduleRepository::class)]
#[ORM\Table(name: 'weekly_schedules')]
#[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor', columns: ['doctor_id'])]
class WeeklySchedule
{
    public const MODE_SLOT    = 'slot';
    public const MODE_SERVICE = 'service';
    ...
    #[ORM\OneToOne(targetEntity: Doctor::class)]
    #[ORM\JoinColumn(name: 'doctor_id', onDelete: 'CASCADE')]
    private Doctor $doctor;

    #[ORM\Column(type: 'json')]
    private array $setting = [];

۳. تشخیص context فقط از روی role (و doctor برنده است)

src/ClinicService/Controller/ClinicServiceController.php:492-505 — این متد در ۹+ کنترلر تکرار شده:

    private function resolveEntity(User $user): array
    {
        if ($user->hasRole('ROLE_DOCTOR')) {
            $doctor = $this->doctorRepo->findByUser($user);
            return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
        }

        if ($user->hasRole('ROLE_CLINIC')) {
            $clinic = $this->clinicRepo->findByUser($user);
            return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
        }

        return ['unknown', null];
    }

کاربری که هر دو role را دارد، همیشه به‌عنوان doctor حل می‌شود و هرگز سرویس‌های کلینیکش را نمی‌بیند.

۴. Authorization از کلینیک عبور می‌کند ولی context را حمل نمی‌کند

src/Appointment/Controller/AppointmentSettingsController.php:437-450:

    private function denyDoctorAccess(\App\Doctor\Entity\Doctor $doctor, User $user, string $action): ?JsonResponse
    {
        if ($user->hasRole('ROLE_ADMIN') || $doctor->getUser()->getId() === $user->getId()) {
            return null;
        }

        foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) {
            if ($this->permChecker->can($user, $clinic, 'appointment_settings', $action)) {
                return null;
            }
        }

        return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
    }

مالک کلینیک مجاز است بنویسد، اما هیچ‌جا مشخص نمی‌شود که این نوشتن «در context کلینیک» است.

۵. فرانت context را ارسال نمی‌کند

assets/admin/components/schedule/ScheduleSection.tsx:546-563:

        ? api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta })
        : api.post<ApiResponse<any>>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta });

هر دو صفحهٔ شخصی و کلینیک دقیقاً همین ScheduleSection را رندر می‌کنند و هیچ تفاوتی در payload ندارند.

وظایف

۱. مدل‌سازی Context در WeeklySchedule

ستون clinic_id (nullable) به weekly_schedules اضافه شود:

  • clinic_id IS NULL → context شخصی پزشک
  • clinic_id = X → context کلینیک X برای همین پزشک

تغییرات لازم در src/Appointment/Entity/WeeklySchedule.php:

#[ORM\Entity(repositoryClass: WeeklyScheduleRepository::class)]
#[ORM\Table(name: 'weekly_schedules')]
#[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor_clinic', columns: ['doctor_id', 'clinic_id'])]
class WeeklySchedule
{
    // OneToOne → ManyToOne (یک پزشک چند برنامه دارد: شخصی + هر کلینیک)
    #[ORM\ManyToOne(targetEntity: Doctor::class)]
    #[ORM\JoinColumn(name: 'doctor_id', nullable: false, onDelete: 'CASCADE')]
    private Doctor $doctor;

    #[ORM\ManyToOne(targetEntity: Clinic::class)]
    #[ORM\JoinColumn(name: 'clinic_id', nullable: true, onDelete: 'CASCADE')]
    private ?Clinic $clinic = null;

نکته دربارهٔ unique در MySQL/MariaDB: NULL در unique index تکراری مجاز است، پس (doctor_id, NULL) چند بار می‌تواند ثبت شود. برای جلوگیری، یا در سطح Repository قبل از insert چک کن، یا به‌جای NULL از clinic_id = 0 استفاده کن. گزینهٔ توصیه‌شده: nullable نگه‌دار و یکتایی را در سرویس/Repository تضمین کن (سازگارتر با FK).

Migration بنویس. برای رکوردهای موجود clinic_id = NULL بگذار (همه به‌عنوان تنظیمات شخصی تفسیر می‌شوند) — و در توضیح migration این تصمیم را ذکر کن.

تصمیم قطعی دربارهٔ DateOverride و Holiday

این دو معنای متفاوتی دارند و رفتارشان یکسان نیست:

DateOverride → همیشه per-context (clinic_id مطابق schedule). یک override یعنی «ساعت کاری این روزِ خاص با برنامهٔ عادی فرق دارد». ساعت کاری خودش per-context است، پس استثنای آن هم per-context است. اگر پزشک در کلینیک پنجشنبه را تا ۱۲ کار کند، هیچ ربطی به مطب شخصی‌اش ندارد. ستون clinic_id nullable اضافه شود و همیشه با clinic_id همان WeeklySchedule مقداردهی شود (NULL = context شخصی). عملاً بهتر است DateOverride به WeeklySchedule رفرنس بدهد نه به Doctor، ولی برای کم‌کردن ریسک migration، (doctor_id, clinic_id) کافی است.

Holiday → پیش‌فرض سراسری (doctor-level)، با امکان محدودسازی به یک context. تعطیلی یعنی «پزشک آن روز نیست» — یک واقعیت فیزیکی است. پزشکی که در سفر یا مرخصی است، هم‌زمان در مطب شخصی و در کلینیک غایب است؛ اگر per-context باشد، پزشک باید یک مرخصی را N بار ثبت کند و فراموش‌کردن یکی از آن‌ها = نوبت‌گرفتن بیمار برای روزی که پزشک نیست. این بدترین خطای ممکن در این دامنه است.

پس clinic_id nullable با این معنا:

clinic_id معنی
NULL پزشک آن روز در هیچ محلی نیست — روی همهٔ contextها اثر می‌گذارد
X پزشک آن روز فقط در کلینیک X نیست (مطب شخصی و بقیه کلینیک‌ها باز)

محاسبهٔ تعطیلی مؤثر برای یک context، اجتماع دو مجموعه است:

// در HolidayRepository
->where('h.doctor = :doctor')
->andWhere('h.clinic IS NULL OR h.clinic = :clinic')

قواعد نوشتن (اجباری، در سرویس اعمال شود):

  • مالک/کارمند کلینیک فقط می‌تواند Holiday با clinic_id = <کلینیک خودش> بسازد یا حذف کند. تلاش برای ساخت تعطیلی سراسری (clinic_id = NULL) → 403. دلیل: کلینیک نباید بتواند مطب شخصی پزشک را تعطیل کند.
  • خودِ پزشک در context شخصی می‌تواند هر دو نوع را بسازد، ولی UI باید صریح بپرسد. یک انتخاب دوتایی در فرم ثبت تعطیلی:
    • «در همهٔ محل‌ها نیستم» → clinic_id = NULL (پیش‌فرض)
    • «فقط در …» → انتخاب یک محل
  • تعطیلی سراسریِ ساخته‌شده توسط پزشک، در پنل کلینیک فقط-خواندنی نمایش داده شود (کلینیک باید ببیند پزشک نیست، ولی نتواند حذفش کند).

Migration: همهٔ رکوردهای موجود Holiday و DateOverride با clinic_id = NULL بمانند — برای Holiday معنایش دقیقاً همان رفتار فعلی است (سراسری)، برای DateOverride یعنی به context شخصی نسبت داده می‌شوند که با تصمیم بند ۱ سازگار است.

نکتهٔ مرزی: «کلینیک کلاً تعطیل است» (برای همهٔ پزشکان) با این مدل بیان نمی‌شود و نیاز به یک ClinicHoliday جدا دارد. خارج از scope این تسک — فقط در docs/ به‌عنوان کار بعدی ثبت شود.

۲. یک سرویس مرکزی برای حل Context

به‌جای تکرار resolveEntity() در ۹ کنترلر، یک سرویس بساز:

src/Common/Service/EntityContextResolver.php (یا محل مناسب مطابق ساختار موجود):

final class EntityContextResolver
{
    /**
     * context مؤثر را برمی‌گرداند: ['doctor'|'clinic', id, ?Clinic]
     * اولویت: clinic_uuid صریح در request > UserActiveContext > role
     */
    public function resolve(User $user, ?string $clinicUuid = null): EntityContext;

    /** آیا این کاربر مجاز است در context کلینیک داده‌شده عمل کند؟ */
    public function assertCanActAs(User $user, EntityContext $ctx): void;
}

قواعد:

  • اگر clinic_uuid در درخواست آمد → context کلینیک، مشروط به اینکه permChecker->can($user, $clinic, ...) مجاز باشد؛ در غیر این صورت 403.
  • اگر نیامد → از UserActiveContext بخوان (src/Auth/Entity/UserActiveContext.php).
  • اگر آن هم نبود → fallback به منطق فعلی مبتنی بر role.

مهم: اولویت فعلی که ROLE_DOCTOR را بر ROLE_CLINIC مقدم می‌کند، برای کاربر دو-نقشی اشتباه است. با این سرویس، UserActiveContext باید تعیین‌کننده باشد.

سپس resolveEntity() را در کنترلرهای موجود (ClinicService, Inventory, Patient, Staff, Billing, Insurance, Subscription, Tag, Sms) با این سرویس جایگزین کن. اگر ریسک این refactor بزرگ بود، حداقل ClinicServiceController و AppointmentSettingsController را مهاجرت بده و بقیه را در یک TODO مستند کن.

۳. اصلاح validation سرویس bookable بر اساس Context

در AppointmentSettingsController:

    private function serviceModeHasNoBookable(array $meta, EntityContext $ctx): bool
    {
        if (($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) !== WeeklySchedule::MODE_SERVICE) {
            return false;
        }

        return $this->itemRepo->countBookableByEntity($ctx->type, $ctx->id) === 0;
    }

و پیام خطا بسته به context، دقیق‌تر شود:

$msg = $ctx->type === 'clinic'
    ? 'برای نوبت‌دهی سرویسی، کلینیک باید حداقل یک سرویس با «نمایش در نوبت‌دهی» داشته باشد'
    : 'برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است';

۴. محدودسازی آدرس‌ها بر اساس Context

GET /api/v1/appointment-settings/available-locations/{doctorUuid} (:399) الان همهٔ آدرس‌های شخصی + همهٔ کلینیک‌ها را union می‌کند:

        $clinics    = $this->clinicRepo->findByDoctor($doctor);
        $clinicIds  = array_map(fn(Clinic $c) => $c->getId(), $clinics);
        $addresses  = $this->addressRepo->findAvailableForDoctor($doctor, $clinicIds);

باید پارامتر ?clinic_uuid= بپذیرد:

  • با clinic_uuid → فقط آدرس‌های همان کلینیک
  • بدون آن (context شخصی) → فقط DoctorAddress با type = TYPE_PERSONAL / clinicId IS NULL

همچنین در validateSessionsHaveLocation() (:456-466) اضافه کن که location_id انتخاب‌شده حتماً متعلق به همان context باشد؛ الان هر آدرسی پذیرفته می‌شود.

۵. لیست سرویس‌ها برای context

الان هیچ endpointای برای «سرویس‌های bookable یک پزشک در یک کلینیک» وجود ندارد؛ GET /api/v1/service-items (ClinicServiceController:217) owner را از کاربر لاگین‌شده می‌گیرد.

  • GET /api/v1/service-items باید ?clinic_uuid= بپذیرد و از EntityContextResolver استفاده کند.
  • AppointmentController.php:234-262 که findBookableByEntity('doctor', ...) را هاردکد کرده، باید context را از WeeklySchedule مربوطه (که حالا clinic دارد) استخراج کند — نه از role. این مسیر عمومی است و nobat724_front مصرف‌کنندهٔ آن است.

۶. تغییرات endpointهای تنظیمات نوبت‌دهی

همهٔ endpointهای AppointmentSettingsController باید context بپذیرند:

  • POST /api/v1/appointment-settings/weekly-schedule → بدنه clinic_uuid اختیاری
  • GET/PATCH /api/v1/appointment-settings/weekly-schedule/{uuid} → query ?clinic_uuid=
  • WeeklyScheduleRepository متد findOneByDoctorAndClinic(Doctor $d, ?Clinic $c) بگیرد؛ همهٔ findOneBy(['doctor' => ...])ها به‌روز شوند.
  • assertModeImmutable() باید mode را از schedule همان context بخواند، نه از تنها schedule پزشک.

پاسخ‌ها طبق BaseController با $this->success() / $this->error() بمانند.

۷. پنل ادمین React

  • assets/admin/components/schedule/ScheduleSection.tsx یک prop جدید clinicUuid?: string بگیرد و در هر دو فراخوانی POST/PATCH و در query key و در fetch آدرس‌ها آن را ارسال کند.
  • AppointmentSettingsPage.tsx (شخصی) → clinicUuid ندهد.
  • ClinicAppointmentSettingsPage.tsxclinicUuid={clinicUuid} بدهد.
  • query keyهای React Query حتماً شامل clinicUuid شوند، وگرنه cache بین دو context نشت می‌کند.
  • متن راهنمای ScheduleSection.tsx:715 بسته به context متفاوت شود: در کلینیک به بخش سرویس‌های کلینیک ارجاع دهد.

۸. مستندات و تست

  • فایل‌های docs/api/ مربوط به appointment-settings و service-items با پارامتر جدید clinic_uuid به‌روز شوند (قانون ثابت پروژه).
  • تست موجود tests/Appointment/AppointmentSettingsListOwnershipTest.php را گسترش بده؛ حداقل این سناریوها:
    1. پزشک عضو کلینیک، در context شخصی، mode=service با صفر سرویس شخصی → 422.
    2. همان پزشک در context کلینیک که کلینیک سرویس bookable دارد → 200.
    3. پزشک در context شخصی نمی‌تواند location_id متعلق به کلینیک را انتخاب کند → 422.
    4. دو schedule مستقل برای یک پزشک (شخصی + کلینیک) هم‌زمان ذخیره می‌شوند و mode مستقل قفل می‌شود.
    5. کاربری بدون permission روی کلینیک، با clinic_uuid آن کلینیک → 403.

۹. داشبورد پزشک دعوت‌شده در context کلینیک

مشکل مشاهده‌شده: پزشک دعوت‌شده («دکتر دعوت تست ۲») وقتی داخل محیط کلینیک «علی بهروزی» است، داشبورد کاملِ پزشک را می‌بیند: «میزان درآمد»، «کل پرداختی‌ها»، «پرداختی‌های امروز»، «تعداد کل مراجعین» و کارت «کلینیک‌های من». این داده‌ها به context شخصی پزشک تعلق دارند و نباید در محیط کلینیک نمایش داده شوند. علاوه بر این، پزشک دعوت‌شده اصلاً نباید اطلاعات مالی ببیند.

ریشه: انتخاب داشبورد فقط بر اساس primaryRole است و context.scope نادیده گرفته می‌شود.

assets/admin/pages/DashboardPage.tsx:1116:

export default function DashboardPage() {
  const primaryRole = useAuthStore(s => s.primaryRole);
  if (!primaryRole) return <LoadingSkeleton />;
  if (primaryRole === 'admin')          return <AdminDashboard />;
  if (primaryRole === 'clinic')         return <ClinicDashboard />;
  if (primaryRole === 'doctor')         return <DoctorDashboard />;
  ...

در حالی که Sidebar دقیقاً همین تمایز را می‌شناسدassets/admin/components/layout/Sidebar.tsx:60-83:

if (primaryRole === "doctor" && scope === "clinic") {
    const items: SectionItem[] = [
        { to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" },
    ];
    if (can("appointments", "view")) { ... }
    if (can("patients", "view")) { ... }
    return [{ label: "عمومی", items }];
}

منبع scope: src/Auth/Controller/AuthController.php:700-729 — پزشک دعوت‌شده role='doctor', scope='clinic', permissions از ClinicDoctorPermission؛ مالک کلینیک role='clinic', scope=null, permissions=null.

وظایف:

  1. در DashboardPage.tsx قبل از dispatch، scope را هم بخوان و یک شاخهٔ جدید اضافه کن:
const primaryRole = useAuthStore(s => s.primaryRole);
const scope       = useAuthStore(s => s.context?.scope ?? null);
...
if (primaryRole === 'doctor' && scope === 'clinic') return <InvitedDoctorDashboard />;
if (primaryRole === 'doctor')                       return <DoctorDashboard />;
  1. InvitedDoctorDashboard فقط این‌ها را نشان دهد:

    • «تعداد نوبت‌های امروز» (محدود به نوبت‌های همین پزشک در همین کلینیک)
    • «لیست نوبت‌های جدید» همین پزشک در همین کلینیک
    • در صورت داشتن can('patients','view')، «تعداد مراجعین» همین context

    و این‌ها حذف شوند: «میزان درآمد»، «کل پرداختی‌ها»، «پرداختی‌های امروز»، «نمودار درآمد»، کارت «کلینیک‌های من» (DashboardPage.tsx:851)، و کارت دعوت‌های کلینیک (DoctorClinicInvitationsCard, :709) — دعوت‌ها فقط در context شخصی معنا دارند.

    کارت‌ها بر اساس permissions همان context نمایش داده شوند (همان usePermissions() که Sidebar استفاده می‌کند)، نه صرفاً hardcode.

  2. Backend مهم‌تر است — مخفی‌کردن در UI کافی نیست. src/Dashboard/Controller/DashboardController.php:180-182 (GET /api/v1/dashboard/doctor) داده را از doctorRepo->findByUser($user) می‌گیرد و روی همهٔ کلینیک‌ها + مطب شخصی جمع می‌زند؛ UserActiveContextRepository تزریق شده (:34) ولی مصرف نمی‌شود. پزشک دعوت‌شده الان می‌تواند مستقیماً این endpoint را صدا بزند و درآمد شخصی‌اش را بگیرد.

    • ?clinic_uuid= بپذیرد و از EntityContextResolver (وظیفهٔ ۲) استفاده کند.
    • وقتی context کلینیک است: فیلدهای مالی (revenue_period_rials, today_payments_rials, charts.revenue_by_day) در پاسخ قرار نگیرند مگر اینکه permChecker مجوز مالی (billing/payments view) برای آن پزشک در آن کلینیک بدهد.
    • آمار نوبت/بیمار به نوبت‌های همان پزشک در همان کلینیک محدود شود، نه همهٔ کلینیک‌ها.
    • sms_balance هم در context کلینیک نباید از کیف پول شخصی پزشک خوانده شود.
  3. مسیر /admin/dashboard در assets/admin/App.tsx:171 هیچ role gate ندارد؛ لازم نیست gate اضافه شود (خود صفحه dispatch می‌کند) اما مطمئن شو RoleRoute مسیرهای مالی را برای scope === 'clinic' مسدود می‌کند.

  4. تست: پزشک دعوت‌شده در context کلینیک، GET /api/v1/dashboard/doctor?clinic_uuid=... → پاسخ نباید هیچ فیلد مالی داشته باشد؛ و بدون clinic_uuid وقتی active context کلینیک است، نتیجه باید همان محدودیت را داشته باشد.

۱۰. قرارداد عمومی برای چند schedule (مصرف‌کننده: nobat724_front)

تصمیم قطعی: همهٔ scheduleها نمایش داده شوند، تفکیک‌شده بر اساس محل نوبت‌دهی.

انتخاب یکی و پنهان‌کردن بقیه یعنی حذف ظرفیت واقعی پزشک از سایت — پزشکی که سه‌شنبه‌ها فقط در کلینیک است، آن روز اصلاً قابل رزرو نخواهد بود. ضمناً قیمت و سرویس‌ها بین محل‌ها فرق می‌کند، پس بیمار باید محل را آگاهانه انتخاب کند، نه اینکه سیستم به‌جایش تصمیم بگیرد.

قرارداد API عمومی — به‌جای یک آبجکت، آرایه‌ای از «محل‌های نوبت‌دهی» برگردد:

{
  "success": true,
  "data": {
    "doctor": { "uuid": "...", "name": "..." },
    "booking_locations": [
      {
        "location_uuid": "...",
        "type": "personal",
        "title": "مطب شخصی",
        "address": "...",
        "clinic_uuid": null,
        "booking_mode": "slot",
        "services": [],
        "next_available_at": 1755000000
      },
      {
        "location_uuid": "...",
        "type": "clinic",
        "title": "کلینیک علی بهروزی",
        "address": "...",
        "clinic_uuid": "41e325c4-...",
        "booking_mode": "service",
        "services": [ { "uuid": "...", "name": "...", "price_rials": 0, "duration_minutes": 20 } ],
        "next_available_at": 1754900000
      }
    ]
  }
}

قواعد:

  • پیش‌فرض انتخاب‌شده: محلی با کمترین next_available_at (زودترین نوبت آزاد). این هم برای بیمار بهترین است و هم نیاز به قاعدهٔ دلبخواهی «شخصی اول یا کلینیک اول» را حذف می‌کند. اگر هیچ محلی نوبت آزاد نداشت، ترتیب: شخصی، سپس کلینیک‌ها بر اساس نام.
  • لینک مستقیم: /doctor/{uuid}?location={location_uuid} تا هر محل قابل اشتراک‌گذاری و ایندکس باشد. بدون پارامتر → پیش‌فرض بالا.
  • endpointهای اسلات و ثبت نوبت باید location_uuid (یا clinic_uuid) اجباری بگیرند. الان محل را از تنها schedule پزشک استنتاج می‌کنند؛ با چند schedule این استنتاج غلط می‌شود و بی‌سروصدا نوبت را به محل اشتباه ثبت می‌کند. این را به‌عنوان یک شکست خاموش جدی در نظر بگیر: تا وقتی این پارامتر اجباری نشده، migration بند ۱ را روی production اجرا نکن.
  • سازگاری عقب‌رو: تا وقتی nobat724_front به‌روز نشده، اگر پزشک فقط یک schedule دارد (اکثریت مطلق داده‌های فعلی)، پاسخ قدیمی هم در کنار booking_locations برگردانده شود؛ بعد از استقرار فرانت حذف شود. این را در docs/api/appointment.md صریح علامت بزن.
  • JSON-LD: به‌جای یک openingHoursSpecification، برای هر محل یک entry جدا با location مشخص. یک نود Physician با چند availableAtOrFrom. پرامپت همتا در nobat724_front لازم است.

نکات مهم

  • سازگاری با داده موجود: هر پزشکی که الان schedule دارد، بعد از migration باید دقیقاً همان رفتار را در context شخصی ببیند. اگر آن schedule عملاً برای کلینیک تنظیم شده بوده (session‌هایش location_id کلینیکی دارند)، migration نمی‌تواند خودکار تشخیص دهد — این را به‌عنوان محدودیت شناخته‌شده مستند کن و یک اسکریپت console برای انتقال دستی بنویس.
  • سرویس‌ها polymorphic هستند و هرگز بین doctor و clinic مشترک نمی‌شوند؛ هیچ‌جا سرویس‌های دو context را union نکن.
  • تاریخ‌ها Unix timestamp صحیح بمانند؛ رشته‌های جدید فارسی و تاریخ‌ها شمسی.
  • در پنل ادمین از SearchableSelect استفاده کن، نه <select> بومی.
  • نشت داده مالی: وظیفهٔ ۹ فقط یک مسئلهٔ UI نیست — GET /api/v1/dashboard/doctor الان درآمد شخصی پزشک را بدون هیچ فیلتر contextی برمی‌گرداند. اصلاح backend اجباری است.
  • ترتیب پیاده‌سازی پیشنهادی: (۱) Entity + migration → (۲) EntityContextResolver → (۳) کنترلر تنظیمات + validation → (۴) آدرس‌ها و سرویس‌ها → (۵) فرانت → (۶) AppointmentController عمومی → (۷) داشبورد پزشک دعوت‌شده (وظیفهٔ ۹) → (۸) تست و docs. هر مرحله جدا تست شود. وظیفهٔ ۹ به EntityContextResolver وابسته است ولی مستقل از migration قابل شروع است.
  • کاربر تست: 09390039833 / 09390039833. سناریوی باگ: دکتر تست 09100652121 در کلینیک 41e325c4-e825-4067-8438-5d828ecaee09.