# سخت‌سازی ضدتکرار ایمپورت پزشک با شناسهٔ پروفایل نظام پزشکی

## پروژه

`clinicpro` (backend). این پرامپت **گارد اجرایی** ضدتکرار است؛ پرامپت همتای خزنده
`clinicpro-crawler/.claude/prompt/crawler-dedup-and-data-audit.md` منبع داده را اصلاح می‌کند.
این یکی اول اجرا شود — خزنده به این قرارداد تکیه می‌کند.

## زمینه

خزندهٔ نظام پزشکی (`clinicpro-crawler`) پزشکان را با `POST /api/v1/admin/doctors/import`
وارد می‌کند. idempotency فعلی فقط روی `(source, medical_system_code)` است
(`DoctorImportService::doImport` + یونیک‌ایندکس `uniq_doctors_source_code`). اما شناسهٔ
**authoritative** واقعی irimc، آی‌دیِ پروفایل است که در `source_ref` ذخیره می‌شود
(`https://membersearch.irimc.org/member/profile?id=<uuid>`) — نه `medical_system_code`.

بررسی دادهٔ فعلی (۲۳۳۷ رکورد `source='irimc'`): همه `source_ref` یکتا دارند و همه
`medical_system_code` یکتا. تکرار دقیق فعلاً وجود ندارد. ولی هم‌نام‌هایی مثل «محمد صالحی»
با کدهای متفاوت (`42507`, `آ-1469` — هر دو «آسیب‌شناسی») وجود دارند که profile id متمایز
دارند؛ ممکن است یک نفر باشند ولی از دید irimc دو پروفایل جدا هستند.

## مشکل / هدف

**هدف: هیچ‌وقت یک پروفایلِ irimc دو رکورد نسازد** — حتی اگر در جست‌وجوهای مختلف با
`medical_system_code` متفاوت برگردد. کلید idempotency باید علاوه بر
`(source, medical_system_code)`، روی `(source, source_ref)` (profile id) هم باشد.

**ضدهدف: ادغام خودکار پروفایل‌های متمایز.** هم‌نام‌ها با profile id متفاوت **نباید**
خودکار ادغام شوند — فقط برای بازبینی دستی گزارش شوند.

## فایل‌های مرتبط

| فایل | نقش |
|------|-----|
| `src/Doctor/Service/DoctorImportService.php` | منطق idempotency ایمپورت |
| `src/Doctor/Entity/Doctor.php` | فیلد `sourceRef` + یونیک‌ایندکس‌ها |
| `src/Doctor/Repository/DoctorRepository.php` | کوئری‌های پیدا کردن پزشک |
| `migrations/` | یونیک‌ایندکس جدید روی `(source, source_ref)` |
| `src/Doctor/Command/RepairImportedDoctorsCommand.php` | جای مناسب برای گام گزارش تکراری |
| `src/Doctor/Service/Repair/` | گام‌های ترمیم (الگوی `DoctorRepairStep`) |
| `docs/api/doctor-import.md` | مستند idempotency |

## وضعیت فعلی

### idempotency فقط با medical_system_code (`DoctorImportService::doImport`)

```php
$code   = trim((string) ($data['medical_system_code'] ?? $data['medicalSystemCode']));
$source = trim((string) ($data['source'] ?? 'irimc')) ?: 'irimc';

// idempotency: همان پزشکِ منبع → به‌روزرسانی، نه ساخت تکراری
$doctor  = $doctorRepo->findOneBy(['source' => $source, 'medicalSystemCode' => $code]);

if ($doctor === null) {
    // جلوگیری از تکرار بین منابع بر پایهٔ کد
    $existing = $doctorRepo->findOneBy(['medicalSystemCode' => $code]);
    if ($existing !== null) {
        return new DoctorImportResult($existing, false, 'duplicate');
    }
}
// ...
// source_ref فقط ذخیره می‌شود، کلید dedup نیست:
if (array_key_exists('source_ref', $data) || array_key_exists('profile_url', $data)) {
    $doctor->setSourceRef($data['source_ref'] ?? $data['profile_url'] ?? null);
}
```

### یونیک‌ایندکس فعلی

```
uniq_doctors_source_code  →  (source, medical_system_code)
```
`source_ref` هیچ ایندکس/قید یکتایی ندارد.

## وظایف

### ۱. dedup روی profile id نظام پزشکی

**استخراج آی‌دی پایدار از `source_ref`.** خودِ URL کامل ممکن است تغییر قالب بدهد
(query param اضافه/کم)، پس آی‌دیِ UUID داخل آن استخراج و نرمالایز شود. یک متد در
`DoctorImportService` (یا یک VO کوچک):

```php
/** آی‌دی پایدار پروفایل نظام پزشکی از داخل source_ref/profile_url. */
private function extractProfileId(?string $ref): ?string
{
    if ($ref === null || trim($ref) === '') {
        return null;
    }
    // .../member/profile?id=<uuid>  →  <uuid> (بدون توجه به دامنه/پارامترهای دیگر)
    if (preg_match('/[?&]id=([0-9a-f-]{8,})/i', $ref, $m)) {
        return strtolower($m[1]);
    }
    return null; // قالب ناشناخته → روی همان medical_system_code fallback کن
}
```

**اولویت dedup:** اول با profile id، بعد با کد.

```php
$profileId = $this->extractProfileId($data['source_ref'] ?? $data['profile_url'] ?? null);

$doctor = null;
if ($profileId !== null) {
    // همان پروفایل irimc → همیشه همان رکورد، حتی اگر کد فرق کرده باشد
    $doctor = $doctorRepo->findOneByProfileId($source, $profileId);
}
if ($doctor === null) {
    $doctor = $doctorRepo->findOneBy(['source' => $source, 'medicalSystemCode' => $code]);
}
```

