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