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