> تصمیم لازم: برای مطابقت با profile id، یا ستون `source_ref` را عیناً کوئری بزن
> (اگر خزنده URL پایدار می‌فرستد) یا یک ستون نرمالِ `source_profile_id` اضافه کن
> (بهتر، چون ایندکس یکتا روی UUID خالص تمیزتر است). گزینهٔ دوم پیشنهاد می‌شود:
> ستون `source_profile_id VARCHAR(36) NULL` + پرکردنش در ایمپورت + یونیک‌ایندکس
> `(source, source_profile_id)`. برای دادهٔ موجود در گام backfill (وظیفهٔ ۳) پر شود.

### ۲. یونیک‌ایندکس + migration

- ستون `source_profile_id` به `Doctor` (اگر گزینهٔ دوم را گرفتی).
- یونیک‌ایندکس جزئی: `(source, source_profile_id)` که `NULL` را نادیده می‌گیرد
  (پزشکان `manual`/`seed` بدون profile id نباید تداخل کنند — MariaDB روی چند `NULL`
  در یونیک‌ایندکس تداخل نمی‌گیرد، پس امن است).
- `doctrine:migrations:diff` → `migrate`.

```php
#[ORM\Column(name: 'source_profile_id', type: 'string', length: 36, nullable: true)]
private ?string $sourceProfileId = null;
```
```php
#[ORM\UniqueConstraint(name: 'uniq_doctors_source_profile', columns: ['source', 'source_profile_id'])]
```

**نکتهٔ هم‌زمانی:** الگوی موجود `doImport` روی `UniqueConstraintViolationException` رفت
(`resetManager` + تلاش مجدد → مسیر update). این را برای ایندکس جدید هم حفظ کن.

### ۳. گام گزارش تکراری مشکوک (بدون ادغام خودکار)

یک `DoctorRepairStep` جدید `report-suspected-duplicates` در
`src/Doctor/Service/Repair/` اضافه کن (خودکار کشف می‌شود؛ الگوی گام‌های موجود).
این گام **هیچ‌چیز تغییر نمی‌دهد** — فقط خوشه‌های مشکوک را چاپ می‌کند:

- معیار: نام نرمال‌شده یکسان (`PersianText::normalize`) **و** حداقل یک `specialty_id`
  مشترک **و** همان شهر، ولی `source_profile_id` (یا `source_ref`) متفاوت.
- خروجی: جدول `[نام، تعداد، profile_idها، تخصص مشترک، شهر]` برای بازبینی دستی.
- در همین گام، `source_profile_id` خالیِ رکوردهای قدیمی را از `source_ref` backfill کن
  (این‌جا idempotent و قابل `--dry-run`).

> چرا ادغام خودکار نه: profile id در irimc authoritative است و متمایز بودن آن یعنی
> irimc آن‌ها را دو نفر می‌داند. «محمد صالحیِ آسیب‌شناس» می‌تواند دو نفر واقعی باشد.
> ادغام اشتباه، دادهٔ دو پزشک را یکی می‌کند و برگشت‌ناپذیر است.

### ۴. مستندسازی

`docs/api/doctor-import.md`: بخش idempotency را به‌روزرسانی کن — اولویت
`source_profile_id` بر `medical_system_code`، رفتار پزشکان بدون profile id، و گام
`report-suspected-duplicates`.

## نکات مهم

- **پزشکان `manual`/`seed` بدون profile id نباید بشکنند:** `source_profile_id = NULL`
  و یونیک‌ایندکس چند `NULL` را می‌پذیرد. مسیر ایمپورت فقط وقتی profile id دارد از آن
  استفاده می‌کند؛ وگرنه دقیقاً مثل قبل روی `medical_system_code` کار می‌کند.
- **پروفایل `claimed` دست‌نخورده:** منطق فعلی که ایمپورت مجددِ پروفایل تصاحب‌شده را
  بازنویسی نمی‌کند (`return ... 'claimed'`) باید حفظ شود.
- **backfill قبل از افزودن یونیک‌ایندکس:** اگر دادهٔ موجود پس از پرکردن
  `source_profile_id` تکراری داشته باشد، migration یونیک‌ایندکس می‌شکند. اول در گام
  ترمیم backfill کن و تکراری‌های دقیق (همان profile id، دو رکورد) را — که الان صفرند
  ولی باید چک شوند — گزارش/حل کن، بعد ایندکس را بزن.
- **الگوها:** Controller از `BaseController`؛ خطا با `$this->error()` / `AppException`؛
  تایم‌استمپ Unix؛ گام ترمیم با `DoctorRepairStep` و tag `app.doctor_repair_step`.
- **تست الزامی (موفق + خطا + مرزی):**
  - همان profile id با `medical_system_code` متفاوت → **یک** رکورد (update، نه create).
  - profile id متفاوت با همان نام/تخصص → **دو** رکورد (ادغام نشود).
  - رکورد بدون profile id (manual) → رفتار قبلی، بدون تداخل یونیک.
  - idempotency: ایمپورت دوبارهٔ همان payload → صفر رکورد جدید.
  - گام گزارش: خوشهٔ مشکوک را می‌یابد ولی هیچ رکوردی را تغییر نمی‌دهد.
- بعد از هر تغییر: `ddev exec php bin/phpunit` سبز، `docs/api/doctor-import.md` به‌روز.
