feat(doctor): complete IRIMC import feature — claim flow, least-privilege importer, unique import key

- Extract import logic from AdminApiController into DoctorImportService
  (thin DoctorImportController keeps the same route/contract)
- Surrogate users get marker role ROLE_UNCLAIMED_DOCTOR (+ backfill command
  app:doctors:backfill-surrogate-role) enabling safe deletion after claim
- DB-level UNIQUE (source, medical_system_code) + concurrent-import retry
- Doctor profile claim flow (climed.md): shahkar + PersonInfo identity checks
  via existing ApiIrService, Persian name normalization (PersianText),
  pessimistic-lock race protection, DoctorClaimRequest audit table
  (national code hashed, mobile masked), doctor_claim rate limiter,
  public claim-info endpoint, welcome SMS
- Admin support tools: manual transfer endpoint + paginated doctor-claims
  audit list + owner_status filter/fields in admin doctors list
- Least privilege: system owner now gets ROLE_IMPORTER (ROLE_ADMIN stripped),
  import endpoint accepts ADMIN|IMPORTER, isStaff includes IMPORTER
- Headless crawler login: X-Service-Token header bypasses captcha only
  (rate limit + password checks intact; empty env = no bypass)
- docs: doctor-claim.md (new), doctor-import.md, admin.md, doctor.md
- tests: DoctorImportTest (6), DoctorClaimTest (11), PersianTextTest (5)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-11 11:39:15 +03:30
co-authored by Claude Opus 4.8
parent 83c872bb78
commit af125572c9
29 changed files with 1944 additions and 303 deletions
+295 -154
View File
@@ -1,201 +1,342 @@
# تکمیل فیچر ایمپورت پزشکان نظام پزشکی (قطعات باقی‌مانده) # فیچر کامل ایمپورت پزشکان نظام پزشکی (IRIMC): ایمپورت، تصاحب پروفایل، کرالر State-Based
## پروژه > نسخهٔ بازنویسی‌شده — production-grade. جایگزین نسخهٔ قبلی این فایل.
> مبنا: بررسی کامل `docs/scenarios/` (هر ۴ سند) + کد واقعی. هر ادعای این پرامپت با `file:line` تأیید شده است.
`clinicpro` (backend) + یک تغییر کوچک در `clinicpro-crawler/clinicpro_client.py` ## پروژه‌ها و برنچ
> **برنچ:** تغییرات backend روی برنچ جدید در repo خود clinicpro: `git -C clinicpro checkout -b feature/irimc-doctor-import` **قانون برنچ (الزامی):** هیچ تغییری روی `main` هیچ repoیی انجام نشود. برای **هر repo** قبل از اولین تغییر، یک برنچ جدید بساز و تمام کار همان repo را روی همان برنچ پیش ببر:
> (تغییر کرالر در repo والد است — همان‌جا commit شود.)
## زمینه | repo | نقش در این فیچر | برنچ جدید |
|---|---|---|
| `clinicpro` | backend + پنل ادمین | `git -C clinicpro checkout -b feature/irimc-doctor-import` |
| `nobat724_front` | جریان Claim (سایت عمومی، همهٔ دامنه‌ها) | `git -C nobat724_front checkout -b feature/doctor-claim` |
| repo والد `clinic_pro` (شامل `clinicpro-crawler/`) | کرالر state-based + پنل توکن | `git -C . checkout -b feature/crawler-state-panel` |
سند سناریو: [docs/scenarios/ایمپورت-پزشکان-نظام-پزشکی.md](../docs/scenarios/ایمپورت-پزشکان-نظام-پزشکی.md). > `clinic-pro-tauri` **کاملاً خارج از scope این فیچر است** — هیچ تغییری در آن نده و آن را در نظر نگیر.
بخش عمدهٔ فیچر **قبلاً پیاده شده و در کد موجود است** — دوباره نساز:
| قطعه | وضعیت | ترتیب اجرا (قانون workspace): backend اول → مستندات API → کلاینت‌ها.
|------|-------|
| ستون‌های مالکیت `doctors` (`owner_status`, `source`, `source_ref`, `managed_by`, `claimed_at`) + متد `transferOwnershipTo()` | ✅ `src/Doctor/Entity/Doctor.php:81-98,390` |
| Migration | ✅ `migrations/Version20260711120000.php` (اعمال‌شده) |
| `POST /api/v1/admin/doctors/import` — idempotent، کاربر جانشین `imp_<hash>`، skip روی claimed | ✅ `src/Admin/Controller/AdminApiController.php:483` |
| دستور `app:system-owner` | ✅ `src/Auth/Command/SystemOwnerCommand.php` |
| مستند | ✅ `docs/api/doctor-import.md` |
| کرالر (`clinicpro_client.py`, `pipeline.py`) | ✅ `clinicpro-crawler/` |
**چهار قطعه از سند هنوز پیاده نشده** — این پرامپت فقط همان‌هاست: ---
1. نقش `ROLE_UNCLAIMED_DOCTOR` برای کاربر جانشین (§۳ سند) — الان جانشین فقط `ROLE_USER` می‌گیرد. ## ۱. هدف فیچر
2. اندپوینت انتقال مالکیت `POST /api/v1/admin/doctors/{uuid}/transfer` (§۴) — متد entity هست، کنترلر **نیست**.
3. حذف امن کاربر جانشین بعد از انتقال (§۳) — وابسته به ۱ و ۲.
4. رد شدن کپچا برای لاگین سرویسیِ کرالر (§۷) — الان لاگین headless با `ERR_CAPTCHA_001` می‌شکند.
## فایل‌های مرتبط پزشکان از سامانهٔ نظام پزشکی (`membersearch.irimc.org`) — که **موبایل ندارند** — به کلینیک‌پرو ایمپورت می‌شوند تا در Nobat724 نمایش داده شوند؛ سپس پزشک واقعی از طریق سایت، با **احراز هویت API.ir + OTP**، پروفایل خود را تصاحب (claim) می‌کند. یک کرالر پایتونی مستقل، با state داخلی SQLite و پنل مدیریت توکن، دادهٔ نظام پزشکی را استان‌به‌استان/شهربه‌شهر می‌خزد و از طریق API رسمی ایمپورت می‌کند.
| فایل | نقش | ---
|------|-----|
| `src/Admin/Controller/AdminApiController.php` | `importDoctor` خط ۴۸۳ (اصلاح نقش) + اندپوینت transfer جدید |
| `src/Doctor/Entity/Doctor.php` | `transferOwnershipTo(User)` خط ۳۹۰ — آماده، فقط صدا بزن |
| `src/Auth/Entity/User.php` | `addRole()` خط ۱۰۵، `hasRole()` خط ۱۱۴، `setStatus()` |
| `src/Auth/Security/PasswordAuthenticator.php` | خط ۴۹: `$this->captcha->assertValid($request)` — نقطهٔ bypass |
| `src/Shared/Captcha/CaptchaGuard.php` | گارد کپچا (برای فهم امضا) |
| `docs/api/doctor-import.md` | باید transfer + هدر سرویس مستند شود |
| `clinicpro-crawler/clinicpro_client.py` | افزودن هدر سرویس به لاگین |
## وضعیت فعلی ## ۲. تحلیل معماری موجود — حقایق تأییدشده (دوباره کشف نکن، دوباره نساز)
ساخت کاربر جانشین در `importDoctor` (خط ~۵۱۴) — **بدون نقش اختصاصی**: ### ۲.۱ آنچه از قبل پیاده شده و کار می‌کند
```php | قطعه | محل | وضعیت |
$synthetic = 'imp_' . substr(md5($source . ':' . $code), 0, 14); |---|---|---|
$user = $userRepo->findOneBy(['mobileNumber' => $synthetic]); | ستون‌های مالکیت `doctors`: `owner_status`, `source`, `source_ref`, `managed_by`, `claimed_at` | `src/Doctor/Entity/Doctor.php:81-98`؛ migration `migrations/Version20260711120000.php` | ✅ اعمال‌شده در dev/test — **روی prod باید قبل از deploy بررسی شود** |
if ($user === null) { | `Doctor::transferOwnershipTo(User)` — user_id، claimed، claimed_at | `src/Doctor/Entity/Doctor.php:390` | ✅ |
$user = new User($synthetic); | `POST /api/v1/admin/doctors/import` — idempotent روی `(source, medical_system_code)`، کاربر جانشین `imp_<md5-14>`، skip روی claimed | `src/Admin/Controller/AdminApiController.php:483` | ✅ ولی **fat controller** (وظیفهٔ ۳.۱) و **بدون نقش جانشین** (وظیفهٔ ۳.۲) |
$user->setRealName($name); | دستور `app:system-owner` (ساخت/فعال/غیرفعال کاربر `0000000000`) | `src/Auth/Command/SystemOwnerCommand.php` | ✅ |
$user->setStatus(0); // جانشین: هرگز لاگین نمی‌کند | `ApiIrService``shahkarMatch(nationalCode, mobile)` (ShahkarLite) و `ibanMatch` | `src/Shared/Service/ApiIrService.php:39,58`؛ الگوی مصرف: `src/Representation/Controller/RepresentationActionController.php:103` | ✅ — برای PersonInfo فقط **متد جدید به همین سرویس** اضافه کن، سرویس موازی نساز |
$this->em->persist($user); | فیلدهای هویتی User: `national_code` (unique, nullable) + `national_code_verified` (تغییر کد → ابطال تأیید) | `src/Auth/Entity/User.php:37-41,93-96` | ✅ |
} | OTP: `POST /api/v1/user/send-code`, `verify-code`, `otp-login` — عمومی در firewall | `src/Auth/Controller/AuthController.php:137,200,377` | ✅ — سرویس OTP جدید نساز |
``` | Rate limiterهای نام‌دار (`send_code`, `login`, `verify_code`, ...) | `config/packages/rate_limiter.yaml` | ✅ الگو برای limiter جدید claim |
| Messenger: transport های `async`, `failed` (failure_transport), `scheduler_default` | `config/packages/messenger.yaml` | ✅ |
| لاگ ساخت‌یافته در DB (جدول `app_log` — همان که CSV لاگ‌های سرور از آن است) | کانال Monolog پروژه | ✅ برای audit ادعاها استفاده کن |
| کرالر پایتون: `crawler_core.py` (resumable، rate-limit ~۶۳s)، `mapping.py`، `pipeline.py`، `clinicpro_client.py` (re-login در 401)، `server.py` (Flask UI پورت 5001) | `clinicpro-crawler/` | ✅ پایه؛ state file JSON است نه SQLite (وظیفهٔ ۶) |
| ErrorCodes موجود: `ERR_IDENTITY_001..004` (تطبیق کد ملی/شبا)، `ERR_EXTERNAL_001/002`، `ERR_CONFLICT_001`، `ERR_RATE_LIMIT_001`، `ERR_CAPTCHA_001` | `src/Shared/Constant/ErrorCodes.php` | ✅ کد جدید فقط اگر معنای موجود نبود |
متد آمادهٔ entity: ### ۲.۲ واگرایی‌های سند-با-کد که این پرامپت حل می‌کند (تصمیم‌های معماری مستند)
```php 1. **گزینه A در برابر B.** سند طراحی (`docs/scenarios/irimc-doctor-import-ownership.md` §۲،§۱۳) گزینهٔ A (nullable کردن `user_id` + حذف قید یکتا) را توصیه کرده بود؛ اما پیاده‌سازی واقعی **گزینهٔ B (کاربر جانشین به‌ازای هر پزشک)** را انجام داده و migration هم اعمال شده.
// Doctor.php:388-396 — user_id را پر می‌کند، مدیریت سیستمی را برمی‌دارد و وضعیت را claimed می‌کند. **تصمیم: گزینهٔ B حفظ می‌شود.** دلیل: `Doctor::$user` در کد `OneToOne NOT NULL` است و `getUser()` غیر-nullable در ده‌ها نقطه مصرف می‌شود (چک‌های مالکیت `getUser()->getId()`، پنل ادمین، `toArray`ها)؛ nullable کردن آن یعنی بازبینی همهٔ call-siteها = ریسک رگرسیون بزرگ بدون نیاز واقعی. جدول `users` با کاربران جانشینِ قابل‌شناسایی (نقش اختصاصی، وظیفهٔ ۳.۲) و حذف خودکار پس از claim تمیز نگه داشته می‌شود. سند سناریو باید پس از پیاده‌سازی با این تصمیم به‌روز شود.
public function transferOwnershipTo(User $user): self 2. **`ROLE_UNCLAIMED_DOCTOR` در مستند هست، در کد نیست.** `docs/api/doctor-import.md` این نقش را توصیف می‌کند ولی `importDoctor` آن را نمی‌دهد (`AdminApiController.php:~514` فقط `new User + setStatus(0)`). کد باید به مستند برسد (وظیفهٔ ۳.۲).
{ 3. **تأیید ادمین در برابر انتقال خودکار.** سند قدیمی‌تر approve دستی ادمین را برای فاز اول الزامی کرده بود؛ سند جدیدتر `docs/scenarios/climed.md` (مؤخر و صریح) claim را پس از موفقیت PersonInfo **خودکار نهایی** می‌کند.
... **تصمیم: climed.md حاکم است** — claim پس از تطبیق هویت خودکار نهایی می‌شود؛ ادمین به‌جای approve، **visibility** می‌گیرد (لاگ ادعاها + انتقال دستی برای پشتیبانی، وظیفهٔ ۳.۴ و ۵).
$this->ownerStatus = 'claimed'; 4. **ایندکس `(source, medical_system_code)` یکتا نیست.** `Version20260711120000` فقط `INDEX` ساخته؛ dedup فقط application-level است → با دو درخواست هم‌زمان (دو worker کرالر یا retry شبکه) رکورد تکراری ممکن است. باید UNIQUE شود (وظیفهٔ ۳.۳).
$this->claimedAt = time(); 5. **کرالر NestJS؟** `docs/scenarios/crawler.md` در انتها NestJS را «پیشنهاد» می‌کند؛ کرالر موجود Python/Flask بالغ است (rate-limit، mapping، resumable). **تصمیم: Python می‌ماند**؛ الزامات crawler.md (SQLite state، پنل توکن، ترتیب استان→شهر) روی همین پایه پیاده می‌شود (وظیفهٔ ۶).
``` 6. **کپچا.** `PasswordAuthenticator::authenticate()` خط ۴۹ بی‌قید `$this->captcha->assertValid($request)` را صدا می‌زند → لاگین headless کرالر با `ERR_CAPTCHA_001` می‌شکند (سند سناریو §۷). راه‌حل هدر سرویسی محدود (وظیفهٔ ۴.۲).
کپچا (بدون استثنا): ---
```php ## ۳. Workstream A — بک‌اند clinicpro
// PasswordAuthenticator.php:49 — ابتدای authenticate()
$this->captcha->assertValid($request);
```
## وظایف ### ۳.۱ استخراج منطق ایمپورت از کنترلر (thin controller)
### ۱. نقش `ROLE_UNCLAIMED_DOCTOR` برای کاربر جانشین `importDoctor` الان ~۱۰۰ خط منطق دامنه داخل کنترلر دارد (ساخت جانشین، idempotency، sync روابط). استخراج به سرویس:
در `importDoctor`، هنگام ساخت کاربر جانشین: - فایل جدید `src/Doctor/Service/DoctorImportService.php` با متد `import(array $payload, User $importedBy): DoctorImportResult`.
- `syncRefCollection` (خط ~۵۵۶ کنترلر) هم به سرویس منتقل شود.
- کنترلر فقط: parse + validation ورودی + صدازدن سرویس + `$this->success(...)`. **قرارداد HTTP (route، body، پاسخ‌های 200/201/422، فرمت `{uuid, created, skipped}`) عیناً حفظ شود** — کرالر و `docs/api/doctor-import.md` به آن وابسته‌اند.
- تراکنش: کل import یک رکورد داخل `$this->em->wrapInTransaction(...)`.
- رگرسیون: رفتار idempotent موجود (created=201 / updated=200 / skipped-claimed=200) تست integration بگیرد **قبل از** جابه‌جایی، بعد refactor، تست سبز بماند.
### ۳.۲ نقش `ROLE_UNCLAIMED_DOCTOR` برای کاربر جانشین
در `DoctorImportService` (پس از استخراج):
```php ```php
$user = new User($synthetic); $user = new User($synthetic);
$user->setRealName($name); $user->setRealName($name);
$user->setStatus(0); $user->setStatus(0);
$user->addRole('ROLE_UNCLAIMED_DOCTOR'); $user->addRole('ROLE_UNCLAIMED_DOCTOR'); // User.php:105
$this->em->persist($user);
``` ```
- نقش را به‌صورت رشته اضافه کن (الگوی موجود `addRole('ROLE_DOCTOR')` در پروژه). - **Backfill جانشین‌های موجود:** چون import idempotent است، در مسیر update (`$doctor !== null && unclaimed`) نقش را روی `$doctor->getUser()` تضمین کن. برای رکوردهایی که دیگر ایمپورت نمی‌شوند، یک migration دیتایی/دستور یک‌بارمصرف: هر user که `mobile_number LIKE 'imp\_%'` و `status=0` و دقیقاً یک پزشک `unclaimed` به او وصل است → نقش اضافه شود. destructive نیست؛ dry-run داشته باشد.
- **ایمپورت‌های قبلی** (جانشین‌های موجود بدون این نقش): چون idempotent است، در همان `importDoctor` وقتی `$doctor !== null && unclaimed` است هم نقش را به کاربر فعلی‌اش تضمین کن (`if (!$user->hasRole(...)) addRole(...)`) — کاربر جانشین از `$doctor->getUser()` در دسترس است. - این نقش **هیچ permission جدیدی نمی‌دهد** (در `security.yaml` به هیچ path وصل نشود) — فقط marker برای شناسایی و حذف امن است. `status=0` لاگین را همچنان می‌بندد.
### ۲. اندپوینت انتقال مالکیت ### ۳.۳ یکتاسازی دیتابیسیِ کلید ایمپورت (رفع race)
در `AdminApiController` (کنار `importDoctor`، همان الگوی OA + `$this->success/error`): Migration جدید:
```php ```sql
#[Route('/api/v1/admin/doctors/{uuid}/transfer', methods: ['POST'])] -- پیش‌شرط (در همان migration با abortIf یا بررسی دستی قبل از deploy):
public function transferDoctor(string $uuid, Request $request): JsonResponse SELECT source, medical_system_code, COUNT(*) c FROM doctors
{ WHERE medical_system_code IS NOT NULL AND medical_system_code <> ''
$data = json_decode($request->getContent(), true) ?? []; GROUP BY source, medical_system_code HAVING c > 1;
$mobile = trim((string) ($data['mobile'] ?? '')); -- dev فعلی: ۵۰۲ رکورد، صفر تکراری (تأییدشده). prod باید جدا چک شود.
if (!preg_match('/^09\d{9}$/', $mobile)) {
return $this->error(ErrorCodes::VALIDATION, 'شماره موبایل نامعتبر است', 422, 'mobile');
}
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]); DROP INDEX idx_doctors_source ON doctors;
if ($doctor === null) { CREATE UNIQUE INDEX uniq_doctors_source_code ON doctors (source, medical_system_code);
return $this->error(ErrorCodes::NOT_FOUND, 'پزشک یافت نشد', 404);
}
if ($doctor->getOwnerStatus() === 'claimed') {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این پروفایل قبلاً تصاحب شده است', 409);
}
$userRepo = $this->em->getRepository(User::class);
$target = $userRepo->findOneBy(['mobileNumber' => $mobile]);
if ($target === null) {
$target = new User($mobile);
$target->setRealName($doctor->getName());
$target->setStatus(1);
$this->em->persist($target);
}
// قید یکتای user_id: کاربر هدف نباید از قبل پزشک دیگری داشته باشد
$already = $this->em->getRepository(Doctor::class)->findOneBy(['user' => $target]);
if ($already !== null && $already->getId() !== $doctor->getId()) {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این کاربر قبلاً پروفایل پزشک دیگری دارد', 409);
}
$surrogate = $doctor->getUser();
$target->addRole('ROLE_DOCTOR');
$doctor->transferOwnershipTo($target);
// حذف امن جانشین: فقط اگر واقعاً جانشین است و هیچ پزشک دیگری به او وصل نیست
if ($surrogate !== null
&& $surrogate->getId() !== $target->getId()
&& $surrogate->hasRole('ROLE_UNCLAIMED_DOCTOR')
&& $this->em->getRepository(Doctor::class)->count(['user' => $surrogate]) === 0) {
$this->em->remove($surrogate);
}
$this->em->flush();
return $this->success([
'uuid' => $doctor->getUuid(),
'owner_status' => $doctor->getOwnerStatus(),
'user_mobile' => $mobile,
]);
}
``` ```
نکته‌ها: - MariaDB چند `NULL` را در ایندکس یکتا مجاز می‌داند → پزشکان manual بدون کد می‌مانند، مشکلی نیست. رکوردهای manual با کد تکراری اگر در prod وجود داشتند، migration باید **متوقف شود نه اینکه داده حذف کند** — گزارش بده، پاک‌سازی دستی/جداگانه.
- **ترتیب**: اول `transferOwnershipTo` (که `user_id` را عوض می‌کند)، بعد شمارش پزشکانِ جانشین — دقت کن Doctrine تا `flush` تغییر را به DB نمی‌برد، پس `count(['user' => $surrogate])` ممکن است هنوز همین پزشک را بشمارد. یا اول flush کن بعد حذف در flush دوم، یا شرط را `count === 0 || (count === 1 && همین doctor)` بگذار. سناریوی سادهٔ امن: دو مرحله — `flush()` بعد از transfer، سپس شمارش و `remove($surrogate)` و `flush()` دوم. - در `DoctorImportService`، `UniqueConstraintViolationException` را بگیر و به‌عنوان «برندهٔ هم‌زمانی، رکورد موجود را آپدیت کن» retry کن (یک بار) — این کنار قید DB، مسیر concurrent-import را قطعی می‌کند.
- امضای دقیق فیلد رابطهٔ `Doctor::user` را قبل از `findOneBy(['user' => ...])` از entity تأیید کن.
- ثابت‌های `ErrorCodes` موجود (`VALIDATION`, `NOT_FOUND`, `ERR_CONFLICT_001`) — چیز جدید نساز.
- بلوک OA (سواگر) مثل `importDoctor` بنویس: body `{ mobile }`، پاسخ‌های 200/404/409/422.
### ۳. رد شدن کپچا برای لاگین سرویسی کرالر ### ۳.۴ جریان Claim (تصاحب پروفایل توسط پزشک واقعی) — طبق `climed.md`
راه انتخابی سند (§۷، گزینهٔ «هدر سرّی مورد اعتماد»)، امن‌تر از خاموش‌کردن ALTCHA: **سرویس:** `src/Doctor/Service/DoctorClaimService.php`. **کنترلر:** `src/Doctor/Controller/DoctorClaimController.php` (thin، extends `BaseController`).
در `PasswordAuthenticator::authenticate()`، قبل از `assertValid`: **موجودیت audit جدید:** `DoctorClaimRequest` (جدول `doctor_claim_requests` + migration):
```php ```
$serviceToken = $_ENV['CRAWLER_SERVICE_TOKEN'] ?? ''; id, uuid, doctor_id (FK), user_id (FK), status VARCHAR(20) -- pending|verified|completed|failed|rejected
$sentToken = (string) $request->headers->get('X-Service-Token', ''); national_code_hash VARCHAR(64) -- sha256؛ کد ملی خام ذخیره/لاگ نشود
$isServiceLogin = $serviceToken !== '' && hash_equals($serviceToken, $sentToken); mobile_masked VARCHAR(15) -- 0912***4567
failure_reason VARCHAR(100) NULL, verification_method VARCHAR(30) -- apiir_personinfo(+shahkar|otp)
created_at INT, completed_at INT NULL
INDEX (doctor_id, status)
```
if (!$isServiceLogin) { **API (قرارداد کامل):**
```
GET /api/v1/doctor/{uuid}/claim-info [PUBLIC — در الگوی public_endpoints فعلی `api/v1/doctors` نیست؛ به pattern اضافه شود]
→ 200 { success, data: { claimable: bool, owner_status } }
فقط برای رندر دکمهٔ «آیا شما این پزشک هستید؟». هیچ دادهٔ هویتی برنمی‌گرداند.
POST /api/v1/doctor/{uuid}/claim [IS_AUTHENTICATED_FULLY — کاربر با OTP لاگین شده]
body: { national_code, birth_date_jalali, first_name, last_name }
RateLimiter جدید 'doctor_claim': sliding_window, limit 5 / 1h — کلید: user_id + doctor uuid؛ و یک limiter ثانویه روی IP.
خطاها (همه از ErrorCodes موجود؛ فرمت BaseController):
401 بدون لاگین
404 ERR_NOT_FOUND_001 پزشک یافت نشد
409 ERR_CONFLICT_001 پروفایل claimable نیست (claimed یا pending_transfer فعالِ دیگری)
409 ERR_CONFLICT_001 کاربر از قبل پزشک دیگری دارد
422 ERR_IDENTITY_001 عدم تطبیق هویت (پیام عمومی — نگو دقیقاً کدام فیلد؛ ضد enumeration)
422 ERR_VALIDATION_001 ورودی نامعتبر (کد ملی/تاریخ)
429 ERR_RATE_LIMIT_001
502/503 ERR_EXTERNAL_001 API.ir خطا/تایم‌اوت — پیام: «خطا در استعلام. لطفاً بعداً تلاش کنید»
→ 200 { success, data: { status: 'claimed', doctor: { uuid }, message } }
```
**الگوریتم `DoctorClaimService::claim()` (ترتیب دقیق):**
1. **قفل و گارد وضعیت** — داخل تراکنش، `SELECT ... FOR UPDATE` روی ردیف پزشک (`$em->find(Doctor::class, $id, LockMode::PESSIMISTIC_WRITE)`)؛ اگر `owner_status !== 'unclaimed'` → 409. این + قید منطقی «کاربر فقط یک پزشک» (`findOneBy(['user' => $target])`) شرط race در climed.md را برآورده می‌کند: یک Doctor هرگز به دو User وصل نمی‌شود.
2. وضعیت → `pending_transfer` + ساخت `DoctorClaimRequest(pending)` + flush + **پایان تراکنش کوتاه** (قفل آزاد؛ فراخوان خارجی داخل قفل ممنوع).
3. **تطبیق موبایل↔کدملی:** `ApiIrService::shahkarMatch($nationalCode, $user->getMobileNumber())` — سرویس و الگوی مصرفش موجود است (Representation:103). climed.md می‌گوید «اگر API.ir تطبیق موبایل دارد از آن استفاده کن» → دارد. اگر `isConfigured() === false` (env نبود)، fallback: موبایل کاربر لاگین‌شده قبلاً با OTP تأیید شده (مسیر ورود موجود) — کافی شمرده می‌شود، در `verification_method` ثبت شود.
4. **PersonInfo:** متد جدید `ApiIrService::personInfo(string $nationalCode, string $birthDateJalali): ?array` — همان الگوی `post()` موجود (`/api/sw1/PersonInfo`)؛ timeout موجود سرویس؛ `alive === false` → توقف با `ERR_IDENTITY_001`.
5. **تطبیق نام:** normalize فارسی سپس compare:
- `firstName+lastName` برگشتی API.ir ↔ نام پزشک ایمپورت‌شده (`Doctor::name` — پیشوند «دکتر» را strip کن)
- ورودی کاربر ↔ دادهٔ تأییدشدهٔ API.ir
- **Normalizer مشترک:** اول `src/Shared/` را برای util موجود بگرد (`User::setNationalCode` normalize ارقام دارد — ببین از کجا)؛ اگر normalizer نام فارسی نبود، `src/Shared/Util/PersianText.php` بساز: ي→ی، ك→ک، حذف نیم‌فاصله/فاصله‌های تکراری، trim، `Normalizer::normalize(..., FORM_KC)`. تست واحد جدا دارد.
6. **نهایی‌سازی (تراکنش دوم، اتمیک):** re-check `owner_status === 'pending_transfer'` و همین claim فعال → `$user->setNationalCode(...)->setNationalCodeVerified(true)``$user->addRole('ROLE_DOCTOR')``$doctor->transferOwnershipTo($user)` (موجود، Doctor.php:390) → claim `completed` → flush → **حذف امن جانشین در flush دوم**: فقط اگر `hasRole('ROLE_UNCLAIMED_DOCTOR')` && هیچ Doctor دیگری به او وصل نیست && غیر از کاربر هدف (شمارش بعد از flush اول تا UnitOfWork گمراه نکند).
7. شکست در هر مرحلهٔ ۳-۵: claim → `failed` + `failure_reason`، پزشک → **برگشت به `unclaimed`** (تا برای تلاش مجدد/شخص واقعی آزاد بماند). خطای API.ir → `retryable` است؛ خطای تطبیق → permanent، retry بی‌معنا.
8. **اطلاع‌رسانی:** پیامک خوش‌آمد با `SmsService::dispatchTemplate` موجود (async از قبل Messenger است).
**Logging/Audit:** context ساخت‌یافته `{claim_uuid, doctor_uuid, user_id, verification_method}`. **هرگز لاگ نشود:** کد ملی خام، تاریخ تولد، موبایل کامل، توکن API.ir، request/response کامل API.ir (قانون صریح climed.md). فقط hash/mask.
**ALTCHA:** endpoint claim پشت `IS_AUTHENTICATED_FULLY` است (کاربر قبلاً از مسیر OTP+کپچای موجود گذشته) → کپچای مجزا لازم نیست؛ rate limiter کفایت می‌کند. مستند کن.
### ۳.۵ انتقال دستی ادمین (ابزار پشتیبانی)
`POST /api/v1/admin/doctors/{uuid}/transfer` body `{ mobile }` — برای موارد پشتیبانی (پزشک بدون دسترسی به claim آنلاین). همان منطق نهایی‌سازی ۳.۴/۶ را از **همان `DoctorClaimService`** صدا بزن (متد `transferByAdmin`) — منطق را در کنترلر ادمین تکرار نکن. قواعد: 404/409 (claimed)/409 (کاربر پزشک دارد)/422 (موبایل نامعتبر `^09\d{9}$`). کاربر هدف اگر نبود ساخته می‌شود (status=1). `DoctorClaimRequest` با `verification_method='admin_manual'` ثبت شود.
### ۳.۶ فیلتر ادمین + نمایش وضعیت
- `GET /api/v1/admin/doctors` (لیست موجود در AdminApiController): پارامتر `owner_status` + ستون در خروجی (الگوی موجود: DQL/SQL array hydration — `getArrayResult`).
- `GET /api/v1/admin/doctor-claims?status=&page=&limit=` [ROLE_ADMIN]: paginated از `doctor_claim_requests` (join نام پزشک) — برای صفحه‌ی ادمین (§۵).
- خروجی عمومی پزشک (`toListArray`/`toArray`): فیلد `owner_status` اضافه شود تا Nobat724 دکمهٔ claim را رندر کند. **فیلد اضافه کن، هیچ فیلد موجودی را تغییر نده/حذف نکن** (سازگاری قرارداد؛ سایت عمومی از همین می‌خواند).
### ۳.۷ احراز هویت کرالر — کمینه‌سازی دسترسی
وضع فعلی: کاربر سیستمی `0000000000` با `ROLE_ADMIN` لاگین می‌کند و `AdminApiController` کلاً `#[IsGranted('ROLE_ADMIN')]` است (`AdminApiController.php:36`) → کرالر عملاً به کل پنل ادمین دسترسی دارد. **نقض least privilege — باید اصلاح شود:**
1. نقش جدید `ROLE_IMPORTER`. `SystemOwnerCommand` را طوری تغییر بده که کاربر سیستمی `['ROLE_USER','ROLE_IMPORTER']` بگیرد (نه ADMIN)؛ برای کاربر موجود در DBها یک پاس migration دیتایی/اجرای مجدد دستور.
2. اندپوینت import از `AdminApiController` (class-level ADMIN) به کنترلر ایمپورت اختصاصی منتقل شود: `src/Doctor/Controller/DoctorImportController.php` با `#[IsGranted(new Expression("is_granted('ROLE_ADMIN') or is_granted('ROLE_IMPORTER')"))]`**همان path فعلی `/api/v1/admin/doctors/import` حفظ شود** (قرارداد کرالر/مستند نشکند). در `security.yaml` سلسله‌مراتب نقش دست نخورد.
3. **کپچا (لاگین headless):** در `PasswordAuthenticator::authenticate()` قبل از `assertValid` (خط ۴۹):
```php
if (!$this->isTrustedServiceLogin($request)) {
$this->captcha->assertValid($request); $this->captcha->assertValid($request);
} }
// hash_equals($this->crawlerServiceToken, $request->headers->get('X-Service-Token', ''))
// فقط وقتی env CRAWLER_SERVICE_TOKEN غیرخالی ست شده؛ فقط کپچا skip می‌شود —
// rate limiter لاگین و اعتبارسنجی رمز دست‌نخورده می‌مانند.
```
env از طریق bind در `services.yaml` (الگوی `$appUrl: '%env(APP_BASE_URL)%'`) تزریق شود، نه `$_ENV` مستقیم. مقدار خالی = هیچ bypass (secure by default). به `.env`/`.env.example` اضافه شود.
4. چرخهٔ توکن: JWT صادرهٔ lexik همان TTL عادی را دارد؛ کرالر از قبل در 401 دوباره لاگین می‌کند (`clinicpro_client.py`). ابطال = غیرفعال‌کردن کاربر سیستمی (`app:system-owner --deactivate` یا API موجود deactivate) + چرخش `CRAWLER_SERVICE_TOKEN`. در مستند ثبت شود.
### ۳.۸ مستندات API (قانون پروژه — همان session)
- `docs/api/doctor-import.md`: نقش `ROLE_IMPORTER`، کنترلر جدید، هدر `X-Service-Token` لاگین، UNIQUE index.
- فایل جدید `docs/api/doctor-claim.md`: claim-info، claim، admin doctor-claims، transfer — هر کدام method/route/permission/body/validation/همهٔ پاسخ‌ها با JSON واقعی/rate limit.
- `docs/api/admin.md`: فیلتر `owner_status`.
- `docs/scenarios/irimc-doctor-import-ownership.md`: حاشیه‌نویسی تصمیم‌های §۲.۲ این پرامپت (گزینهٔ B، auto-claim).
---
## ۴. Workstream B — nobat724_front (جریان Claim در سایت عمومی)
مبنا: climed.md + قواعد پروژه (App Router، RTL، MUI v5+Tailwind، Vazir، Jalali).
1. **صفحهٔ پزشک** (`app/doctor/[slug]/page.js`): اگر `owner_status === 'unclaimed'`:
- برچسب وضعیت روی پروفایل: «این پروفایل هنوز توسط پزشک مدیریت نمی‌شود»
- دقیقاً زیر بخش نوبت‌دهی: بلوک «آیا شما این پزشک هستید؟» + دکمهٔ «تأیید و مدیریت این پروفایل»
- نوبت‌دهی آنلاین غیرفعال می‌ماند (از قبل `active=false` چون برنامهٔ کاری ندارد — رفتار موجود، تغییر نده).
2. **کامپوننت مشترک** `components/doctor/ClaimProfileModal.jsx` — یک کامپوننت برای دامنهٔ اصلی + همهٔ subdomainها + دامنه‌های نماینده (multi-domain از قبل با `ProvinceProvider`/`getStateInfo` حل است؛ منطق claim به دامنه وابسته نیست، duplicate نکن).
3. **جریان داخل Modal:**
- کاربر لاگین نیست → مسیر OTP موجود (send-code/verify-code) داخل همان modal یا redirect به فلوی ورود موجود — از الگوی auth موجود سایت استفاده کن، فرم OTP جدید نساز.
- فرم: موبایل (پیش‌پرشده از کاربر لاگین)، کد ملی، تاریخ تولد شمسی (**date picker موجود پروژه**)، نام، نام خانوادگی.
- `request.post('doctor/{uuid}/claim', body, { requireAuth: true })` از `services/response.js`.
4. **stateهای الزامی UI:** loading (دکمه disable + spinner)، خطای validation فیلدبه‌فیلد، خطای هویت (پیام عمومی)، 409 (قبلاً تصاحب‌شده)، 429، خطای شبکه با دکمهٔ تلاش مجدد، جلوگیری از double-submit (disable در حین flight)، success.
5. **پیام موفقیت (متن دقیق climed.md):**
«دکتر [نام پزشک]، به نوبت ۷۲۴ خوش آمدید 🎉 پروفایل شما با موفقیت تأیید شد و اکنون می‌توانید اطلاعات پروفایل و تنظیمات نوبت‌دهی خود را مدیریت کنید.» سپس هدایت طبق فلوی auth موجود به پنل.
6. هیچ درخواست مستقیمی از فرانت به API.ir نمی‌رود؛ هیچ توکنی به فرانت نمی‌رسد (همه backend، §۳.۴).
7. قواعد کسب‌وکار در فرانت تکرار نشود — دکمه با `owner_status` رندر می‌شود ولی مرجع نهایی backend است (403/409 هندل شود).
---
## ۵. Workstream C — پنل ادمین clinicpro (visibility عملیاتی)
صفحهٔ جدید `assets/admin/pages/DoctorClaimsPage.tsx` (الگوی موجود: `PaginatedResponse<T>` + TanStack Query + `DataTable`/`Pagination`/`StatusBadge`):
- تب/فیلتر: `pending / completed / failed` + جستجو.
- ستون‌ها: پزشک، وضعیت، روش احراز (`apiir_personinfo` / `admin_manual`)، موبایل mask‌شده، `failure_reason`، تاریخ شمسی (`formatDate`).
- اکشن: «انتقال دستی» (فرم موبایل → `POST .../transfer`) با `ConfirmDialog` موجود.
- در `DoctorsPage` موجود: فیلتر `owner_status` + badge وضعیت.
- ادمین باید علت شکست claim را بدون خواندن لاگ سرور ببیند (`failure_reason` انسانی‌خوان، فارسی).
---
## ۶. Workstream D — کرالر (طبق `docs/scenarios/crawler.md`)
Python می‌ماند (§۲.۲-۵). تغییرات:
### ۶.۱ State داخلی → SQLite (stdlib `sqlite3`، وابستگی جدید نصب نکن)
فایل `crawler_state.db` (volume-پایدار). جداول:
```sql
provinces(id INTEGER PK, name TEXT, clinicpro_state_id INT, status TEXT DEFAULT 'pending', started_at INT, completed_at INT)
cities(id INTEGER PK, province_id INT, name TEXT, clinicpro_city_id INT, status TEXT, started_at INT, completed_at INT)
doctors(id INTEGER PK, city_id INT, medical_system_code TEXT, name TEXT,
crawl_status TEXT, -- crawled|failed
push_status TEXT, -- pending|sent|failed|skipped_claimed
clinicpro_uuid TEXT, attempts INT DEFAULT 0, last_error TEXT, updated_at INT,
UNIQUE(medical_system_code))
meta(key TEXT PK, value TEXT) -- current_province, current_city, schema_version
``` ```
- **فقط کپچا** دور زده می‌شود؛ rate-limit و اعتبارسنجی رمز سر جای خود می‌مانند. - ماژول جدید `state_db.py`؛ `pipeline.py` و `crawler_core.py` به‌جای `.import_state.json` از آن بخوانند/بنویسند. مهاجرت یک‌باره از state file قدیمی اگر موجود بود.
- اگر env خالی باشد هیچ bypass وجود ندارد (پیش‌فرض امن). - **Resume:** در استارت، `meta.current_*` + وضعیت‌ها خوانده می‌شود و دقیقاً از همان‌جا ادامه می‌یابد؛ crash/restart هیچ‌چیز را از صفر شروع نمی‌کند.
- env جدید را به `.env` (خالی) و `.env.example` اضافه کن + ذکر در مستند.
- ترجیحاً env را از طریق constructor bind کن (الگوی `services.yaml` مثل `$appUrl: '%env(APP_BASE_URL)%'`) نه `$_ENV` مستقیم — با الگوی موجود فایل هماهنگ شو.
### ۴. کرالر: ارسال هدر سرویس ### ۶.۲ ترتیب پردازش (state machine)
در `clinicpro-crawler/clinicpro_client.py`، متد لاگین: اگر env `CLINICPRO_SERVICE_TOKEN` ست بود، هدر `X-Service-Token` را به درخواست لاگین اضافه کن (فقط لاگین کافی است). به `.env.example` کرالر هم اضافه کن. `Province → City → Crawl → Push → City completed → next City → Province completed → next Province`
### ۵. مستند + تست - لیست استان/شهر **از خود کلینیک‌پرو** گرفته می‌شود: `GET /api/v1/categorys/state` و `categorys/city` (اندپوینت‌های عمومی موجود — قالب پاسخ double-nested category را رعایت کن) و در جداول بالا seed می‌شود.
- یک شهر تا `completed` نشده، شهر بعدی شروع نمی‌شود. rate-limit موجود (~۶۳s بین جستجوها، ~۶۰s بین pushها) حفظ شود.
- خطاهای push: کلاس‌بندی — 4xx اعتبارسنجی = permanent (ثبت `failed` + `last_error`، ادامه)، 5xx/شبکه = retryable با exponential backoff و سقف `attempts` (مثلاً ۵)؛ بعد سقف → failed، ادامهٔ صف. هیچ خطای silent.
- `docs/api/doctor-import.md`: بخش transfer (method/path/permission/body/پاسخ‌ها/خطاها با مثال JSON) + توضیح `X-Service-Token` برای لاگین سرویسی + نقش `ROLE_UNCLAIMED_DOCTOR`. ### ۶.۳ پنل وب توکن (توسعهٔ `server.py` موجود)
- تست e2e مطابق §۹ سند:
```bash
ddev exec php bin/console app:system-owner 0000000000 --password=test123 --activate
cd ../clinicpro-crawler && CLINICPRO_PASSWORD=test123 .venv/bin/python pipeline.py \
--mode file --file output/یاسوج/doctors.json --no-photos --limit 2 --interval 2
```
سپس یک transfer دستی با curl و بررسی: `owner_status=claimed`، کاربر جانشین حذف‌شده، کاربر واقعی `ROLE_DOCTOR` دارد.
- تست‌های موجود `tests/Admin`/`tests/Doctor` را اجرا کن؛ اگر تست importDoctor وجود دارد، case انتقال را کنارش اضافه کن.
## نکات مهم - **auth استاتیک ساده** (crawler.md صریحاً می‌گوید static کافی است): `PANEL_USER`/`PANEL_PASS` از env؛ session cookie Flask. پشت شبکهٔ خصوصی/Coolify است، عمومی نیست.
- صفحهٔ «اتصال به کلینیک‌پرو»: فرم username/password کلینیک‌پرو → کرالر `POST /api/v1/user/login` (+ هدر `X-Service-Token` از env، §۳.۷) → JWT دریافت و **رمز دور ریخته می‌شود؛ فقط توکن** در جدول `meta` (یا فایل با `chmod 600`) ذخیره می‌شود. نمایش وضعیت توکن (valid/expired) + دکمهٔ re-login. رمز و توکن هرگز لاگ نشوند.
- `clinicpro_client.py`: توکن را از state بخواند؛ در 401 اگر credential ذخیره نیست، در پنل «نیاز به ورود مجدد» علامت بزند (نه crash).
- داشبورد پیشرفت: استان/شهر جاری، شمارنده‌های crawled/sent/failed/remaining از SQLite.
- `transferOwnershipTo` از قبل `claimed_at`/`owner_status`/`managed_by` را هندل می‌کند — منطق را در کنترلر تکرار نکن. ### ۶.۴ قواعد سخت کرالر
- پروفایل `pending_transfer` در این فاز فقط یک مقدار enum است؛ جریان درخواست تصاحب از سمت Nobat724 فاز بعدی است (§۹) — نساز.
- حذف جانشین باید **دقیقاً** سه شرط سند را داشته باشد: نقش `ROLE_UNCLAIMED_DOCTOR` + هیچ پزشک متصل + غیر از کاربر هدف. کاربر واقعی را هرگز حذف نکن. - کرالر **هرگز** به DB کلینیک‌پرو مستقیم وصل نمی‌شود؛ فقط API مستند (`import`, `categorys/*`, `login`).
- `activeDoctorAppointment` بعد از transfer دست نزن — روشن‌کردن نوبت‌دهی با مالک جدید است. - همزمان بیش از یک خزش فعال نشود (rate-limit روی IP است — قفل موجود اپ وب حفظ شود).
- بعد از تغییر API، به‌روزرسانی `docs/api/doctor-import.md` در همین session الزامی است (قانون پروژه). - `--dry-run` برای pipeline (فقط گزارش، بدون POST).
- لاگ ساخت‌یافته با `medical_system_code` به‌عنوان correlation؛ بدون توکن/رمز.
---
## ۷. مالکیت داده — سیاست فیلد-به-فیلد (source of truth)
| فیلد | unclaimed (ایمپورت مجدد) | بعد از claimed |
|---|---|---|
| `name`, `gender`, `degree`, `info`, `medical_system_code` | source-controlled — ایمپورت به‌روزرسانی می‌کند | **immutable برای import** — فقط مالک/ادمین (skip موجود) |
| روابط specialty/province/city | ایمپورت sync می‌کند (فقط اگر آرایه در payload باشد — رفتار موجود `syncRefCollection`) | دست import نمی‌خورد |
| `images` | ایمپورت/enrich عکس | مالک |
| `owner_status`, `claimed_at`, `user` | فقط از مسیر claim/transfer (سرویس ۳.۴) | — |
| `active_doctor_appointment` | همیشه `false` هنگام ایمپورت | فقط مالک واقعی روشن می‌کند — **ایمپورت و claim هیچ‌وقت روشنش نمی‌کنند** |
| `source`, `source_ref`, `managed_by` | ایمپورت | نگه داشته می‌شوند (ممیزی) |
قاعدهٔ کلی (از هر دو سند): رکورد `claimed` توسط ایمپورت **هرگز** بازنویسی نمی‌شود (پیاده‌سازی موجود این را دارد — تست بگیرد).
---
## ۸. استراتژی تست (الزامی؛ suiteهای موجود سبز بمانند)
Backend (`ddev exec php bin/phpunit`؛ الگوی `tests/ApiTestCase.php`):
| سناریو | نوع |
|---|---|
| import یک پزشک → 201 + جانشین با `ROLE_UNCLAIMED_DOCTOR` + `unclaimed` | integration (موجود را کامل کن) |
| import همان پزشک دوباره → 200 update، رکورد تکراری نه | integration |
| import هم‌زمان همان کد (شبیه‌سازی UniqueConstraintViolation) → یک رکورد | integration |
| import پزشک claimed → skipped، دادهٔ مالک دست‌نخورده | integration |
| رکورد بدون `medical_system_code` → 422 | integration |
| specialty/city ناموجود → رکورد ساخته می‌شود، رابطه خالی | integration |
| claim موفق: unclaimed→claimed، `ROLE_DOCTOR`، حذف جانشین، `national_code_verified` | integration + **mock ApiIrService** (تست هرگز به API.ir واقعی نزند — قانون climed.md؛ سرویس را در container تست جایگزین کن) |
| claim: alive=false / عدم تطبیق نام / کد ملی غلط → failed + برگشت unclaimed + عدم حذف جانشین | integration |
| claim هم‌زمان دو کاربر → یکی برنده، دیگری 409 | integration (دو درخواست متوالی روی pending_transfer) |
| کاربری که پزشک دارد → 409 | integration |
| API.ir timeout/5xx → ERR_EXTERNAL_001، وضعیت برگشته | integration با mock |
| rate limit claim → 429 | integration |
| normalize نام فارسی (ي/ی، ك/ک، نیم‌فاصله، فاصله) | unit (`PersianText`) |
| transfer ادمین: happy + 409ها | integration |
| لاگین با X-Service-Token درست/غلط/بدون env → کپچا skip فقط در حالت درست | integration |
| **رگرسیون:** ساخت پزشک عادی (`POST /api/v1/doctor`)، لاگین عادی (کپچا فعال)، delete پزشک، suiteهای `tests/Doctor tests/Auth tests/Admin` | موجود — سبز |
Frontend: تست کامپوننت Modal (stateهای loading/error/success/double-submit) با ابزار تست موجود پروژه؛ اگر پروژه تست FE ندارد، حداقل بررسی دستی مستند در PR.
Crawler: تست `state_db.py` (resume از هر مرحله، idempotency of seed) با `pytest` یا `unittest` stdlib — وابستگی تازه نصب نکن.
---
## ۹. Deployment / عملیات
- **پیش از هر چیز روی prod:** بررسی اعمال بودن `Version20260711120000` (در dev امروز جا مانده بود و 500 می‌داد — روی prod حتماً چک شود: `doctrine:migrations:status`).
- migration جدید UNIQUE: اول کوئری تکراری‌ها روی prod؛ متوقف‌شدنی، غیرمخرب، rollback = بازگشت به INDEX ساده.
- env جدید: `CRAWLER_SERVICE_TOKEN` (backend)، `APIIR_*` موجود برای PersonInfo کافی است (`ApiIrService::isConfigured`)، `PANEL_USER/PANEL_PASS` (کرالر). هیچ‌کدام در git.
- کرالر روی سرور جدا: پرامپت داکرایز جدا موجود است (`clinicpro-crawler/.claude/prompt/dockerize-crawler.md`) — SQLite state باید روی volume همان طرح بنشیند.
- rollout: backend + مستند → deploy → پنل ادمین (همان repo) → nobat724_front → کرالر. هر مرحله مستقل قابل برگشت.
---
## ۱۰. معیار پذیرش (Definition of Done)
1. کرالر با پنل خودش به کلینیک‌پرو لاگین می‌کند (بدون توکن دستی)، استان→شهر ترتیبی می‌خزد، پس از kill/restart از همان نقطه ادامه می‌دهد، و پزشکان در کلینیک‌پرو `unclaimed` ظاهر می‌شوند — کاربر سیستمی فقط `ROLE_IMPORTER` دارد و به هیچ endpoint ادمین دیگری دسترسی ندارد (تست 403).
2. اجرای دوبارهٔ ایمپورت روی همان دیتاست: صفر رکورد تکراری (قید DB) و پروفایل‌های claimed دست‌نخورده.
3. در Nobat724 (دامنهٔ اصلی + یک subdomain نماینده) پروفایل unclaimed برچسب و دکمهٔ claim دارد؛ جریان کامل claim با API.ir mockنشده در staging طی می‌شود؛ پس از claim: پیام خوش‌آمد، `ROLE_DOCTOR`، جانشین حذف، ویرایش پروفایل توسط پزشک ممکن، نوبت‌دهی همچنان خاموش تا برنامهٔ کاری تعریف شود.
4. ادمین در پنل: لیست claimها با علت شکست + انتقال دستی کارا.
5. هیچ کد ملی/تاریخ تولد/موبایل کامل/توکنی در هیچ لاگی (app_log و لاگ کرالر) ظاهر نمی‌شود — با grep روی لاگ staging تأیید شود.
6. کل suiteهای موجود + تست‌های جدید سبز؛ `docs/api/doctor-import.md`، `docs/api/doctor-claim.md`، `docs/api/admin.md` به‌روز.
## فرضیات صریح (فقط جایی که اطلاعات وجود نداشت)
- قالب `birth_date` برای PersonInfo همان `YYYY/M/D` جلالی نمونهٔ climed.md است؛ هنگام پیاده‌سازی با پاسخ واقعی API.ir در staging تأیید شود.
- «Clinic DataYar» در crawler.md همان backend کلینیک‌پرو است (نامی دیگر برای همان سیستم).
- سقف TTL توکن JWT فعلی برای چرخهٔ کاری کرالر کافی است چون re-login خودکار در 401 موجود است.
+3
View File
@@ -63,3 +63,6 @@ ALLOWED_FRONTEND_HOSTS=clinic-pro.ir,yasuj-nobat.ir,yazd-nobat.ir
APP_BASE_URL=https://clinic-pro.ir APP_BASE_URL=https://clinic-pro.ir
# کلیدهای درگاه (mellat/sep) از پنل «تنظیمات سایت» (DB) خوانده می‌شوند؛ env فقط fallback اختیاری است. # کلیدهای درگاه (mellat/sep) از پنل «تنظیمات سایت» (DB) خوانده می‌شوند؛ env فقط fallback اختیاری است.
###< Payment ### ###< Payment ###
# لاگین سرویسی کرالر: مقدار غیرخالی، هدر X-Service-Token را برای دورزدن کپچای لاگین فعال می‌کند (فقط کپچا)
CRAWLER_SERVICE_TOKEN=
+6
View File
@@ -29,3 +29,9 @@ framework:
policy: 'sliding_window' policy: 'sliding_window'
limit: 5 limit: 5
interval: '60 minutes' interval: '60 minutes'
# Doctor profile claim: max 5 attempts per hour per (user, doctor) — ضد brute-force هویت
doctor_claim:
policy: 'sliding_window'
limit: 5
interval: '60 minutes'
+1 -1
View File
@@ -33,7 +33,7 @@ security:
provider: api_doc_provider provider: api_doc_provider
public_endpoints: public_endpoints:
pattern: ^/(api/v1/altcha/(challenge|config)$|api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic/[^/]+/addresses$|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/appointment-settings/month-availability/|api/v1/comments/|api/v1/rate/[^/]+$|api/v1/specialties|api/v1/blogs$|api/v1/tags$|api/v1/clinic-invitation/|api/v1/pre-registration$) pattern: ^/(api/v1/altcha/(challenge|config)$|api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic/[^/]+/addresses$|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/appointment-settings/month-availability/|api/v1/comments/|api/v1/rate/[^/]+$|api/v1/specialties|api/v1/blogs$|api/v1/tags$|api/v1/clinic-invitation/|api/v1/pre-registration$|api/v1/doctor/[^/]+/claim-info$)
stateless: true stateless: true
security: false security: false
+1
View File
@@ -76,6 +76,7 @@ services:
App\Auth\Security\PasswordAuthenticator: App\Auth\Security\PasswordAuthenticator:
arguments: arguments:
$refreshTokenTtl: '%env(int:REFRESH_TOKEN_TTL)%' $refreshTokenTtl: '%env(int:REFRESH_TOKEN_TTL)%'
$crawlerServiceToken: '%env(default::CRAWLER_SERVICE_TOKEN)%'
$loginLimiter: '@limiter.login' $loginLimiter: '@limiter.login'
App\Auth\Controller\AuthController: App\Auth\Controller\AuthController:
+1
View File
@@ -343,6 +343,7 @@ List all doctors with pagination.
| `status` | string | ❌ | `"active"` or `"inactive"` | | `status` | string | ❌ | `"active"` or `"inactive"` |
| `gender` | string | ❌ | `"male"` or `"female"` | | `gender` | string | ❌ | `"male"` or `"female"` |
| `specialty_id` | integer | ❌ | Filter by specialty | | `specialty_id` | integer | ❌ | Filter by specialty |
| `owner_status` | string | ❌ | `claimed` \| `unclaimed` \| `pending_transfer` — پروفایل‌های ایمپورت IRIMC (خروجی هم `owner_status` و `source` دارد) |
| `sort` | string | ❌ | Sort field | | `sort` | string | ❌ | Sort field |
### Response `200` ### Response `200`
+151
View File
@@ -0,0 +1,151 @@
# Doctor Profile Claim API (تصاحب پروفایل پزشک ایمپورت‌شده)
> **Controller:** `App\Doctor\Controller\DoctorClaimController` — منطق در `App\Doctor\Service\DoctorClaimService`
> **مصرف‌کننده:** سایت عمومی Nobat724 (همهٔ دامنه‌ها) + پنل ادمین
پزشکِ ایمپورت‌شده از نظام پزشکی (`owner_status = unclaimed`) توسط پزشک واقعی تصاحب می‌شود.
احراز هویت سمت سرور با **API.ir** انجام می‌شود (شاهکار: تطبیق موبایل↔کدملی؛ PersonInfo: تطبیق
کدملی+تاریخ تولد و نام). هیچ درخواستی از فرانت به API.ir نمی‌رود و توکن API.ir هرگز به کلاینت
نمی‌رسد. claim پس از تطبیق موفق **خودکار** نهایی می‌شود (بدون approve ادمین — تصمیم مستند در
`.claude/prompt/irimc-import-complete.md` §۲.۲).
## چرخهٔ وضعیت
```
unclaimed ──claim/transfer شروع──▶ pending_transfer ──موفق──▶ claimed
▲ │شکست تطبیق/خطای استعلام
└───────────────────────────────────┘ (برگشت، قابل تلاش مجدد)
```
هر تلاش یک رکورد ممیزی در `doctor_claim_requests` می‌سازد — کد ملی فقط **hash sha256** و
موبایل فقط **mask شده** ذخیره می‌شود؛ هیچ دادهٔ هویتی خام در DB یا لاگ نمی‌ماند.
---
## GET `/api/v1/doctor/{uuid}/claim-info`
**Permission:** عمومی (بدون JWT) — فقط برای رندر دکمهٔ «آیا شما این پزشک هستید؟»
### Response `200`
```json
{ "success": true, "data": { "claimable": true, "owner_status": "unclaimed" } }
```
| کد | حالت |
|---|---|
| `404` | پزشک یافت نشد |
---
## POST `/api/v1/doctor/{uuid}/claim`
**Permission:** `IS_AUTHENTICATED_FULLY` — کاربر با OTP لاگین شده (موبایلش تأییدشده است)
**Rate limit:** limiter `doctor_claim` — ۵ تلاش در ساعت به‌ازای هر (کاربر، پزشک)
### Request
```json
{
"national_code": "0010007700",
"birth_date": "1371/1/1",
"first_name": "فرخنده",
"last_name": "حسینی"
}
```
| فیلد | الزامی | قاعده |
|---|:---:|---|
| `national_code` | ✅ | ۱۰ رقم (ارقام فارسی پذیرفته و نرمال می‌شوند) |
| `birth_date` | ✅ | شمسی `Y/m/d` |
| `first_name` / `last_name` | ✅ | با هویت ثبت احوال و نام پروفایل تطبیق داده می‌شود (نرمال‌سازی ي/ی، ك/ک، نیم‌فاصله — `PersianText`) |
### مراحل سرور (اتمیک/ضد race)
1. قفل `PESSIMISTIC_WRITE` روی ردیف پزشک → اگر `unclaimed` نبود `409`؛ اگر کاربر از قبل پزشکی دارد `409`؛ اگر کد ملی متعلق به کاربر دیگری است `409` — سپس `pending_transfer` + رکورد ممیزی (تراکنش کوتاه، بدون فراخوان خارجی داخل قفل).
2. شاهکار (`ApiIrService::shahkarMatch`) — تطبیق موبایل کاربر با کد ملی. اگر API.ir پیکربندی نشده باشد، این گام skip و مبنا موبایلِ OTP-تأییدشده است.
3. `PersonInfo` — تطبیق کدملی+تاریخ تولد؛ `alive=false` → رد.
4. تطبیق نام: ورودی کاربر ↔ هویت تأییدشده ↔ نام پروفایل (بدون پیشوند «دکتر»).
5. نهایی‌سازی اتمیک: `user_id` → کاربر واقعی، `ROLE_DOCTOR`، `national_code_verified=true`، `owner_status=claimed`، حذف امنِ کاربر جانشین (فقط با `ROLE_UNCLAIMED_DOCTOR` و بدون پزشک دیگر).
6. پیامک خوش‌آمد (تمپلیت `welcome`، async).
> شکست در گام‌های ۲-۴ پروفایل را به `unclaimed` برمی‌گرداند تا پزشک واقعی بتواند دوباره تلاش کند.
> نوبت‌دهی (`active_doctor_appointment`) خاموش می‌ماند تا مالک جدید برنامهٔ کاری تعریف کند.
### Response `200`
```json
{
"success": true,
"data": {
"status": "claimed",
"claim": { "uuid": "…" },
"doctor": { "uuid": "…", "name": "دکتر فرخنده حسینی" }
}
}
```
### Errors
| کد | HTTP | حالت |
|---|---|---|
| `ERR_AUTH_001` | 401 | بدون لاگین |
| `ERR_NOT_FOUND_001` | 404 | پزشک یافت نشد |
| `ERR_CONFLICT_001` | 409 | پروفایل قابل تصاحب نیست / کاربر پزشک دیگری دارد / درخواست هم‌زمان دیگری در جریان است |
| `ERR_PROFILE_001` | 409 | کد ملی قبلاً برای کاربر دیگری ثبت شده است |
| `ERR_VALIDATION_001/002` | 422 | کد ملی/تاریخ/نام نامعتبر |
| `ERR_IDENTITY_001` | 422 | عدم تطبیق هویت (شاهکار/ثبت احوال/نام) — پیام عمومی، ضد enumeration |
| `ERR_RATE_LIMIT_001` | 429 | عبور از سقف تلاش |
| `ERR_EXTERNAL_001` | 502 | خطا/تایم‌اوت API.ir |
| `ERR_EXTERNAL_002` | 503 | API.ir پیکربندی نشده (env) |
---
## POST `/api/v1/admin/doctors/{uuid}/transfer`
انتقال دستی (ابزار پشتیبانی ادمین) — مستند کامل در `docs/api/doctor-import.md`.
**Permission:** `ROLE_ADMIN`. بدنه `{ "mobile": "09…" }`. همان نهایی‌سازی claim را اجرا می‌کند
و رکورد ممیزی با `verification_method="admin_manual"` می‌سازد.
---
## GET `/api/v1/admin/doctor-claims`
**Permission:** `ROLE_ADMIN` — لیست ممیزی درخواست‌های claim برای پشتیبانی عملیاتی.
| Query | پیش‌فرض | توضیح |
|---|---|---|
| `status` | همه | `pending` \| `completed` \| `failed` |
| `page` / `limit` | 1 / 20 (سقف 50) | صفحه‌بندی استاندارد |
### Response `200` (paginated)
```json
{
"success": true,
"data": [
{
"uuid": "…",
"status": "completed",
"doctor": { "uuid": "…", "name": "دکتر فرخنده حسینی" },
"mobile_masked": "0912***4567",
"verification_method": "apiir_personinfo+shahkar",
"failure_reason": null,
"created_at": 1783750000,
"completed_at": 1783750040
}
],
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
}
```
`failure_reason` فارسی و انسانی‌خوان است تا ادمین بدون خواندن لاگ سرور علت شکست را ببیند.
---
## env های مرتبط
| متغیر | نقش |
|---|---|
| `APIIR_*` (baseUrl/token موجود `ApiIrService`) | استعلام شاهکار و PersonInfo |
| `CRAWLER_SERVICE_TOKEN` | فقط برای لاگین سرویسی کرالر (`doctor-import.md`) — ربطی به claim ندارد |
## تست‌ها
`tests/Doctor/DoctorClaimTest.php` (۱۱ سناریو، API.ir همیشه mock — هیچ تستی به سرویس واقعی
درخواست نمی‌زند) و `tests/Shared/PersianTextTest.php` (نرمال‌سازی نام).
+37 -13
View File
@@ -1,8 +1,8 @@
# Doctor Import (IRIMC) API # Doctor Import (IRIMC) API
> **Endpoint:** `POST /api/v1/admin/doctors/import` > **Endpoint:** `POST /api/v1/admin/doctors/import`
> **Permission:** `ROLE_ADMIN` > **Permission:** `ROLE_ADMIN` **یا** `ROLE_IMPORTER` (نقش حداقلی کاربر سیستمی کرالر)
> **Controller:** `App\Admin\Controller\AdminApiController::importDoctor` > **Controller:** `App\Doctor\Controller\DoctorImportController::import` — منطق دامنه در `App\Doctor\Service\DoctorImportService`
وارد کردن یک پزشک از سازمان نظام پزشکی (`membersearch.irimc.org`) **بدون شماره موبایل**. وارد کردن یک پزشک از سازمان نظام پزشکی (`membersearch.irimc.org`) **بدون شماره موبایل**.
برخلاف `POST /api/v1/admin/doctors` (که موبایل معتبر ایرانی می‌خواهد)، این اندپوینت برای برخلاف `POST /api/v1/admin/doctors` (که موبایل معتبر ایرانی می‌خواهد)، این اندپوینت برای
@@ -31,7 +31,9 @@
## idempotency ## idempotency
کلید یکتای منطقی: `(source, medical_system_code)`. کلید یکتای `(source, medical_system_code)` — از این نسخه **در سطح دیتابیس** هم unique است
(`uniq_doctors_source_code`، migration `Version20260711150000`)؛ درخواست هم‌زمانِ همان پزشک
با retry داخلی به مسیر update می‌رود و هرگز رکورد تکراری نمی‌سازد.
- اگر پزشکی با همان `source`+`medical_system_code` وجود نداشته باشد → **ساخته** می‌شود (`201`). - اگر پزشکی با همان `source`+`medical_system_code` وجود نداشته باشد → **ساخته** می‌شود (`201`).
- اگر وجود داشته باشد و `owner_status != claimed`**به‌روزرسانی** می‌شود (`200`). - اگر وجود داشته باشد و `owner_status != claimed`**به‌روزرسانی** می‌شود (`200`).
@@ -132,10 +134,16 @@
// Response 200 // Response 200
{ "success": true, "data": { { "success": true, "data": {
"uuid": "…", "owner_status": "claimed", "uuid": "…", "owner_status": "claimed",
"transferred_to": "09120000000", "placeholder_deleted": true "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` | پزشک یافت نشد | | `404` | پزشک یافت نشد |
@@ -154,13 +162,29 @@ php bin/console app:system-owner 0000000000 --password=<secret> --activate
php bin/console app:system-owner 0000000000 --deactivate php bin/console app:system-owner 0000000000 --deactivate
``` ```
کاربر باید `ROLE_ADMIN` و `status=1` داشته باشد تا لاگینِ رمزی (`POST /api/v1/user/login`) کاربر سیستمی **least privilege** است: فقط `ROLE_USER,ROLE_IMPORTER` می‌گیرد (اجرای مجدد
و فراخوانی این اندپوینت ممکن باشد. دستور، `ROLE_ADMIN` قدیمی را هم حذف می‌کند). `ROLE_IMPORTER` فقط به همین اندپوینت ایمپورت
دسترسی دارد و به هیچ اندپوینت `/api/v1/admin/*` دیگری راه ندارد (تست: `DoctorImportTest::testImporterRoleCanImportButNothingElse`).
> ⚠️ **captcha:** مسیر `/api/v1/user/login` از `CaptchaGuard` رد می‌شود و این guard وقتی ### لاگین سرویسی (captcha)
> `ALTCHA_ENABLED=true` باشد (مقدار فعلی `.env`) یک payloadِ altcha می‌خواهد. برای اجرای
> بدونِ‌مرورگرِ کرالر یکی از این‌ها لازم است: مسیر `/api/v1/user/login` کپچای ALTCHA دارد. برای لاگین headless کرالر، هدر سرّی
> ۱) روی همان سرور `ALTCHA_ENABLED=false` در `.env.local` (ساده‌ترین برای dev)، یا تعریف شده است:
> ۲) افزودن یک استثنا در `PasswordAuthenticator` که برای کاربر مالک سیستمی captcha را رد کند،
> یا ۳) لاگین سرویس با یک هدر سرّیِ مورد اعتماد. تا وقتی این حل نشود، لاگین کرالر با ۴۲۲ ```
> (`ERR_CAPTCHA_001`) رد می‌شود. 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 # اعمال
```
+7 -1
View File
@@ -1,5 +1,9 @@
# Doctor API # Doctor API
> فیلد `owner_status` (`claimed` | `unclaimed` | `pending_transfer`) به خروجی لیست و جزئیات پزشک
> اضافه شده است — پروفایل `unclaimed` (ایمپورت نظام پزشکی) در سایت دکمهٔ «تصاحب پروفایل» می‌گیرد
> (`docs/api/doctor-claim.md`) و نوبت‌دهی آنلاینش غیرفعال است.
> **Prefix:** `/api/v1/doctor`, `/api/v1/doctors`, `/api/v1/clinic-pro/doctor-address*` > **Prefix:** `/api/v1/doctor`, `/api/v1/doctors`, `/api/v1/clinic-pro/doctor-address*`
> >
> Numeric path params on the address routes (`doctor-address/{id}`, `doctor-addresses/{doctorId}`) require `\d+`; a non-numeric value returns a clean `404` instead of a `500`. > Numeric path params on the address routes (`doctor-address/{id}`, `doctor-addresses/{doctorId}`) require `\d+`; a non-numeric value returns a clean `404` instead of a `500`.
@@ -101,6 +105,7 @@ Get doctor detail with clinics.
"free_turn": "دوشنبه 09:0013:00", "free_turn": "دوشنبه 09:0013:00",
"hours_of_work": "شنبه: 09:0013:00 و 14:0018:00 | یکشنبه: 09:0013:00", "hours_of_work": "شنبه: 09:0013:00 و 14:0018:00 | یکشنبه: 09:0013:00",
"active": true, "active": true,
"owner_status": "claimed",
"specialties": [{ "uuid": "...", "id": "1", "name": "قلب و عروق", "parent_id": null }], "specialties": [{ "uuid": "...", "id": "1", "name": "قلب و عروق", "parent_id": null }],
"expertise": [{ "uuid": "...", "id": "3", "name": "نوار قلب" }], "expertise": [{ "uuid": "...", "id": "3", "name": "نوار قلب" }],
"address": [], "address": [],
@@ -204,7 +209,8 @@ List doctors with pagination and filters.
"point": "3.5", "point": "3.5",
"free_turn": "دوشنبه 09:0013:00", "free_turn": "دوشنبه 09:0013:00",
"hours_of_work": "شنبه: 09:0013:00 و 14:0018:00 | یکشنبه: 09:0013:00", "hours_of_work": "شنبه: 09:0013:00 و 14:0018:00 | یکشنبه: 09:0013:00",
"active": true "active": true,
"owner_status": "claimed"
} }
], ],
"meta": { "meta": {
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* کلید طبیعی ایمپورت IRIMC را در سطح دیتابیس یکتا می‌کند.
*
* ایندکس قبلی (source, medical_system_code) ساده بود؛ dedup فقط application-level
* بود و دو درخواست هم‌زمان می‌توانست رکورد تکراری بسازد. MariaDB چند NULL را در
* ایندکس یکتا مجاز می‌داند، پس پزشکان manual بدون کد تحت تأثیر نیستند.
*
* غیرمخرب: اگر دادهٔ تکراری وجود داشته باشد migration متوقف می‌شود (هیچ حذفی
* انجام نمی‌دهد) — تکراری‌ها باید جداگانه و دستی تعیین تکلیف شوند.
*/
final class Version20260711150000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Unique index on doctors (source, medical_system_code) for concurrency-safe IRIMC import';
}
public function up(Schema $schema): void
{
$dupes = $this->connection->fetchAllAssociative(<<<'SQL'
SELECT source, medical_system_code, COUNT(*) c FROM doctors
WHERE medical_system_code IS NOT NULL AND medical_system_code <> ''
GROUP BY source, medical_system_code HAVING c > 1
SQL);
$this->abortIf(
$dupes !== [],
'Duplicate (source, medical_system_code) rows exist — resolve them manually before adding the unique index: '
. json_encode($dupes, JSON_UNESCAPED_UNICODE)
);
$this->addSql('DROP INDEX idx_doctors_source ON doctors');
$this->addSql('CREATE UNIQUE INDEX uniq_doctors_source_code ON doctors (source, medical_system_code)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX uniq_doctors_source_code ON doctors');
$this->addSql('CREATE INDEX idx_doctors_source ON doctors (source, medical_system_code)');
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* جدول ممیزی درخواست‌های تصاحب پروفایل پزشک (claim).
* دادهٔ حساس خام ذخیره نمی‌شود: کد ملی hash (sha256) و موبایل mask.
*/
final class Version20260711151000 extends AbstractMigration
{
public function getDescription(): string
{
return 'doctor_claim_requests audit table for the profile-claim flow';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE doctor_claim_requests (
id INT AUTO_INCREMENT NOT NULL,
uuid VARCHAR(36) NOT NULL,
doctor_id INT NOT NULL,
user_id INT DEFAULT NULL,
status VARCHAR(20) NOT NULL,
national_code_hash VARCHAR(64) NOT NULL,
mobile_masked VARCHAR(15) NOT NULL,
verification_method VARCHAR(40) NOT NULL,
failure_reason VARCHAR(100) DEFAULT NULL,
created_at INT NOT NULL,
completed_at INT DEFAULT NULL,
UNIQUE INDEX UNIQ_claim_uuid (uuid),
INDEX idx_claim_doctor_status (doctor_id, status),
INDEX IDX_claim_user (user_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql('ALTER TABLE doctor_claim_requests ADD CONSTRAINT FK_claim_doctor FOREIGN KEY (doctor_id) REFERENCES doctors (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE doctor_claim_requests ADD CONSTRAINT FK_claim_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE doctor_claim_requests');
}
}
+6 -125
View File
@@ -7,10 +7,8 @@ use App\Shared\Constant\ErrorCodes;
use App\Appointment\Repository\SlotTakenException; use App\Appointment\Repository\SlotTakenException;
use App\Auth\Entity\User; use App\Auth\Entity\User;
use App\Shared\Service\InputValidator; use App\Shared\Service\InputValidator;
use App\Location\Entity\City;
use App\Clinic\Entity\Clinic; use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor; use App\Doctor\Entity\Doctor;
use App\Location\Entity\Province;
use App\Specialty\Entity\Specialty; use App\Specialty\Entity\Specialty;
use App\Payment\Entity\Payment; use App\Payment\Entity\Payment;
use App\Rating\Entity\Comment; use App\Rating\Entity\Comment;
@@ -326,6 +324,11 @@ class AdminApiController extends BaseController
$where[] = 'd.gender = :gender'; $where[] = 'd.gender = :gender';
$params['gender'] = $gender; $params['gender'] = $gender;
} }
$ownerStatus = trim((string) $request->query->get('owner_status', ''));
if (in_array($ownerStatus, ['claimed', 'unclaimed', 'pending_transfer'], true)) {
$where[] = 'd.owner_status = :ownerStatus';
$params['ownerStatus'] = $ownerStatus;
}
if ($specId > 0) { if ($specId > 0) {
$where[] = 'EXISTS (SELECT 1 FROM doctor_specialties ds2 WHERE ds2.doctor_id = d.id AND ds2.specialty_id = :specId)'; $where[] = 'EXISTS (SELECT 1 FROM doctor_specialties ds2 WHERE ds2.doctor_id = d.id AND ds2.specialty_id = :specId)';
$params['specId'] = $specId; $params['specId'] = $specId;
@@ -348,6 +351,7 @@ class AdminApiController extends BaseController
"SELECT d.id, d.uuid, d.name, d.gender, d.degree, d.medical_system_code, "SELECT d.id, d.uuid, d.name, d.gender, d.degree, d.medical_system_code,
d.mobile_number as doctor_mobile, d.active_doctor_appointment, d.mobile_number as doctor_mobile, d.active_doctor_appointment,
d.doctor_rate, d.doctor_rate_percentage, d.images, d.created_at, d.doctor_rate, d.doctor_rate_percentage, d.images, d.created_at,
d.owner_status, d.source,
u.mobile_number as user_mobile, u.email u.mobile_number as user_mobile, u.email
FROM doctors d JOIN users u ON u.id = d.user_id FROM doctors d JOIN users u ON u.id = d.user_id
WHERE $whereStr ORDER BY $orderBy LIMIT $limit OFFSET $offset", WHERE $whereStr ORDER BY $orderBy LIMIT $limit OFFSET $offset",
@@ -444,129 +448,6 @@ class AdminApiController extends BaseController
return $this->success(['uuid' => $doctor->getUuid()], 201); return $this->success(['uuid' => $doctor->getUuid()], 201);
} }
/**
* ایمپورت یک پزشک از سازمان نظام پزشکی (بدون شماره موبایل).
*
* برخلاف createDoctor، این اندپوینت موبایل نمی‌خواهد: برای هر پزشک یک «کاربر
* جانشین» غیرفعال با شناسهٔ مصنوعی ساخته می‌شود و پروفایل در وضعیت unclaimed
* ذخیره می‌گردد تا بعداً به پزشک واقعی منتقل شود. idempotent بر پایهٔ
* (source, medical_system_code): اجرای مجدد، رکورد موجود را به‌روزرسانی می‌کند.
*/
#[OA\Post(
path: '/api/v1/admin/doctors/import',
summary: 'Import an IRIMC doctor without a mobile number (unclaimed profile)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['name', 'medical_system_code'],
properties: [
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'medical_system_code', type: 'string'),
new OA\Property(property: 'source', type: 'string', default: 'irimc'),
new OA\Property(property: 'source_ref', type: 'string', nullable: true, description: 'profile_url یا شناسهٔ مبدأ'),
new OA\Property(property: 'gender', type: 'string', nullable: true),
new OA\Property(property: 'degree', type: 'string', nullable: true),
new OA\Property(property: 'info', type: 'string', nullable: true),
new OA\Property(property: 'specialties', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'states', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'cities', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
]
)
),
responses: [
new OA\Response(response: 201, description: 'Doctor imported (created)'),
new OA\Response(response: 200, description: 'Doctor already existed (updated or skipped)'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
#[Route('/api/v1/admin/doctors/import', methods: ['POST'])]
public function importDoctor(Request $request, #[CurrentUser] User $admin): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$name = trim((string) ($data['name'] ?? ''));
$code = trim((string) ($data['medical_system_code'] ?? $data['medicalSystemCode'] ?? ''));
$source = trim((string) ($data['source'] ?? 'irimc')) ?: 'irimc';
if ($name === '') {
return $this->error(ErrorCodes::VALIDATION, 'نام الزامی است', 422, 'name');
}
if ($code === '') {
return $this->error(ErrorCodes::VALIDATION, 'کد نظام پزشکی الزامی است', 422, 'medical_system_code');
}
$doctorRepo = $this->em->getRepository(Doctor::class);
$userRepo = $this->em->getRepository(User::class);
// idempotency: همان پزشکِ منبع → به‌روزرسانی، نه ساخت تکراری
$doctor = $doctorRepo->findOneBy(['source' => $source, 'medicalSystemCode' => $code]);
$created = false;
// پروفایل تصاحب‌شده را با ایمپورت مجدد بازنویسی نکن (مالک واقعی اولویت دارد)
if ($doctor !== null && $doctor->getOwnerStatus() === 'claimed') {
return $this->success(['uuid' => $doctor->getUuid(), 'created' => false, 'skipped' => 'claimed']);
}
if ($doctor === null) {
// کاربر جانشینِ یکتا و غیرفعال؛ شناسهٔ مصنوعی قطعی از روی کد نظام پزشکی
$synthetic = 'imp_' . substr(md5($source . ':' . $code), 0, 14); // ≤ ۱۸ کاراکتر، ASCII، یکتا
$user = $userRepo->findOneBy(['mobileNumber' => $synthetic]);
if ($user === null) {
$user = new User($synthetic);
$user->setRealName($name);
$user->setStatus(0); // جانشین: هرگز لاگین نمی‌کند
$this->em->persist($user);
}
$doctor = new Doctor($user, $name);
$doctor->setSource($source);
$doctor->setOwnerStatus('unclaimed');
$doctor->setActiveDoctorAppointment(false); // تا مالک واقعی برنامهٔ کاری بسازد
$created = true;
}
// فیلدهای مشترک
$doctor->setName($name);
$doctor->setMedicalSystemCode($code);
$doctor->setManagedBy($admin->getId());
if (array_key_exists('source_ref', $data) || array_key_exists('profile_url', $data)) {
$doctor->setSourceRef($data['source_ref'] ?? $data['profile_url'] ?? null);
}
if (!empty($data['gender'])) $doctor->setGender($data['gender']);
if (!empty($data['degree'])) $doctor->setDegree($data['degree']);
if (array_key_exists('info', $data)) $doctor->setInfo($data['info']);
// روابط بر پایهٔ شناسه‌های مرجع (تخصص/استان/شهر)
$this->syncRefCollection($doctor->getSpecialties(), $data['specialties'] ?? null, Specialty::class);
$this->syncRefCollection($doctor->getProvinces(), $data['states'] ?? null, Province::class);
$this->syncRefCollection($doctor->getCities(), $data['cities'] ?? null, City::class);
$this->em->persist($doctor);
$this->em->flush();
return $this->success(
['uuid' => $doctor->getUuid(), 'created' => $created],
$created ? 201 : 200
);
}
/**
* یک مجموعهٔ ManyToMany پزشک را با آرایه‌ای از شناسه‌های مرجع همگام می‌کند.
* اگر $ids null باشد دست نمی‌خورد؛ اگر آرایه باشد، پاک و از نو پر می‌شود.
*/
private function syncRefCollection(\Doctrine\Common\Collections\Collection $col, ?array $ids, string $class): void
{
if ($ids === null) {
return;
}
$col->clear();
foreach ($ids as $id) {
$ref = $this->em->getRepository($class)->find((int) $id);
if ($ref !== null && !$col->contains($ref)) {
$col->add($ref);
}
}
}
// ── Clinics ─────────────────────────────────────────────────────────────── // ── Clinics ───────────────────────────────────────────────────────────────
#[Route('/api/v1/admin/clinics', methods: ['GET'])] #[Route('/api/v1/admin/clinics', methods: ['GET'])]
+9 -3
View File
@@ -70,11 +70,17 @@ class SystemOwnerCommand extends Command
} }
} }
// least privilege: کاربر سیستمی فقط ROLE_IMPORTER می‌گیرد (دسترسی فقط به اندپوینت
// ایمپورت پزشک). اگر از نسخه‌های قبلی ROLE_ADMIN دارد، حذف می‌شود.
$roles = $user->getRoles(); $roles = $user->getRoles();
if (!in_array('ROLE_ADMIN', $roles, true)) { if (in_array('ROLE_ADMIN', $roles, true)) {
$roles[] = 'ROLE_ADMIN'; $roles = array_values(array_diff($roles, ['ROLE_ADMIN']));
$user->setRoles(array_values(array_unique($roles))); $io->note('ROLE_ADMIN از کاربر سیستمی حذف شد (least privilege).');
} }
if (!in_array('ROLE_IMPORTER', $roles, true)) {
$roles[] = 'ROLE_IMPORTER';
}
$user->setRoles(array_values(array_unique($roles)));
if ($password !== null) { if ($password !== null) {
$user->setPasswordHash($this->hasher->hashPassword($user, (string) $password)); $user->setPasswordHash($this->hasher->hashPassword($user, (string) $password));
+2 -1
View File
@@ -121,6 +121,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this->hasRole('ROLE_DOCTOR') return $this->hasRole('ROLE_DOCTOR')
|| $this->hasRole('ROLE_CLINIC') || $this->hasRole('ROLE_CLINIC')
|| $this->hasRole('ROLE_SECRETARY') || $this->hasRole('ROLE_SECRETARY')
|| $this->hasRole('ROLE_ADMIN'); || $this->hasRole('ROLE_ADMIN')
|| $this->hasRole('ROLE_IMPORTER'); // کاربر سیستمی کرالر — لاگین با رمز؛ دسترسی فقط اندپوینت ایمپورت
} }
} }
@@ -30,8 +30,20 @@ class PasswordAuthenticator extends AbstractAuthenticator
private readonly RateLimiterFactory $loginLimiter, private readonly RateLimiterFactory $loginLimiter,
private readonly CaptchaGuard $captcha, private readonly CaptchaGuard $captcha,
private readonly int $refreshTokenTtl = 2592000, private readonly int $refreshTokenTtl = 2592000,
private readonly ?string $crawlerServiceToken = null,
) {} ) {}
/**
* لاگین سرویسی کرالر: هدر X-Service-Token با مقدار env CRAWLER_SERVICE_TOKEN
* فقط کپچا را دور می‌زند — rate limit و اعتبارسنجی رمز دست‌نخورده می‌مانند.
* env خالی = هیچ bypass (secure by default).
*/
private function isTrustedServiceLogin(Request $request): bool
{
return ($this->crawlerServiceToken ?? '') !== ''
&& hash_equals($this->crawlerServiceToken, (string) $request->headers->get('X-Service-Token', ''));
}
public function supports(Request $request): ?bool public function supports(Request $request): ?bool
{ {
return $request->getPathInfo() === '/api/v1/user/login' return $request->getPathInfo() === '/api/v1/user/login'
@@ -46,7 +58,9 @@ class PasswordAuthenticator extends AbstractAuthenticator
} }
// AppException را ExceptionSubscriber به پاسخ 422 با ERR_CAPTCHA_001 تبدیل می‌کند. // AppException را ExceptionSubscriber به پاسخ 422 با ERR_CAPTCHA_001 تبدیل می‌کند.
if (!$this->isTrustedServiceLogin($request)) {
$this->captcha->assertValid($request); $this->captcha->assertValid($request);
}
$data = json_decode($request->getContent(), true) ?? []; $data = json_decode($request->getContent(), true) ?? [];
$mobile = trim($data['mobile_number'] ?? ''); $mobile = trim($data['mobile_number'] ?? '');
@@ -0,0 +1,76 @@
<?php
namespace App\Doctor\Command;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Doctor\Service\DoctorImportService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* backfill یک‌بارمصرف: نقش ROLE_UNCLAIMED_DOCTOR برای کاربران جانشینِ ایمپورت
* (mobile با پیشوند imp_، غیرفعال، متصل به پزشک unclaimed) که پیش از افزودن
* این نقش ساخته شده‌اند. غیرمخرب؛ با --dry-run فقط گزارش می‌دهد.
*
* php bin/console app:doctors:backfill-surrogate-role --dry-run
* php bin/console app:doctors:backfill-surrogate-role
*/
#[AsCommand(
name: 'app:doctors:backfill-surrogate-role',
description: 'Add ROLE_UNCLAIMED_DOCTOR to legacy IRIMC surrogate users (idempotent, supports --dry-run)',
)]
class BackfillSurrogateRoleCommand extends Command
{
public function __construct(private readonly EntityManagerInterface $em)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only, change nothing');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
/** @var User[] $surrogates */
$surrogates = $this->em->createQueryBuilder()
->select('u')
->from(User::class, 'u')
->join(Doctor::class, 'd', 'WITH', 'd.user = u')
->where("u.mobileNumber LIKE 'imp\\_%'")
->andWhere('u.status = 0')
->andWhere("d.ownerStatus = 'unclaimed'")
->getQuery()
->getResult();
$updated = 0;
foreach ($surrogates as $user) {
if ($user->hasRole(DoctorImportService::ROLE_UNCLAIMED_DOCTOR)) {
continue;
}
$updated++;
$io->text(sprintf('%s %s', $dryRun ? '[dry-run]' : '[update]', $user->getMobileNumber()));
if (!$dryRun) {
$user->addRole(DoctorImportService::ROLE_UNCLAIMED_DOCTOR);
}
}
if (!$dryRun && $updated > 0) {
$this->em->flush();
}
$io->success(sprintf('%d surrogate(s) %s (of %d scanned)', $updated, $dryRun ? 'would be updated' : 'updated', count($surrogates)));
return Command::SUCCESS;
}
}
@@ -0,0 +1,198 @@
<?php
namespace App\Doctor\Controller;
use App\Auth\Entity\User;
use App\Doctor\Repository\DoctorRepository;
use App\Doctor\Service\DoctorClaimService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Doctors')]
class DoctorClaimController extends BaseController
{
public function __construct(
private readonly DoctorRepository $doctorRepo,
private readonly DoctorClaimService $claimService,
private readonly RateLimiterFactory $doctorClaimLimiter,
private readonly \App\Doctor\Repository\DoctorClaimRequestRepository $claimRepo,
) {}
#[OA\Get(
path: '/api/v1/doctor/{uuid}/claim-info',
summary: 'Whether this doctor profile can be claimed (public, renders the claim button)',
responses: [
new OA\Response(response: 200, description: '{ claimable, owner_status }'),
new OA\Response(response: 404, description: 'Doctor not found'),
]
)]
#[Route('/api/v1/doctor/{uuid}/claim-info', methods: ['GET'])]
public function claimInfo(string $uuid): JsonResponse
{
$doctor = $this->doctorRepo->findByUuid($uuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
}
return $this->success([
'claimable' => $doctor->getOwnerStatus() === 'unclaimed',
'owner_status' => $doctor->getOwnerStatus(),
]);
}
#[OA\Post(
path: '/api/v1/doctor/{uuid}/claim',
summary: 'Claim an unclaimed (IRIMC-imported) doctor profile after identity verification',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['national_code', 'birth_date', 'first_name', 'last_name'],
properties: [
new OA\Property(property: 'national_code', type: 'string', example: '0010007700'),
new OA\Property(property: 'birth_date', type: 'string', description: 'شمسی Y/m/d', example: '1371/1/1'),
new OA\Property(property: 'first_name', type: 'string'),
new OA\Property(property: 'last_name', type: 'string'),
]
)
),
responses: [
new OA\Response(response: 200, description: 'Claimed'),
new OA\Response(response: 409, description: 'Not claimable / user already owns a doctor'),
new OA\Response(response: 422, description: 'Validation or identity mismatch'),
new OA\Response(response: 429, description: 'Rate limited'),
new OA\Response(response: 502, description: 'Identity provider unavailable'),
]
)]
#[Route('/api/v1/doctor/{uuid}/claim', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function claim(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$limiter = $this->doctorClaimLimiter->create('claim_' . $user->getId() . '_' . $uuid);
$limit = $limiter->consume();
if (!$limit->isAccepted()) {
throw new TooManyRequestsHttpException($limit->getRetryAfter()->getTimestamp() - time());
}
$doctor = $this->doctorRepo->findByUuid($uuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
$nationalCode = preg_replace('/\D/', '', \App\Shared\Util\PersianText::normalize((string) ($data['national_code'] ?? '')));
$birthDate = trim(\App\Shared\Util\PersianText::normalize((string) ($data['birth_date'] ?? '')));
$firstName = trim((string) ($data['first_name'] ?? ''));
$lastName = trim((string) ($data['last_name'] ?? ''));
if (!preg_match('/^\d{10}$/', $nationalCode)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی نامعتبر است', 422, 'national_code');
}
if (!preg_match('~^1[34]\d{2}/\d{1,2}/\d{1,2}$~', $birthDate)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'تاریخ تولد نامعتبر است (مثال: 1371/1/1)', 422, 'birth_date');
}
if ($firstName === '' || $lastName === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام و نام خانوادگی الزامی است', 422);
}
$claim = $this->claimService->claim($doctor, $user, $nationalCode, $birthDate, $firstName, $lastName);
return $this->success([
'status' => 'claimed',
'claim' => ['uuid' => $claim->getUuid()],
'doctor' => ['uuid' => $doctor->getUuid(), 'name' => $doctor->getName()],
]);
}
#[OA\Post(
path: '/api/v1/admin/doctors/{uuid}/transfer',
summary: 'Manually transfer an unclaimed doctor profile to a real user (admin support tool)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['mobile'],
properties: [new OA\Property(property: 'mobile', type: 'string', example: '09121234567')]
)
),
responses: [
new OA\Response(response: 200, description: 'Transferred'),
new OA\Response(response: 404, description: 'Doctor not found'),
new OA\Response(response: 409, description: 'Already claimed / target user owns another doctor'),
new OA\Response(response: 422, description: 'Invalid mobile'),
]
)]
#[Route('/api/v1/admin/doctors/{uuid}/transfer', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function transfer(string $uuid, Request $request): JsonResponse
{
$doctor = $this->doctorRepo->findByUuid($uuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
$mobile = trim((string) ($data['mobile'] ?? ''));
if (!preg_match('/^09\d{9}$/', $mobile)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل نامعتبر است', 422, 'mobile');
}
$claim = $this->claimService->transferByAdmin($doctor, $mobile);
return $this->success([
'uuid' => $doctor->getUuid(),
'owner_status' => $doctor->getOwnerStatus(),
'user_mobile' => $mobile,
'claim' => ['uuid' => $claim->getUuid()],
]);
}
#[OA\Get(
path: '/api/v1/admin/doctor-claims',
summary: 'Paginated audit list of doctor profile claim requests',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'status', in: 'query', schema: new OA\Schema(type: 'string', enum: ['pending', 'completed', 'failed'])),
new OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', schema: new OA\Schema(type: 'integer', default: 20)),
],
responses: [new OA\Response(response: 200, description: 'Paginated claim requests')]
)]
#[Route('/api/v1/admin/doctor-claims', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function claimsList(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
$status = trim((string) $request->query->get('status', ''));
$qb = $this->claimRepo->createQueryBuilder('c')
->orderBy('c.createdAt', 'DESC');
if (in_array($status, [\App\Doctor\Entity\DoctorClaimRequest::STATUS_PENDING, \App\Doctor\Entity\DoctorClaimRequest::STATUS_COMPLETED, \App\Doctor\Entity\DoctorClaimRequest::STATUS_FAILED], true)) {
$qb->andWhere('c.status = :status')->setParameter('status', $status);
}
$total = (int) (clone $qb)->select('COUNT(c.id)')->getQuery()->getSingleScalarResult();
$items = $qb->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit)
->getQuery()
->getResult();
return $this->paginated(
array_map(fn(\App\Doctor\Entity\DoctorClaimRequest $c) => $c->toArray(), $items),
$total,
$page,
$limit
);
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Doctor\Controller;
use App\Auth\Entity\User;
use App\Doctor\Service\DoctorImportService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* اندپوینت ایمپورت پزشک IRIMC — جدا از AdminApiController تا کاربر سیستمیِ
* کرالر با نقش حداقلی ROLE_IMPORTER (بدون دسترسی به بقیهٔ پنل ادمین) بتواند
* فقط همین عمل را انجام دهد (least privilege). مسیر برای سازگاری با کرالر و
* مستندات، همان مسیر قبلی مانده است.
*/
#[OA\Tag(name: 'Doctors')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class DoctorImportController extends BaseController
{
public function __construct(
private readonly DoctorImportService $importService,
) {}
#[OA\Post(
path: '/api/v1/admin/doctors/import',
summary: 'Import an IRIMC doctor without a mobile number (unclaimed profile)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['name', 'medical_system_code'],
properties: [
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'medical_system_code', type: 'string'),
new OA\Property(property: 'source', type: 'string', default: 'irimc'),
new OA\Property(property: 'source_ref', type: 'string', nullable: true, description: 'profile_url یا شناسهٔ مبدأ'),
new OA\Property(property: 'gender', type: 'string', nullable: true),
new OA\Property(property: 'degree', type: 'string', nullable: true),
new OA\Property(property: 'info', type: 'string', nullable: true),
new OA\Property(property: 'specialties', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'states', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'cities', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
]
)
),
responses: [
new OA\Response(response: 201, description: 'Doctor imported (created)'),
new OA\Response(response: 200, description: 'Doctor already existed (updated or skipped)'),
new OA\Response(response: 403, description: 'Requires ROLE_ADMIN or ROLE_IMPORTER'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
#[Route('/api/v1/admin/doctors/import', methods: ['POST'])]
public function import(Request $request, #[CurrentUser] User $user): JsonResponse
{
if (!$this->isGranted('ROLE_ADMIN') && !$this->isGranted('ROLE_IMPORTER')) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی به این منبع مجاز نیست', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$name = trim((string) ($data['name'] ?? ''));
$code = trim((string) ($data['medical_system_code'] ?? $data['medicalSystemCode'] ?? ''));
if ($name === '') {
return $this->error(ErrorCodes::VALIDATION, 'نام الزامی است', 422, 'name');
}
if ($code === '') {
return $this->error(ErrorCodes::VALIDATION, 'کد نظام پزشکی الزامی است', 422, 'medical_system_code');
}
$result = $this->importService->import($data, $user);
$payload = ['uuid' => $result->doctor->getUuid(), 'created' => $result->created];
if ($result->skipped !== null) {
$payload['skipped'] = $result->skipped;
}
return $this->success($payload, $result->created ? 201 : 200);
}
}
+6 -2
View File
@@ -17,7 +17,9 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: DoctorRepository::class)] #[ORM\Entity(repositoryClass: DoctorRepository::class)]
#[ORM\Table(name: 'doctors')] #[ORM\Table(name: 'doctors')]
#[ORM\UniqueConstraint(name: 'idx_doctors_user', columns: ['user_id'])] #[ORM\UniqueConstraint(name: 'idx_doctors_user', columns: ['user_id'])]
#[ORM\UniqueConstraint(name: 'uniq_doctors_source_code', columns: ['source', 'medical_system_code'])]
#[ORM\Index(columns: ['active_doctor_appointment'], name: 'idx_doctors_active')] #[ORM\Index(columns: ['active_doctor_appointment'], name: 'idx_doctors_active')]
#[ORM\Index(columns: ['owner_status'], name: 'idx_doctors_owner')]
class Doctor class Doctor
{ {
public const DEGREES = ['expert', 'general', 'specialist', 'subspecialistplus']; public const DEGREES = ['expert', 'general', 'specialist', 'subspecialistplus'];
@@ -79,11 +81,11 @@ class Doctor
// ── Profile ownership (IRIMC import) ─────────────────────────────────────── // ── Profile ownership (IRIMC import) ───────────────────────────────────────
// owner_status: claimed | unclaimed | pending_transfer // owner_status: claimed | unclaimed | pending_transfer
#[ORM\Column(name: 'owner_status', type: 'string', length: 20)] #[ORM\Column(name: 'owner_status', type: 'string', length: 20, options: ['default' => 'claimed'])]
private string $ownerStatus = 'claimed'; private string $ownerStatus = 'claimed';
// source: manual | irimc // source: manual | irimc
#[ORM\Column(type: 'string', length: 20)] #[ORM\Column(type: 'string', length: 20, options: ['default' => 'manual'])]
private string $source = 'manual'; private string $source = 'manual';
// شناسه رکورد مبدأ (profile_url یا کد نظام پزشکی) برای idempotency و ممیزی // شناسه رکورد مبدأ (profile_url یا کد نظام پزشکی) برای idempotency و ممیزی
@@ -521,6 +523,7 @@ class Doctor
'free_turn' => $sf['free_turn'], 'free_turn' => $sf['free_turn'],
'hours_of_work' => $sf['hours_of_work'], 'hours_of_work' => $sf['hours_of_work'],
'active' => $this->activeDoctorAppointment && $sf['has_schedule'], 'active' => $this->activeDoctorAppointment && $sf['has_schedule'],
'owner_status' => $this->ownerStatus,
]; ];
} }
@@ -553,6 +556,7 @@ class Doctor
], $this->services->toArray()), ], $this->services->toArray()),
'satisfaction' => (string) $this->doctorRatePercentage, 'satisfaction' => (string) $this->doctorRatePercentage,
'point' => (string) $this->doctorRate, 'point' => (string) $this->doctorRate,
'owner_status' => $this->ownerStatus,
'free_turn' => $sf['free_turn'], 'free_turn' => $sf['free_turn'],
'hours_of_work' => $sf['hours_of_work'], 'hours_of_work' => $sf['hours_of_work'],
'address' => array_map(fn(DoctorAddress $a) => $a->toArray(), $this->addresses->toArray()), 'address' => array_map(fn(DoctorAddress $a) => $a->toArray(), $this->addresses->toArray()),
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Doctor\Entity;
use App\Auth\Entity\User;
use App\Doctor\Repository\DoctorClaimRequestRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* رکورد ممیزی درخواست تصاحب پروفایل پزشک (claim).
*
* دادهٔ حساس خام ذخیره نمی‌شود: کد ملی فقط hash و موبایل فقط mask.
*/
#[ORM\Entity(repositoryClass: DoctorClaimRequestRepository::class)]
#[ORM\Table(name: 'doctor_claim_requests')]
#[ORM\Index(columns: ['doctor_id', 'status'], name: 'idx_claim_doctor_status')]
class DoctorClaimRequest
{
public const STATUS_PENDING = 'pending';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: Doctor::class)]
#[ORM\JoinColumn(name: 'doctor_id', nullable: false, onDelete: 'CASCADE')]
private Doctor $doctor;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'user_id', nullable: true, onDelete: 'SET NULL')]
private ?User $user;
#[ORM\Column(type: 'string', length: 20)]
private string $status = self::STATUS_PENDING;
#[ORM\Column(name: 'national_code_hash', type: 'string', length: 64)]
private string $nationalCodeHash;
#[ORM\Column(name: 'mobile_masked', type: 'string', length: 15)]
private string $mobileMasked;
#[ORM\Column(name: 'verification_method', type: 'string', length: 40)]
private string $verificationMethod;
#[ORM\Column(name: 'failure_reason', type: 'string', length: 100, nullable: true)]
private ?string $failureReason = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'completed_at', type: 'integer', nullable: true)]
private ?int $completedAt = null;
public function __construct(Doctor $doctor, ?User $user, string $nationalCode, string $mobile, string $verificationMethod)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->doctor = $doctor;
$this->user = $user;
$this->nationalCodeHash = hash('sha256', $nationalCode);
$this->mobileMasked = self::maskMobile($mobile);
$this->verificationMethod = $verificationMethod;
$this->createdAt = time();
}
public static function maskMobile(string $mobile): string
{
return strlen($mobile) >= 7
? substr($mobile, 0, 4) . '***' . substr($mobile, -4)
: '***';
}
public function markCompleted(): void
{
$this->status = self::STATUS_COMPLETED;
$this->completedAt = time();
}
public function markFailed(string $reason): void
{
$this->status = self::STATUS_FAILED;
$this->failureReason = mb_substr($reason, 0, 100);
$this->completedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getDoctor(): Doctor { return $this->doctor; }
public function getUser(): ?User { return $this->user; }
public function getStatus(): string { return $this->status; }
public function getFailureReason(): ?string { return $this->failureReason; }
public function getVerificationMethod(): string { return $this->verificationMethod; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getCompletedAt(): ?int { return $this->completedAt; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'status' => $this->status,
'doctor' => ['uuid' => $this->doctor->getUuid(), 'name' => $this->doctor->getName()],
'mobile_masked' => $this->mobileMasked,
'verification_method' => $this->verificationMethod,
'failure_reason' => $this->failureReason,
'created_at' => $this->createdAt,
'completed_at' => $this->completedAt,
];
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\Doctor\Repository;
use App\Doctor\Entity\DoctorClaimRequest;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class DoctorClaimRequestRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, DoctorClaimRequest::class);
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
namespace App\Doctor\Service;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorClaimRequest;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Shared\Service\ApiIrService;
use App\Shared\Util\PersianText;
use App\Sms\Entity\SmsLog;
use App\Sms\Service\SmsService;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* تصاحب پروفایل پزشک ایمپورت‌شده (unclaimed) توسط پزشک واقعی.
*
* دو مسیر: self-claim (احراز هویت API.ir: شاهکار + PersonInfo + تطبیق نام) و
* انتقال دستی ادمین. نهایی‌سازی هر دو مسیر یکی است: اتصال کاربر واقعی، نقش
* ROLE_DOCTOR، حذف امن کاربر جانشین، و ثبت رکورد ممیزی DoctorClaimRequest.
*
* ضد race: تغییر وضعیت unclaimed→pending_transfer زیر قفل PESSIMISTIC_WRITE
* انجام می‌شود؛ درخواست هم‌زمان دوم 409 می‌گیرد. فراخوانی خارجی هرگز داخل قفل نیست.
*/
class DoctorClaimService
{
public const METHOD_APIIR = 'apiir_personinfo';
public const METHOD_APIIR_SHAHKAR = 'apiir_personinfo+shahkar';
public const METHOD_ADMIN = 'admin_manual';
public function __construct(
private readonly EntityManagerInterface $em,
private readonly ApiIrService $apiIr,
private readonly SmsService $smsService,
private readonly LoggerInterface $logger,
) {}
public function claim(Doctor $doctor, User $user, string $nationalCode, string $birthDateJalali, string $firstName, string $lastName): DoctorClaimRequest
{
$method = $this->apiIr->isConfigured() ? self::METHOD_APIIR_SHAHKAR : self::METHOD_APIIR;
// مرحلهٔ ۱ — رزرو اتمیک پروفایل زیر قفل (تراکنش کوتاه، بدون فراخوان خارجی)
$claim = $this->em->wrapInTransaction(function () use ($doctor, $user, $nationalCode, $method): DoctorClaimRequest {
$locked = $this->em->find(Doctor::class, $doctor->getId(), LockMode::PESSIMISTIC_WRITE);
if ($locked->getOwnerStatus() !== 'unclaimed') {
throw new AppException(ErrorCodes::ERR_CONFLICT_001, 'این پروفایل قابل تصاحب نیست یا درخواست دیگری در جریان است', 409);
}
$existing = $this->em->getRepository(Doctor::class)->findOneBy(['user' => $user]);
if ($existing !== null) {
throw new AppException(ErrorCodes::ERR_CONFLICT_001, 'شما از قبل یک پروفایل پزشک دارید', 409);
}
$codeOwner = $this->em->getRepository(User::class)->findOneBy(['nationalCode' => $nationalCode]);
if ($codeOwner !== null && $codeOwner->getId() !== $user->getId()) {
throw new AppException(ErrorCodes::ERR_PROFILE_NATIONAL_CODE_TAKEN, null, 409);
}
$locked->setOwnerStatus('pending_transfer');
$claim = new DoctorClaimRequest($locked, $user, $nationalCode, $user->getMobileNumber(), $method);
$this->em->persist($claim);
return $claim;
});
// مرحلهٔ ۲ — احراز هویت (خارج از قفل)
try {
$this->verifyIdentity($doctor, $user, $nationalCode, $birthDateJalali, $firstName, $lastName);
} catch (AppException $e) {
$this->revert($doctor, $claim, $e->getMessage());
throw $e;
}
// مرحلهٔ ۳ — نهایی‌سازی اتمیک
$this->finalize($doctor, $user, $claim, $nationalCode);
// مرحلهٔ ۴ — پیامک خوش‌آمد (غیر بحرانی؛ شکستش claim را باطل نمی‌کند)
$this->smsService->dispatchTemplate(SmsLog::TAG_WELCOME, $user->getMobileNumber(), [
'name' => PersianText::stripDoctorTitle($doctor->getName()),
'site' => 'نوبت۷۲۴',
]);
return $claim;
}
/** انتقال دستی توسط ادمین (پشتیبانی) — بدون استعلام هویت؛ کاربر هدف با موبایل پیدا/ساخته می‌شود. */
public function transferByAdmin(Doctor $doctor, string $mobile): DoctorClaimRequest
{
$claim = $this->em->wrapInTransaction(function () use ($doctor, $mobile): DoctorClaimRequest {
$locked = $this->em->find(Doctor::class, $doctor->getId(), LockMode::PESSIMISTIC_WRITE);
if ($locked->getOwnerStatus() === 'claimed') {
throw new AppException(ErrorCodes::ERR_CONFLICT_001, 'این پروفایل قبلاً تصاحب شده است', 409);
}
$userRepo = $this->em->getRepository(User::class);
$target = $userRepo->findOneBy(['mobileNumber' => $mobile]);
if ($target === null) {
$target = new User($mobile);
$target->setRealName(PersianText::stripDoctorTitle($locked->getName()));
$target->setStatus(1);
$this->em->persist($target);
}
$existing = $this->em->getRepository(Doctor::class)->findOneBy(['user' => $target]);
if ($existing !== null && $existing->getId() !== $locked->getId()) {
throw new AppException(ErrorCodes::ERR_CONFLICT_001, 'این کاربر قبلاً پروفایل پزشک دیگری دارد', 409);
}
$locked->setOwnerStatus('pending_transfer');
$claim = new DoctorClaimRequest($locked, $target, '', $mobile, self::METHOD_ADMIN);
$this->em->persist($claim);
return $claim;
});
$this->finalize($doctor, $claim->getUser(), $claim, null);
return $claim;
}
private function verifyIdentity(Doctor $doctor, User $user, string $nationalCode, string $birthDateJalali, string $firstName, string $lastName): void
{
// تطبیق موبایل ↔ کد ملی (شاهکار). بدون پیکربندی api.ir استعلام ممکن نیست →
// موبایلِ OTP-تأییدشدهٔ کاربر لاگین‌شده مبنا می‌ماند و فقط PersonInfo چک می‌شود.
if ($this->apiIr->isConfigured() && !$this->apiIr->shahkarMatch($nationalCode, $user->getMobileNumber())) {
throw new AppException(ErrorCodes::ERR_NATIONAL_CODE_MISMATCH, null, 422);
}
$person = $this->apiIr->personInfo($nationalCode, $birthDateJalali);
if ($person === null || !$person['alive']) {
throw new AppException(ErrorCodes::ERR_NATIONAL_CODE_MISMATCH, 'اطلاعات هویتی با سامانهٔ ثبت احوال مطابقت ندارد', 422);
}
// ورودی کاربر ↔ هویت تأییدشده
if (!PersianText::sameName($firstName . ' ' . $lastName, $person['firstName'] . ' ' . $person['lastName'])) {
throw new AppException(ErrorCodes::ERR_NATIONAL_CODE_MISMATCH, 'نام واردشده با اطلاعات هویتی مطابقت ندارد', 422);
}
// هویت تأییدشده ↔ نام پروفایل ایمپورت‌شده از نظام پزشکی
$profileName = PersianText::stripDoctorTitle($doctor->getName());
$verifiedName = PersianText::normalize($person['firstName'] . ' ' . $person['lastName']);
if ($profileName !== $verifiedName) {
throw new AppException(ErrorCodes::ERR_NATIONAL_CODE_MISMATCH, 'نام شما با نام این پروفایل پزشک مطابقت ندارد', 422);
}
}
private function finalize(Doctor $doctor, User $target, DoctorClaimRequest $claim, ?string $nationalCode): void
{
$surrogate = $this->em->wrapInTransaction(function () use ($doctor, $target, $claim, $nationalCode): ?User {
$locked = $this->em->find(Doctor::class, $doctor->getId(), LockMode::PESSIMISTIC_WRITE);
if ($locked->getOwnerStatus() !== 'pending_transfer') {
throw new AppException(ErrorCodes::ERR_CONFLICT_001, 'وضعیت پروفایل در این میان تغییر کرده است', 409);
}
$surrogate = $locked->getUser();
if ($nationalCode !== null && $nationalCode !== '') {
$target->setNationalCode($nationalCode);
$target->setNationalCodeVerified(true);
}
$target->addRole('ROLE_DOCTOR');
$locked->transferOwnershipTo($target);
$claim->markCompleted();
return $surrogate;
});
// حذف امن جانشین — پس از flush انتقال، تا شمارش پزشکانِ متصل قطعی باشد
if ($surrogate !== null
&& $surrogate->getId() !== $target->getId()
&& $surrogate->hasRole(DoctorImportService::ROLE_UNCLAIMED_DOCTOR)
&& $this->em->getRepository(Doctor::class)->count(['user' => $surrogate]) === 0) {
$this->em->remove($surrogate);
$this->em->flush();
}
$this->logger->info('doctor profile claimed', [
'claim_uuid' => $claim->getUuid(),
'doctor_uuid' => $doctor->getUuid(),
'user_id' => $target->getId(),
'method' => $claim->getVerificationMethod(),
]);
}
private function revert(Doctor $doctor, DoctorClaimRequest $claim, string $reason): void
{
$this->em->wrapInTransaction(function () use ($doctor, $claim, $reason): void {
$locked = $this->em->find(Doctor::class, $doctor->getId(), LockMode::PESSIMISTIC_WRITE);
if ($locked->getOwnerStatus() === 'pending_transfer') {
$locked->setOwnerStatus('unclaimed');
}
$claim->markFailed($reason);
});
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Doctor\Service;
use App\Doctor\Entity\Doctor;
final class DoctorImportResult
{
public function __construct(
public readonly Doctor $doctor,
public readonly bool $created,
public readonly ?string $skipped = null,
) {}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
namespace App\Doctor\Service;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Location\Entity\City;
use App\Location\Entity\Province;
use App\Specialty\Entity\Specialty;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
/**
* ایمپورت پزشک از سازمان نظام پزشکی (IRIMC) — منطق دامنه، جدا از کنترلر.
*
* برخلاف ساخت عادی پزشک، موبایل لازم نیست: برای هر پزشک یک «کاربر جانشین»
* غیرفعال با شناسهٔ مصنوعی ساخته می‌شود و پروفایل در وضعیت unclaimed ذخیره
* می‌گردد تا بعداً به پزشک واقعی منتقل شود. idempotent بر پایهٔ
* (source, medical_system_code): اجرای مجدد، رکورد موجود را به‌روزرسانی می‌کند
* و پروفایل claimed هرگز بازنویسی نمی‌شود (مالک واقعی اولویت دارد).
*/
class DoctorImportService
{
/** نقش marker کاربر جانشین — permission نمی‌دهد؛ مبنای شناسایی و حذف امن پس از claim است. */
public const ROLE_UNCLAIMED_DOCTOR = 'ROLE_UNCLAIMED_DOCTOR';
public function __construct(
private readonly EntityManagerInterface $em,
private readonly ManagerRegistry $registry,
) {}
/** @param array $data بدنهٔ validated (name و medical_system_code غیرخالی). */
public function import(array $data, User $importedBy): DoctorImportResult
{
try {
return $this->doImport($data, $importedBy);
} catch (UniqueConstraintViolationException) {
// برندهٔ هم‌زمانی رکورد را همین الان ساخته (قید uniq_doctors_source_code).
// EM پس از این خطا بسته است — reset و اجرای مجدد که این‌بار مسیر update را می‌رود.
$this->registry->resetManager();
return $this->doImport($data, $importedBy);
}
}
private function doImport(array $data, User $importedBy): DoctorImportResult
{
return $this->em->wrapInTransaction(function () use ($data, $importedBy): DoctorImportResult {
$name = trim((string) $data['name']);
$code = trim((string) ($data['medical_system_code'] ?? $data['medicalSystemCode']));
$source = trim((string) ($data['source'] ?? 'irimc')) ?: 'irimc';
$doctorRepo = $this->em->getRepository(Doctor::class);
$userRepo = $this->em->getRepository(User::class);
// idempotency: همان پزشکِ منبع → به‌روزرسانی، نه ساخت تکراری
$doctor = $doctorRepo->findOneBy(['source' => $source, 'medicalSystemCode' => $code]);
$created = false;
// پروفایل تصاحب‌شده را با ایمپورت مجدد بازنویسی نکن (مالک واقعی اولویت دارد)
if ($doctor !== null && $doctor->getOwnerStatus() === 'claimed') {
return new DoctorImportResult($doctor, false, 'claimed');
}
if ($doctor === null) {
// کاربر جانشینِ یکتا و غیرفعال؛ شناسهٔ مصنوعی قطعی از روی کد نظام پزشکی
$synthetic = 'imp_' . substr(md5($source . ':' . $code), 0, 14); // ≤ ۱۸ کاراکتر، ASCII، یکتا
$user = $userRepo->findOneBy(['mobileNumber' => $synthetic]);
if ($user === null) {
$user = new User($synthetic);
$user->setRealName($name);
$user->setStatus(0); // جانشین: هرگز لاگین نمی‌کند
$user->addRole(self::ROLE_UNCLAIMED_DOCTOR);
$this->em->persist($user);
}
$doctor = new Doctor($user, $name);
$doctor->setSource($source);
$doctor->setOwnerStatus('unclaimed');
$doctor->setActiveDoctorAppointment(false); // تا مالک واقعی برنامهٔ کاری بسازد
$created = true;
}
// backfill: جانشین‌های ایمپورت‌شده پیش از افزودن نقش marker، در ایمپورت مجدد نقش می‌گیرند
$surrogate = $doctor->getUser();
if (!$created
&& str_starts_with($surrogate->getMobileNumber(), 'imp_')
&& !$surrogate->hasRole(self::ROLE_UNCLAIMED_DOCTOR)) {
$surrogate->addRole(self::ROLE_UNCLAIMED_DOCTOR);
}
// فیلدهای مشترک
$doctor->setName($name);
$doctor->setMedicalSystemCode($code);
$doctor->setManagedBy($importedBy->getId());
if (array_key_exists('source_ref', $data) || array_key_exists('profile_url', $data)) {
$doctor->setSourceRef($data['source_ref'] ?? $data['profile_url'] ?? null);
}
if (!empty($data['gender'])) $doctor->setGender($data['gender']);
if (!empty($data['degree'])) $doctor->setDegree($data['degree']);
if (array_key_exists('info', $data)) $doctor->setInfo($data['info']);
// روابط بر پایهٔ شناسه‌های مرجع (تخصص/استان/شهر)
$this->syncRefCollection($doctor->getSpecialties(), $data['specialties'] ?? null, Specialty::class);
$this->syncRefCollection($doctor->getProvinces(), $data['states'] ?? null, Province::class);
$this->syncRefCollection($doctor->getCities(), $data['cities'] ?? null, City::class);
$this->em->persist($doctor);
$this->em->flush();
return new DoctorImportResult($doctor, $created);
});
}
/**
* یک مجموعهٔ ManyToMany پزشک را با آرایه‌ای از شناسه‌های مرجع همگام می‌کند.
* اگر $ids null باشد دست نمی‌خورد؛ اگر آرایه باشد، پاک و از نو پر می‌شود.
*/
private function syncRefCollection(\Doctrine\Common\Collections\Collection $col, ?array $ids, string $class): void
{
if ($ids === null) {
return;
}
$col->clear();
foreach ($ids as $id) {
$ref = $this->em->getRepository($class)->find((int) $id);
if ($ref !== null && !$col->contains($ref)) {
$col->add($ref);
}
}
}
}
+25
View File
@@ -70,6 +70,31 @@ class ApiIrService
]; ];
} }
/**
* استعلام هویت شخص از روی کد ملی و تاریخ تولد (PersonInfo).
*
* @param string $birthDateJalali تاریخ تولد شمسی به فرمت Y/m/d (مثلاً 1371/1/1)
* @return array{firstName: string, lastName: string, alive: bool}|null null یعنی رکوردی مطابقت نکرد.
*/
public function personInfo(string $nationalCode, string $birthDateJalali): ?array
{
$data = $this->post('/api/sw1/PersonInfo', [
'nationalCode' => $nationalCode,
'birthDate' => $birthDateJalali,
]);
$person = $data['data'] ?? null;
if (!is_array($person) || ($person['nationalCode'] ?? '') === '') {
return null;
}
return [
'firstName' => (string) ($person['firstName'] ?? ''),
'lastName' => (string) ($person['lastName'] ?? ''),
'alive' => filter_var($person['alive'] ?? false, FILTER_VALIDATE_BOOLEAN),
];
}
/** /**
* @param array<string,mixed> $payload * @param array<string,mixed> $payload
* @return array<string,mixed> * @return array<string,mixed>
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Shared\Util;
/**
* نرمال‌سازی متن فارسی برای مقایسهٔ نام‌ها.
*
* تفاوت‌های رایج را یکدست می‌کند: ي/ی عربی-فارسی، ك/ک، ارقام عربی/فارسی،
* نیم‌فاصله و فاصله‌های تکراری، فاصلهٔ ابتدا/انتها و Unicode normalization —
* تا مقایسهٔ نام هرگز با compare خام رشته انجام نشود.
*/
final class PersianText
{
public static function normalize(string $text): string
{
if (class_exists(\Normalizer::class)) {
$text = \Normalizer::normalize($text, \Normalizer::FORM_KC) ?: $text;
}
$text = strtr($text, [
"\u{064A}" => 'ی', // ي عربی
"\u{0649}" => 'ی', // ى الف مقصوره
"\u{0643}" => 'ک', // ك عربی
"\u{200C}" => ' ', // نیم‌فاصله → فاصله
"\u{200B}" => '', // zero-width space
"\u{FEFF}" => '', // BOM
"\u{0640}" => '', // کشیده ـ
]);
// ارقام فارسی/عربی → لاتین
$text = strtr($text, array_combine(
['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹','٠','١','٢','٣','٤','٥','٦','٧','٨','٩'],
['0','1','2','3','4','5','6','7','8','9','0','1','2','3','4','5','6','7','8','9'],
));
return trim(preg_replace('/\s+/u', ' ', $text) ?? $text);
}
/** مقایسهٔ دو نام فارسی پس از نرمال‌سازی. */
public static function sameName(string $a, string $b): bool
{
return self::normalize($a) === self::normalize($b);
}
/** حذف عنوان «دکتر» از ابتدای نام (برای مقایسهٔ نام پروفایل با نام ثبت احوال). */
public static function stripDoctorTitle(string $name): string
{
return trim(preg_replace('/^\s*دکتر\s+/u', '', self::normalize($name)) ?? $name);
}
}
+219
View File
@@ -0,0 +1,219 @@
<?php
namespace App\Tests\Doctor;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorClaimRequest;
use App\Shared\Service\ApiIrService;
use App\Tests\ApiTestCase;
/**
* جریان تصاحب پروفایل پزشک ایمپورت‌شده. ApiIrService همیشه mock می‌شود —
* تست‌ها هرگز به سرویس واقعی api.ir درخواست نمی‌زنند.
*/
class DoctorClaimTest extends ApiTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->client->disableReboot();
}
private function mockApiIr(bool $shahkar = true, ?array $person = ['firstName' => 'تست', 'lastName' => 'ایمپورت', 'alive' => true]): void
{
$mock = $this->createMock(ApiIrService::class);
$mock->method('isConfigured')->willReturn(true);
$mock->method('shahkarMatch')->willReturn($shahkar);
$mock->method('personInfo')->willReturn($person);
static::getContainer()->set(ApiIrService::class, $mock);
}
private function importUnclaimedDoctor(): string
{
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$code = 'C' . random_int(100_000, 999_999) . random_int(100, 999);
$data = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, [
'name' => 'دکتر تست ایمپورت',
'medical_system_code' => $code,
]);
return $data['data']['uuid'];
}
private function claimBody(): array
{
// db_test هرگز reset نمی‌شود و users.national_code یکتاست → کد ملی هر تست تصادفی
return [
'national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT),
'birth_date' => '1371/1/1',
'first_name' => 'تست',
'last_name' => 'ایمپورت',
];
}
public function testSuccessfulClaimTransfersOwnershipAndDeletesSurrogate(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
$surrogateId = $doctor->getUser()->getId();
$claimer = $this->createUser(['ROLE_USER']);
$res = $this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, $this->claimBody());
$this->assertSame(200, $this->responseCode());
$this->assertSame('claimed', $res['data']['status']);
$this->em->clear();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
$this->assertSame('claimed', $doctor->getOwnerStatus());
$this->assertSame($claimer->getId(), $doctor->getUser()->getId());
$this->assertNull($doctor->getManagedBy());
$claimer = $this->em->getRepository(User::class)->find($claimer->getId());
$this->assertTrue($claimer->hasRole('ROLE_DOCTOR'));
$this->assertTrue($claimer->isNationalCodeVerified());
$this->assertNull($this->em->getRepository(User::class)->find($surrogateId), 'surrogate must be deleted');
$claim = $this->em->getRepository(DoctorClaimRequest::class)->findOneBy(['doctor' => $doctor]);
$this->assertSame(DoctorClaimRequest::STATUS_COMPLETED, $claim->getStatus());
}
public function testNameMismatchRevertsToUnclaimed(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr(person: ['firstName' => 'کس', 'lastName' => 'دیگری', 'alive' => true]);
$claimer = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, $this->claimBody());
$this->assertSame(422, $this->responseCode());
$this->em->clear();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
$this->assertSame('unclaimed', $doctor->getOwnerStatus(), 'must be free for the real doctor to retry');
$this->assertStringStartsWith('imp_', $doctor->getUser()->getMobileNumber(), 'surrogate must survive');
$claim = $this->em->getRepository(DoctorClaimRequest::class)->findOneBy(['doctor' => $doctor]);
$this->assertSame(DoctorClaimRequest::STATUS_FAILED, $claim->getStatus());
$this->assertNotNull($claim->getFailureReason());
}
public function testDeceasedPersonIsRejected(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr(person: ['firstName' => 'تست', 'lastName' => 'ایمپورت', 'alive' => false]);
$claimer = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, $this->claimBody());
$this->assertSame(422, $this->responseCode());
}
public function testShahkarMismatchIsRejected(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr(shahkar: false);
$claimer = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, $this->claimBody());
$this->assertSame(422, $this->responseCode());
}
public function testAlreadyClaimedIsConflict(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr();
$first = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $first, $this->claimBody());
$this->assertSame(200, $this->responseCode());
$second = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $second, $this->claimBody());
$this->assertSame(409, $this->responseCode());
}
public function testUserWhoAlreadyOwnsADoctorCannotClaim(): void
{
$uuidA = $this->importUnclaimedDoctor();
$uuidB = $this->importUnclaimedDoctor();
$this->mockApiIr();
$claimer = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuidA}/claim", $claimer, $this->claimBody());
$this->assertSame(200, $this->responseCode());
$this->authJson('POST', "/api/v1/doctor/{$uuidB}/claim", $claimer, $this->claimBody());
$this->assertSame(409, $this->responseCode());
$this->em->clear();
$doctorB = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuidB]);
$this->assertSame('unclaimed', $doctorB->getOwnerStatus());
}
public function testValidationErrors(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr();
$claimer = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, ['national_code' => '123', 'birth_date' => '1371/1/1', 'first_name' => 'الف', 'last_name' => 'ب']);
$this->assertSame(422, $this->responseCode());
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, ['national_code' => '0010007700', 'birth_date' => 'invalid', 'first_name' => 'الف', 'last_name' => 'ب']);
$this->assertSame(422, $this->responseCode());
}
public function testClaimInfoIsPublic(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->client->request('GET', "/api/v1/doctor/{$uuid}/claim-info");
$this->assertSame(200, $this->responseCode());
$data = json_decode($this->client->getResponse()->getContent(), true);
$this->assertTrue($data['data']['claimable']);
$this->assertSame('unclaimed', $data['data']['owner_status']);
}
public function testAdminTransferHappyPath(): void
{
$uuid = $this->importUnclaimedDoctor();
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$mobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
$res = $this->authJson('POST', "/api/v1/admin/doctors/{$uuid}/transfer", $admin, ['mobile' => $mobile]);
$this->assertSame(200, $this->responseCode());
$this->assertSame('claimed', $res['data']['owner_status']);
$this->em->clear();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
$this->assertSame('claimed', $doctor->getOwnerStatus());
$this->assertSame($mobile, $doctor->getUser()->getMobileNumber());
$this->assertTrue($doctor->getUser()->hasRole('ROLE_DOCTOR'));
// transfer دوباره → 409
$this->authJson('POST', "/api/v1/admin/doctors/{$uuid}/transfer", $admin, ['mobile' => $mobile]);
$this->assertSame(409, $this->responseCode());
}
public function testAdminTransferRequiresAdmin(): void
{
$uuid = $this->importUnclaimedDoctor();
$user = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/admin/doctors/{$uuid}/transfer", $user, ['mobile' => '09121234567']);
$this->assertSame(403, $this->responseCode());
}
public function testClaimRequiresAuthentication(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->client->request('POST', "/api/v1/doctor/{$uuid}/claim", server: ['CONTENT_TYPE' => 'application/json'], content: json_encode($this->claimBody()));
$this->assertSame(401, $this->responseCode());
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace App\Tests\Doctor;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* Regression contract for POST /api/v1/admin/doctors/import (IRIMC import).
* Written BEFORE extracting the logic into DoctorImportService — the HTTP
* contract (routes, statuses, {uuid, created, skipped} payload) must not change.
*/
class DoctorImportTest extends ApiTestCase
{
private function importPayload(string $code): array
{
return [
'name' => 'دکتر تست ایمپورت',
'medical_system_code' => $code,
'source_ref' => 'https://membersearch.irimc.org/member/profile?id=test',
'gender' => 'man',
'degree' => 'general',
'info' => 'دکترای حرفه‌ای پزشکی',
];
}
/** db_test is never reset — randomise the natural key per run. */
private function freshCode(): string
{
return 'T' . random_int(100_000, 999_999) . random_int(100, 999);
}
public function testImportCreatesUnclaimedDoctorWithSurrogateUser(): void
{
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$code = $this->freshCode();
$data = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $this->importPayload($code));
$this->assertSame(201, $this->responseCode());
$this->assertTrue($data['success']);
$this->assertTrue($data['data']['created']);
$this->assertNotEmpty($data['data']['uuid']);
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $data['data']['uuid']]);
$this->assertSame('unclaimed', $doctor->getOwnerStatus());
$this->assertSame('irimc', $doctor->getSource());
$this->assertFalse($doctor->isActiveDoctorAppointment());
$surrogate = $doctor->getUser();
$this->assertStringStartsWith('imp_', $surrogate->getMobileNumber());
$this->assertSame(0, $surrogate->getStatus());
$this->assertTrue($surrogate->hasRole('ROLE_UNCLAIMED_DOCTOR'));
}
public function testReimportUpdatesInsteadOfDuplicating(): void
{
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$code = $this->freshCode();
$first = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $this->importPayload($code));
$this->assertSame(201, $this->responseCode());
$payload = $this->importPayload($code);
$payload['name'] = 'دکتر تست ویرایش‌شده';
$second = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $payload);
$this->assertSame(200, $this->responseCode());
$this->assertFalse($second['data']['created']);
$this->assertSame($first['data']['uuid'], $second['data']['uuid']);
$count = $this->em->getRepository(Doctor::class)->count(['source' => 'irimc', 'medicalSystemCode' => $code]);
$this->assertSame(1, $count);
$this->em->clear();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $first['data']['uuid']]);
$this->assertSame('دکتر تست ویرایش‌شده', $doctor->getName());
}
public function testClaimedDoctorIsNeverOverwritten(): void
{
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$code = $this->freshCode();
$created = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $this->importPayload($code));
$uuid = $created['data']['uuid'];
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
$owner = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor->transferOwnershipTo($owner);
$this->em->flush();
$originalName = $doctor->getName();
$payload = $this->importPayload($code);
$payload['name'] = 'دکتر بازنویسی ممنوع';
$reimport = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $payload);
$this->assertSame(200, $this->responseCode());
$this->assertSame('claimed', $reimport['data']['skipped']);
$this->em->clear();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
$this->assertSame($originalName, $doctor->getName());
$this->assertSame('claimed', $doctor->getOwnerStatus());
}
public function testValidationErrors(): void
{
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$this->authJson('POST', '/api/v1/admin/doctors/import', $admin, ['medical_system_code' => $this->freshCode()]);
$this->assertSame(422, $this->responseCode());
$this->authJson('POST', '/api/v1/admin/doctors/import', $admin, ['name' => 'دکتر بی‌کد']);
$this->assertSame(422, $this->responseCode());
}
public function testNonAdminIsRejected(): void
{
$user = $this->createUser(['ROLE_USER']);
$this->authJson('POST', '/api/v1/admin/doctors/import', $user, $this->importPayload($this->freshCode()));
$this->assertSame(403, $this->responseCode());
}
public function testImporterRoleCanImportButNothingElse(): void
{
$importer = $this->createUser(['ROLE_USER', 'ROLE_IMPORTER']);
$this->authJson('POST', '/api/v1/admin/doctors/import', $importer, $this->importPayload($this->freshCode()));
$this->assertSame(201, $this->responseCode(), 'ROLE_IMPORTER must be able to import');
$this->authJson('GET', '/api/v1/admin/users', $importer);
$this->assertSame(403, $this->responseCode(), 'ROLE_IMPORTER must NOT reach other admin endpoints');
$this->authJson('GET', '/api/v1/admin/doctor-claims', $importer);
$this->assertSame(403, $this->responseCode());
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Tests\Shared;
use App\Shared\Util\PersianText;
use PHPUnit\Framework\TestCase;
class PersianTextTest extends TestCase
{
public function testArabicYehAndKafAreUnified(): void
{
$this->assertTrue(PersianText::sameName("علي اكبري", 'علی اکبری'));
}
public function testHalfSpaceAndExtraWhitespace(): void
{
$this->assertTrue(PersianText::sameName("محمد\u{200C}رضا کریمی ", 'محمد رضا کریمی'));
}
public function testPersianAndArabicDigits(): void
{
$this->assertSame('1371/1/1', PersianText::normalize('۱۳۷۱/۱/۱'));
$this->assertSame('0912', PersianText::normalize('٠٩١٢'));
}
public function testStripDoctorTitle(): void
{
$this->assertSame('فرخنده حسینی', PersianText::stripDoctorTitle('دکتر فرخنده حسینی'));
$this->assertSame('فرخنده حسینی', PersianText::stripDoctorTitle(' دکتر فرخنده حسینی '));
$this->assertSame('فرخنده حسینی', PersianText::stripDoctorTitle('فرخنده حسینی'));
}
public function testDifferentNamesStayDifferent(): void
{
$this->assertFalse(PersianText::sameName('علی اکبری', 'ولی اکبری'));
}
}