Files
clinicpro/.claude/prompt/backfill-doctor-specialty-parents.md
T

14 KiB
Raw Blame History

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

    #[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 کار می‌کنند:

    #[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):

    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):

        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:

        // 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 را یک‌بار بخوان و کش کن:

    /** @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:

        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-176syncRefCollection() عمومی است و برای استان/شهر هم استفاده می‌شود، پس داخل آن گسترش نکن. به‌جایش در فراخوانی خط ۱۱۴-۱۱۷ ids را قبل از پاس‌دادن گسترش بده:
            $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() یک‌بار و مشروط.

منطق:

        $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().

۴. اجرا و تأیید

ddev exec php bin/console app:doctors:backfill-specialty-parents --dry-run
ddev exec php bin/console app:doctors:backfill-specialty-parents

تأیید با 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.