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`.