# Doctor Profile Claim API (تصاحب پروفایل پزشک ایمپورت‌شده)

> **Controller:** `App\Doctor\Controller\DoctorClaimController` — منطق در `App\Doctor\Service\DoctorClaimService`
> **مصرف‌کننده:** سایت عمومی Nobat724 (همهٔ دامنه‌ها) + پنل ادمین

پزشکِ ایمپورت‌شده از نظام پزشکی (`owner_status = unclaimed`) توسط پزشک واقعی تصاحب می‌شود.
احراز هویت سمت سرور با **API.ir** انجام می‌شود (شاهکار: تطبیق موبایل↔کدملی؛ PersonInfo: تطبیق
کدملی+تاریخ تولد و نام). هیچ درخواستی از فرانت به API.ir نمی‌رود و توکن API.ir هرگز به کلاینت
نمی‌رسد. claim پس از تطبیق موفق **خودکار** نهایی می‌شود (بدون approve ادمین — تصمیم مستند در
`.claude/prompt/irimc-import-complete.md` §۲.۲).

## چرخهٔ وضعیت

```
unclaimed ──claim/transfer شروع──▶ pending_transfer ──موفق──▶ claimed
     ▲                                   │شکست تطبیق/خطای استعلام
     └───────────────────────────────────┘ (برگشت، قابل تلاش مجدد)
```

هر تلاش یک رکورد ممیزی در `doctor_claim_requests` می‌سازد — کد ملی فقط **hash sha256** و
موبایل فقط **mask شده** ذخیره می‌شود؛ هیچ دادهٔ هویتی خام در DB یا لاگ نمی‌ماند.

---

## GET `/api/v1/doctor/{uuid}/claim-info`

**Permission:** عمومی (بدون JWT) — فقط برای رندر دکمهٔ «آیا شما این پزشک هستید؟»

### Response `200`
```json
{ "success": true, "data": { "claimable": true, "owner_status": "unclaimed" } }
```

| کد | حالت |
|---|---|
| `404` | پزشک یافت نشد |

---

## POST `/api/v1/doctor/{uuid}/claim`

**Permission:** `IS_AUTHENTICATED_FULLY` — کاربر با OTP لاگین شده (موبایلش تأییدشده است)
**Rate limit:** limiter `doctor_claim` — ۵ تلاش در ساعت به‌ازای هر (کاربر، پزشک)
**Captcha:** ALTCHA — بدنه باید payload کپچا بفرستد (`CaptchaGuard::assertValid`)؛ در dev با `ALTCHA_ENABLED=false` بی‌اثر است، در prod اجباری. خطا → `ERR_CAPTCHA_001` (۴۲۲).

### Request
```json
{
  "national_code": "0010007700",
  "birth_date": "1371/1/1",
  "first_name": "فرخنده",
  "last_name": "حسینی",
  "mobile": "09121234567",
  "altcha": "<payload کپچا>"
}
```

| فیلد | الزامی | قاعده |
|---|:---:|---|
| `national_code` | ✅ | ۱۰ رقم (ارقام فارسی پذیرفته و نرمال می‌شوند) |
| `birth_date` | ✅ | شمسی `Y/m/d` |
| `first_name` / `last_name` | ✅ | با هویت ثبت احوال و نام پروفایل تطبیق داده می‌شود (نرمال‌سازی ي/ی، ك/ک، نیم‌فاصله — `PersianText`) |
| `mobile` | ❌ | اگر داده شود، باید با موبایل حساب کاربری یکی باشد وگرنه `422 ERR_CONFLICT_001`؛ اگر خالی باشد از موبایل کاربر لاگین‌شده استفاده می‌شود |
| `altcha` | prod | payload کپچای ALTCHA |

### مراحل سرور (اتمیک/ضد race)

