feat: implement specialty hierarchy handling in doctor and representation APIs, add backfill command and tests
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
# اصلاح دادهٔ موجود: افزودن تخصصهای والد به پزشکان (backfill)
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Backend — Symfony 7.4 / Doctrine)
|
||||
|
||||
پرامپت همتا (خزنده، برای جلوگیری از تکرار مشکل در ایمپورتهای آینده):
|
||||
`clinicpro-crawler/.claude/prompt/specialty-parent-chain.md` — **آن را اول اجرا کن.**
|
||||
|
||||
## زمینه
|
||||
|
||||
جدول `specialties` سلسلهمراتبی است (`parent_id` self-referencing). خزنده تا امروز فقط id تخصص برگ را در `POST /api/v1/admin/doctors/import` میفرستاد، پس در `doctor_specialties` فقط ردیف برگ ثبت شده. مثال: پزشک با تخصص «گوارش و کبد» (id=3) ردیف تخصص «داخلی» (id=2) را ندارد و در فیلتر تخصص سطحاول سایت عمومی دیده نمیشود.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
۱. **دادهٔ موجود:** برای هر ردیف `doctor_specialties` که تخصصش `parent_id` دارد، تمام والدها تا ریشه باید اضافه شوند — چند سطح، بدون رکورد تکراری.
|
||||
۲. **جلوگیری از بازگشت مشکل:** مسیرهای نوشتن تخصص در بکاند باید خودشان زنجیرهٔ والد را باز کنند، تا هر کلاینتی (خزنده، پنل ادمین، پنل نماینده) که فقط برگ بفرستد داده درست ثبت شود.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Specialty/Entity/Specialty.php` | Entity تخصص؛ رابطهٔ `parent` |
|
||||
| `src/Doctor/Entity/Doctor.php` | ManyToMany به `Specialty` روی جدول `doctor_specialties` |
|
||||
| `src/Doctor/Service/DoctorImportService.php` | ایمپورت irimc؛ `syncRefCollection()` |
|
||||
| `src/Admin/Controller/AdminApiController.php` | ساخت پزشک از پنل ادمین |
|
||||
| `src/Representation/Controller/RepresentationActionController.php` | ساخت پزشک توسط نماینده |
|
||||
| `src/Doctor/Controller/DoctorController.php` | بهروزرسانی پروفایل پزشک |
|
||||
| `src/Doctor/Command/BackfillSurrogateRoleCommand.php` | الگوی مرجع برای نوشتن command جدید backfill |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
`src/Specialty/Entity/Specialty.php:37-39` — رابطهٔ والد موجود است، ولی **inverse collection `children` وجود ندارد**:
|
||||
|
||||
```php
|
||||
#[ORM\ManyToOne(targetEntity: self::class)]
|
||||
#[ORM\JoinColumn(name: 'parent_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?self $parent = null;
|
||||
```
|
||||
|
||||
`src/Doctor/Entity/Doctor.php:109-115` — رابطهٔ یکطرفه؛ **متد `addSpecialty()` وجود ندارد** و همهٔ فراخوانها مستقیم روی Collection کار میکنند:
|
||||
|
||||
```php
|
||||
#[ORM\ManyToMany(targetEntity: Specialty::class)]
|
||||
#[ORM\JoinTable(
|
||||
name: 'doctor_specialties',
|
||||
joinColumns: [new ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id')],
|
||||
inverseJoinColumns: [new ORM\JoinColumn(name: 'specialty_id', referencedColumnName: 'id')]
|
||||
)]
|
||||
private Collection $specialties;
|
||||
```
|
||||
|
||||
جدول: `doctor_specialties (doctor_id INT, specialty_id INT, PRIMARY KEY (doctor_id, specialty_id))` — کلید اصلی مرکب، پس درج تکراری در سطح DB هم غیرممکن است.
|
||||
|
||||
`src/Doctor/Service/DoctorImportService.php:164-176` — سینک عمومی (replace semantics):
|
||||
|
||||
```php
|
||||
private function syncRefCollection(\Doctrine\Common\Collections\Collection $col, ?array $ids, string $class): void
|
||||
{
|
||||
if ($ids === null) {
|
||||
return;
|
||||
}
|
||||
$col->clear();
|
||||
foreach ($ids as $id) {
|
||||
$ref = $this->em->getRepository($class)->find((int) $id);
|
||||
if ($ref !== null && !$col->contains($ref)) {
|
||||
$col->add($ref);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`src/Admin/Controller/AdminApiController.php:492-497` (و عیناً همین شکل در `RepresentationActionController.php:291-296`):
|
||||
|
||||
```php
|
||||
if (!empty($data['specialties']) && is_array($data['specialties'])) {
|
||||
foreach ($data['specialties'] as $id) {
|
||||
$s = $this->em->getRepository(Specialty::class)->find((int) $id);
|
||||
if ($s !== null) $doctor->getSpecialties()->add($s);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`src/Doctor/Controller/DoctorController.php:792-799`:
|
||||
|
||||
```php
|
||||
// Specialties
|
||||
if (array_key_exists('specialties', $data) && is_array($data['specialties'])) {
|
||||
$doctor->getSpecialties()->clear();
|
||||
foreach ($data['specialties'] as $id) {
|
||||
$s = $this->specialtyRepo->find((int) $id);
|
||||
if ($s !== null) $doctor->getSpecialties()->add($s);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. متد `expandWithAncestors()` در `SpecialtyRepository`
|
||||
|
||||
فایل: `src/Specialty/Repository/SpecialtyRepository.php`
|
||||
|
||||
یک متد بگذار که لیستی از idها را بگیرد و لیست کامل (خود + همهٔ والدها تا ریشه، یکتا) برگرداند. برای پرهیز از N+1 در backfill انبوه، نقشهٔ `id => parent_id` را یکبار بخوان و کش کن:
|
||||
|
||||
```php
|
||||
/** @var array<int,?int>|null */
|
||||
private ?array $parentMap = null;
|
||||
|
||||
/** نقشهٔ id => parent_id از کل جدول تخصصها (یکبار در طول عمر ریکوئست). */
|
||||
private function parentMap(): array
|
||||
{
|
||||
if ($this->parentMap === null) {
|
||||
$rows = $this->createQueryBuilder('s')
|
||||
->select('s.id AS id', 'IDENTITY(s.parent) AS parent')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$this->parentMap = [];
|
||||
foreach ($rows as $r) {
|
||||
$this->parentMap[(int) $r['id']] = $r['parent'] !== null ? (int) $r['parent'] : null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->parentMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* شناسههای تخصص → همان شناسهها بهعلاوهٔ تمام والدها تا ریشه، یکتا و مرتب.
|
||||
*
|
||||
* @param int[] $ids
|
||||
* @return int[]
|
||||
*/
|
||||
public function expandWithAncestors(array $ids): array
|
||||
{
|
||||
$map = $this->parentMap();
|
||||
$out = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$cur = (int) $id;
|
||||
$seen = [];
|
||||
while ($cur !== 0 && !isset($seen[$cur]) && array_key_exists($cur, $map)) {
|
||||
$seen[$cur] = true;
|
||||
$out[$cur] = true;
|
||||
$cur = $map[$cur] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
$out = array_keys($out);
|
||||
sort($out);
|
||||
|
||||
return $out;
|
||||
}
|
||||
```
|
||||
|
||||
نکات پیادهسازی:
|
||||
- `isset($seen[$cur])` محافظ چرخه است (`parent_id == id` یا چرخهٔ چندنودی در دادهٔ seed) — بدون آن، حلقه بینهایت میشود.
|
||||
- `array_key_exists($cur, $map)` idهای ناموجود را دور میاندازد (رکورد جعل نمیکنیم).
|
||||
- `parentMap` را per-instance کش کن، نه static؛ در یک اجرای command کافی است.
|
||||
|
||||
### ۲. اعمال گسترش در همهٔ مسیرهای نوشتن
|
||||
|
||||
هر چهار جای بالا باید قبل از حلقهٔ افزودن، ids را از `expandWithAncestors()` رد کنند. مثال برای `AdminApiController.php:492-497`:
|
||||
|
||||
```php
|
||||
if (!empty($data['specialties']) && is_array($data['specialties'])) {
|
||||
$repo = $this->em->getRepository(Specialty::class);
|
||||
foreach ($repo->expandWithAncestors(array_map('intval', $data['specialties'])) as $id) {
|
||||
$s = $repo->find($id);
|
||||
if ($s !== null && !$doctor->getSpecialties()->contains($s)) {
|
||||
$doctor->getSpecialties()->add($s);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `RepresentationActionController.php:291-296` — همین الگو عیناً.
|
||||
- `DoctorController.php:792-799` — همین الگو، ولی `clear()` قبلی حفظ شود (replace semantics پروفایل).
|
||||
- `DoctorImportService.php:164-176` — `syncRefCollection()` عمومی است و برای استان/شهر هم استفاده میشود، پس **داخل آن گسترش نکن**. بهجایش در فراخوانی خط ۱۱۴-۱۱۷ ids را قبل از پاسدادن گسترش بده:
|
||||
|
||||
```php
|
||||
$specialtyIds = $data['specialties'] ?? null;
|
||||
if (is_array($specialtyIds)) {
|
||||
$specialtyIds = $this->em->getRepository(Specialty::class)
|
||||
->expandWithAncestors(array_map('intval', $specialtyIds));
|
||||
}
|
||||
$this->syncRefCollection($doctor->getSpecialties(), $specialtyIds, Specialty::class);
|
||||
```
|
||||
|
||||
`null` باید `null` بماند (یعنی «دست نزن»)، نه آرایهٔ خالی — وگرنه ایمپورتی که فیلد تخصص ندارد، تخصصهای موجود پزشک را پاک میکند.
|
||||
|
||||
### ۳. Command جدید backfill
|
||||
|
||||
فایل: `src/Doctor/Command/BackfillDoctorSpecialtyParentsCommand.php`
|
||||
نام: `app:doctors:backfill-specialty-parents`
|
||||
|
||||
از `src/Doctor/Command/BackfillSurrogateRoleCommand.php` بهعنوان الگو استفاده کن — همان کنوانسیونها: namespace `App\Doctor\Command`، `#[AsCommand]` چندخطی با کاما انتهایی، docblock فارسی با خط دقیق فراخوانی، DI با promoted readonly، `--dry-run` از نوع `InputOption::VALUE_NONE`، `SymfonyStyle` در اولین خط `execute()`، خروجی هر ردیف با `[dry-run]` / `[update]`، `$io->success(sprintf(...))` انتهایی، `flush()` یکبار و مشروط.
|
||||
|
||||
منطق:
|
||||
|
||||
```php
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
$repo = $this->em->getRepository(Specialty::class);
|
||||
|
||||
$doctors = $this->em->getRepository(Doctor::class)->createQueryBuilder('d')
|
||||
->getQuery()->toIterable();
|
||||
|
||||
$touched = 0; $added = 0;
|
||||
foreach ($doctors as $i => $doctor) {
|
||||
$current = array_map(static fn (Specialty $s) => $s->getId(), $doctor->getSpecialties()->toArray());
|
||||
if ($current === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$missing = array_diff($repo->expandWithAncestors($current), $current);
|
||||
if ($missing === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$io->text(sprintf(
|
||||
'%s پزشک #%d — افزودن تخصص: %s',
|
||||
$dryRun ? '[dry-run]' : '[update]',
|
||||
$doctor->getId(),
|
||||
implode(', ', $missing)
|
||||
));
|
||||
|
||||
if (!$dryRun) {
|
||||
foreach ($missing as $id) {
|
||||
$s = $repo->find($id);
|
||||
if ($s !== null) {
|
||||
$doctor->getSpecialties()->add($s);
|
||||
}
|
||||
}
|
||||
}
|
||||
$touched++; $added += count($missing);
|
||||
|
||||
if (!$dryRun && $i % 200 === 0) {
|
||||
$this->em->flush();
|
||||
$this->em->clear(); // توجه به هشدار زیر
|
||||
}
|
||||
}
|
||||
|
||||
if (!$dryRun && $touched > 0) {
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
$io->success(sprintf('%d پزشک اصلاح شد، %d ردیف تخصص افزوده شد.', $touched, $added));
|
||||
|
||||
return Command::SUCCESS;
|
||||
```
|
||||
|
||||
**هشدار دربارهٔ `clear()`:** اگر `$this->em->clear()` صدا بزنی، کش `parentMap` داخل repository پاک نمیشود ولی entityهای `Specialty` detach میشوند و `add()` بعدی خطا میدهد. یا `clear()` را حذف کن (سادگی، حافظهٔ بیشتر)، یا بعد از هر `clear()` رفرنسها را دوباره `find()` کن. برای حجم فعلی، سادهترین و امنترین کار: `flush()` دستهای بدون `clear()`.
|
||||
|
||||
### ۴. اجرا و تأیید
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console app:doctors:backfill-specialty-parents --dry-run
|
||||
ddev exec php bin/console app:doctors:backfill-specialty-parents
|
||||
```
|
||||
|
||||
تأیید با SQL — باید بعد از اجرا صفر ردیف برگرداند:
|
||||
|
||||
```sql
|
||||
SELECT ds.doctor_id, ds.specialty_id, s.parent_id
|
||||
FROM doctor_specialties ds
|
||||
JOIN specialties s ON s.id = ds.specialty_id
|
||||
WHERE s.parent_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM doctor_specialties ds2
|
||||
WHERE ds2.doctor_id = ds.doctor_id AND ds2.specialty_id = s.parent_id
|
||||
);
|
||||
```
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **migration لازم نیست** — هیچ Entity یا اسکیمایی تغییر نمیکند؛ فقط ردیف در جدول واسط اضافه میشود.
|
||||
- درج تکراری غیرممکن است: `PRIMARY KEY (doctor_id, specialty_id)` روی `doctor_specialties`؛ در سطح ORM هم `contains()` چک میشود.
|
||||
- Command باید **idempotent** باشد: اجرای دوباره باید «۰ پزشک اصلاح شد» بدهد.
|
||||
- `SeedCategoriesCommand` (`app:seed-categories`) هنگام seed مجدد `DELETE FROM specialties` میزند (`CategoryImporter::replace()`, `src/Category/Service/CategoryImporter.php:179-196`). اگر seed تخصصها دوباره اجرا شد، این backfill را هم دوباره بزن.
|
||||
- بعد از تغییر رفتار اندپوینتها (وظیفهٔ ۲)، فایل مربوطه در `clinicpro/docs/api/` باید بهروز شود: ذکر کن که `specialties` ارسالی بهصورت خودکار با تخصصهای والد گسترش مییابد و پاسخ ممکن است idهای بیشتری از ورودی داشته باشد.
|
||||
- پنل ادمین: در فرم ویرایش پزشک، تخصصهای والدِ خودکاراضافهشده در لیست انتخابشدهها ظاهر میشوند؛ اگر کاربر والد را دستی حذف کند و ذخیره بزند، دوباره اضافه میشود — این رفتار عمدی است، در PR ذکرش کن.
|
||||
- تست با کاربر تست: `09390039833` / `09390039833`.
|
||||
Reference in New Issue
Block a user