Files
clinicpro/.claude/prompt/clinic-doctor-deactivate.md
T
hamed 0333b24071 feat: enhance DoctorAddress entity to support clinic addresses and types
- Added `clinic_id` and `type` fields to `DoctorAddress` entity to differentiate between personal and clinic addresses.
- Updated constructor to support creation of addresses for both doctors and clinics.
- Modified repository methods to handle new address types and added methods for counting and finding addresses by clinic.
- Implemented migration to update the database schema accordingly.
- Removed deprecated endpoint for creating addresses from clinics and updated related controller methods.
- Added new endpoints for managing clinic addresses, including CRUD operations.
- Updated frontend components to handle new address types and display accordingly.
2026-06-12 13:39:37 +03:30

15 KiB
Raw Blame History

پرامپت: غیرفعال کردن دکتر توسط صاحب کلینیک

هدف

صاحب کلینیک بتواند یک دکتر را از کلینیک خودش غیرفعال کند (نه حذف — تا سوابق حفظ شوند).
از لحظه غیرفعال شدن، تمام نوبت‌های آینده (pending/confirmed) آن دکتر لغو می‌شوند.
صاحب کلینیک می‌تواند در آینده دکتر را مجدداً فعال کند.


زمینه فنی موجود

جدول clinic_doctors (join table فعلی):

clinic_id INT  (PK)
doctor_id INT  (PK)
-- فاقد is_active یا deactivated_at

API موجود:

  • GET /api/v1/clinic/doctor-list/{clinicUuid} → لیست دکترهای کلینیک
    پیاده‌سازی: $clinic->getDoctors()->toArray()$d->toListArray()
    فیلد active در response = doctor.active_doctor_appointment (global flag — نه clinic-specific)

Frontend موجود:

  • ClinicDetailPage.tsx: نمایش لیست دکترها با badge active/inactive
    Interface: { id, uuid, name, gender, degree, img, specialties, active: boolean }
    Route: /admin/clinics/:uuid
    دسترسی: admin + clinic owner (هر دو می‌توانند ببینند)

احراز هویت clinic owner:

// در ClinicController.update:
if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
    return $this->error(..., 403);
}

همین الگو را در endpoint های جدید استفاده کن.

نوبت‌ها:

  • جدول appointments فقط doctor_id دارد — clinic_id و address_id ندارد
  • جدول doctor_addresses هم clinic_id ندارد — فقط doctor_id, name, address, telephone, city_id, province_id
  • weekly_schedules.setting (JSON) هر session دارای location_id است که به doctor_addresses.id اشاره دارد — اما این مقدار در appointment ذخیره نمی‌شود
  • بنابراین در حال حاضر از طریق دیتابیس نمی‌توان فهمید هر نوبت در کدام کلینیک بوده
  • استاتوس‌ها: pending, confirmed, completed, cancelled_by_doctor, cancelled_by_user, expired, no_show
  • کنسل کردن = تغییر status به cancelled_by_doctor

قابلیت‌ها


قابلیت ۱ — Migration: سه تغییر schema

برای پیاده‌سازی لغو نوبت‌های مختص کلینیک، سه ستون در سه جدول مختلف لازم است:

۱-الف: جدول clinic_doctors

is_active      TINYINT(1)  NOT NULL DEFAULT 1
deactivated_at INT(11)     NULL

۱-ب: جدول doctor_addresses

clinic_id      INT(11)     NULL   -- FK به clinics.id (اگر این آدرس متعلق به کلینیکی باشد)

۱-ج: جدول appointments

address_id     INT(11)     NULL   -- FK به doctor_addresses.id (آدرس محل نوبت)

چرا این سه تغییر؟

  • clinic_doctors.is_active + deactivated_at → برای مدیریت وضعیت دکتر در کلینیک
  • doctor_addresses.clinic_id → برای دانستن کدام آدرس‌ها به این کلینیک تعلق دارند
  • appointments.address_id → برای دانستن نوبت در کدام آدرس (و در نتیجه کدام کلینیک) ثبت شده

⚠️ هر سه migration باید دستی نوشته شوند (join table و فیلدهای extra با Doctrine Entity آپدیت نمی‌شوند به صورت خودکار).