1. قفل `PESSIMISTIC_WRITE` روی ردیف پزشک → اگر `unclaimed` نبود `409`؛ اگر کاربر از قبل پزشکی دارد `409`؛ اگر کد ملی متعلق به کاربر دیگری است `409` — سپس `pending_transfer` + رکورد ممیزی (تراکنش کوتاه، بدون فراخوان خارجی داخل قفل).
2. شاهکار (`ApiIrService::shahkarMatch`) — تطبیق موبایل کاربر با کد ملی. اگر API.ir پیکربندی نشده باشد، این گام skip و مبنا موبایلِ OTP-تأییدشده است.
3. `PersonInfo` — تطبیق کدملی+تاریخ تولد؛ `alive=false` → رد.
4. تطبیق نام: ورودی کاربر ↔ هویت تأییدشده ↔ نام پروفایل (بدون پیشوند «دکتر»).
5. نهایی‌سازی اتمیک: `user_id` → کاربر واقعی، `ROLE_DOCTOR`، `national_code_verified=true`، `owner_status=claimed`، حذف امنِ کاربر جانشین (فقط با `ROLE_UNCLAIMED_DOCTOR` و بدون پزشک دیگر).
6. پیامک خوش‌آمد (تمپلیت `welcome`، async).

> شکست در گام‌های ۲-۴ پروفایل را به `unclaimed` برمی‌گرداند تا پزشک واقعی بتواند دوباره تلاش کند.
> نوبت‌دهی (`active_doctor_appointment`) خاموش می‌ماند تا مالک جدید برنامهٔ کاری تعریف کند.

### Response `200`
```json
{
  "success": true,
  "data": {
    "status": "claimed",
    "claim": { "uuid": "…" },
    "doctor": { "uuid": "…", "name": "فرخنده حسینی" }
  }
}
```

### Errors
| کد | HTTP | حالت |
|---|---|---|
| `ERR_AUTH_001` | 401 | بدون لاگین |
| `ERR_NOT_FOUND_001` | 404 | پزشک یافت نشد |
| `ERR_CONFLICT_001` | 409 | پروفایل قابل تصاحب نیست / کاربر پزشک دیگری دارد / درخواست هم‌زمان دیگری در جریان است |
| `ERR_PROFILE_001` | 409 | کد ملی قبلاً برای کاربر دیگری ثبت شده است |
| `ERR_VALIDATION_001/002` | 422 | کد ملی/تاریخ/نام نامعتبر |
| `ERR_IDENTITY_001` | 422 | عدم تطبیق هویت (شاهکار/ثبت احوال/نام) — پیام عمومی، ضد enumeration |
| `ERR_RATE_LIMIT_001` | 429 | عبور از سقف تلاش |
| `ERR_EXTERNAL_001` | 502 | خطا/تایم‌اوت API.ir |
| `ERR_EXTERNAL_002` | 503 | API.ir پیکربندی نشده (env) |

---

## POST `/api/v1/admin/doctors/{uuid}/transfer`

انتقال دستی (ابزار پشتیبانی ادمین) — مستند کامل در `docs/api/doctor-import.md`.
**Permission:** `ROLE_ADMIN`. بدنه `{ "mobile": "09…" }`. همان نهایی‌سازی claim را اجرا می‌کند
و رکورد ممیزی با `verification_method="admin_manual"` می‌سازد.

---

## GET `/api/v1/admin/doctor-claims`

**Permission:** `ROLE_ADMIN` — لیست ممیزی درخواست‌های claim برای پشتیبانی عملیاتی.

| Query | پیش‌فرض | توضیح |
|---|---|---|
| `status` | همه | `pending` \| `completed` \| `failed` |
| `page` / `limit` | 1 / 20 (سقف 50) | صفحه‌بندی استاندارد |

### Response `200` (paginated)
```json
{
  "success": true,
  "data": [
    {
      "uuid": "…",
      "status": "completed",
      "doctor": { "uuid": "…", "name": "فرخنده حسینی" },
      "mobile_masked": "0912***4567",
      "verification_method": "apiir_personinfo+shahkar",
      "failure_reason": null,
      "created_at": 1783750000,
      "completed_at": 1783750040
    }
  ],
  "meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
}
```

`failure_reason` فارسی و انسانی‌خوان است تا ادمین بدون خواندن لاگ سرور علت شکست را ببیند.

---

## env های مرتبط

| متغیر | نقش |
|---|---|
| `APIIR_*` (baseUrl/token موجود `ApiIrService`) | استعلام شاهکار و PersonInfo |
| `CRAWLER_SERVICE_TOKEN` | فقط برای لاگین سرویسی کرالر (`doctor-import.md`) — ربطی به claim ندارد |

## تست‌ها

`tests/Doctor/DoctorClaimTest.php` (۱۱ سناریو، API.ir همیشه mock — هیچ تستی به سرویس واقعی
درخواست نمی‌زند) و `tests/Shared/PersianTextTest.php` (نرمال‌سازی نام).
