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.
This commit is contained in:
hamed
2026-06-12 13:39:37 +03:30
parent 63073c6a42
commit 0333b24071
13 changed files with 1446 additions and 89 deletions
+355
View File
@@ -0,0 +1,355 @@
# پرامپت: غیرفعال کردن دکتر توسط صاحب کلینیک
## هدف
صاحب کلینیک بتواند یک دکتر را از کلینیک خودش **غیرفعال** کند (نه حذف — تا سوابق حفظ شوند).
از لحظه غیرفعال شدن، تمام نوبت‌های آینده (pending/confirmed) آن دکتر **لغو** می‌شوند.
صاحب کلینیک می‌تواند در آینده دکتر را مجدداً فعال کند.
---
## زمینه فنی موجود
### جدول `clinic_doctors` (join table فعلی):
```sql
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:
```php
// در 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`
```sql
is_active TINYINT(1) NOT NULL DEFAULT 1
deactivated_at INT(11) NULL
```
#### ۱-ب: جدول `doctor_addresses`
```sql
clinic_id INT(11) NULL -- FK به clinics.id (اگر این آدرس متعلق به کلینیکی باشد)
```
#### ۱-ج: جدول `appointments`
```sql
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):**
```php
$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)
**اجرا:**
```bash
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 را آپدیت کن:
```sql
UPDATE clinic_doctors SET is_active = 0, deactivated_at = :now
WHERE clinic_id = :clinicId AND doctor_id = :doctorId
```
7. **کنسل کردن نوبت‌های آینده مختص این کلینیک:**
```php
// فقط نوبت‌هایی که 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`):**
```php
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 مشکل داشت:
> ```php
> 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. آپدیت:
```sql
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 تغییر بده:
```php
$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 (همان ساختار قبلی + فیلدهای جدید):
```json
{
"uuid": "...",
"name": "دکتر احمدی",
"gender": "male",
"degree": "متخصص",
"img": [],
"specialties": [{ "id": "1", "name": "قلب" }],
"active": true,
"deactivated_at": null
}
```
---
### قابلیت ۵ — Frontend: دکمه غیرفعال/فعال در `ClinicDetailPage.tsx`
**فایل:** `assets/admin/pages/ClinicDetailPage.tsx`
**موارد لازم:**
#### آپدیت interface:
```ts
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** نمایش داده شود:
```ts
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`):
```ts
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`
---
## تست هر مرحله
```bash
# 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}` (فیلدهای جدید)
+418
View File
@@ -0,0 +1,418 @@
# پرامپت: مدیریت آدرس کلینیک و location_id در برنامه هفتگی
## هدف
مشکل فعلی: `location_id` در برنامه هفتگی (`weekly_schedules.setting`) به `doctor_addresses.id` اشاره می‌کند،
اما این آدرس‌ها فقط شخصی هستند (به `doctor_id` وابسته‌اند) و هیچ ارتباطی با کلینیک ندارند.
**خواسته:**
۱. کلینیک باید بتواند آدرس خودش را ثبت و مدیریت کند (آدرس کلینیک، نه آدرس شخصی دکتر)
۲. وقتی دکتری به کلینیک اضافه شد، بتواند آدرس آن کلینیک را به عنوان `location_id` در برنامه‌اش انتخاب کند
۳. آدرس‌های شخصی دکتر هم همچنان قابل استفاده باشند
۴. endpoint فعلی `POST /api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}` که یک workaround بود باید حذف شود
---
## زمینه فنی موجود
### جدول `doctor_addresses` (فعلی):
```sql
id, uuid, name, address, telephone, latitude, longitude,
created_at, updated_at,
doctor_id INT NOT NULL, -- FK به doctors.id (CASCADE)
city_id INT NULL,
province_id INT NULL
```
→ فقط برای آدرس‌های شخصی دکتر — clinic_id ندارد
### Entity `DoctorAddress`:
- constructor: `__construct(Doctor $doctor)` — فقط برای دکتر
- `toArray()``{ id, uuid, name, address, telephone, map, city, province }`
### Endpoint موجود (workaround که حذف می‌شود):
- `POST /api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}` در `DoctorController.php`
- این endpoint آدرس کلینیک را کپی می‌کند به عنوان آدرس شخصی دکتر — **باید حذف شود**
### Frontend موجود (`DoctorDetailPage.tsx`):
- `addresses` از `doctor.address` می‌آید (فقط آدرس‌های شخصی دکتر)
- `location_id` dropdown در `SessionEditor` از همین `addresses` ساخته می‌شود
- warning «مکان مطب الزامی است» اگر `location_id = null` در session فعال باشد
- interface: `AddressData { id: string; name: string | null; address: string | null; telephone: string | null; map: {...}; city: {...}; province: {...} }`
### API endpoint موجود برای آدرس دکتر:
- `POST /api/v1/clinic-pro/doctor-address` — ایجاد آدرس شخصی دکتر
- `GET /api/v1/clinic-pro/doctor-addresses/{doctorId}` — لیست آدرس‌های دکتر
- `PATCH /api/v1/clinic-pro/doctor-address/{id}` — ویرایش
- `DELETE /api/v1/clinic-pro/doctor-address/{id}` — حذف
---
## قابلیت‌ها
---
### قابلیت ۱ — Migration: تغییر ساختار `doctor_addresses`
سه تغییر در یک migration file:
```php
// ۱. اضافه کردن type
$this->addSql("ALTER TABLE doctor_addresses ADD COLUMN type VARCHAR(10) NOT NULL DEFAULT 'personal'");
// ۲. clinic_id nullable FK
$this->addSql("ALTER TABLE doctor_addresses ADD COLUMN clinic_id INT NULL");
$this->addSql("ALTER TABLE doctor_addresses ADD CONSTRAINT FK_doctor_addr_clinic FOREIGN KEY (clinic_id) REFERENCES clinics(id) ON DELETE CASCADE");
$this->addSql("CREATE INDEX idx_doctor_addr_clinic ON doctor_addresses (clinic_id)");
// ۳. doctor_id را nullable کن (آدرس‌های کلینیک doctor ندارند)
$this->addSql("ALTER TABLE doctor_addresses MODIFY COLUMN doctor_id INT NULL");
// قانون: حداقل یکی از doctor_id یا clinic_id باید مقدار داشته باشد
$this->addSql("ALTER TABLE doctor_addresses ADD CONSTRAINT chk_addr_owner CHECK (doctor_id IS NOT NULL OR clinic_id IS NOT NULL)");
```
**مقادیر `type`:**
- `personal` — آدرس شخصی دکتر (doctor_id NOT NULL, clinic_id NULL)
- `clinic` — آدرس کلینیک (clinic_id NOT NULL, doctor_id NULL)
```bash
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```
---
### قابلیت ۲ — آپدیت Entity `DoctorAddress`
**فایل:** `src/Doctor/Entity/DoctorAddress.php`
تغییرات:
- `doctor_id` → nullable (`?int $doctorId` + join nullable)
- اضافه کردن `clinic_id` (nullable int)
- اضافه کردن `type` string (`personal` | `clinic`)
- constructor: دو حالت — برای دکتر یا برای کلینیک
```php
// فیلدهای جدید
#[ORM\Column(type: 'string', length: 10)]
private string $type = 'personal';
#[ORM\Column(name: 'clinic_id', type: 'integer', nullable: true)]
private ?int $clinicId = null;
// doctor nullable شود
#[ORM\ManyToOne(targetEntity: Doctor::class, inversedBy: 'addresses')]
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Doctor $doctor = null;
```
Constructor باید هر دو حالت را پشتیبانی کند:
```php
public static function forDoctor(Doctor $doctor): self {
$a = new self();
$a->type = 'personal';
$a->doctor = $doctor;
return $a;
}
public static function forClinic(int $clinicId): self {
$a = new self();
$a->type = 'clinic';
$a->clinicId = $clinicId;
return $a;
}
```
`toArray()` آپدیت شود: فیلد `type` اضافه شود:
```php
'type' => $this->type,
'clinic_id' => $this->clinicId,
```
> ⚠️ Constructor قدیمی `__construct(Doctor $doctor)` تغییر می‌کند. همه جاهایی که `new DoctorAddress($doctor)` دارند باید به `DoctorAddress::forDoctor($doctor)` تغییر کنند:
> - `src/Doctor/Controller/DoctorController.php` متد `createAddress` و `createAddressFromClinic`
---
### قابلیت ۳ — حذف endpoint قدیمی و آپدیت DoctorController
**فایل:** `src/Doctor/Controller/DoctorController.php`
۱. متد `createAddressFromClinic` (route: `POST /api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}`) را کامل حذف کن.
۲. در `createAddress`: `new DoctorAddress($doctor)``DoctorAddress::forDoctor($doctor)`
۳. در `updateAddress` (PATCH): چک کن آدرس از نوع `personal` باشد — آدرس‌های `clinic` type را فقط صاحب کلینیک می‌تواند ویرایش کند (در قابلیت ۴ هندل می‌شود)
---
### قابلیت ۴ — CRUD آدرس کلینیک
**فایل:** `src/Clinic/Controller/ClinicController.php`
#### `POST /api/v1/clinic/{clinicUuid}/address` — ایجاد آدرس کلینیک
Auth: صاحب کلینیک یا admin
Request body:
```json
{
"name": "شعبه مرکزی",
"address": "تهران، خیابان ولیعصر...",
"telephone": "02112345678",
"latitude": 35.699,
"longitude": 51.337,
"city_id": 123,
"province_id": 7
}
```
منطق:
1. کلینیک را پیدا کن با `clinicUuid` → 404 اگر نبود
2. بررسی owner → 403 اگر دسترسی نداشت
3. `DoctorAddress::forClinic($clinic->getId())` بساز
4. فیلدها را ست کن (name, address, telephone, lat/lng, city, province)
5. ذخیره و برگردان
Response: `{ success, data: { id, uuid, type: 'clinic', name, address, ... } }`
#### `PATCH /api/v1/clinic/{clinicUuid}/address/{addressUuid}` — ویرایش
- پیدا کردن `DoctorAddress` با `uuid` و `clinic_id = clinic.id`
- بررسی ownership
- آپدیت فیلدها
#### `DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}` — حذف
- اگر فقط یک آدرس مانده → 409 با پیام «کلینیک باید حداقل یک آدرس داشته باشد»
- در غیر این صورت حذف کن
#### `GET /api/v1/clinic/{clinicUuid}/addresses` — لیست آدرس‌ها
- برگرداندن همه `doctor_addresses` که `clinic_id = clinic.id`
- Auth: public (برای نمایش در صفحه کلینیک برای بیماران)
> ⚠️ برای find با uuid نیاز به متد `findByUuidAndClinic(string $uuid, int $clinicId)` در `DoctorAddressRepository` است.
---
### قابلیت ۵ — Endpoint آدرس‌های در دسترس دکتر
**فایل:** `src/Appointment/Controller/AppointmentSettingsController.php`
**Route:** `GET /api/v1/appointment-settings/available-locations/{doctorUuid}`
**Auth:** IS_AUTHENTICATED_FULLY (دکتر یا admin)
**منطق:**
```php
// ۱. آدرس‌های شخصی دکتر
$personal = $addressRepo->findBy(['doctor' => $doctor, 'type' => 'personal']);
// ۲. آدرس‌های کلینیک‌هایی که این دکتر عضوشان است
$clinicIds = array_map(fn(Clinic $c) => $c->getId(), $clinicRepo->findByDoctor($doctor));
$clinicAddresses = $addressRepo->findBy(['clinicId' => $clinicIds, 'type' => 'clinic']);
// ۳. ادغام با label مناسب
```
Response:
```json
{
"success": true,
"data": [
{
"id": "5",
"uuid": "...",
"type": "personal",
"name": "مطب شخصی",
"address": "...",
"clinic_name": null
},
{
"id": "12",
"uuid": "...",
"type": "clinic",
"name": "شعبه مرکزی",
"address": "...",
"clinic_name": "کلینیک نور"
}
]
}
```
> `clinic_name` برای آدرس‌های `clinic` type از جدول `clinics` می‌آید — با یک query join بگیر.
**Repository method لازم:**
```php
// در DoctorAddressRepository
public function findAvailableForDoctor(Doctor $doctor, array $clinicIds): array
{
$qb = $this->createQueryBuilder('a');
return $qb
->where('(a.doctor = :doctor AND a.type = :personal)')
->orWhere('(a.clinicId IN (:clinicIds) AND a.type = :clinic)')
->setParameter('doctor', $doctor)
->setParameter('personal', 'personal')
->setParameter('clinicIds', empty($clinicIds) ? [0] : $clinicIds)
->setParameter('clinic', 'clinic')
->getQuery()
->getResult();
}
```
**Security:** اضافه کردن به `public_endpoints` در `security.yaml`:
```yaml
pattern: ^/(api/v1/appointment-settings/available-locations/|...)
```
---
### قابلیت ۶ — Frontend: `DoctorDetailPage.tsx`
**فایل:** `assets/admin/pages/DoctorDetailPage.tsx`
#### آپدیت interface:
```ts
interface AddressData {
id: string;
uuid: string;
type: 'personal' | 'clinic';
name: string | null;
address: string | null;
telephone: string | null;
map: { latitude: string | null; longitude: string | null };
city: { id: string; name: string } | null;
province: { id: string; name: string } | null;
clinic_name: string | null; // جدید
}
```
#### آپدیت بارگذاری آدرس‌ها در `ScheduleSection`:
فعلاً: `addresses = doctor.address ?? []` (فقط شخصی)
جدید: آدرس‌ها از `available-locations` endpoint بارگذاری شوند:
```ts
const locationsQ = useQuery({
queryKey: ['available-locations', doctorUuid],
queryFn: () => api.get<ApiResponse<AddressData[]>>(`/api/v1/appointment-settings/available-locations/${doctorUuid}`),
enabled: !!doctorUuid,
});
const availableLocations = locationsQ.data?.data ?? [];
```
این query در `ScheduleSection` component باشد و `addresses` prop از parent حذف شود.
#### آپدیت `SessionEditor` — dropdown لوکیشن:
نمایش label مناسب با type:
```tsx
<select value={session.location_id ?? ''}
onChange={e => upd('location_id', e.target.value ? Number(e.target.value) : null)}>
<option value="">انتخاب کنید</option>
{/* آدرس‌های شخصی */}
{addresses.filter(a => a.type === 'personal').length > 0 && (
<optgroup label="مطب شخصی">
{addresses.filter(a => a.type === 'personal').map(a => (
<option key={a.id} value={a.id}>{a.name ?? a.address ?? `مطب ${a.id}`}</option>
))}
</optgroup>
)}
{/* آدرس‌های کلینیک */}
{addresses.filter(a => a.type === 'clinic').length > 0 && (
<optgroup label="کلینیک‌ها">
{addresses.filter(a => a.type === 'clinic').map(a => (
<option key={a.id} value={a.id}>{a.clinic_name ? `${a.clinic_name}${a.name ?? a.address}` : (a.name ?? a.address ?? `کلینیک ${a.id}`)}</option>
))}
</optgroup>
)}
</select>
```
---
### قابلیت ۷ — Frontend: مدیریت آدرس کلینیک در `ClinicDetailPage.tsx`
**فایل:** `assets/admin/pages/ClinicDetailPage.tsx`
#### Interface جدید:
```ts
interface ClinicAddress {
id: string; uuid: string;
name: string | null; address: string | null;
telephone: string | null;
map: { latitude: string | null; longitude: string | null };
city: { id: string; name: string } | null;
province: { id: string; name: string } | null;
}
```
#### بارگذاری آدرس‌های کلینیک:
```ts
const addressesQ = useQuery({
queryKey: ['clinic-addresses', uuid],
queryFn: () => api.get<ApiResponse<ClinicAddress[]>>(`/api/v1/clinic/${uuid}/addresses`),
});
const clinicAddresses = addressesQ.data?.data ?? [];
```
#### UI (فقط برای clinic owner):
- سکشن «آدرس‌های کلینیک» با دکمه «افزودن آدرس»
- هر آدرس: نام + آدرس + تلفن + دکمه‌های ویرایش/حذف
- اگر آدرسی ندارد: warning «کلینیک هنوز آدرسی ثبت نکرده — دکتران نمی‌توانند این کلینیک را به عنوان لوکیشن انتخاب کنند»
- Modal برای add/edit با فیلدهای: name, address, telephone, city_id, province_id
- mutation‌های `addAddressMutation`, `editAddressMutation`, `deleteAddressMutation`
---
## ترتیب اجرا
1. Migration: آپدیت `doctor_addresses` (type + clinic_id + nullable doctor_id)
2. Entity: آپدیت `DoctorAddress` (static factory methods, فیلدهای جدید)
3. Controller: آپدیت `DoctorController` (حذف `from-clinic` workaround، آپدیت `createAddress`)
4. Controller: اضافه کردن CRUD آدرس کلینیک به `ClinicController`
5. Repository: `findAvailableForDoctor` و `findByUuidAndClinic` به `DoctorAddressRepository`
6. Controller: endpoint `available-locations` به `AppointmentSettingsController`
7. Security: آپدیت `security.yaml` برای `available-locations` public
8. تست backend
9. Frontend: آپدیت `DoctorDetailPage.tsx` (ScheduleSection، SessionEditor)
10. Frontend: آپدیت `ClinicDetailPage.tsx` (مدیریت آدرس)
11. تست frontend
12. مستندسازی `docs/api/clinic.md` و `docs/api/appointment-settings.md`
---
## تست هر مرحله
```bash
# migration
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
# syntax
ddev exec php -l src/Doctor/Entity/DoctorAddress.php
ddev exec php -l src/Doctor/Controller/DoctorController.php
ddev exec php -l src/Clinic/Controller/ClinicController.php
ddev exec php -l src/Appointment/Controller/AppointmentSettingsController.php
# cache + routes
ddev exec php bin/console cache:clear
ddev exec php bin/console debug:router | grep -E "clinic.*address|available-location"
# frontend
ddev exec yarn dev
ddev exec npx tsc --noEmit --project tsconfig.json 2>&1 | head -30
```
---
## مستندسازی
### `docs/api/clinic.md`:
- `POST /api/v1/clinic/{clinicUuid}/address`
- `PATCH /api/v1/clinic/{clinicUuid}/address/{addressUuid}`
- `DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}`
- `GET /api/v1/clinic/{clinicUuid}/addresses`
- حذف endpoint: `POST /api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}`
### `docs/api/appointment-settings.md`:
- `GET /api/v1/appointment-settings/available-locations/{doctorUuid}`