- 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.
15 KiB
پرامپت: غیرفعال کردن دکتر توسط صاحب کلینیک
هدف
صاحب کلینیک بتواند یک دکتر را از کلینیک خودش غیرفعال کند (نه حذف — تا سوابق حفظ شوند).
از لحظه غیرفعال شدن، تمام نوبتهای آینده (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 کلینیک باشد
منطق:
- کلینیک را با
clinicUuidپیدا کن — اگر نبود: 404 - بررسی owner:
$clinic->getUser()->getId() !== $user->getId()→ 403 - دکتر را با
doctorUuidپیدا کن — اگر نبود: 404 - بررسی عضویت: دکتر باید در
clinic_doctorsاین کلینیک باشد → اگر نبود: 404 - بررسی: اگر قبلاً غیرفعال بود (
is_active = 0) →ERR_CONFLICT_001/ 409 - با native SQL جدول join را آپدیت کن:
UPDATE clinic_doctors SET is_active = 0, deactivated_at = :now WHERE clinic_id = :clinicId AND doctor_id = :doctorId - کنسل کردن نوبتهای آینده مختص این کلینیک:
// فقط نوبتهایی که address_id آنها به آدرسهای این کلینیک اشاره میکند $future = $appointmentRepo->findFutureActiveByDoctorAndClinic($doctor, $clinic, time()); foreach ($future as $appt) { $appt->transitionTo(Appointment::STATUS_CANCELLED_BY_DOCTOR); } $this->em->flush(); - 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
منطق:
- کلینیک + owner check + دکتر + عضویت (همان قابلیت ۲)
- بررسی: اگر قبلاً فعال بود (
is_active = 1) →ERR_CONFLICT_001/ 409 - آپدیت:
UPDATE clinic_doctors SET is_active = 1, deactivated_at = NULL WHERE clinic_id = :clinicId AND doctor_id = :doctorId - 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
ترتیب اجرا
- Migration دستی (یک فایل):
clinic_doctors.is_active/deactivated_at+doctor_addresses.clinic_id+appointments.address_id - آپدیت Entity:
DoctorAddress.clinicIdوAppointment.addressId(getter/setter) - Backend: آپدیت booking endpoint تا
address_idرا بگیرد و ذخیره کند - Backend:
findFutureActiveByDoctorAndClinicدرAppointmentRepository - Backend:
deactivateendpoint - Backend:
reactivateendpoint - Backend: آپدیت
doctorListبه native SQL (شاملis_activeازclinic_doctors) - تست backend
- Frontend: آپدیت
ClinicDetailPage.tsx(دکمه deactivate/reactivate) - تست frontend
- مستندسازی
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 اضافه کن: