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