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