# Doctor Import (IRIMC) API

> **Endpoint:** `POST /api/v1/admin/doctors/import`
> **Permission:** `ROLE_ADMIN` **یا** `ROLE_IMPORTER` (نقش حداقلی کاربر سیستمی کرالر)
> **Controller:** `App\Doctor\Controller\DoctorImportController::import` — منطق دامنه در `App\Doctor\Service\DoctorImportService`

وارد کردن یک پزشک از سازمان نظام پزشکی (`membersearch.irimc.org`) **بدون شماره موبایل**.
برخلاف `POST /api/v1/admin/doctors` (که موبایل معتبر ایرانی می‌خواهد)، این اندپوینت برای
هر پزشک یک **کاربر جانشینِ غیرفعال** با شناسهٔ مصنوعی و نقشِ اختصاصی `ROLE_UNCLAIMED_DOCTOR`
می‌سازد و پروفایل را در وضعیت `unclaimed` ذخیره می‌کند تا بعداً به پزشک واقعی منتقل شود.
این نقش، هم کاربر جانشین را قابل‌شناسایی می‌کند و هم مبنای حذفِ امنِ او پس از انتقال است.

مصرف‌کنندهٔ اصلی: خزندهٔ پایتون (`clinicpro-crawler/pipeline.py`) که با کاربر «مالک
سیستمی» (`0000000000`) لاگین می‌کند و هر ~۶۰ ثانیه یک پزشک را می‌فرستد.

---

## مدل مالکیت (ستون‌های جدید `doctors`)

| ستون | مقدار هنگام ایمپورت | توضیح |
|---|---|---|
| `owner_status` | `unclaimed` | `claimed` \| `unclaimed` \| `pending_transfer` |
| `source` | `irimc` | `manual` (پیش‌فرض رکوردهای قدیمی) \| `irimc` |
| `source_ref` | `profile_url` | شناسهٔ رکورد مبدأ برای ممیزی |
| `source_profile_id` | UUID داخل `source_ref` | شناسهٔ authoritative پروفایل نظام پزشکی؛ کلید اصلی idempotency |
| `managed_by` | id کاربر ادمینِ فراخوان | کاربری که پروفایل را مدیریت می‌کند |
| `claimed_at` | `null` | زمان انتقال مالکیت (هنگام claim پر می‌شود) |

رکوردهای موجود در migration مقدار `manual` + `claimed` می‌گیرند تا رفتارشان تغییر نکند.

---

## idempotency

**اولویت کلید:** اول `source_profile_id` (شناسهٔ authoritative پروفایل نظام پزشکی، استخراج‌شده
از UUID داخل `source_ref`)، سپس `(source, medical_system_code)`. هر دو در سطح دیتابیس unique
هستند (`uniq_doctors_source_profile` و `uniq_doctors_source_code`)؛ درخواست هم‌زمانِ همان پزشک
با retry داخلی به مسیر update می‌رود و هرگز رکورد تکراری نمی‌سازد.

- **همان پروفایل با کد متفاوت:** اگر پزشکی با همان `source`+`source_profile_id` وجود داشته
  باشد → **به‌روزرسانی** می‌شود، حتی اگر `medical_system_code` عوض شده باشد. یک پروفایل irimc
  هرگز دو رکورد نمی‌سازد (تست: `DoctorImportTest::testSameProfileIdWithDifferentCodeUpdatesInsteadOfDuplicating`).
- اگر پروفایل شناسه نداشت (قالب `source_ref` ناشناخته) → روی `(source, medical_system_code)`
  fallback می‌شود؛ رفتار رکوردهای `manual`/`seed` بدون تغییر می‌ماند (چند `NULL` در
  یونیک‌ایندکس MariaDB تداخل نمی‌گیرد).
- اگر پزشکی نبود → **ساخته** می‌شود (`201`).
- اگر بود و `owner_status != claimed` → **به‌روزرسانی** (`200`).
- اگر بود و `owner_status == claimed` → **رد** (`200`, `skipped: "claimed"`) تا دادهٔ مالک
  واقعی بازنویسی نشود.
