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

17 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 شناسهٔ رکورد مبدأ برای ممیزی
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

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

{ "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 به کاربران جانشین قدیمی
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) دست‌نخورده می‌مانند.

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

حذف FK-safe است (همان ۱۶ جدول وابستهٔ app:doctors:purge). اگر نوبتی به پزشک claim‌نشده وصل باشد در dry-run هشدار می‌دهد چون آن نوبت هم حذف می‌شود. کاربر جانشین فقط وقتی حذف می‌شود که غیرفعال (status=0) و با موبایلِ imp_ باشد — تا کاربر واقعی به‌اشتباه پاک نشود.

پاک‌سازی کامل برای دیتابیس تست

حذف همهٔ پزشکان + داده‌های وابسته (FK-safe) برای شروع تمیز:

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

⚠️ مخرب — appointments/comments/rates را هم پاک می‌کند. فقط در محیط dev/test اجرا می‌شود؛ روی هر APP_ENV دیگری (از جمله prod) بدون هیچ راه فراری با خطا متوقف می‌شود.