- Removed the "دکتر" prefix from doctor names in various components and API responses to ensure consistency and clarity. - Updated the AppointmentDetailPage, CommentsPage, DashboardPage, RatingsPage, SecretariesPage, and other relevant files to reflect the changes in doctor name formatting. - Adjusted API documentation to align with the new naming conventions. - Implemented validation to prevent the creation of clinics without a name and restricted users to a single clinic. - Added tests to verify that doctor names are stored without titles and that clinic creation adheres to the new validation rules.
237 lines
12 KiB
Markdown
237 lines
12 KiB
Markdown
# 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
|
||
|
||
```json
|
||
{
|
||
"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` (ساخته شد)
|
||
```json
|
||
{ "success": true, "data": { "uuid": "…", "created": true } }
|
||
```
|
||
|
||
### `200 OK` (بهروزرسانی شد)
|
||
```json
|
||
{ "success": true, "data": { "uuid": "…", "created": false } }
|
||
```
|
||
|
||
### `200 OK` (رد بهدلیل تصاحبشده)
|
||
```json
|
||
{ "success": true, "data": { "uuid": "…", "created": false, "skipped": "claimed" } }
|
||
```
|
||
|
||
### `200 OK` (رد بهدلیل کد نظام پزشکی تکراری با منبع دیگر)
|
||
```json
|
||
{ "success": true, "data": { "uuid": "…", "created": false, "skipped": "duplicate" } }
|
||
```
|
||
|
||
### `422` (اعتبارسنجی)
|
||
```json
|
||
{ "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` را به پزشک واقعی منتقل میکند.
|
||
|
||
```json
|
||
// 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` داشته باشد و
|
||
دیگر هیچ پزشکی به او وصل نباشد (حذف امن).
|
||
|
||
```json
|
||
// 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` | موبایل نامعتبر |
|
||
|
||
---
|
||
|
||
## کاربر سیستمی و چرخهٔ عمر
|
||
|
||
```bash
|
||
# ساخت/فعالسازی کاربر مالک سیستمی (کرالر با این لاگین میکند)
|
||
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:
|
||
|
||
```bash
|
||
php bin/console app:doctors:backfill-surrogate-role --dry-run # فقط گزارش
|
||
php bin/console app:doctors:backfill-surrogate-role # اعمال
|
||
```
|
||
|
||
### اصلاح نام رکوردهای قدیمی (حذف پیشوند «دکتر»)
|
||
|
||
رکوردهای IRIMC که پیش از این تغییر با پیشوند «دکتر» ذخیره شده بودند:
|
||
|
||
```bash
|
||
php bin/console app:doctors:fix-irimc-names --dry-run # فقط گزارش
|
||
php bin/console app:doctors:fix-irimc-names # اعمال (فقط source='irimc')
|
||
```
|
||
|
||
### پاکسازی کامل برای دیتابیس تست
|
||
|
||
حذف همهٔ پزشکان + دادههای وابسته (FK-safe) برای شروع تمیز:
|
||
|
||
```bash
|
||
php bin/console app:doctors:purge # dry-run: فقط گزارش تعداد هر جدول
|
||
php bin/console app:doctors:purge --force # حذف واقعی + کاربران جانشین یتیم
|
||
```
|
||
|
||
> ⚠️ مخرب — `appointments`/`comments`/`rates` را هم پاک میکند. روی prod نیازمند
|
||
> `--i-know-this-is-prod` است و پیشفرض متوقف میشود.
|