# اصلاح دادهٔ موجود: افزودن تخصص‌های والد به پزشکان (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`.