Migration file (یک فایل، سه addSql):

$this->addSql("ALTER TABLE clinic_doctors ADD COLUMN is_active TINYINT(1) NOT NULL DEFAULT 1");
$this->addSql("ALTER TABLE clinic_doctors ADD COLUMN deactivated_at INT(11) NULL");
$this->addSql("ALTER TABLE doctor_addresses ADD COLUMN clinic_id INT(11) NULL");
$this->addSql("ALTER TABLE doctor_addresses ADD CONSTRAINT FK_doctor_addr_clinic FOREIGN KEY (clinic_id) REFERENCES clinics(id) ON DELETE SET NULL");
$this->addSql("ALTER TABLE appointments ADD COLUMN address_id INT(11) NULL");
$this->addSql("ALTER TABLE appointments ADD CONSTRAINT FK_appt_address FOREIGN KEY (address_id) REFERENCES doctor_addresses(id) ON DELETE SET NULL");

آپدیت Entity:

  • در DoctorAddress.php: فیلد private ?int $clinicId = null; اضافه کن (با getter/setter)
  • در Appointment.php: فیلد private ?int $addressId = null; اضافه کن (با getter/setter)

اجرا:

ddev exec php bin/console doctrine:migrations:migrate --no-interaction

قابلیت ۲ — Backend: endpoint غیرفعال کردن دکتر

فایل: src/Clinic/Controller/ClinicController.php

Route: PATCH /api/v1/clinic/{clinicUuid}/doctor/{doctorUuid}/deactivate

Auth: IS_AUTHENTICATED_FULLY + باید owner کلینیک باشد

منطق:

  1. کلینیک را با clinicUuid پیدا کن — اگر نبود: 404
  2. بررسی owner: $clinic->getUser()->getId() !== $user->getId() → 403
  3. دکتر را با doctorUuid پیدا کن — اگر نبود: 404
  4. بررسی عضویت: دکتر باید در clinic_doctors این کلینیک باشد → اگر نبود: 404
  5. بررسی: اگر قبلاً غیرفعال بود (is_active = 0) → ERR_CONFLICT_001 / 409
  6. با native SQL جدول join را آپدیت کن:
    UPDATE clinic_doctors SET is_active = 0, deactivated_at = :now
    WHERE clinic_id = :clinicId AND doctor_id = :doctorId
    
  7. کنسل کردن نوبت‌های آینده مختص این کلینیک:
    // فقط نوبت‌هایی که address_id آن‌ها به آدرس‌های این کلینیک اشاره می‌کند
    $future = $appointmentRepo->findFutureActiveByDoctorAndClinic($doctor, $clinic, time());
    foreach ($future as $appt) {
        $appt->transitionTo(Appointment::STATUS_CANCELLED_BY_DOCTOR);
    }
    $this->em->flush();
    
  8. Response: { message: 'دکتر غیرفعال شد', cancelled_appointments: count }

Repository method لازم (AppointmentRepository):

public function findFutureActiveByDoctorAndClinic(Doctor $doctor, Clinic $clinic, int $now): array
{
    // نوبت‌های آینده این دکتر که در آدرس‌های متعلق به این کلینیک رزرو شده‌اند
    return $this->createQueryBuilder('a')
        ->join(DoctorAddress::class, 'addr', 'WITH', 'a.addressId = addr.id')
        ->where('a.doctor = :doctor')
        ->andWhere('addr.clinicId = :clinicId')
        ->andWhere('a.slotStart > :now')
        ->andWhere('a.status IN (:statuses)')
        ->setParameter('doctor', $doctor)
        ->setParameter('clinicId', $clinic->getId())
        ->setParameter('now', $now)
        ->setParameter('statuses', [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED])
        ->getQuery()
        ->getResult();
}

⚠️ چون address_id یک integer column است (نه ORM relation)، از native query استفاده کن اگر DQL مشکل داشت:

