From af125572c96dfb0ff720cd1442ee59363766b4b1 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 11 Jul 2026 11:39:15 +0330 Subject: [PATCH] =?UTF-8?q?feat(doctor):=20complete=20IRIMC=20import=20fea?= =?UTF-8?q?ture=20=E2=80=94=20claim=20flow,=20least-privilege=20importer,?= =?UTF-8?q?=20unique=20import=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .claude/prompt/irimc-import-complete.md | 453 ++++++++++++------ .env.example | 3 + config/packages/rate_limiter.yaml | 6 + config/packages/security.yaml | 2 +- config/services.yaml | 1 + docs/api/admin.md | 1 + docs/api/doctor-claim.md | 151 ++++++ docs/api/doctor-import.md | 50 +- docs/api/doctor.md | 8 +- migrations/Version20260711150000.php | 49 ++ migrations/Version20260711151000.php | 50 ++ src/Admin/Controller/AdminApiController.php | 131 +---- src/Auth/Command/SystemOwnerCommand.php | 12 +- src/Auth/Entity/User.php | 3 +- src/Auth/Security/PasswordAuthenticator.php | 16 +- .../Command/BackfillSurrogateRoleCommand.php | 76 +++ .../Controller/DoctorClaimController.php | 198 ++++++++ .../Controller/DoctorImportController.php | 86 ++++ src/Doctor/Entity/Doctor.php | 8 +- src/Doctor/Entity/DoctorClaimRequest.php | 115 +++++ .../DoctorClaimRequestRepository.php | 15 + src/Doctor/Service/DoctorClaimService.php | 199 ++++++++ src/Doctor/Service/DoctorImportResult.php | 14 + src/Doctor/Service/DoctorImportService.php | 131 +++++ src/Shared/Service/ApiIrService.php | 25 + src/Shared/Util/PersianText.php | 50 ++ tests/Doctor/DoctorClaimTest.php | 219 +++++++++ tests/Doctor/DoctorImportTest.php | 138 ++++++ tests/Shared/PersianTextTest.php | 37 ++ 29 files changed, 1944 insertions(+), 303 deletions(-) create mode 100644 docs/api/doctor-claim.md create mode 100644 migrations/Version20260711150000.php create mode 100644 migrations/Version20260711151000.php create mode 100644 src/Doctor/Command/BackfillSurrogateRoleCommand.php create mode 100644 src/Doctor/Controller/DoctorClaimController.php create mode 100644 src/Doctor/Controller/DoctorImportController.php create mode 100644 src/Doctor/Entity/DoctorClaimRequest.php create mode 100644 src/Doctor/Repository/DoctorClaimRequestRepository.php create mode 100644 src/Doctor/Service/DoctorClaimService.php create mode 100644 src/Doctor/Service/DoctorImportResult.php create mode 100644 src/Doctor/Service/DoctorImportService.php create mode 100644 src/Shared/Util/PersianText.php create mode 100644 tests/Doctor/DoctorClaimTest.php create mode 100644 tests/Doctor/DoctorImportTest.php create mode 100644 tests/Shared/PersianTextTest.php diff --git a/.claude/prompt/irimc-import-complete.md b/.claude/prompt/irimc-import-complete.md index c6dc867a..32a47eb9 100644 --- a/.claude/prompt/irimc-import-complete.md +++ b/.claude/prompt/irimc-import-complete.md @@ -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` -> (تغییر کرالر در repo والد است — همان‌جا commit شود.) +**قانون برنچ (الزامی):** هیچ تغییری روی `main` هیچ repoیی انجام نشود. برای **هر repo** قبل از اولین تغییر، یک برنچ جدید بساز و تمام کار همان repo را روی همان برنچ پیش ببر: -## زمینه +| 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 این فیچر است** — هیچ تغییری در آن نده و آن را در نظر نگیر. -| قطعه | وضعیت | -|------|-------| -| ستون‌های مالکیت `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_`، 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/` | +ترتیب اجرا (قانون workspace): backend اول → مستندات API → کلاینت‌ها. -**چهار قطعه از سند هنوز پیاده نشده** — این پرامپت فقط همان‌هاست: +--- -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]); -if ($user === null) { - $user = new User($synthetic); - $user->setRealName($name); - $user->setStatus(0); // جانشین: هرگز لاگین نمی‌کند - $this->em->persist($user); -} -``` +| قطعه | محل | وضعیت | +|---|---|---| +| ستون‌های مالکیت `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 بررسی شود** | +| `Doctor::transferOwnershipTo(User)` — user_id، claimed، claimed_at | `src/Doctor/Entity/Doctor.php:390` | ✅ | +| `POST /api/v1/admin/doctors/import` — idempotent روی `(source, medical_system_code)`، کاربر جانشین `imp_`، skip روی claimed | `src/Admin/Controller/AdminApiController.php:483` | ✅ ولی **fat controller** (وظیفهٔ ۳.۱) و **بدون نقش جانشین** (وظیفهٔ ۳.۲) | +| دستور `app:system-owner` (ساخت/فعال/غیرفعال کاربر `0000000000`) | `src/Auth/Command/SystemOwnerCommand.php` | ✅ | +| `ApiIrService` — `shahkarMatch(nationalCode, mobile)` (ShahkarLite) و `ibanMatch` | `src/Shared/Service/ApiIrService.php:39,58`؛ الگوی مصرف: `src/Representation/Controller/RepresentationActionController.php:103` | ✅ — برای PersonInfo فقط **متد جدید به همین سرویس** اضافه کن، سرویس موازی نساز | +| فیلدهای هویتی 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 -// Doctor.php:388-396 — user_id را پر می‌کند، مدیریت سیستمی را برمی‌دارد و وضعیت را claimed می‌کند. -public function transferOwnershipTo(User $user): self -{ - ... - $this->ownerStatus = 'claimed'; - $this->claimedAt = time(); -``` +1. **گزینه A در برابر B.** سند طراحی (`docs/scenarios/irimc-doctor-import-ownership.md` §۲،§۱۳) گزینهٔ A (nullable کردن `user_id` + حذف قید یکتا) را توصیه کرده بود؛ اما پیاده‌سازی واقعی **گزینهٔ B (کاربر جانشین به‌ازای هر پزشک)** را انجام داده و migration هم اعمال شده. + **تصمیم: گزینهٔ B حفظ می‌شود.** دلیل: `Doctor::$user` در کد `OneToOne NOT NULL` است و `getUser()` غیر-nullable در ده‌ها نقطه مصرف می‌شود (چک‌های مالکیت `getUser()->getId()`، پنل ادمین، `toArray`ها)؛ nullable کردن آن یعنی بازبینی همهٔ call-siteها = ریسک رگرسیون بزرگ بدون نیاز واقعی. جدول `users` با کاربران جانشینِ قابل‌شناسایی (نقش اختصاصی، وظیفهٔ ۳.۲) و حذف خودکار پس از claim تمیز نگه داشته می‌شود. سند سناریو باید پس از پیاده‌سازی با این تصمیم به‌روز شود. +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** می‌گیرد (لاگ ادعاها + انتقال دستی برای پشتیبانی، وظیفهٔ ۳.۴ و ۵). +4. **ایندکس `(source, medical_system_code)` یکتا نیست.** `Version20260711120000` فقط `INDEX` ساخته؛ dedup فقط application-level است → با دو درخواست هم‌زمان (دو worker کرالر یا retry شبکه) رکورد تکراری ممکن است. باید UNIQUE شود (وظیفهٔ ۳.۳). +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 -// PasswordAuthenticator.php:49 — ابتدای authenticate() -$this->captcha->assertValid($request); -``` +## ۳. Workstream A — بک‌اند clinicpro -## وظایف +### ۳.۱ استخراج منطق ایمپورت از کنترلر (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 $user = new User($synthetic); $user->setRealName($name); $user->setStatus(0); -$user->addRole('ROLE_UNCLAIMED_DOCTOR'); -$this->em->persist($user); +$user->addRole('ROLE_UNCLAIMED_DOCTOR'); // User.php:105 ``` -- نقش را به‌صورت رشته اضافه کن (الگوی موجود `addRole('ROLE_DOCTOR')` در پروژه). -- **ایمپورت‌های قبلی** (جانشین‌های موجود بدون این نقش): چون idempotent است، در همان `importDoctor` وقتی `$doctor !== null && unclaimed` است هم نقش را به کاربر فعلی‌اش تضمین کن (`if (!$user->hasRole(...)) addRole(...)`) — کاربر جانشین از `$doctor->getUser()` در دسترس است. +- **Backfill جانشین‌های موجود:** چون import idempotent است، در مسیر update (`$doctor !== null && unclaimed`) نقش را روی `$doctor->getUser()` تضمین کن. برای رکوردهایی که دیگر ایمپورت نمی‌شوند، یک migration دیتایی/دستور یک‌بارمصرف: هر user که `mobile_number LIKE 'imp\_%'` و `status=0` و دقیقاً یک پزشک `unclaimed` به او وصل است → نقش اضافه شود. destructive نیست؛ dry-run داشته باشد. +- این نقش **هیچ permission جدیدی نمی‌دهد** (در `security.yaml` به هیچ path وصل نشود) — فقط marker برای شناسایی و حذف امن است. `status=0` لاگین را همچنان می‌بندد. -### ۲. اندپوینت انتقال مالکیت +### ۳.۳ یکتاسازی دیتابیسیِ کلید ایمپورت (رفع race) -در `AdminApiController` (کنار `importDoctor`، همان الگوی OA + `$this->success/error`): +Migration جدید: -```php -#[Route('/api/v1/admin/doctors/{uuid}/transfer', methods: ['POST'])] -public function transferDoctor(string $uuid, Request $request): JsonResponse -{ - $data = json_decode($request->getContent(), true) ?? []; - $mobile = trim((string) ($data['mobile'] ?? '')); - if (!preg_match('/^09\d{9}$/', $mobile)) { - return $this->error(ErrorCodes::VALIDATION, 'شماره موبایل نامعتبر است', 422, 'mobile'); - } +```sql +-- پیش‌شرط (در همان migration با abortIf یا بررسی دستی قبل از deploy): +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; +-- dev فعلی: ۵۰۲ رکورد، صفر تکراری (تأییدشده). prod باید جدا چک شود. - $doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]); - if ($doctor === null) { - 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, - ]); -} +DROP INDEX idx_doctors_source ON doctors; +CREATE UNIQUE INDEX uniq_doctors_source_code ON doctors (source, medical_system_code); ``` -نکته‌ها: -- **ترتیب**: اول `transferOwnershipTo` (که `user_id` را عوض می‌کند)، بعد شمارش پزشکانِ جانشین — دقت کن Doctrine تا `flush` تغییر را به DB نمی‌برد، پس `count(['user' => $surrogate])` ممکن است هنوز همین پزشک را بشمارد. یا اول flush کن بعد حذف در flush دوم، یا شرط را `count === 0 || (count === 1 && همین doctor)` بگذار. سناریوی سادهٔ امن: دو مرحله — `flush()` بعد از transfer، سپس شمارش و `remove($surrogate)` و `flush()` دوم. -- امضای دقیق فیلد رابطهٔ `Doctor::user` را قبل از `findOneBy(['user' => ...])` از entity تأیید کن. -- ثابت‌های `ErrorCodes` موجود (`VALIDATION`, `NOT_FOUND`, `ERR_CONFLICT_001`) — چیز جدید نساز. -- بلوک OA (سواگر) مثل `importDoctor` بنویس: body `{ mobile }`، پاسخ‌های 200/404/409/422. +- MariaDB چند `NULL` را در ایندکس یکتا مجاز می‌داند → پزشکان manual بدون کد می‌مانند، مشکلی نیست. رکوردهای manual با کد تکراری اگر در prod وجود داشتند، migration باید **متوقف شود نه اینکه داده حذف کند** — گزارش بده، پاک‌سازی دستی/جداگانه. +- در `DoctorImportService`، `UniqueConstraintViolationException` را بگیر و به‌عنوان «برندهٔ هم‌زمانی، رکورد موجود را آپدیت کن» retry کن (یک بار) — این کنار قید DB، مسیر concurrent-import را قطعی می‌کند. -### ۳. رد شدن کپچا برای لاگین سرویسی کرالر +### ۳.۴ جریان 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'] ?? ''; -$sentToken = (string) $request->headers->get('X-Service-Token', ''); -$isServiceLogin = $serviceToken !== '' && hash_equals($serviceToken, $sentToken); - -if (!$isServiceLogin) { - $this->captcha->assertValid($request); -} +``` +id, uuid, doctor_id (FK), user_id (FK), status VARCHAR(20) -- pending|verified|completed|failed|rejected +national_code_hash VARCHAR(64) -- sha256؛ کد ملی خام ذخیره/لاگ نشود +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) ``` -- **فقط کپچا** دور زده می‌شود؛ rate-limit و اعتبارسنجی رمز سر جای خود می‌مانند. -- اگر env خالی باشد هیچ bypass وجود ندارد (پیش‌فرض امن). -- env جدید را به `.env` (خالی) و `.env.example` اضافه کن + ذکر در مستند. -- ترجیحاً env را از طریق constructor bind کن (الگوی `services.yaml` مثل `$appUrl: '%env(APP_BASE_URL)%'`) نه `$_ENV` مستقیم — با الگوی موجود فایل هماهنگ شو. +**API (قرارداد کامل):** -### ۴. کرالر: ارسال هدر سرویس +``` +GET /api/v1/doctor/{uuid}/claim-info [PUBLIC — در الگوی public_endpoints فعلی `api/v1/doctors` نیست؛ به pattern اضافه شود] + → 200 { success, data: { claimable: bool, owner_status } } + فقط برای رندر دکمهٔ «آیا شما این پزشک هستید؟». هیچ دادهٔ هویتی برنمی‌گرداند. -در `clinicpro-crawler/clinicpro_client.py`، متد لاگین: اگر env `CLINICPRO_SERVICE_TOKEN` ست بود، هدر `X-Service-Token` را به درخواست لاگین اضافه کن (فقط لاگین کافی است). به `.env.example` کرالر هم اضافه کن. +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()` (ترتیب دقیق):** -- `docs/api/doctor-import.md`: بخش transfer (method/path/permission/body/پاسخ‌ها/خطاها با مثال JSON) + توضیح `X-Service-Token` برای لاگین سرویسی + نقش `ROLE_UNCLAIMED_DOCTOR`. -- تست 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 انتقال را کنارش اضافه کن. +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. -- `transferOwnershipTo` از قبل `claimed_at`/`owner_status`/`managed_by` را هندل می‌کند — منطق را در کنترلر تکرار نکن. -- پروفایل `pending_transfer` در این فاز فقط یک مقدار enum است؛ جریان درخواست تصاحب از سمت Nobat724 فاز بعدی است (§۹) — نساز. -- حذف جانشین باید **دقیقاً** سه شرط سند را داشته باشد: نقش `ROLE_UNCLAIMED_DOCTOR` + هیچ پزشک متصل + غیر از کاربر هدف. کاربر واقعی را هرگز حذف نکن. -- `activeDoctorAppointment` بعد از transfer دست نزن — روشن‌کردن نوبت‌دهی با مالک جدید است. -- بعد از تغییر API، به‌روزرسانی `docs/api/doctor-import.md` در همین session الزامی است (قانون پروژه). +**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); + } + // 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` + 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 +``` + +- ماژول جدید `state_db.py`؛ `pipeline.py` و `crawler_core.py` به‌جای `.import_state.json` از آن بخوانند/بنویسند. مهاجرت یک‌باره از state file قدیمی اگر موجود بود. +- **Resume:** در استارت، `meta.current_*` + وضعیت‌ها خوانده می‌شود و دقیقاً از همان‌جا ادامه می‌یابد؛ crash/restart هیچ‌چیز را از صفر شروع نمی‌کند. + +### ۶.۲ ترتیب پردازش (state machine) + +`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. + +### ۶.۳ پنل وب توکن (توسعهٔ `server.py` موجود) + +- **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. + +### ۶.۴ قواعد سخت کرالر + +- کرالر **هرگز** به DB کلینیک‌پرو مستقیم وصل نمی‌شود؛ فقط API مستند (`import`, `categorys/*`, `login`). +- همزمان بیش از یک خزش فعال نشود (rate-limit روی IP است — قفل موجود اپ وب حفظ شود). +- `--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 موجود است. diff --git a/.env.example b/.env.example index 13af84d2..8285fe4e 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,6 @@ ALLOWED_FRONTEND_HOSTS=clinic-pro.ir,yasuj-nobat.ir,yazd-nobat.ir APP_BASE_URL=https://clinic-pro.ir # کلیدهای درگاه (mellat/sep) از پنل «تنظیمات سایت» (DB) خوانده می‌شوند؛ env فقط fallback اختیاری است. ###< Payment ### + +# لاگین سرویسی کرالر: مقدار غیرخالی، هدر X-Service-Token را برای دورزدن کپچای لاگین فعال می‌کند (فقط کپچا) +CRAWLER_SERVICE_TOKEN= diff --git a/config/packages/rate_limiter.yaml b/config/packages/rate_limiter.yaml index 00dfa7c0..2cc8c613 100644 --- a/config/packages/rate_limiter.yaml +++ b/config/packages/rate_limiter.yaml @@ -29,3 +29,9 @@ framework: policy: 'sliding_window' limit: 5 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' diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 6b64f114..e8c3bf92 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -33,7 +33,7 @@ security: provider: api_doc_provider 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 security: false diff --git a/config/services.yaml b/config/services.yaml index dc3cbb53..8a1b3ed4 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -76,6 +76,7 @@ services: App\Auth\Security\PasswordAuthenticator: arguments: $refreshTokenTtl: '%env(int:REFRESH_TOKEN_TTL)%' + $crawlerServiceToken: '%env(default::CRAWLER_SERVICE_TOKEN)%' $loginLimiter: '@limiter.login' App\Auth\Controller\AuthController: diff --git a/docs/api/admin.md b/docs/api/admin.md index 8afdcd64..7a0edd9b 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -343,6 +343,7 @@ List all doctors with pagination. | `status` | string | ❌ | `"active"` or `"inactive"` | | `gender` | string | ❌ | `"male"` or `"female"` | | `specialty_id` | integer | ❌ | Filter by specialty | +| `owner_status` | string | ❌ | `claimed` \| `unclaimed` \| `pending_transfer` — پروفایل‌های ایمپورت IRIMC (خروجی هم `owner_status` و `source` دارد) | | `sort` | string | ❌ | Sort field | ### Response `200` diff --git a/docs/api/doctor-claim.md b/docs/api/doctor-claim.md new file mode 100644 index 00000000..fed3ed34 --- /dev/null +++ b/docs/api/doctor-claim.md @@ -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` (نرمال‌سازی نام). diff --git a/docs/api/doctor-import.md b/docs/api/doctor-import.md index bae2475f..0bf8bd74 100644 --- a/docs/api/doctor-import.md +++ b/docs/api/doctor-import.md @@ -1,8 +1,8 @@ # Doctor Import (IRIMC) API > **Endpoint:** `POST /api/v1/admin/doctors/import` -> **Permission:** `ROLE_ADMIN` -> **Controller:** `App\Admin\Controller\AdminApiController::importDoctor` +> **Permission:** `ROLE_ADMIN` **یا** `ROLE_IMPORTER` (نقش حداقلی کاربر سیستمی کرالر) +> **Controller:** `App\Doctor\Controller\DoctorImportController::import` — منطق دامنه در `App\Doctor\Service\DoctorImportService` وارد کردن یک پزشک از سازمان نظام پزشکی (`membersearch.irimc.org`) **بدون شماره موبایل**. برخلاف `POST /api/v1/admin/doctors` (که موبایل معتبر ایرانی می‌خواهد)، این اندپوینت برای @@ -31,7 +31,9 @@ ## idempotency -کلید یکتای منطقی: `(source, medical_system_code)`. +کلید یکتای `(source, medical_system_code)` — از این نسخه **در سطح دیتابیس** هم unique است +(`uniq_doctors_source_code`، migration `Version20260711150000`)؛ درخواست هم‌زمانِ همان پزشک +با retry داخلی به مسیر update می‌رود و هرگز رکورد تکراری نمی‌سازد. - اگر پزشکی با همان `source`+`medical_system_code` وجود نداشته باشد → **ساخته** می‌شود (`201`). - اگر وجود داشته باشد و `owner_status != claimed` → **به‌روزرسانی** می‌شود (`200`). @@ -132,10 +134,16 @@ // Response 200 { "success": true, "data": { "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` | پزشک یافت نشد | @@ -154,13 +162,29 @@ php bin/console app:system-owner 0000000000 --password= --activate 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 وقتی -> `ALTCHA_ENABLED=true` باشد (مقدار فعلی `.env`) یک payloadِ altcha می‌خواهد. برای اجرای -> بدونِ‌مرورگرِ کرالر یکی از این‌ها لازم است: -> ۱) روی همان سرور `ALTCHA_ENABLED=false` در `.env.local` (ساده‌ترین برای dev)، یا -> ۲) افزودن یک استثنا در `PasswordAuthenticator` که برای کاربر مالک سیستمی captcha را رد کند، -> یا ۳) لاگین سرویس با یک هدر سرّیِ مورد اعتماد. تا وقتی این حل نشود، لاگین کرالر با ۴۲۲ -> (`ERR_CAPTCHA_001`) رد می‌شود. +### لاگین سرویسی (captcha) + +مسیر `/api/v1/user/login` کپچای ALTCHA دارد. برای لاگین headless کرالر، هدر سرّی +تعریف شده است: + +``` +X-Service-Token: <مقدار env CRAWLER_SERVICE_TOKEN> +``` + +- فقط **کپچا** دور زده می‌شود؛ rate limit و اعتبارسنجی رمز دست‌نخورده می‌مانند. +- اگر env خالی/تعریف‌نشده باشد هیچ bypass وجود ندارد (secure by default). +- چرخش credential: تغییر `CRAWLER_SERVICE_TOKEN` + تغییر رمز با `app:system-owner … --password=…`؛ + ابطال فوری: `--deactivate`. + +### backfill نقش جانشین‌های قدیمی + +جانشین‌های ساخته‌شده قبل از افزودن نقش marker: + +```bash +php bin/console app:doctors:backfill-surrogate-role --dry-run # فقط گزارش +php bin/console app:doctors:backfill-surrogate-role # اعمال +``` diff --git a/docs/api/doctor.md b/docs/api/doctor.md index 6d5f5fe3..21c8a5b9 100644 --- a/docs/api/doctor.md +++ b/docs/api/doctor.md @@ -1,5 +1,9 @@ # 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*` > > 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:00–13:00", "hours_of_work": "شنبه: 09:00–13:00 و 14:00–18:00 | یکشنبه: 09:00–13:00", "active": true, + "owner_status": "claimed", "specialties": [{ "uuid": "...", "id": "1", "name": "قلب و عروق", "parent_id": null }], "expertise": [{ "uuid": "...", "id": "3", "name": "نوار قلب" }], "address": [], @@ -204,7 +209,8 @@ List doctors with pagination and filters. "point": "3.5", "free_turn": "دوشنبه 09:00–13:00", "hours_of_work": "شنبه: 09:00–13:00 و 14:00–18:00 | یکشنبه: 09:00–13:00", - "active": true + "active": true, + "owner_status": "claimed" } ], "meta": { diff --git a/migrations/Version20260711150000.php b/migrations/Version20260711150000.php new file mode 100644 index 00000000..0a0fcec3 --- /dev/null +++ b/migrations/Version20260711150000.php @@ -0,0 +1,49 @@ +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)'); + } +} diff --git a/migrations/Version20260711151000.php b/migrations/Version20260711151000.php new file mode 100644 index 00000000..369a0541 --- /dev/null +++ b/migrations/Version20260711151000.php @@ -0,0 +1,50 @@ +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'); + } +} diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index ba8b54e4..797246c8 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -7,10 +7,8 @@ use App\Shared\Constant\ErrorCodes; use App\Appointment\Repository\SlotTakenException; use App\Auth\Entity\User; use App\Shared\Service\InputValidator; -use App\Location\Entity\City; use App\Clinic\Entity\Clinic; use App\Doctor\Entity\Doctor; -use App\Location\Entity\Province; use App\Specialty\Entity\Specialty; use App\Payment\Entity\Payment; use App\Rating\Entity\Comment; @@ -326,6 +324,11 @@ class AdminApiController extends BaseController $where[] = 'd.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) { $where[] = 'EXISTS (SELECT 1 FROM doctor_specialties ds2 WHERE ds2.doctor_id = d.id AND ds2.specialty_id = :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, d.mobile_number as doctor_mobile, d.active_doctor_appointment, 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 FROM doctors d JOIN users u ON u.id = d.user_id WHERE $whereStr ORDER BY $orderBy LIMIT $limit OFFSET $offset", @@ -444,129 +448,6 @@ class AdminApiController extends BaseController 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 ─────────────────────────────────────────────────────────────── #[Route('/api/v1/admin/clinics', methods: ['GET'])] diff --git a/src/Auth/Command/SystemOwnerCommand.php b/src/Auth/Command/SystemOwnerCommand.php index f69f330f..df3404b0 100644 --- a/src/Auth/Command/SystemOwnerCommand.php +++ b/src/Auth/Command/SystemOwnerCommand.php @@ -70,11 +70,17 @@ class SystemOwnerCommand extends Command } } + // least privilege: کاربر سیستمی فقط ROLE_IMPORTER می‌گیرد (دسترسی فقط به اندپوینت + // ایمپورت پزشک). اگر از نسخه‌های قبلی ROLE_ADMIN دارد، حذف می‌شود. $roles = $user->getRoles(); - if (!in_array('ROLE_ADMIN', $roles, true)) { - $roles[] = 'ROLE_ADMIN'; - $user->setRoles(array_values(array_unique($roles))); + if (in_array('ROLE_ADMIN', $roles, true)) { + $roles = array_values(array_diff($roles, ['ROLE_ADMIN'])); + $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) { $user->setPasswordHash($this->hasher->hashPassword($user, (string) $password)); diff --git a/src/Auth/Entity/User.php b/src/Auth/Entity/User.php index 2985bf1e..63624988 100644 --- a/src/Auth/Entity/User.php +++ b/src/Auth/Entity/User.php @@ -121,6 +121,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this->hasRole('ROLE_DOCTOR') || $this->hasRole('ROLE_CLINIC') || $this->hasRole('ROLE_SECRETARY') - || $this->hasRole('ROLE_ADMIN'); + || $this->hasRole('ROLE_ADMIN') + || $this->hasRole('ROLE_IMPORTER'); // کاربر سیستمی کرالر — لاگین با رمز؛ دسترسی فقط اندپوینت ایمپورت } } diff --git a/src/Auth/Security/PasswordAuthenticator.php b/src/Auth/Security/PasswordAuthenticator.php index c981b595..36d683b4 100644 --- a/src/Auth/Security/PasswordAuthenticator.php +++ b/src/Auth/Security/PasswordAuthenticator.php @@ -30,8 +30,20 @@ class PasswordAuthenticator extends AbstractAuthenticator private readonly RateLimiterFactory $loginLimiter, private readonly CaptchaGuard $captcha, 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 { return $request->getPathInfo() === '/api/v1/user/login' @@ -46,7 +58,9 @@ class PasswordAuthenticator extends AbstractAuthenticator } // AppException را ExceptionSubscriber به پاسخ 422 با ERR_CAPTCHA_001 تبدیل می‌کند. - $this->captcha->assertValid($request); + if (!$this->isTrustedServiceLogin($request)) { + $this->captcha->assertValid($request); + } $data = json_decode($request->getContent(), true) ?? []; $mobile = trim($data['mobile_number'] ?? ''); diff --git a/src/Doctor/Command/BackfillSurrogateRoleCommand.php b/src/Doctor/Command/BackfillSurrogateRoleCommand.php new file mode 100644 index 00000000..66f35ced --- /dev/null +++ b/src/Doctor/Command/BackfillSurrogateRoleCommand.php @@ -0,0 +1,76 @@ +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; + } +} diff --git a/src/Doctor/Controller/DoctorClaimController.php b/src/Doctor/Controller/DoctorClaimController.php new file mode 100644 index 00000000..d9a54946 --- /dev/null +++ b/src/Doctor/Controller/DoctorClaimController.php @@ -0,0 +1,198 @@ +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 + ); + } +} diff --git a/src/Doctor/Controller/DoctorImportController.php b/src/Doctor/Controller/DoctorImportController.php new file mode 100644 index 00000000..15ec6829 --- /dev/null +++ b/src/Doctor/Controller/DoctorImportController.php @@ -0,0 +1,86 @@ + []]], + 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); + } +} diff --git a/src/Doctor/Entity/Doctor.php b/src/Doctor/Entity/Doctor.php index eda37d10..1a73f018 100644 --- a/src/Doctor/Entity/Doctor.php +++ b/src/Doctor/Entity/Doctor.php @@ -17,7 +17,9 @@ use Symfony\Component\Uid\Uuid; #[ORM\Entity(repositoryClass: DoctorRepository::class)] #[ORM\Table(name: 'doctors')] #[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: ['owner_status'], name: 'idx_doctors_owner')] class Doctor { public const DEGREES = ['expert', 'general', 'specialist', 'subspecialistplus']; @@ -79,11 +81,11 @@ class Doctor // ── Profile ownership (IRIMC import) ─────────────────────────────────────── // 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'; // source: manual | irimc - #[ORM\Column(type: 'string', length: 20)] + #[ORM\Column(type: 'string', length: 20, options: ['default' => 'manual'])] private string $source = 'manual'; // شناسه رکورد مبدأ (profile_url یا کد نظام پزشکی) برای idempotency و ممیزی @@ -521,6 +523,7 @@ class Doctor 'free_turn' => $sf['free_turn'], 'hours_of_work' => $sf['hours_of_work'], 'active' => $this->activeDoctorAppointment && $sf['has_schedule'], + 'owner_status' => $this->ownerStatus, ]; } @@ -553,6 +556,7 @@ class Doctor ], $this->services->toArray()), 'satisfaction' => (string) $this->doctorRatePercentage, 'point' => (string) $this->doctorRate, + 'owner_status' => $this->ownerStatus, 'free_turn' => $sf['free_turn'], 'hours_of_work' => $sf['hours_of_work'], 'address' => array_map(fn(DoctorAddress $a) => $a->toArray(), $this->addresses->toArray()), diff --git a/src/Doctor/Entity/DoctorClaimRequest.php b/src/Doctor/Entity/DoctorClaimRequest.php new file mode 100644 index 00000000..8b8665ed --- /dev/null +++ b/src/Doctor/Entity/DoctorClaimRequest.php @@ -0,0 +1,115 @@ +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, + ]; + } +} diff --git a/src/Doctor/Repository/DoctorClaimRequestRepository.php b/src/Doctor/Repository/DoctorClaimRequestRepository.php new file mode 100644 index 00000000..cfdc2ffa --- /dev/null +++ b/src/Doctor/Repository/DoctorClaimRequestRepository.php @@ -0,0 +1,15 @@ +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); + }); + } +} diff --git a/src/Doctor/Service/DoctorImportResult.php b/src/Doctor/Service/DoctorImportResult.php new file mode 100644 index 00000000..1c1c54b5 --- /dev/null +++ b/src/Doctor/Service/DoctorImportResult.php @@ -0,0 +1,14 @@ +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); + } + } + } +} diff --git a/src/Shared/Service/ApiIrService.php b/src/Shared/Service/ApiIrService.php index 4b6e8fc4..c504046d 100644 --- a/src/Shared/Service/ApiIrService.php +++ b/src/Shared/Service/ApiIrService.php @@ -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 $payload * @return array diff --git a/src/Shared/Util/PersianText.php b/src/Shared/Util/PersianText.php new file mode 100644 index 00000000..7aea4786 --- /dev/null +++ b/src/Shared/Util/PersianText.php @@ -0,0 +1,50 @@ + 'ی', // ي عربی + "\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); + } +} diff --git a/tests/Doctor/DoctorClaimTest.php b/tests/Doctor/DoctorClaimTest.php new file mode 100644 index 00000000..c432315f --- /dev/null +++ b/tests/Doctor/DoctorClaimTest.php @@ -0,0 +1,219 @@ +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()); + } +} diff --git a/tests/Doctor/DoctorImportTest.php b/tests/Doctor/DoctorImportTest.php new file mode 100644 index 00000000..434a746d --- /dev/null +++ b/tests/Doctor/DoctorImportTest.php @@ -0,0 +1,138 @@ + 'دکتر تست ایمپورت', + '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()); + } +} diff --git a/tests/Shared/PersianTextTest.php b/tests/Shared/PersianTextTest.php new file mode 100644 index 00000000..7d2fcb24 --- /dev/null +++ b/tests/Shared/PersianTextTest.php @@ -0,0 +1,37 @@ +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('علی اکبری', 'ولی اکبری')); + } +}