19 KiB
Doctor Import (IRIMC) API
Endpoint:
POST /api/v1/admin/doctors/importPermission: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" } ] }
عکس پروفایل
پس از ایمپورت، عکس با دو مرحله اضافه میشود (همان مسیر پزشکِ عادی):
POST /file/upload/clinic_pro/doctor/field_image— بدنه: باینری خام؛ هدرContent-Disposition: attachment; filename="145657.jpg". پاسخ:{ fid, uuid, url, filename, filemime, filesize }.PATCH /api/v1/doctor/{uuid}با بدنهٔ{ "image_data": <پاسخ مرحلهٔ ۱> }تا به آرایهٔimagesپروفایل افزوده شود.
انتقال مالکیت به پزشک واقعی
Endpoint:
POST /api/v1/admin/doctors/{uuid}/transfer— permissionROLE_ADMIN
مالکیت یک پروفایلِ unclaimed را به پزشک واقعی منتقل میکند.
// Request
{ "mobile": "09120000000" }
مراحل اتمیک:
- کاربر واقعی بر پایهٔ
mobileپیدا یا ساخته میشود (باید موبایل معتبر ایران باشد). - اگر کاربر واقعی از قبل صاحب پزشک دیگری باشد →
409. user_idپروفایل به کاربر واقعی تغییر میکند،ROLE_DOCTORبه او داده میشود،owner_status = claimedوclaimed_at = nowوmanaged_by = nullمیشود.- کاربر جانشین حذف میشود — فقط اگر واقعاً
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 |
محیط از 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) برای شروع تمیز:
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 |
❌ هرگز |
| کاربرد | پاککردن دستهٔ اشتباهِ ایمپورت از سایت زنده | ساخت دیتابیس تمیز برای تست |