return $this->getEntityManager()->createNativeQuery(
    "SELECT a.* FROM appointments a
     JOIN doctor_addresses da ON da.id = a.address_id
     WHERE a.doctor_id = :doctorId
       AND da.clinic_id = :clinicId
       AND a.slot_start > :now
       AND a.status IN ('pending', 'confirmed')",
    (new ResultSetMappingBuilder($this->getEntityManager()))->addRootEntityFromClassMetadata(Appointment::class, 'a')
)->setParameters(['doctorId' => $doctor->getId(), 'clinicId' => $clinic->getId(), 'now' => $now])
 ->getResult();

قابلیت ۲-ب — Backend: ذخیره address_id هنگام ثبت نوبت

فایل: src/Appointment/Controller/AppointmentController.php

برای اینکه بعداً بتوان نوبت‌ها را بر اساس کلینیک فیلتر کرد، باید address_id در هنگام booking ذخیره شود.

تغییر لازم در booking endpoint:

  • Request body باید address_id (nullable integer) را بپذیرد
  • این مقدار از location_id در slot response می‌آید (فرانت‌اند آن را دارد)
  • هنگام ایجاد Appointment: $appointment->setAddressId($request->get('address_id'))

⚠️ این تغییر باید با frontend booking flow هماهنگ باشد — location_id از هر slot به عنوان address_id ارسال شود.


قابلیت ۳ — Backend: endpoint فعال کردن مجدد دکتر

Route: PATCH /api/v1/clinic/{clinicUuid}/doctor/{doctorUuid}/reactivate

Auth: IS_AUTHENTICATED_FULLY + owner

منطق:

  1. کلینیک + owner check + دکتر + عضویت (همان قابلیت ۲)
  2. بررسی: اگر قبلاً فعال بود (is_active = 1) → ERR_CONFLICT_001 / 409
  3. آپدیت:
    UPDATE clinic_doctors SET is_active = 1, deactivated_at = NULL
    WHERE clinic_id = :clinicId AND doctor_id = :doctorId
    
  4. Response: { message: 'دکتر مجدداً فعال شد' }

قابلیت ۴ — Backend: آپدیت endpoint doctor-list

فایل: src/Clinic/Controller/ClinicController.php — متد doctorList

مشکل فعلی: active field از doctor.active_doctor_appointment می‌آید (global) — باید از clinic_doctors.is_active بیاید (clinic-specific).

راه‌حل: Query را به native SQL تغییر بده:

$rows = $conn->fetchAllAssociative(
    "SELECT d.uuid, d.name, d.gender, d.degree, d.images,
            cd.is_active, cd.deactivated_at,
            GROUP_CONCAT(DISTINCT cs.name SEPARATOR '||') as specialty_names,
            GROUP_CONCAT(DISTINCT cs.id   SEPARATOR '||') as specialty_ids
     FROM clinic_doctors cd
     JOIN doctors d ON d.id = cd.doctor_id
     JOIN clinics c  ON c.id = cd.clinic_id
     LEFT JOIN doctor_specialties ds ON ds.doctor_id = d.id
     LEFT JOIN categories cs ON cs.id = ds.category_id
     WHERE c.uuid = :uuid
     GROUP BY d.id, cd.is_active, cd.deactivated_at",
    ['uuid' => $clinicUuid]
);

Response format (همان ساختار قبلی + فیلدهای جدید):

{
  "uuid": "...",
  "name": "دکتر احمدی",
  "gender": "male",
  "degree": "متخصص",
  "img": [],
  "specialties": [{ "id": "1", "name": "قلب" }],
  "active": true,
  "deactivated_at": null
}

قابلیت ۵ — Frontend: دکمه غیرفعال/فعال در ClinicDetailPage.tsx

فایل: assets/admin/pages/ClinicDetailPage.tsx

موارد لازم:

آپدیت interface:

interface ClinicDoctorItem {
  id: string; uuid: string; name: string;
  gender: string | null; degree: string | null;
  img: { url: string }[];
  specialties: { id: string; name: string }[];
  active: boolean;
  deactivated_at: number | null; // جدید
}

منطق نمایش دکمه:

  • دکمه فقط برای clinic owner نمایش داده شود:
    const authStore = useAuthStore();
    const isOwner = authStore.context?.type === 'clinic' && authStore.dbUuid === uuid; // uuid از params
    
  • اگر isOwner = true: کنار هر دکتر دکمه نمایش بده