- **تکرار بین منابع:** اگر پزشکی با همان `medical_system_code` ولی `source` متفاوت
  (مثلاً ثبت دستی در پنل) وجود داشته باشد → **رد** می‌شود (`200`, `skipped: "duplicate"`)؛
  نه رکورد جدیدی ساخته می‌شود و نه رکورد موجود بازنویسی می‌شود. `uuid` همان رکورد موجود
  برگردانده می‌شود (تست: `DoctorImportTest::testManualDoctorWithSameCodeIsNeverDuplicated`).

> **پروفایل‌های هم‌نامِ متمایز ادغام نمی‌شوند:** دو `source_profile_id` متفاوت یعنی irimc
> آن‌ها را دو پزشک می‌داند (ممکن است دو نفر واقعی باشند). گام
> `app:doctors:repair --only=report-suspected-duplicates` خوشه‌های مشکوک (نام+تخصص+شهرِ
> یکسان، profile id متفاوت) را فقط **گزارش** می‌کند؛ تصمیم ادغام انسانی است.

---

## Request

```json
{
  "name": "فرخنده حسینی",
  "medical_system_code": "145657",
  "source": "irimc",
  "source_ref": "https://membersearch.irimc.org/member/profile?id=…",
  "gender": "woman",
  "degree": "general",
  "info": "دکترای حرفه‌ای پزشکی",
  "specialties": [1],
  "states": [23],
  "cities": [123]
}
```

| فیلد | الزامی | توضیح |
|---|:---:|---|
| `name` | ✅ | نام کامل پزشک — پیشوند «دکتر» **هنگام ذخیره حذف** می‌شود (کنوانسیون: نام بدون عنوان؛ UI خودش «دکتر» را جلو می‌گذارد). ورودی می‌تواند با یا بدون «دکتر» باشد. |
| `medical_system_code` | ✅ | کد نظام پزشکی (کلید idempotency ثانویه) |
| `source` | — | پیش‌فرض `irimc` |
| `source_ref` | — | `profile_url` نظام پزشکی؛ UUID داخلش استخراج و در `source_profile_id` ذخیره می‌شود و **کلید اصلی idempotency** است |
| `gender` | — | `man` \| `woman` |
| `degree` | — | `general` \| `expert` \| `specialist` \| `subspecialistplus` |
| `info` | — | متن تخصص/توضیح |
| `specialties` / `states` / `cities` | — | آرایهٔ **شناسه‌های مرجع** (id) |

> `gender`/`degree` باید با ثابت‌های `Doctor::GENDERS` / `Doctor::DEGREES` سازگار باشند.
> `specialties`/`states`/`cities` اگر داده شوند مجموعهٔ فعلی را **جایگزین** می‌کنند.
> تخصص‌ها درختی‌اند: هر شناسهٔ فرزند پیش از ذخیره با تمام والدهایش تا ریشه گسترش می‌یابد
> (ارسال `[3]` «گوارش و کبد» → ذخیرهٔ `[2, 3]` یعنی «داخلی» + «گوارش و کبد»)، پس فرستادن
> فقط برگ کافی است. شناسه‌های ناموجود نادیده گرفته می‌شوند.
> برای دادهٔ ایمپورت‌شدهٔ قبل از این تغییر: `php bin/console app:doctors:repair --only=specialty-parents`.

---

## آدرس پیش‌فرض (مطب)

هر پزشک ایمپورت‌شده **همیشه دست‌کم یک آدرس** (`doctor_addresses`، type=`personal`) دارد:

- اگر پزشک هیچ آدرسی نداشته باشد، یک آدرس با نام **«مطب دکتر {نام}»** ساخته می‌شود و
  شهر/استان آن از **اولین** عنصر آرایه‌های `cities`/`states` همان درخواست پر می‌شود.
- در ایمپورت مجدد آدرس تکراری ساخته نمی‌شود؛ فقط فیلدهای **خالیِ** آدرس موجود
  (نام/شهر/استان) backfill می‌شوند — مقادیر ویرایش‌شده توسط کاربر بازنویسی نمی‌شوند.
