Files
clinicpro/docs/api/doctor-import.md
T

318 lines
19 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` | شناسهٔ رکورد مبدأ برای ممیزی |
| `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` | ❌ هرگز |
| کاربرد | پاک‌کردن دستهٔ اشتباهِ ایمپورت از سایت زنده | ساخت دیتابیس تمیز برای تست |