دکمه‌ها:

  • اگر doc.active = true:
    دکمه «غیرفعال کردن» (className: btn sm soft) با آیکون NoSymbolIcon
    با ConfirmDialog که متن هشدار داشته باشد: «تمام نوبت‌های آینده این دکتر لغو خواهند شد»

  • اگر doc.active = false:
    دکمه «فعال کردن» (className: btn sm primary) با آیکون CheckCircleIcon
    بدون confirm dialog

API calls (با useMutation):

const deactivateMutation = useMutation({
  mutationFn: (doctorUuid: string) =>
    api.patch(`/api/v1/clinic/${uuid}/doctor/${doctorUuid}/deactivate`, {}),
  onSuccess: (res) => {
    const count = res?.data?.cancelled_appointments ?? 0;
    toast.success(`دکتر غیرفعال شد${count ? ` — ${count} نوبت لغو شد` : ''}`);
    queryClient.invalidateQueries({ queryKey: ['clinic-doctors', uuid] });
  },
  onError: (err) => toast.error(err.message ?? 'خطا'),
});

const reactivateMutation = useMutation({
  mutationFn: (doctorUuid: string) =>
    api.patch(`/api/v1/clinic/${uuid}/doctor/${doctorUuid}/reactivate`, {}),
  onSuccess: () => {
    toast.success('دکتر مجدداً فعال شد');
    queryClient.invalidateQueries({ queryKey: ['clinic-doctors', uuid] });
  },
  onError: (err) => toast.error(err.message ?? 'خطا'),
});

UX نکات:

  • ConfirmDialog برای deactivate: عنوان «غیرفعال کردن دکتر»، متن «تمام نوبت‌های آینده این دکتر لغو می‌شوند. آیا مطمئن هستید؟»
  • badge دکتر غیرفعال: className="badge gray" + تاریخ غیرفعالی با formatDate(String(doc.deactivated_at))
  • import لازم: NoSymbolIcon, CheckCircleIcon از @heroicons/react/24/outline
  • ConfirmDialog از ../components/ui/ConfirmDialog

ترتیب اجرا

  1. Migration دستی (یک فایل): clinic_doctors.is_active/deactivated_at + doctor_addresses.clinic_id + appointments.address_id
  2. آپدیت Entity: DoctorAddress.clinicId و Appointment.addressId (getter/setter)
  3. Backend: آپدیت booking endpoint تا address_id را بگیرد و ذخیره کند
  4. Backend: findFutureActiveByDoctorAndClinic در AppointmentRepository
  5. Backend: deactivate endpoint
  6. Backend: reactivate endpoint
  7. Backend: آپدیت doctorList به native SQL (شامل is_active از clinic_doctors)
  8. تست backend
  9. Frontend: آپدیت ClinicDetailPage.tsx (دکمه deactivate/reactivate)
  10. تست frontend
  11. مستندسازی docs/api/clinic.md

تست هر مرحله

# migration (دستی ساخته می‌شود — نه از طریق diff)
ddev exec php bin/console doctrine:migrations:migrate --no-interaction

# syntax
ddev exec php -l src/Clinic/Controller/ClinicController.php
ddev exec php -l src/Appointment/Repository/AppointmentRepository.php
ddev exec php -l src/Doctor/Entity/DoctorAddress.php
ddev exec php -l src/Appointment/Entity/Appointment.php
ddev exec php -l src/Appointment/Controller/AppointmentController.php

# cache + routes
ddev exec php bin/console cache:clear
ddev exec php bin/console debug:router | grep -E "deactivate|reactivate"

# frontend
ddev exec yarn dev
ddev exec npx tsc --noEmit --project tsconfig.json 2>&1 | head -20

مستندسازی

به docs/api/clinic.md اضافه کن:

PATCH /api/v1/clinic/{clinicUuid}/doctor/{doctorUuid}/deactivate

PATCH /api/v1/clinic/{clinicUuid}/doctor/{doctorUuid}/reactivate

آپدیت GET /api/v1/clinic/doctor-list/{clinicUuid} (فیلدهای جدید)