- اگر `cities`/`states` در درخواست نباشند، آدرس فقط با نام ساخته می‌شود (کرالر در حالت
  auto همیشه هر دو را می‌فرستد).

(تست‌ها: `DoctorImportTest::testImportCreatesDefaultOfficeAddressWithCityAndProvince`،
`testImportWithoutLocationStillCreatesOfficeAddress`)

---

## Response

### `201 Created` (ساخته شد)
```json
{ "success": true, "data": { "uuid": "…", "created": true } }
```

### `200 OK` (به‌روزرسانی شد)
```json
{ "success": true, "data": { "uuid": "…", "created": false } }
```

### `200 OK` (رد به‌دلیل تصاحب‌شده)
```json
{ "success": true, "data": { "uuid": "…", "created": false, "skipped": "claimed" } }
```

### `200 OK` (رد به‌دلیل کد نظام پزشکی تکراری با منبع دیگر)
```json
{ "success": true, "data": { "uuid": "…", "created": false, "skipped": "duplicate" } }
```

### `422` (اعتبارسنجی)
```json
{ "success": false, "data": null, "errors": [ { "code": "…", "message": "کد نظام پزشکی الزامی است", "field": "medical_system_code" } ] }
```

---

## عکس پروفایل

پس از ایمپورت، عکس با دو مرحله اضافه می‌شود (همان مسیر پزشکِ عادی):

1. `POST /file/upload/clinic_pro/doctor/field_image` — بدنه: باینری خام؛ هدر
   `Content-Disposition: attachment; filename="145657.jpg"`. پاسخ: `{ fid, uuid, url, filename, filemime, filesize }`.
2. `PATCH /api/v1/doctor/{uuid}` با بدنهٔ `{ "image_data": <پاسخ مرحلهٔ ۱> }` تا به
   آرایهٔ `images` پروفایل افزوده شود.

---

## انتقال مالکیت به پزشک واقعی

> **Endpoint:** `POST /api/v1/admin/doctors/{uuid}/transfer` — permission `ROLE_ADMIN`

مالکیت یک پروفایلِ `unclaimed` را به پزشک واقعی منتقل می‌کند.

```json
// Request
{ "mobile": "09120000000" }
```

مراحل اتمیک:

1. کاربر واقعی بر پایهٔ `mobile` پیدا یا ساخته می‌شود (باید موبایل معتبر ایران باشد).
2. اگر کاربر واقعی از قبل صاحب پزشک دیگری باشد → `409`.
3. `user_id` پروفایل به کاربر واقعی تغییر می‌کند، `ROLE_DOCTOR` به او داده می‌شود،
   `owner_status = claimed` و `claimed_at = now` و `managed_by = null` می‌شود.
4. **کاربر جانشین حذف می‌شود** — فقط اگر واقعاً `ROLE_UNCLAIMED_DOCTOR` داشته باشد و
   دیگر هیچ پزشکی به او وصل نباشد (حذف امن).

```json
// Response 200
{ "success": true, "data": {
  "uuid": "…", "owner_status": "claimed",
  "user_mobile": "09120000000",
  "claim": { "uuid": "…" }
} }
```

> این اندپوینت در `App\Doctor\Controller\DoctorClaimController::transfer` است و همان
> `DoctorClaimService::transferByAdmin` را صدا می‌زند؛ هر انتقال یک رکورد ممیزی در
> `doctor_claim_requests` با `verification_method = "admin_manual"` می‌سازد.
> جریان self-claim پزشک (با احراز هویت API.ir) در `docs/api/doctor-claim.md` مستند است.

| کد | حالت |
|---|---|
| `404` | پزشک یافت نشد |
| `409` | قبلاً `claimed` است، یا کاربر مقصد پزشک دیگری دارد |
| `422` | موبایل نامعتبر |

---

## کاربر سیستمی و چرخهٔ عمر

```bash
# ساخت/فعال‌سازی کاربر مالک سیستمی (کرالر با این لاگین می‌کند)
php bin/console app:system-owner 0000000000 --password=<secret> --activate

# غیرفعال‌سازی پس از پایان کار (کرالر هم با --deactivate-on-finish این را از طریق API انجام می‌دهد)
php bin/console app:system-owner 0000000000 --deactivate
```

