Files
clinicpro/docs/api/doctor-import.md
T
hamedandClaude Opus 4.8 af125572c9 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>
2026-07-11 11:39:15 +03:30

191 lines
8.7 KiB
Markdown
Raw 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.
# 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` | شناسهٔ رکورد مبدأ برای ممیزی |
| `managed_by` | id کاربر ادمینِ فراخوان | کاربری که پروفایل را مدیریت می‌کند |
| `claimed_at` | `null` | زمان انتقال مالکیت (هنگام claim پر می‌شود) |
رکوردهای موجود در migration مقدار `manual` + `claimed` می‌گیرند تا رفتارشان تغییر نکند.
---
## idempotency
کلید یکتای `(source, medical_system_code)` — از این نسخه **در سطح دیتابیس** هم unique است
(`uniq_doctors_source_code`، migration `Version20260711150000`)؛ درخواست هم‌زمانِ همان پزشک
با retry داخلی به مسیر update می‌رود و هرگز رکورد تکراری نمی‌سازد.
- اگر پزشکی با همان `source`+`medical_system_code` وجود نداشته باشد → **ساخته** می‌شود (`201`).
- اگر وجود داشته باشد و `owner_status != claimed`**به‌روزرسانی** می‌شود (`200`).
- اگر وجود داشته باشد و `owner_status == claimed`**رد** می‌شود (`200`, `skipped: "claimed"`)
تا دادهٔ مالک واقعی بازنویسی نشود.
---
## 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` | ✅ | نام کامل پزشک |
| `medical_system_code` | ✅ | کد نظام پزشکی (کلید idempotency) |
| `source` | — | پیش‌فرض `irimc` |
| `source_ref` | — | `profile_url` یا شناسهٔ مبدأ |
| `gender` | — | `man` \| `woman` |
| `degree` | — | `general` \| `expert` \| `specialist` \| `subspecialistplus` |
| `info` | — | متن تخصص/توضیح |
| `specialties` / `states` / `cities` | — | آرایهٔ **شناسه‌های مرجع** (id) |
> `gender`/`degree` باید با ثابت‌های `Doctor::GENDERS` / `Doctor::DEGREES` سازگار باشند.
> `specialties`/`states`/`cities` اگر داده شوند مجموعهٔ فعلی را **جایگزین** می‌کنند.
---
## 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" } }
```
### `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`.
### backfill نقش جانشین‌های قدیمی
جانشین‌های ساخته‌شده قبل از افزودن نقش marker:
```bash
php bin/console app:doctors:backfill-surrogate-role --dry-run # فقط گزارش
php bin/console app:doctors:backfill-surrogate-role # اعمال
```