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

14 KiB
Raw Blame History

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

{
  "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 (ساخته شد)

{ "success": true, "data": { "uuid": "…", "created": true } }

200 OK (به‌روزرسانی شد)

{ "success": true, "data": { "uuid": "…", "created": false } }

200 OK (رد به‌دلیل تصاحب‌شده)

{ "success": true, "data": { "uuid": "…", "created": false, "skipped": "claimed" } }

200 OK (رد به‌دلیل کد نظام پزشکی تکراری با منبع دیگر)

{ "success": true, "data": { "uuid": "…", "created": false, "skipped": "duplicate" } }

422 (اعتبارسنجی)

{ "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 را به پزشک واقعی منتقل می‌کند.

// 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 داشته باشد و دیگر هیچ پزشکی به او وصل نباشد (حذف امن).
// 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 موبایل نامعتبر

کاربر سیستمی و چرخهٔ عمر

# ساخت/فعال‌سازی کاربر مالک سیستمی (کرالر با این لاگین می‌کند)
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‌اند؛ اجرای دوباره بی‌ضرر است و صفر تغییر می‌دهد.

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) برای شروع تمیز:

php bin/console app:doctors:purge            # dry-run: فقط گزارش تعداد هر جدول
php bin/console app:doctors:purge --force    # حذف واقعی + کاربران جانشین یتیم

⚠️ مخرب — appointments/comments/rates را هم پاک می‌کند. روی prod نیازمند --i-know-this-is-prod است و پیش‌فرض متوقف می‌شود.