کاربر سیستمی **least privilege** است: فقط `ROLE_USER,ROLE_IMPORTER` می‌گیرد (اجرای مجدد
دستور، `ROLE_ADMIN` قدیمی را هم حذف می‌کند). `ROLE_IMPORTER` فقط به همین اندپوینت ایمپورت
دسترسی دارد و به هیچ اندپوینت `/api/v1/admin/*` دیگری راه ندارد (تست: `DoctorImportTest::testImporterRoleCanImportButNothingElse`).

### لاگین سرویسی (captcha)

مسیر `/api/v1/user/login` کپچای ALTCHA دارد. برای لاگین headless کرالر، هدر سرّی
تعریف شده است:

```
X-Service-Token: <مقدار env CRAWLER_SERVICE_TOKEN>
```

- فقط **کپچا** دور زده می‌شود؛ rate limit و اعتبارسنجی رمز دست‌نخورده می‌مانند.
- اگر env خالی/تعریف‌نشده باشد هیچ bypass وجود ندارد (secure by default).
- چرخش credential: تغییر `CRAWLER_SERVICE_TOKEN` + تغییر رمز با `app:system-owner … --password=…`؛
  ابطال فوری: `--deactivate`.

### ترمیم دادهٔ ایمپورت‌شده — یک کامند برای همه

پس از هر خزش، **یک** دستور کافی است. همهٔ گام‌ها idempotent‌اند؛ اجرای دوباره بی‌ضرر است
و صفر تغییر می‌دهد.

```bash
php bin/console app:doctors:repair --dry-run   # گزارش کامل، بدون هیچ تغییری
php bin/console app:doctors:repair             # اعمال همهٔ گام‌ها
php bin/console app:doctors:repair --list      # فهرست گام‌ها
```

| گام | چه چیزی را درست می‌کند |
|---|---|
| `names` | حذف پیشوند «دکتر» از نام (نظام پزشکی نام را با عنوان می‌دهد؛ لایهٔ نمایش خودش عنوان می‌گذارد) |
| `degrees` | بازمحاسبهٔ درجه از روی عنوان خام `info` |
| `specialty-parents` | افزودن تخصص‌های والد به پزشکانی که فقط تخصص فرزند دارند |
| `surrogate-role` | افزودن `ROLE_UNCLAIMED_DOCTOR` به کاربران جانشین قدیمی |
| `source-profile-id` | پرکردن `source_profile_id` از `source_ref` (پیش‌نیاز dedup مبتنی بر profile id) |
| `report-suspected-duplicates` | گزارش خوشه‌های مشکوک به تکراری (نام+تخصص+شهرِ یکسان، profile id متفاوت) — **بدون** ادغام خودکار |

سوییچ‌ها:

| سوییچ | اثر |
|---|---|
| `--dry-run` | فقط گزارش؛ هیچ نوشتنی انجام نمی‌شود |
| `--only=a,b` | فقط این گام‌ها |
| `--skip=a,b` | همه جز این گام‌ها |
| `--all-sources` | پزشکان هر منبعی، نه فقط `source='irimc'` |
| `--include-claimed` | پروفایل‌های تصاحب‌شده را هم بازنویسی کن (پیش‌فرض: خیر — ورودی مالک مقدم است) |

> **گام `degrees` چرا لازم شد:** خزنده (`crawler_core.py::map_degree`) تا ۱۴۰۵/۰۴/۲۸ دو کد را
> جابه‌جا می‌فرستاد — «فوق تخصص» را `specialist` (برچسب: متخصص) و «تخصص» را `expert`
> (برچسب: فوق تخصص). خزنده اصلاح شده است؛ این گام رکوردهای ایمپورت‌شدهٔ همان دوره را
> از روی متن خام `info` بازمحاسبه می‌کند، نه با معکوس‌کردن کورکورانهٔ مقدار فعلی.

> **افزودن گام جدید:** یک کلاس با `DoctorRepairStep` در `src/Doctor/Service/Repair/` بساز؛
> خودکار کشف و اجرا می‌شود و نیازی به تغییر کامند نیست.

