- 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.
14 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 |
شناسهٔ رکورد مبدأ برای ممیزی |
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" } ] }
عکس پروفایل
پس از ایمپورت، عکس با دو مرحله اضافه میشود (همان مسیر پزشکِ عادی):
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 به کاربران جانشین قدیمی |
سوییچها:
| سوییچ | اثر |
|---|---|
--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است و پیشفرض متوقف میشود.