feat: implement specialty hierarchy handling in doctor and representation APIs, add backfill command and tests

This commit is contained in:
hamed
2026-07-19 17:30:46 +03:30
parent 21b67ec075
commit 6496ebf336
12 changed files with 539 additions and 12 deletions
@@ -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<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`.
+1 -1
View File
@@ -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 |
+4
View File
@@ -76,6 +76,10 @@
> `gender`/`degree` باید با ثابت‌های `Doctor::GENDERS` / `Doctor::DEGREES` سازگار باشند.
> `specialties`/`states`/`cities` اگر داده شوند مجموعهٔ فعلی را **جایگزین** می‌کنند.
> تخصص‌ها درختی‌اند: هر شناسهٔ فرزند پیش از ذخیره با تمام والدهایش تا ریشه گسترش می‌یابد
> (ارسال `[3]` «گوارش و کبد» → ذخیرهٔ `[2, 3]` یعنی «داخلی» + «گوارش و کبد»)، پس فرستادن
> فقط برگ کافی است. شناسه‌های ناموجود نادیده گرفته می‌شوند.
> برای دادهٔ ایمپورت‌شدهٔ قبل از این تغییر: `php bin/console app:doctors:backfill-specialty-parents`.
---
+1 -1
View File
@@ -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` (سال تجربه) در پاسخ |
+1 -1
View File
@@ -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
+4 -3
View File
@@ -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);
}
}
@@ -0,0 +1,100 @@
<?php
namespace App\Doctor\Command;
use App\Doctor\Entity\Doctor;
use App\Specialty\Entity\Specialty;
use App\Specialty\Repository\SpecialtyRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* backfill: پزشکانی که فقط تخصص فرزند دارند (مثلاً «گوارش و کبد» بدون «داخلی»)
* تمام تخصص‌های والد تا ریشهٔ درخت را می‌گیرند. idempotent؛ با --dry-run فقط گزارش می‌دهد.
*
* php bin/console app:doctors:backfill-specialty-parents --dry-run
* php bin/console app:doctors:backfill-specialty-parents
*/
#[AsCommand(
name: 'app:doctors:backfill-specialty-parents',
description: 'Attach every ancestor specialty to doctors that only have the child one (idempotent, supports --dry-run)',
)]
class BackfillDoctorSpecialtyParentsCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly SpecialtyRepository $specialtyRepo,
) {
parent::__construct();
}
protected function configure(): void
{
$this->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;
}
}
+2 -2
View File
@@ -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);
}
}
+6 -1
View File
@@ -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);
@@ -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);
}
}
@@ -9,6 +9,9 @@ use Doctrine\Persistence\ManagerRegistry;
class SpecialtyRepository extends ServiceEntityRepository
{
/** @var array<int,?int>|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<int,?int> 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);
@@ -0,0 +1,86 @@
<?php
namespace App\Tests\Doctor;
use App\Doctor\Entity\Doctor;
use App\Specialty\Entity\Specialty;
use App\Specialty\Repository\SpecialtyRepository;
use App\Tests\ApiTestCase;
/**
* Specialties are a tree. Whenever a child specialty is attached to a doctor,
* every ancestor up to the root must be attached too — clients (the IRIMC
* crawler, the admin panel) only ever send the leaf id.
*/
class DoctorSpecialtyParentsTest extends ApiTestCase
{
private function makeSpecialty(string $name, ?Specialty $parent = null): Specialty
{
$specialty = new Specialty($name, 'sp-' . bin2hex(random_bytes(6)), $parent);
$this->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);
}
}