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:
@@ -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}`
|
||||
Reference in New Issue
Block a user