feat(doctor): complete IRIMC import feature — claim flow, least-privilege importer, unique import key

- Extract import logic from AdminApiController into DoctorImportService
  (thin DoctorImportController keeps the same route/contract)
- Surrogate users get marker role ROLE_UNCLAIMED_DOCTOR (+ backfill command
  app:doctors:backfill-surrogate-role) enabling safe deletion after claim
- DB-level UNIQUE (source, medical_system_code) + concurrent-import retry
- Doctor profile claim flow (climed.md): shahkar + PersonInfo identity checks
  via existing ApiIrService, Persian name normalization (PersianText),
  pessimistic-lock race protection, DoctorClaimRequest audit table
  (national code hashed, mobile masked), doctor_claim rate limiter,
  public claim-info endpoint, welcome SMS
- Admin support tools: manual transfer endpoint + paginated doctor-claims
  audit list + owner_status filter/fields in admin doctors list
- Least privilege: system owner now gets ROLE_IMPORTER (ROLE_ADMIN stripped),
  import endpoint accepts ADMIN|IMPORTER, isStaff includes IMPORTER
- Headless crawler login: X-Service-Token header bypasses captcha only
  (rate limit + password checks intact; empty env = no bypass)
- docs: doctor-claim.md (new), doctor-import.md, admin.md, doctor.md
- tests: DoctorImportTest (6), DoctorClaimTest (11), PersianTextTest (5)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-11 11:39:15 +03:30
co-authored by Claude Opus 4.8
parent 83c872bb78
commit af125572c9
29 changed files with 1944 additions and 303 deletions
+1
View File
@@ -343,6 +343,7 @@ List all doctors with pagination.
| `status` | string | ❌ | `"active"` or `"inactive"` |
| `gender` | string | ❌ | `"male"` or `"female"` |
| `specialty_id` | integer | ❌ | Filter by specialty |
| `owner_status` | string | ❌ | `claimed` \| `unclaimed` \| `pending_transfer` — پروفایل‌های ایمپورت IRIMC (خروجی هم `owner_status` و `source` دارد) |
| `sort` | string | ❌ | Sort field |
### Response `200`
+151
View File
@@ -0,0 +1,151 @@
# 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` — ۵ تلاش در ساعت به‌ازای هر (کاربر، پزشک)
### Request
```json
{
"national_code": "0010007700",
"birth_date": "1371/1/1",
"first_name": "فرخنده",
"last_name": "حسینی"
}
```
| فیلد | الزامی | قاعده |
|---|:---:|---|
| `national_code` | ✅ | ۱۰ رقم (ارقام فارسی پذیرفته و نرمال می‌شوند) |
| `birth_date` | ✅ | شمسی `Y/m/d` |
| `first_name` / `last_name` | ✅ | با هویت ثبت احوال و نام پروفایل تطبیق داده می‌شود (نرمال‌سازی ي/ی، ك/ک، نیم‌فاصله — `PersianText`) |
### مراحل سرور (اتمیک/ضد 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` (نرمال‌سازی نام).
+37 -13
View File
@@ -1,8 +1,8 @@
# Doctor Import (IRIMC) API
> **Endpoint:** `POST /api/v1/admin/doctors/import`
> **Permission:** `ROLE_ADMIN`
> **Controller:** `App\Admin\Controller\AdminApiController::importDoctor`
> **Permission:** `ROLE_ADMIN` **یا** `ROLE_IMPORTER` (نقش حداقلی کاربر سیستمی کرالر)
> **Controller:** `App\Doctor\Controller\DoctorImportController::import` — منطق دامنه در `App\Doctor\Service\DoctorImportService`
وارد کردن یک پزشک از سازمان نظام پزشکی (`membersearch.irimc.org`) **بدون شماره موبایل**.
برخلاف `POST /api/v1/admin/doctors` (که موبایل معتبر ایرانی می‌خواهد)، این اندپوینت برای
@@ -31,7 +31,9 @@
## idempotency
کلید یکتای منطقی: `(source, medical_system_code)`.
کلید یکتای `(source, medical_system_code)` — از این نسخه **در سطح دیتابیس** هم unique است
(`uniq_doctors_source_code`، migration `Version20260711150000`)؛ درخواست هم‌زمانِ همان پزشک
با retry داخلی به مسیر update می‌رود و هرگز رکورد تکراری نمی‌سازد.
- اگر پزشکی با همان `source`+`medical_system_code` وجود نداشته باشد → **ساخته** می‌شود (`201`).
- اگر وجود داشته باشد و `owner_status != claimed`**به‌روزرسانی** می‌شود (`200`).
@@ -132,10 +134,16 @@
// Response 200
{ "success": true, "data": {
"uuid": "…", "owner_status": "claimed",
"transferred_to": "09120000000", "placeholder_deleted": true
"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` | پزشک یافت نشد |
@@ -154,13 +162,29 @@ php bin/console app:system-owner 0000000000 --password=<secret> --activate
php bin/console app:system-owner 0000000000 --deactivate
```
کاربر باید `ROLE_ADMIN` و `status=1` داشته باشد تا لاگینِ رمزی (`POST /api/v1/user/login`)
و فراخوانی این اندپوینت ممکن باشد.
کاربر سیستمی **least privilege** است: فقط `ROLE_USER,ROLE_IMPORTER` می‌گیرد (اجرای مجدد
دستور، `ROLE_ADMIN` قدیمی را هم حذف می‌کند). `ROLE_IMPORTER` فقط به همین اندپوینت ایمپورت
دسترسی دارد و به هیچ اندپوینت `/api/v1/admin/*` دیگری راه ندارد (تست: `DoctorImportTest::testImporterRoleCanImportButNothingElse`).
> ⚠️ **captcha:** مسیر `/api/v1/user/login` از `CaptchaGuard` رد می‌شود و این guard وقتی
> `ALTCHA_ENABLED=true` باشد (مقدار فعلی `.env`) یک payloadِ altcha می‌خواهد. برای اجرای
> بدونِ‌مرورگرِ کرالر یکی از این‌ها لازم است:
> ۱) روی همان سرور `ALTCHA_ENABLED=false` در `.env.local` (ساده‌ترین برای dev)، یا
> ۲) افزودن یک استثنا در `PasswordAuthenticator` که برای کاربر مالک سیستمی captcha را رد کند،
> یا ۳) لاگین سرویس با یک هدر سرّیِ مورد اعتماد. تا وقتی این حل نشود، لاگین کرالر با ۴۲۲
> (`ERR_CAPTCHA_001`) رد می‌شود.
### لاگین سرویسی (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`.
### backfill نقش جانشین‌های قدیمی
جانشین‌های ساخته‌شده قبل از افزودن نقش marker:
```bash
php bin/console app:doctors:backfill-surrogate-role --dry-run # فقط گزارش
php bin/console app:doctors:backfill-surrogate-role # اعمال
```
+7 -1
View File
@@ -1,5 +1,9 @@
# Doctor API
> فیلد `owner_status` (`claimed` | `unclaimed` | `pending_transfer`) به خروجی لیست و جزئیات پزشک
> اضافه شده است — پروفایل `unclaimed` (ایمپورت نظام پزشکی) در سایت دکمهٔ «تصاحب پروفایل» می‌گیرد
> (`docs/api/doctor-claim.md`) و نوبت‌دهی آنلاینش غیرفعال است.
> **Prefix:** `/api/v1/doctor`, `/api/v1/doctors`, `/api/v1/clinic-pro/doctor-address*`
>
> Numeric path params on the address routes (`doctor-address/{id}`, `doctor-addresses/{doctorId}`) require `\d+`; a non-numeric value returns a clean `404` instead of a `500`.
@@ -101,6 +105,7 @@ Get doctor detail with clinics.
"free_turn": "دوشنبه 09:0013:00",
"hours_of_work": "شنبه: 09:0013:00 و 14:0018:00 | یکشنبه: 09:0013:00",
"active": true,
"owner_status": "claimed",
"specialties": [{ "uuid": "...", "id": "1", "name": "قلب و عروق", "parent_id": null }],
"expertise": [{ "uuid": "...", "id": "3", "name": "نوار قلب" }],
"address": [],
@@ -204,7 +209,8 @@ List doctors with pagination and filters.
"point": "3.5",
"free_turn": "دوشنبه 09:0013:00",
"hours_of_work": "شنبه: 09:0013:00 و 14:0018:00 | یکشنبه: 09:0013:00",
"active": true
"active": true,
"owner_status": "claimed"
}
],
"meta": {