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

282 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# اصلاح دادهٔ موجود: افزودن تخصص‌های والد به پزشکان (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`.