From 6496ebf336fff48f1e3d15d87f5cc945c9460c90 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 19 Jul 2026 17:30:46 +0330 Subject: [PATCH] feat: implement specialty hierarchy handling in doctor and representation APIs, add backfill command and tests --- .../backfill-doctor-specialty-parents.md | 281 ++++++++++++++++++ docs/api/admin.md | 2 +- docs/api/doctor-import.md | 4 + docs/api/doctor.md | 2 +- docs/api/representation.md | 2 +- src/Admin/Controller/AdminApiController.php | 7 +- .../BackfillDoctorSpecialtyParentsCommand.php | 100 +++++++ src/Doctor/Controller/DoctorController.php | 4 +- src/Doctor/Service/DoctorImportService.php | 7 +- .../RepresentationActionController.php | 7 +- .../Repository/SpecialtyRepository.php | 49 +++ tests/Doctor/DoctorSpecialtyParentsTest.php | 86 ++++++ 12 files changed, 539 insertions(+), 12 deletions(-) create mode 100644 .claude/prompt/backfill-doctor-specialty-parents.md create mode 100644 src/Doctor/Command/BackfillDoctorSpecialtyParentsCommand.php create mode 100644 tests/Doctor/DoctorSpecialtyParentsTest.php diff --git a/.claude/prompt/backfill-doctor-specialty-parents.md b/.claude/prompt/backfill-doctor-specialty-parents.md new file mode 100644 index 00000000..e67667c3 --- /dev/null +++ b/.claude/prompt/backfill-doctor-specialty-parents.md @@ -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|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`. diff --git a/docs/api/admin.md b/docs/api/admin.md index c431b3e3..8149c297 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -297,7 +297,7 @@ Delete a user. | `name` | string | ✅ | نام پزشک | | `gender` / `degree` / `medical_system_code` / `info` | string | ❌ | اطلاعات حرفه‌ای | | `activity_time` | integer | ❌ | Unix timestamp (ثانیه) تاریخ شروع فعالیت؛ مبنای محاسبهٔ سال تجربه | -| `specialties` | integer[] | ❌ | آرایهٔ IDهای تخصص | +| `specialties` | integer[] | ❌ | آرایهٔ IDهای تخصص. هر شناسهٔ فرزند سمت سرور با تمام والدهایش تا ریشهٔ درخت گسترش می‌یابد؛ شناسه‌های ناموجود نادیده گرفته می‌شوند. | #### Errors | Code | HTTP | Description | diff --git a/docs/api/doctor-import.md b/docs/api/doctor-import.md index ff1c86ce..0d76472f 100644 --- a/docs/api/doctor-import.md +++ b/docs/api/doctor-import.md @@ -76,6 +76,10 @@ > `gender`/`degree` باید با ثابت‌های `Doctor::GENDERS` / `Doctor::DEGREES` سازگار باشند. > `specialties`/`states`/`cities` اگر داده شوند مجموعهٔ فعلی را **جایگزین** می‌کنند. +> تخصص‌ها درختی‌اند: هر شناسهٔ فرزند پیش از ذخیره با تمام والدهایش تا ریشه گسترش می‌یابد +> (ارسال `[3]` «گوارش و کبد» → ذخیرهٔ `[2, 3]` یعنی «داخلی» + «گوارش و کبد»)، پس فرستادن +> فقط برگ کافی است. شناسه‌های ناموجود نادیده گرفته می‌شوند. +> برای دادهٔ ایمپورت‌شدهٔ قبل از این تغییر: `php bin/console app:doctors:backfill-specialty-parents`. --- diff --git a/docs/api/doctor.md b/docs/api/doctor.md index ee385fd0..4e885e6a 100644 --- a/docs/api/doctor.md +++ b/docs/api/doctor.md @@ -41,7 +41,7 @@ Create a doctor profile for the authenticated user. | `medical_system_code` | string | ❌ | Nظام پزشکی code | | `degree` | string | ❌ | Academic degree | | `info` | string | ❌ | Bio/description | -| `specialties` | integer[] | ❌ | Array of specialty IDs | +| `specialties` | integer[] | ❌ | Array of specialty IDs. تخصص‌ها درختی‌اند: هر شناسهٔ فرزند سمت سرور با تمام والدهایش تا ریشه گسترش می‌یابد، پس پاسخ ممکن است تخصص‌های بیشتری از ورودی داشته باشد (مثلاً ارسال «گوارش و کبد» → ذخیرهٔ «داخلی» + «گوارش و کبد»). شناسه‌های ناموجود نادیده گرفته می‌شوند. | | `doctor_services` | integer[] | ❌ | Array of doctor service IDs | | `activity_time` | integer | ❌ | Unix timestamp (ثانیه) تاریخ شروع فعالیت؛ مبنای محاسبهٔ `experience` (سال تجربه) در پاسخ | diff --git a/docs/api/representation.md b/docs/api/representation.md index 55ddd9bd..689d1c5f 100644 --- a/docs/api/representation.md +++ b/docs/api/representation.md @@ -388,7 +388,7 @@ Get yearly earnings dashboard for a representation. | `name` | string | ✅ | | `gender` / `degree` / `medical_system_code` / `info` | string | ❌ | | `activity_time` | integer (Unix ts، تاریخ شروع فعالیت) | ❌ | -| `specialties` | integer[] | ❌ | +| `specialties` | integer[] | ❌ — هر شناسهٔ فرزند با تمام والدهایش تا ریشهٔ درخت گسترش می‌یابد | #### Response `201` ```json diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index f717ef6b..6e24ac29 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -490,9 +490,10 @@ class AdminApiController extends BaseController if (!empty($data['activity_time'])) $doctor->setActivityTime((int) $data['activity_time']); 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); + $specialtyRepo = $this->em->getRepository(Specialty::class); + foreach ($specialtyRepo->expandWithAncestors(array_map('intval', $data['specialties'])) as $id) { + $s = $specialtyRepo->find($id); + if ($s !== null && !$doctor->getSpecialties()->contains($s)) $doctor->getSpecialties()->add($s); } } diff --git a/src/Doctor/Command/BackfillDoctorSpecialtyParentsCommand.php b/src/Doctor/Command/BackfillDoctorSpecialtyParentsCommand.php new file mode 100644 index 00000000..80a744ec --- /dev/null +++ b/src/Doctor/Command/BackfillDoctorSpecialtyParentsCommand.php @@ -0,0 +1,100 @@ +addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only, change nothing'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $dryRun = (bool) $input->getOption('dry-run'); + + /** @var Doctor[] $doctors */ + $doctors = $this->em->getRepository(Doctor::class)->findAll(); + + $touched = 0; + $added = 0; + + foreach ($doctors as $doctor) { + $current = array_map( + static fn (Specialty $s) => $s->getId(), + $doctor->getSpecialties()->toArray() + ); + if ($current === []) { + continue; + } + + $missing = array_diff($this->specialtyRepo->expandWithAncestors($current), $current); + if ($missing === []) { + continue; + } + + $io->text(sprintf( + '%s doctor #%d — adding specialty %s', + $dryRun ? '[dry-run]' : '[update]', + $doctor->getId(), + implode(', ', $missing) + )); + + if (!$dryRun) { + foreach ($missing as $id) { + $s = $this->specialtyRepo->find($id); + if ($s !== null) { + $doctor->getSpecialties()->add($s); + } + } + } + + $touched++; + $added += count($missing); + } + + if (!$dryRun && $touched > 0) { + $this->em->flush(); + } + + $io->success(sprintf( + '%d doctor(s) %s, %d specialty link(s) added (of %d scanned)', + $touched, + $dryRun ? 'would be updated' : 'updated', + $added, + count($doctors) + )); + + return Command::SUCCESS; + } +} diff --git a/src/Doctor/Controller/DoctorController.php b/src/Doctor/Controller/DoctorController.php index 712496fe..48d161cd 100644 --- a/src/Doctor/Controller/DoctorController.php +++ b/src/Doctor/Controller/DoctorController.php @@ -792,8 +792,8 @@ class DoctorController extends BaseController // 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); + foreach ($this->specialtyRepo->expandWithAncestors(array_map('intval', $data['specialties'])) as $id) { + $s = $this->specialtyRepo->find($id); if ($s !== null) $doctor->getSpecialties()->add($s); } } diff --git a/src/Doctor/Service/DoctorImportService.php b/src/Doctor/Service/DoctorImportService.php index 22f5a807..815646ac 100644 --- a/src/Doctor/Service/DoctorImportService.php +++ b/src/Doctor/Service/DoctorImportService.php @@ -112,7 +112,12 @@ class DoctorImportService if (array_key_exists('info', $data)) $doctor->setInfo($data['info']); // روابط بر پایهٔ شناسه‌های مرجع (تخصص/استان/شهر) - $this->syncRefCollection($doctor->getSpecialties(), $data['specialties'] ?? null, Specialty::class); + $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); $this->syncRefCollection($doctor->getProvinces(), $data['states'] ?? null, Province::class); $this->syncRefCollection($doctor->getCities(), $data['cities'] ?? null, City::class); diff --git a/src/Representation/Controller/RepresentationActionController.php b/src/Representation/Controller/RepresentationActionController.php index fcf11d61..f31610c4 100644 --- a/src/Representation/Controller/RepresentationActionController.php +++ b/src/Representation/Controller/RepresentationActionController.php @@ -289,9 +289,10 @@ class RepresentationActionController extends BaseController if (!empty($data['activity_time'])) $doctor->setActivityTime((int) $data['activity_time']); 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); + $specialtyRepo = $this->em->getRepository(Specialty::class); + foreach ($specialtyRepo->expandWithAncestors(array_map('intval', $data['specialties'])) as $id) { + $s = $specialtyRepo->find($id); + if ($s !== null && !$doctor->getSpecialties()->contains($s)) $doctor->getSpecialties()->add($s); } } diff --git a/src/Specialty/Repository/SpecialtyRepository.php b/src/Specialty/Repository/SpecialtyRepository.php index 6524f455..de38d548 100644 --- a/src/Specialty/Repository/SpecialtyRepository.php +++ b/src/Specialty/Repository/SpecialtyRepository.php @@ -9,6 +9,9 @@ use Doctrine\Persistence\ManagerRegistry; class SpecialtyRepository extends ServiceEntityRepository { + /** @var array|null */ + private ?array $parentMap = null; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Specialty::class); @@ -62,6 +65,52 @@ class SpecialtyRepository extends ServiceEntityRepository return $this->findOneBy(['slug' => $slug]); } + /** + * Specialty ids plus every ancestor up to the root, unique and sorted. + * Unknown ids are dropped; a cyclic parent chain stops at the repeated id. + * + * @param int[] $ids + * @return int[] + */ + public function expandWithAncestors(array $ids): array + { + $map = $this->parentMap(); + $out = []; + + foreach ($ids as $id) { + $cur = (int) $id; + $seen = []; + while (array_key_exists($cur, $map) && !isset($seen[$cur])) { + $seen[$cur] = true; + $out[$cur] = true; + $cur = $map[$cur] ?? 0; + } + } + + $out = array_keys($out); + sort($out); + + return $out; + } + + /** @return array id => parentId for every specialty */ + 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 $row) { + $this->parentMap[(int) $row['id']] = $row['parent'] !== null ? (int) $row['parent'] : null; + } + } + + return $this->parentMap; + } + public function save(Specialty $specialty, bool $flush = true): void { $this->getEntityManager()->persist($specialty); diff --git a/tests/Doctor/DoctorSpecialtyParentsTest.php b/tests/Doctor/DoctorSpecialtyParentsTest.php new file mode 100644 index 00000000..02b8ca12 --- /dev/null +++ b/tests/Doctor/DoctorSpecialtyParentsTest.php @@ -0,0 +1,86 @@ +em->persist($specialty); + $this->em->flush(); + + return $specialty; + } + + private function repo(): SpecialtyRepository + { + return $this->em->getRepository(Specialty::class); + } + + public function testExpandWithAncestorsWalksTheWholeChain(): void + { + $root = $this->makeSpecialty('داخلی'); + $child = $this->makeSpecialty('گوارش و کبد', $root); + $grand = $this->makeSpecialty('آندوسکوپی', $child); + + $expected = [$root->getId(), $child->getId(), $grand->getId()]; + sort($expected); + + $this->assertSame($expected, $this->repo()->expandWithAncestors([$grand->getId()])); + } + + public function testExpandWithAncestorsDeduplicatesAndDropsUnknownIds(): void + { + $root = $this->makeSpecialty('داخلی'); + $child = $this->makeSpecialty('گوارش و کبد', $root); + + $expected = [$root->getId(), $child->getId()]; + sort($expected); + + $this->assertSame( + $expected, + $this->repo()->expandWithAncestors([$child->getId(), $root->getId(), $child->getId(), 99_999_999]) + ); + } + + public function testExpandWithAncestorsReturnsEmptyForNoInput(): void + { + $this->assertSame([], $this->repo()->expandWithAncestors([])); + $this->assertSame([], $this->repo()->expandWithAncestors([99_999_999])); + } + + public function testImportAttachesParentSpecialty(): void + { + $admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']); + $root = $this->makeSpecialty('داخلی'); + $child = $this->makeSpecialty('گوارش و کبد', $root); + + $data = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, [ + 'name' => 'دکتر تست تخصص', + 'medical_system_code' => 'T' . random_int(100_000, 999_999) . random_int(100, 999), + 'specialties' => [$child->getId()], + ]); + + $this->assertSame(201, $this->responseCode()); + + $doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $data['data']['uuid']]); + $attached = array_map(static fn (Specialty $s) => $s->getId(), $doctor->getSpecialties()->toArray()); + sort($attached); + + $expected = [$root->getId(), $child->getId()]; + sort($expected); + + $this->assertSame($expected, $attached); + } +}