Files
clinicpro/docs/api/doctor-import.md
T
hamed 801c6f96db Refactor doctor data repair commands into a single command
- Removed individual commands for backfilling specialty parents, surrogate roles, and fixing IRIMC names.
- Introduced RepairImportedDoctorsCommand to consolidate functionality.
- Implemented a step-based approach for repairs, allowing for idempotent execution.
- Added new service classes for handling specific repair steps, including BackfillSpecialtyParentsStep, BackfillSurrogateRoleStep, FixDegreeStep, and StripNameTitleStep.
- Created RepairOptions and RepairResult classes to manage step execution options and results.
- Updated tests to ensure new command structure and functionality are covered, including idempotency and dry-run behavior.
- Added IrimcDegreeMapper for mapping IRIMC titles to degrees.
2026-07-19 19:20:04 +03:30

259 lines
14 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"`)
تا دادهٔ مالک واقعی بازنویسی نشود.
- **تکرار بین منابع:** اگر پزشکی با همان `medical_system_code` ولی `source` متفاوت
(مثلاً ثبت دستی در پنل) وجود داشته باشد → **رد** می‌شود (`200`, `skipped: "duplicate"`
نه رکورد جدیدی ساخته می‌شود و نه رکورد موجود بازنویسی می‌شود. `uuid` همان رکورد موجود
برگردانده می‌شود (تست: `DoctorImportTest::testManualDoctorWithSameCodeIsNeverDuplicated`).
---
## 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` یا شناسهٔ مبدأ |
| `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` به کاربران جانشین قدیمی |
سوییچ‌ها:
| سوییچ | اثر |
|---|---|
| `--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/` بساز؛
> خودکار کشف و اجرا می‌شود و نیازی به تغییر کامند نیست.
### پاک‌سازی کامل برای دیتابیس تست
حذف همهٔ پزشکان + داده‌های وابسته (FK-safe) برای شروع تمیز:
```bash
php bin/console app:doctors:purge # dry-run: فقط گزارش تعداد هر جدول
php bin/console app:doctors:purge --force # حذف واقعی + کاربران جانشین یتیم
```
> ⚠️ مخرب — `appointments`/`comments`/`rates` را هم پاک می‌کند. روی prod نیازمند
> `--i-know-this-is-prod` است و پیش‌فرض متوقف می‌شود.