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

12 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 اگر داده شوند مجموعهٔ فعلی را جایگزین می‌کنند.


آدرس پیش‌فرض (مطب)

هر پزشک ایمپورت‌شده همیشه دست‌کم یک آدرس (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.

backfill نقش جانشین‌های قدیمی

جانشین‌های ساخته‌شده قبل از افزودن نقش marker:

php bin/console app:doctors:backfill-surrogate-role --dry-run   # فقط گزارش
php bin/console app:doctors:backfill-surrogate-role             # اعمال

اصلاح نام رکوردهای قدیمی (حذف پیشوند «دکتر»)

رکوردهای IRIMC که پیش از این تغییر با پیشوند «دکتر» ذخیره شده بودند:

php bin/console app:doctors:fix-irimc-names --dry-run   # فقط گزارش
php bin/console app:doctors:fix-irimc-names             # اعمال (فقط source='irimc')

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

حذف همهٔ پزشکان + داده‌های وابسته (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 است و پیش‌فرض متوقف می‌شود.