### حذف پزشکانِ ایمپورت‌شدهٔ claim‌نشده

فقط رکوردهای خزنده که هنوز مالکیتشان گرفته نشده (`owner_status='unclaimed'`) و کاربران
جانشینِ یتیمشان را حذف می‌کند. پزشکان دستی (`source='manual'`) و پروفایل‌های تصاحب‌شده
(`claimed`/`pending_transfer`) دست‌نخورده می‌مانند.

```bash
php bin/console app:doctors:purge-unclaimed                 # dry-run: تعداد هدف + هر جدول وابسته
php bin/console app:doctors:purge-unclaimed --force         # حذف
php bin/console app:doctors:purge-unclaimed --source=irimc  # فقط این منبع (پیش‌فرض irimc)
php bin/console app:doctors:purge-unclaimed --all-sources   # هر منبعِ غیر manual
```

| سوییچ | اثر |
|---|---|
| `--force` | حذف واقعی (بدون آن فقط گزارش) |
| `--source=X` | فقط منبع X (پیش‌فرض `irimc`) |
| `--all-sources` | هر پزشک claim‌نشدهٔ غیرِ `manual` |
| `--i-know-this-is-prod` | لازم برای اجرا روی `APP_ENV=prod` |

> محیط از kernel خوانده می‌شود (`%kernel.environment%`)، نه `$_ENV['APP_ENV']` — تا اگر
> APP_ENV از راهی غیر از dotenv ست شده باشد گارد دور زده نشود.

> حذف FK-safe است (همان ۱۶ جدول وابستهٔ `app:doctors:purge`). اگر نوبتی به پزشک
> claim‌نشده وصل باشد در dry-run هشدار می‌دهد چون آن نوبت هم حذف می‌شود. کاربر جانشین
> فقط وقتی حذف می‌شود که غیرفعال (`status=0`) و با موبایلِ `imp_` باشد — تا کاربر واقعی
> به‌اشتباه پاک نشود.

> ✅ **این کامند روی پروداکشن قابل اجراست** — با `--i-know-this-is-prod`. برای همین هم
> ساخته شده: پاک‌کردن دستهٔ اشتباهِ خزنده از روی سایت زنده، بدون دست‌زدن به پزشکان واقعی.
> با `app:doctors:purge` اشتباه نگیر (پایین) که هرگز روی prod اجرا نمی‌شود.

### پاک‌سازی کامل برای دیتابیس تست

حذف **همهٔ** پزشکان + داده‌های وابسته (FK-safe) برای شروع تمیز:

```bash
php bin/console app:doctors:purge            # dry-run: فقط گزارش تعداد هر جدول
php bin/console app:doctors:purge --force    # حذف واقعی + کاربران جانشین یتیم
```

> ⚠️ مخرب — `appointments`/`comments`/`rates` را هم پاک می‌کند. **فقط در محیط
> `dev`/`test` اجرا می‌شود**؛ روی هر `APP_ENV` دیگری (از جمله `prod`) بدون هیچ راه
> فراری با خطا متوقف می‌شود. سوییچ `--i-know-this-is-prod` اینجا **وجود ندارد** —
> تنها کامند این خانواده است که هیچ راهی برای اجرا روی prod ندارد.
> محیط از `%kernel.environment%` می‌آید، پس با دست‌کاری `$_ENV` هم دور نمی‌خورد.
> (تست‌ها: `PurgeDoctorsCommandTest`، `PurgeUnclaimedDoctorsCommandTest::testRefusesOnProdWithoutExplicitSwitch`)

### خلاصهٔ تفاوت دو کامند حذف

| | `purge-unclaimed` | `purge` |
|---|---|---|
| چه چیزی حذف می‌شود | فقط پزشکان خزنده‌ای که claim نشده‌اند | **همهٔ** پزشکان، حتی دستی و claim‌شده |
| اجرا روی prod | ✅ با `--i-know-this-is-prod` | ❌ هرگز |
| کاربرد | پاک‌کردن دستهٔ اشتباهِ ایمپورت از سایت زنده | ساخت دیتابیس تمیز برای تست |
