feat(doctor): complete IRIMC import feature — claim flow, least-privilege importer, unique import key
- Extract import logic from AdminApiController into DoctorImportService (thin DoctorImportController keeps the same route/contract) - Surrogate users get marker role ROLE_UNCLAIMED_DOCTOR (+ backfill command app:doctors:backfill-surrogate-role) enabling safe deletion after claim - DB-level UNIQUE (source, medical_system_code) + concurrent-import retry - Doctor profile claim flow (climed.md): shahkar + PersonInfo identity checks via existing ApiIrService, Persian name normalization (PersianText), pessimistic-lock race protection, DoctorClaimRequest audit table (national code hashed, mobile masked), doctor_claim rate limiter, public claim-info endpoint, welcome SMS - Admin support tools: manual transfer endpoint + paginated doctor-claims audit list + owner_status filter/fields in admin doctors list - Least privilege: system owner now gets ROLE_IMPORTER (ROLE_ADMIN stripped), import endpoint accepts ADMIN|IMPORTER, isStaff includes IMPORTER - Headless crawler login: X-Service-Token header bypasses captcha only (rate limit + password checks intact; empty env = no bypass) - docs: doctor-claim.md (new), doctor-import.md, admin.md, doctor.md - tests: DoctorImportTest (6), DoctorClaimTest (11), PersianTextTest (5) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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_<hash>`، skip روی claimed | ✅ `src/Admin/Controller/AdminApiController.php:483` |
|
||||
| دستور `app:system-owner` | ✅ `src/Auth/Command/SystemOwnerCommand.php` |
|
||||
| مستند | ✅ `docs/api/doctor-import.md` |
|
||||
| کرالر (`clinicpro_client.py`, `pipeline.py`) | ✅ `clinicpro-crawler/` |
|
||||
ترتیب اجرا (قانون 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_<md5-14>`، 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<T>` + TanStack Query + `DataTable`/`Pagination`/`StatusBadge`):
|
||||
|
||||
- تب/فیلتر: `pending / completed / failed` + جستجو.
|
||||
- ستونها: پزشک، وضعیت، روش احراز (`apiir_personinfo` / `admin_manual`)، موبایل maskشده، `failure_reason`، تاریخ شمسی (`formatDate`).
|
||||
- اکشن: «انتقال دستی» (فرم موبایل → `POST .../transfer`) با `ConfirmDialog` موجود.
|
||||
- در `DoctorsPage` موجود: فیلتر `owner_status` + badge وضعیت.
|
||||
- ادمین باید علت شکست claim را بدون خواندن لاگ سرور ببیند (`failure_reason` انسانیخوان، فارسی).
|
||||
|
||||
---
|
||||
|
||||
## ۶. Workstream D — کرالر (طبق `docs/scenarios/crawler.md`)
|
||||
|
||||
Python میماند (§۲.۲-۵). تغییرات:
|
||||
|
||||
### ۶.۱ State داخلی → SQLite (stdlib `sqlite3`، وابستگی جدید نصب نکن)
|
||||
|
||||
فایل `crawler_state.db` (volume-پایدار). جداول:
|
||||
|
||||
```sql
|
||||
provinces(id INTEGER PK, name TEXT, clinicpro_state_id INT, status TEXT DEFAULT 'pending', started_at INT, completed_at INT)
|
||||
cities(id INTEGER PK, province_id INT, name TEXT, clinicpro_city_id INT, status TEXT, started_at INT, completed_at INT)
|
||||
doctors(id INTEGER PK, city_id INT, medical_system_code TEXT, name TEXT,
|
||||
crawl_status TEXT, -- crawled|failed
|
||||
push_status TEXT, -- pending|sent|failed|skipped_claimed
|
||||
clinicpro_uuid TEXT, attempts INT DEFAULT 0, last_error TEXT, updated_at INT,
|
||||
UNIQUE(medical_system_code))
|
||||
meta(key TEXT PK, value TEXT) -- current_province, current_city, schema_version
|
||||
```
|
||||
|
||||
- ماژول جدید `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 موجود است.
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# Doctor Profile Claim API (تصاحب پروفایل پزشک ایمپورتشده)
|
||||
|
||||
> **Controller:** `App\Doctor\Controller\DoctorClaimController` — منطق در `App\Doctor\Service\DoctorClaimService`
|
||||
> **مصرفکننده:** سایت عمومی Nobat724 (همهٔ دامنهها) + پنل ادمین
|
||||
|
||||
پزشکِ ایمپورتشده از نظام پزشکی (`owner_status = unclaimed`) توسط پزشک واقعی تصاحب میشود.
|
||||
احراز هویت سمت سرور با **API.ir** انجام میشود (شاهکار: تطبیق موبایل↔کدملی؛ PersonInfo: تطبیق
|
||||
کدملی+تاریخ تولد و نام). هیچ درخواستی از فرانت به API.ir نمیرود و توکن API.ir هرگز به کلاینت
|
||||
نمیرسد. claim پس از تطبیق موفق **خودکار** نهایی میشود (بدون approve ادمین — تصمیم مستند در
|
||||
`.claude/prompt/irimc-import-complete.md` §۲.۲).
|
||||
|
||||
## چرخهٔ وضعیت
|
||||
|
||||
```
|
||||
unclaimed ──claim/transfer شروع──▶ pending_transfer ──موفق──▶ claimed
|
||||
▲ │شکست تطبیق/خطای استعلام
|
||||
└───────────────────────────────────┘ (برگشت، قابل تلاش مجدد)
|
||||
```
|
||||
|
||||
هر تلاش یک رکورد ممیزی در `doctor_claim_requests` میسازد — کد ملی فقط **hash sha256** و
|
||||
موبایل فقط **mask شده** ذخیره میشود؛ هیچ دادهٔ هویتی خام در DB یا لاگ نمیماند.
|
||||
|
||||
---
|
||||
|
||||
## GET `/api/v1/doctor/{uuid}/claim-info`
|
||||
|
||||
**Permission:** عمومی (بدون JWT) — فقط برای رندر دکمهٔ «آیا شما این پزشک هستید؟»
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "success": true, "data": { "claimable": true, "owner_status": "unclaimed" } }
|
||||
```
|
||||
|
||||
| کد | حالت |
|
||||
|---|---|
|
||||
| `404` | پزشک یافت نشد |
|
||||
|
||||
---
|
||||
|
||||
## POST `/api/v1/doctor/{uuid}/claim`
|
||||
|
||||
**Permission:** `IS_AUTHENTICATED_FULLY` — کاربر با OTP لاگین شده (موبایلش تأییدشده است)
|
||||
**Rate limit:** limiter `doctor_claim` — ۵ تلاش در ساعت بهازای هر (کاربر، پزشک)
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"national_code": "0010007700",
|
||||
"birth_date": "1371/1/1",
|
||||
"first_name": "فرخنده",
|
||||
"last_name": "حسینی"
|
||||
}
|
||||
```
|
||||
|
||||
| فیلد | الزامی | قاعده |
|
||||
|---|:---:|---|
|
||||
| `national_code` | ✅ | ۱۰ رقم (ارقام فارسی پذیرفته و نرمال میشوند) |
|
||||
| `birth_date` | ✅ | شمسی `Y/m/d` |
|
||||
| `first_name` / `last_name` | ✅ | با هویت ثبت احوال و نام پروفایل تطبیق داده میشود (نرمالسازی ي/ی، ك/ک، نیمفاصله — `PersianText`) |
|
||||
|
||||
### مراحل سرور (اتمیک/ضد race)
|
||||
|
||||
1. قفل `PESSIMISTIC_WRITE` روی ردیف پزشک → اگر `unclaimed` نبود `409`؛ اگر کاربر از قبل پزشکی دارد `409`؛ اگر کد ملی متعلق به کاربر دیگری است `409` — سپس `pending_transfer` + رکورد ممیزی (تراکنش کوتاه، بدون فراخوان خارجی داخل قفل).
|
||||
2. شاهکار (`ApiIrService::shahkarMatch`) — تطبیق موبایل کاربر با کد ملی. اگر API.ir پیکربندی نشده باشد، این گام skip و مبنا موبایلِ OTP-تأییدشده است.
|
||||
3. `PersonInfo` — تطبیق کدملی+تاریخ تولد؛ `alive=false` → رد.
|
||||
4. تطبیق نام: ورودی کاربر ↔ هویت تأییدشده ↔ نام پروفایل (بدون پیشوند «دکتر»).
|
||||
5. نهاییسازی اتمیک: `user_id` → کاربر واقعی، `ROLE_DOCTOR`، `national_code_verified=true`، `owner_status=claimed`، حذف امنِ کاربر جانشین (فقط با `ROLE_UNCLAIMED_DOCTOR` و بدون پزشک دیگر).
|
||||
6. پیامک خوشآمد (تمپلیت `welcome`، async).
|
||||
|
||||
> شکست در گامهای ۲-۴ پروفایل را به `unclaimed` برمیگرداند تا پزشک واقعی بتواند دوباره تلاش کند.
|
||||
> نوبتدهی (`active_doctor_appointment`) خاموش میماند تا مالک جدید برنامهٔ کاری تعریف کند.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"status": "claimed",
|
||||
"claim": { "uuid": "…" },
|
||||
"doctor": { "uuid": "…", "name": "دکتر فرخنده حسینی" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Errors
|
||||
| کد | HTTP | حالت |
|
||||
|---|---|---|
|
||||
| `ERR_AUTH_001` | 401 | بدون لاگین |
|
||||
| `ERR_NOT_FOUND_001` | 404 | پزشک یافت نشد |
|
||||
| `ERR_CONFLICT_001` | 409 | پروفایل قابل تصاحب نیست / کاربر پزشک دیگری دارد / درخواست همزمان دیگری در جریان است |
|
||||
| `ERR_PROFILE_001` | 409 | کد ملی قبلاً برای کاربر دیگری ثبت شده است |
|
||||
| `ERR_VALIDATION_001/002` | 422 | کد ملی/تاریخ/نام نامعتبر |
|
||||
| `ERR_IDENTITY_001` | 422 | عدم تطبیق هویت (شاهکار/ثبت احوال/نام) — پیام عمومی، ضد enumeration |
|
||||
| `ERR_RATE_LIMIT_001` | 429 | عبور از سقف تلاش |
|
||||
| `ERR_EXTERNAL_001` | 502 | خطا/تایماوت API.ir |
|
||||
| `ERR_EXTERNAL_002` | 503 | API.ir پیکربندی نشده (env) |
|
||||
|
||||
---
|
||||
|
||||
## POST `/api/v1/admin/doctors/{uuid}/transfer`
|
||||
|
||||
انتقال دستی (ابزار پشتیبانی ادمین) — مستند کامل در `docs/api/doctor-import.md`.
|
||||
**Permission:** `ROLE_ADMIN`. بدنه `{ "mobile": "09…" }`. همان نهاییسازی claim را اجرا میکند
|
||||
و رکورد ممیزی با `verification_method="admin_manual"` میسازد.
|
||||
|
||||
---
|
||||
|
||||
## GET `/api/v1/admin/doctor-claims`
|
||||
|
||||
**Permission:** `ROLE_ADMIN` — لیست ممیزی درخواستهای claim برای پشتیبانی عملیاتی.
|
||||
|
||||
| Query | پیشفرض | توضیح |
|
||||
|---|---|---|
|
||||
| `status` | همه | `pending` \| `completed` \| `failed` |
|
||||
| `page` / `limit` | 1 / 20 (سقف 50) | صفحهبندی استاندارد |
|
||||
|
||||
### Response `200` (paginated)
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"uuid": "…",
|
||||
"status": "completed",
|
||||
"doctor": { "uuid": "…", "name": "دکتر فرخنده حسینی" },
|
||||
"mobile_masked": "0912***4567",
|
||||
"verification_method": "apiir_personinfo+shahkar",
|
||||
"failure_reason": null,
|
||||
"created_at": 1783750000,
|
||||
"completed_at": 1783750040
|
||||
}
|
||||
],
|
||||
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
`failure_reason` فارسی و انسانیخوان است تا ادمین بدون خواندن لاگ سرور علت شکست را ببیند.
|
||||
|
||||
---
|
||||
|
||||
## env های مرتبط
|
||||
|
||||
| متغیر | نقش |
|
||||
|---|---|
|
||||
| `APIIR_*` (baseUrl/token موجود `ApiIrService`) | استعلام شاهکار و PersonInfo |
|
||||
| `CRAWLER_SERVICE_TOKEN` | فقط برای لاگین سرویسی کرالر (`doctor-import.md`) — ربطی به claim ندارد |
|
||||
|
||||
## تستها
|
||||
|
||||
`tests/Doctor/DoctorClaimTest.php` (۱۱ سناریو، API.ir همیشه mock — هیچ تستی به سرویس واقعی
|
||||
درخواست نمیزند) و `tests/Shared/PersianTextTest.php` (نرمالسازی نام).
|
||||
+37
-13
@@ -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=<secret> --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 # اعمال
|
||||
```
|
||||
|
||||
+7
-1
@@ -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": {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* کلید طبیعی ایمپورت IRIMC را در سطح دیتابیس یکتا میکند.
|
||||
*
|
||||
* ایندکس قبلی (source, medical_system_code) ساده بود؛ dedup فقط application-level
|
||||
* بود و دو درخواست همزمان میتوانست رکورد تکراری بسازد. MariaDB چند NULL را در
|
||||
* ایندکس یکتا مجاز میداند، پس پزشکان manual بدون کد تحت تأثیر نیستند.
|
||||
*
|
||||
* غیرمخرب: اگر دادهٔ تکراری وجود داشته باشد migration متوقف میشود (هیچ حذفی
|
||||
* انجام نمیدهد) — تکراریها باید جداگانه و دستی تعیین تکلیف شوند.
|
||||
*/
|
||||
final class Version20260711150000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Unique index on doctors (source, medical_system_code) for concurrency-safe IRIMC import';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$dupes = $this->connection->fetchAllAssociative(<<<'SQL'
|
||||
SELECT source, medical_system_code, COUNT(*) c FROM doctors
|
||||
WHERE medical_system_code IS NOT NULL AND medical_system_code <> ''
|
||||
GROUP BY source, medical_system_code HAVING c > 1
|
||||
SQL);
|
||||
$this->abortIf(
|
||||
$dupes !== [],
|
||||
'Duplicate (source, medical_system_code) rows exist — resolve them manually before adding the unique index: '
|
||||
. json_encode($dupes, JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
|
||||
$this->addSql('DROP INDEX idx_doctors_source ON doctors');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_doctors_source_code ON doctors (source, medical_system_code)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX uniq_doctors_source_code ON doctors');
|
||||
$this->addSql('CREATE INDEX idx_doctors_source ON doctors (source, medical_system_code)');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* جدول ممیزی درخواستهای تصاحب پروفایل پزشک (claim).
|
||||
* دادهٔ حساس خام ذخیره نمیشود: کد ملی hash (sha256) و موبایل mask.
|
||||
*/
|
||||
final class Version20260711151000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'doctor_claim_requests audit table for the profile-claim flow';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE doctor_claim_requests (
|
||||
id INT AUTO_INCREMENT NOT NULL,
|
||||
uuid VARCHAR(36) NOT NULL,
|
||||
doctor_id INT NOT NULL,
|
||||
user_id INT DEFAULT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
national_code_hash VARCHAR(64) NOT NULL,
|
||||
mobile_masked VARCHAR(15) NOT NULL,
|
||||
verification_method VARCHAR(40) NOT NULL,
|
||||
failure_reason VARCHAR(100) DEFAULT NULL,
|
||||
created_at INT NOT NULL,
|
||||
completed_at INT DEFAULT NULL,
|
||||
UNIQUE INDEX UNIQ_claim_uuid (uuid),
|
||||
INDEX idx_claim_doctor_status (doctor_id, status),
|
||||
INDEX IDX_claim_user (user_id),
|
||||
PRIMARY KEY(id)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
|
||||
SQL);
|
||||
$this->addSql('ALTER TABLE doctor_claim_requests ADD CONSTRAINT FK_claim_doctor FOREIGN KEY (doctor_id) REFERENCES doctors (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE doctor_claim_requests ADD CONSTRAINT FK_claim_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE doctor_claim_requests');
|
||||
}
|
||||
}
|
||||
@@ -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'])]
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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'); // کاربر سیستمی کرالر — لاگین با رمز؛ دسترسی فقط اندپوینت ایمپورت
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'] ?? '');
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctor\Command;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Service\DoctorImportService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* backfill یکبارمصرف: نقش ROLE_UNCLAIMED_DOCTOR برای کاربران جانشینِ ایمپورت
|
||||
* (mobile با پیشوند imp_، غیرفعال، متصل به پزشک unclaimed) که پیش از افزودن
|
||||
* این نقش ساخته شدهاند. غیرمخرب؛ با --dry-run فقط گزارش میدهد.
|
||||
*
|
||||
* php bin/console app:doctors:backfill-surrogate-role --dry-run
|
||||
* php bin/console app:doctors:backfill-surrogate-role
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:doctors:backfill-surrogate-role',
|
||||
description: 'Add ROLE_UNCLAIMED_DOCTOR to legacy IRIMC surrogate users (idempotent, supports --dry-run)',
|
||||
)]
|
||||
class BackfillSurrogateRoleCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only, change nothing');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
/** @var User[] $surrogates */
|
||||
$surrogates = $this->em->createQueryBuilder()
|
||||
->select('u')
|
||||
->from(User::class, 'u')
|
||||
->join(Doctor::class, 'd', 'WITH', 'd.user = u')
|
||||
->where("u.mobileNumber LIKE 'imp\\_%'")
|
||||
->andWhere('u.status = 0')
|
||||
->andWhere("d.ownerStatus = 'unclaimed'")
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$updated = 0;
|
||||
foreach ($surrogates as $user) {
|
||||
if ($user->hasRole(DoctorImportService::ROLE_UNCLAIMED_DOCTOR)) {
|
||||
continue;
|
||||
}
|
||||
$updated++;
|
||||
$io->text(sprintf('%s %s', $dryRun ? '[dry-run]' : '[update]', $user->getMobileNumber()));
|
||||
if (!$dryRun) {
|
||||
$user->addRole(DoctorImportService::ROLE_UNCLAIMED_DOCTOR);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$dryRun && $updated > 0) {
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
$io->success(sprintf('%d surrogate(s) %s (of %d scanned)', $updated, $dryRun ? 'would be updated' : 'updated', count($surrogates)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctor\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Doctor\Service\DoctorClaimService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Doctors')]
|
||||
class DoctorClaimController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly DoctorClaimService $claimService,
|
||||
private readonly RateLimiterFactory $doctorClaimLimiter,
|
||||
private readonly \App\Doctor\Repository\DoctorClaimRequestRepository $claimRepo,
|
||||
) {}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/doctor/{uuid}/claim-info',
|
||||
summary: 'Whether this doctor profile can be claimed (public, renders the claim button)',
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: '{ claimable, owner_status }'),
|
||||
new OA\Response(response: 404, description: 'Doctor not found'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/doctor/{uuid}/claim-info', methods: ['GET'])]
|
||||
public function claimInfo(string $uuid): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'claimable' => $doctor->getOwnerStatus() === 'unclaimed',
|
||||
'owner_status' => $doctor->getOwnerStatus(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/api/v1/doctor/{uuid}/claim',
|
||||
summary: 'Claim an unclaimed (IRIMC-imported) doctor profile after identity verification',
|
||||
security: [['bearerAuth' => []]],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['national_code', 'birth_date', 'first_name', 'last_name'],
|
||||
properties: [
|
||||
new OA\Property(property: 'national_code', type: 'string', example: '0010007700'),
|
||||
new OA\Property(property: 'birth_date', type: 'string', description: 'شمسی Y/m/d', example: '1371/1/1'),
|
||||
new OA\Property(property: 'first_name', type: 'string'),
|
||||
new OA\Property(property: 'last_name', type: 'string'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Claimed'),
|
||||
new OA\Response(response: 409, description: 'Not claimable / user already owns a doctor'),
|
||||
new OA\Response(response: 422, description: 'Validation or identity mismatch'),
|
||||
new OA\Response(response: 429, description: 'Rate limited'),
|
||||
new OA\Response(response: 502, description: 'Identity provider unavailable'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/doctor/{uuid}/claim', methods: ['POST'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function claim(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$limiter = $this->doctorClaimLimiter->create('claim_' . $user->getId() . '_' . $uuid);
|
||||
$limit = $limiter->consume();
|
||||
if (!$limit->isAccepted()) {
|
||||
throw new TooManyRequestsHttpException($limit->getRetryAfter()->getTimestamp() - time());
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$nationalCode = preg_replace('/\D/', '', \App\Shared\Util\PersianText::normalize((string) ($data['national_code'] ?? '')));
|
||||
$birthDate = trim(\App\Shared\Util\PersianText::normalize((string) ($data['birth_date'] ?? '')));
|
||||
$firstName = trim((string) ($data['first_name'] ?? ''));
|
||||
$lastName = trim((string) ($data['last_name'] ?? ''));
|
||||
|
||||
if (!preg_match('/^\d{10}$/', $nationalCode)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی نامعتبر است', 422, 'national_code');
|
||||
}
|
||||
if (!preg_match('~^1[34]\d{2}/\d{1,2}/\d{1,2}$~', $birthDate)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'تاریخ تولد نامعتبر است (مثال: 1371/1/1)', 422, 'birth_date');
|
||||
}
|
||||
if ($firstName === '' || $lastName === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام و نام خانوادگی الزامی است', 422);
|
||||
}
|
||||
|
||||
$claim = $this->claimService->claim($doctor, $user, $nationalCode, $birthDate, $firstName, $lastName);
|
||||
|
||||
return $this->success([
|
||||
'status' => 'claimed',
|
||||
'claim' => ['uuid' => $claim->getUuid()],
|
||||
'doctor' => ['uuid' => $doctor->getUuid(), 'name' => $doctor->getName()],
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/api/v1/admin/doctors/{uuid}/transfer',
|
||||
summary: 'Manually transfer an unclaimed doctor profile to a real user (admin support tool)',
|
||||
security: [['bearerAuth' => []]],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['mobile'],
|
||||
properties: [new OA\Property(property: 'mobile', type: 'string', example: '09121234567')]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Transferred'),
|
||||
new OA\Response(response: 404, description: 'Doctor not found'),
|
||||
new OA\Response(response: 409, description: 'Already claimed / target user owns another doctor'),
|
||||
new OA\Response(response: 422, description: 'Invalid mobile'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/admin/doctors/{uuid}/transfer', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function transfer(string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$mobile = trim((string) ($data['mobile'] ?? ''));
|
||||
if (!preg_match('/^09\d{9}$/', $mobile)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل نامعتبر است', 422, 'mobile');
|
||||
}
|
||||
|
||||
$claim = $this->claimService->transferByAdmin($doctor, $mobile);
|
||||
|
||||
return $this->success([
|
||||
'uuid' => $doctor->getUuid(),
|
||||
'owner_status' => $doctor->getOwnerStatus(),
|
||||
'user_mobile' => $mobile,
|
||||
'claim' => ['uuid' => $claim->getUuid()],
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/admin/doctor-claims',
|
||||
summary: 'Paginated audit list of doctor profile claim requests',
|
||||
security: [['bearerAuth' => []]],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'status', in: 'query', schema: new OA\Schema(type: 'string', enum: ['pending', 'completed', 'failed'])),
|
||||
new OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer', default: 1)),
|
||||
new OA\Parameter(name: 'limit', in: 'query', schema: new OA\Schema(type: 'integer', default: 20)),
|
||||
],
|
||||
responses: [new OA\Response(response: 200, description: 'Paginated claim requests')]
|
||||
)]
|
||||
#[Route('/api/v1/admin/doctor-claims', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function claimsList(Request $request): JsonResponse
|
||||
{
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
|
||||
$status = trim((string) $request->query->get('status', ''));
|
||||
|
||||
$qb = $this->claimRepo->createQueryBuilder('c')
|
||||
->orderBy('c.createdAt', 'DESC');
|
||||
|
||||
if (in_array($status, [\App\Doctor\Entity\DoctorClaimRequest::STATUS_PENDING, \App\Doctor\Entity\DoctorClaimRequest::STATUS_COMPLETED, \App\Doctor\Entity\DoctorClaimRequest::STATUS_FAILED], true)) {
|
||||
$qb->andWhere('c.status = :status')->setParameter('status', $status);
|
||||
}
|
||||
|
||||
$total = (int) (clone $qb)->select('COUNT(c.id)')->getQuery()->getSingleScalarResult();
|
||||
|
||||
$items = $qb->setFirstResult(($page - 1) * $limit)
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return $this->paginated(
|
||||
array_map(fn(\App\Doctor\Entity\DoctorClaimRequest $c) => $c->toArray(), $items),
|
||||
$total,
|
||||
$page,
|
||||
$limit
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctor\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Service\DoctorImportService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* اندپوینت ایمپورت پزشک IRIMC — جدا از AdminApiController تا کاربر سیستمیِ
|
||||
* کرالر با نقش حداقلی ROLE_IMPORTER (بدون دسترسی به بقیهٔ پنل ادمین) بتواند
|
||||
* فقط همین عمل را انجام دهد (least privilege). مسیر برای سازگاری با کرالر و
|
||||
* مستندات، همان مسیر قبلی مانده است.
|
||||
*/
|
||||
#[OA\Tag(name: 'Doctors')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class DoctorImportController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DoctorImportService $importService,
|
||||
) {}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/api/v1/admin/doctors/import',
|
||||
summary: 'Import an IRIMC doctor without a mobile number (unclaimed profile)',
|
||||
security: [['bearerAuth' => []]],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['name', 'medical_system_code'],
|
||||
properties: [
|
||||
new OA\Property(property: 'name', type: 'string'),
|
||||
new OA\Property(property: 'medical_system_code', type: 'string'),
|
||||
new OA\Property(property: 'source', type: 'string', default: 'irimc'),
|
||||
new OA\Property(property: 'source_ref', type: 'string', nullable: true, description: 'profile_url یا شناسهٔ مبدأ'),
|
||||
new OA\Property(property: 'gender', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'degree', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'info', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'specialties', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
|
||||
new OA\Property(property: 'states', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
|
||||
new OA\Property(property: 'cities', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 201, description: 'Doctor imported (created)'),
|
||||
new OA\Response(response: 200, description: 'Doctor already existed (updated or skipped)'),
|
||||
new OA\Response(response: 403, description: 'Requires ROLE_ADMIN or ROLE_IMPORTER'),
|
||||
new OA\Response(response: 422, description: 'Validation error'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/admin/doctors/import', methods: ['POST'])]
|
||||
public function import(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
if (!$this->isGranted('ROLE_ADMIN') && !$this->isGranted('ROLE_IMPORTER')) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی به این منبع مجاز نیست', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$name = trim((string) ($data['name'] ?? ''));
|
||||
$code = trim((string) ($data['medical_system_code'] ?? $data['medicalSystemCode'] ?? ''));
|
||||
|
||||
if ($name === '') {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'نام الزامی است', 422, 'name');
|
||||
}
|
||||
if ($code === '') {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'کد نظام پزشکی الزامی است', 422, 'medical_system_code');
|
||||
}
|
||||
|
||||
$result = $this->importService->import($data, $user);
|
||||
|
||||
$payload = ['uuid' => $result->doctor->getUuid(), 'created' => $result->created];
|
||||
if ($result->skipped !== null) {
|
||||
$payload['skipped'] = $result->skipped;
|
||||
}
|
||||
|
||||
return $this->success($payload, $result->created ? 201 : 200);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctor\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Repository\DoctorClaimRequestRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* رکورد ممیزی درخواست تصاحب پروفایل پزشک (claim).
|
||||
*
|
||||
* دادهٔ حساس خام ذخیره نمیشود: کد ملی فقط hash و موبایل فقط mask.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DoctorClaimRequestRepository::class)]
|
||||
#[ORM\Table(name: 'doctor_claim_requests')]
|
||||
#[ORM\Index(columns: ['doctor_id', 'status'], name: 'idx_claim_doctor_status')]
|
||||
class DoctorClaimRequest
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'user_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $user;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20)]
|
||||
private string $status = self::STATUS_PENDING;
|
||||
|
||||
#[ORM\Column(name: 'national_code_hash', type: 'string', length: 64)]
|
||||
private string $nationalCodeHash;
|
||||
|
||||
#[ORM\Column(name: 'mobile_masked', type: 'string', length: 15)]
|
||||
private string $mobileMasked;
|
||||
|
||||
#[ORM\Column(name: 'verification_method', type: 'string', length: 40)]
|
||||
private string $verificationMethod;
|
||||
|
||||
#[ORM\Column(name: 'failure_reason', type: 'string', length: 100, nullable: true)]
|
||||
private ?string $failureReason = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'completed_at', type: 'integer', nullable: true)]
|
||||
private ?int $completedAt = null;
|
||||
|
||||
public function __construct(Doctor $doctor, ?User $user, string $nationalCode, string $mobile, string $verificationMethod)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->doctor = $doctor;
|
||||
$this->user = $user;
|
||||
$this->nationalCodeHash = hash('sha256', $nationalCode);
|
||||
$this->mobileMasked = self::maskMobile($mobile);
|
||||
$this->verificationMethod = $verificationMethod;
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public static function maskMobile(string $mobile): string
|
||||
{
|
||||
return strlen($mobile) >= 7
|
||||
? substr($mobile, 0, 4) . '***' . substr($mobile, -4)
|
||||
: '***';
|
||||
}
|
||||
|
||||
public function markCompleted(): void
|
||||
{
|
||||
$this->status = self::STATUS_COMPLETED;
|
||||
$this->completedAt = time();
|
||||
}
|
||||
|
||||
public function markFailed(string $reason): void
|
||||
{
|
||||
$this->status = self::STATUS_FAILED;
|
||||
$this->failureReason = mb_substr($reason, 0, 100);
|
||||
$this->completedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getUser(): ?User { return $this->user; }
|
||||
public function getStatus(): string { return $this->status; }
|
||||
public function getFailureReason(): ?string { return $this->failureReason; }
|
||||
public function getVerificationMethod(): string { return $this->verificationMethod; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getCompletedAt(): ?int { return $this->completedAt; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'status' => $this->status,
|
||||
'doctor' => ['uuid' => $this->doctor->getUuid(), 'name' => $this->doctor->getName()],
|
||||
'mobile_masked' => $this->mobileMasked,
|
||||
'verification_method' => $this->verificationMethod,
|
||||
'failure_reason' => $this->failureReason,
|
||||
'created_at' => $this->createdAt,
|
||||
'completed_at' => $this->completedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctor\Repository;
|
||||
|
||||
use App\Doctor\Entity\DoctorClaimRequest;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class DoctorClaimRequestRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, DoctorClaimRequest::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctor\Service;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorClaimRequest;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Service\ApiIrService;
|
||||
use App\Shared\Util\PersianText;
|
||||
use App\Sms\Entity\SmsLog;
|
||||
use App\Sms\Service\SmsService;
|
||||
use Doctrine\DBAL\LockMode;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* تصاحب پروفایل پزشک ایمپورتشده (unclaimed) توسط پزشک واقعی.
|
||||
*
|
||||
* دو مسیر: self-claim (احراز هویت API.ir: شاهکار + PersonInfo + تطبیق نام) و
|
||||
* انتقال دستی ادمین. نهاییسازی هر دو مسیر یکی است: اتصال کاربر واقعی، نقش
|
||||
* ROLE_DOCTOR، حذف امن کاربر جانشین، و ثبت رکورد ممیزی DoctorClaimRequest.
|
||||
*
|
||||
* ضد race: تغییر وضعیت unclaimed→pending_transfer زیر قفل PESSIMISTIC_WRITE
|
||||
* انجام میشود؛ درخواست همزمان دوم 409 میگیرد. فراخوانی خارجی هرگز داخل قفل نیست.
|
||||
*/
|
||||
class DoctorClaimService
|
||||
{
|
||||
public const METHOD_APIIR = 'apiir_personinfo';
|
||||
public const METHOD_APIIR_SHAHKAR = 'apiir_personinfo+shahkar';
|
||||
public const METHOD_ADMIN = 'admin_manual';
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly ApiIrService $apiIr,
|
||||
private readonly SmsService $smsService,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function claim(Doctor $doctor, User $user, string $nationalCode, string $birthDateJalali, string $firstName, string $lastName): DoctorClaimRequest
|
||||
{
|
||||
$method = $this->apiIr->isConfigured() ? self::METHOD_APIIR_SHAHKAR : self::METHOD_APIIR;
|
||||
|
||||
// مرحلهٔ ۱ — رزرو اتمیک پروفایل زیر قفل (تراکنش کوتاه، بدون فراخوان خارجی)
|
||||
$claim = $this->em->wrapInTransaction(function () use ($doctor, $user, $nationalCode, $method): DoctorClaimRequest {
|
||||
$locked = $this->em->find(Doctor::class, $doctor->getId(), LockMode::PESSIMISTIC_WRITE);
|
||||
|
||||
if ($locked->getOwnerStatus() !== 'unclaimed') {
|
||||
throw new AppException(ErrorCodes::ERR_CONFLICT_001, 'این پروفایل قابل تصاحب نیست یا درخواست دیگری در جریان است', 409);
|
||||
}
|
||||
$existing = $this->em->getRepository(Doctor::class)->findOneBy(['user' => $user]);
|
||||
if ($existing !== null) {
|
||||
throw new AppException(ErrorCodes::ERR_CONFLICT_001, 'شما از قبل یک پروفایل پزشک دارید', 409);
|
||||
}
|
||||
$codeOwner = $this->em->getRepository(User::class)->findOneBy(['nationalCode' => $nationalCode]);
|
||||
if ($codeOwner !== null && $codeOwner->getId() !== $user->getId()) {
|
||||
throw new AppException(ErrorCodes::ERR_PROFILE_NATIONAL_CODE_TAKEN, null, 409);
|
||||
}
|
||||
|
||||
$locked->setOwnerStatus('pending_transfer');
|
||||
$claim = new DoctorClaimRequest($locked, $user, $nationalCode, $user->getMobileNumber(), $method);
|
||||
$this->em->persist($claim);
|
||||
|
||||
return $claim;
|
||||
});
|
||||
|
||||
// مرحلهٔ ۲ — احراز هویت (خارج از قفل)
|
||||
try {
|
||||
$this->verifyIdentity($doctor, $user, $nationalCode, $birthDateJalali, $firstName, $lastName);
|
||||
} catch (AppException $e) {
|
||||
$this->revert($doctor, $claim, $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// مرحلهٔ ۳ — نهاییسازی اتمیک
|
||||
$this->finalize($doctor, $user, $claim, $nationalCode);
|
||||
|
||||
// مرحلهٔ ۴ — پیامک خوشآمد (غیر بحرانی؛ شکستش claim را باطل نمیکند)
|
||||
$this->smsService->dispatchTemplate(SmsLog::TAG_WELCOME, $user->getMobileNumber(), [
|
||||
'name' => PersianText::stripDoctorTitle($doctor->getName()),
|
||||
'site' => 'نوبت۷۲۴',
|
||||
]);
|
||||
|
||||
return $claim;
|
||||
}
|
||||
|
||||
/** انتقال دستی توسط ادمین (پشتیبانی) — بدون استعلام هویت؛ کاربر هدف با موبایل پیدا/ساخته میشود. */
|
||||
public function transferByAdmin(Doctor $doctor, string $mobile): DoctorClaimRequest
|
||||
{
|
||||
$claim = $this->em->wrapInTransaction(function () use ($doctor, $mobile): DoctorClaimRequest {
|
||||
$locked = $this->em->find(Doctor::class, $doctor->getId(), LockMode::PESSIMISTIC_WRITE);
|
||||
|
||||
if ($locked->getOwnerStatus() === 'claimed') {
|
||||
throw new AppException(ErrorCodes::ERR_CONFLICT_001, 'این پروفایل قبلاً تصاحب شده است', 409);
|
||||
}
|
||||
|
||||
$userRepo = $this->em->getRepository(User::class);
|
||||
$target = $userRepo->findOneBy(['mobileNumber' => $mobile]);
|
||||
if ($target === null) {
|
||||
$target = new User($mobile);
|
||||
$target->setRealName(PersianText::stripDoctorTitle($locked->getName()));
|
||||
$target->setStatus(1);
|
||||
$this->em->persist($target);
|
||||
}
|
||||
|
||||
$existing = $this->em->getRepository(Doctor::class)->findOneBy(['user' => $target]);
|
||||
if ($existing !== null && $existing->getId() !== $locked->getId()) {
|
||||
throw new AppException(ErrorCodes::ERR_CONFLICT_001, 'این کاربر قبلاً پروفایل پزشک دیگری دارد', 409);
|
||||
}
|
||||
|
||||
$locked->setOwnerStatus('pending_transfer');
|
||||
$claim = new DoctorClaimRequest($locked, $target, '', $mobile, self::METHOD_ADMIN);
|
||||
$this->em->persist($claim);
|
||||
|
||||
return $claim;
|
||||
});
|
||||
|
||||
$this->finalize($doctor, $claim->getUser(), $claim, null);
|
||||
|
||||
return $claim;
|
||||
}
|
||||
|
||||
private function verifyIdentity(Doctor $doctor, User $user, string $nationalCode, string $birthDateJalali, string $firstName, string $lastName): void
|
||||
{
|
||||
// تطبیق موبایل ↔ کد ملی (شاهکار). بدون پیکربندی api.ir استعلام ممکن نیست →
|
||||
// موبایلِ OTP-تأییدشدهٔ کاربر لاگینشده مبنا میماند و فقط PersonInfo چک میشود.
|
||||
if ($this->apiIr->isConfigured() && !$this->apiIr->shahkarMatch($nationalCode, $user->getMobileNumber())) {
|
||||
throw new AppException(ErrorCodes::ERR_NATIONAL_CODE_MISMATCH, null, 422);
|
||||
}
|
||||
|
||||
$person = $this->apiIr->personInfo($nationalCode, $birthDateJalali);
|
||||
if ($person === null || !$person['alive']) {
|
||||
throw new AppException(ErrorCodes::ERR_NATIONAL_CODE_MISMATCH, 'اطلاعات هویتی با سامانهٔ ثبت احوال مطابقت ندارد', 422);
|
||||
}
|
||||
|
||||
// ورودی کاربر ↔ هویت تأییدشده
|
||||
if (!PersianText::sameName($firstName . ' ' . $lastName, $person['firstName'] . ' ' . $person['lastName'])) {
|
||||
throw new AppException(ErrorCodes::ERR_NATIONAL_CODE_MISMATCH, 'نام واردشده با اطلاعات هویتی مطابقت ندارد', 422);
|
||||
}
|
||||
|
||||
// هویت تأییدشده ↔ نام پروفایل ایمپورتشده از نظام پزشکی
|
||||
$profileName = PersianText::stripDoctorTitle($doctor->getName());
|
||||
$verifiedName = PersianText::normalize($person['firstName'] . ' ' . $person['lastName']);
|
||||
if ($profileName !== $verifiedName) {
|
||||
throw new AppException(ErrorCodes::ERR_NATIONAL_CODE_MISMATCH, 'نام شما با نام این پروفایل پزشک مطابقت ندارد', 422);
|
||||
}
|
||||
}
|
||||
|
||||
private function finalize(Doctor $doctor, User $target, DoctorClaimRequest $claim, ?string $nationalCode): void
|
||||
{
|
||||
$surrogate = $this->em->wrapInTransaction(function () use ($doctor, $target, $claim, $nationalCode): ?User {
|
||||
$locked = $this->em->find(Doctor::class, $doctor->getId(), LockMode::PESSIMISTIC_WRITE);
|
||||
|
||||
if ($locked->getOwnerStatus() !== 'pending_transfer') {
|
||||
throw new AppException(ErrorCodes::ERR_CONFLICT_001, 'وضعیت پروفایل در این میان تغییر کرده است', 409);
|
||||
}
|
||||
|
||||
$surrogate = $locked->getUser();
|
||||
|
||||
if ($nationalCode !== null && $nationalCode !== '') {
|
||||
$target->setNationalCode($nationalCode);
|
||||
$target->setNationalCodeVerified(true);
|
||||
}
|
||||
$target->addRole('ROLE_DOCTOR');
|
||||
$locked->transferOwnershipTo($target);
|
||||
$claim->markCompleted();
|
||||
|
||||
return $surrogate;
|
||||
});
|
||||
|
||||
// حذف امن جانشین — پس از flush انتقال، تا شمارش پزشکانِ متصل قطعی باشد
|
||||
if ($surrogate !== null
|
||||
&& $surrogate->getId() !== $target->getId()
|
||||
&& $surrogate->hasRole(DoctorImportService::ROLE_UNCLAIMED_DOCTOR)
|
||||
&& $this->em->getRepository(Doctor::class)->count(['user' => $surrogate]) === 0) {
|
||||
$this->em->remove($surrogate);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
$this->logger->info('doctor profile claimed', [
|
||||
'claim_uuid' => $claim->getUuid(),
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'user_id' => $target->getId(),
|
||||
'method' => $claim->getVerificationMethod(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function revert(Doctor $doctor, DoctorClaimRequest $claim, string $reason): void
|
||||
{
|
||||
$this->em->wrapInTransaction(function () use ($doctor, $claim, $reason): void {
|
||||
$locked = $this->em->find(Doctor::class, $doctor->getId(), LockMode::PESSIMISTIC_WRITE);
|
||||
if ($locked->getOwnerStatus() === 'pending_transfer') {
|
||||
$locked->setOwnerStatus('unclaimed');
|
||||
}
|
||||
$claim->markFailed($reason);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctor\Service;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
|
||||
final class DoctorImportResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Doctor $doctor,
|
||||
public readonly bool $created,
|
||||
public readonly ?string $skipped = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctor\Service;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Location\Entity\City;
|
||||
use App\Location\Entity\Province;
|
||||
use App\Specialty\Entity\Specialty;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* ایمپورت پزشک از سازمان نظام پزشکی (IRIMC) — منطق دامنه، جدا از کنترلر.
|
||||
*
|
||||
* برخلاف ساخت عادی پزشک، موبایل لازم نیست: برای هر پزشک یک «کاربر جانشین»
|
||||
* غیرفعال با شناسهٔ مصنوعی ساخته میشود و پروفایل در وضعیت unclaimed ذخیره
|
||||
* میگردد تا بعداً به پزشک واقعی منتقل شود. idempotent بر پایهٔ
|
||||
* (source, medical_system_code): اجرای مجدد، رکورد موجود را بهروزرسانی میکند
|
||||
* و پروفایل claimed هرگز بازنویسی نمیشود (مالک واقعی اولویت دارد).
|
||||
*/
|
||||
class DoctorImportService
|
||||
{
|
||||
/** نقش marker کاربر جانشین — permission نمیدهد؛ مبنای شناسایی و حذف امن پس از claim است. */
|
||||
public const ROLE_UNCLAIMED_DOCTOR = 'ROLE_UNCLAIMED_DOCTOR';
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly ManagerRegistry $registry,
|
||||
) {}
|
||||
|
||||
/** @param array $data بدنهٔ validated (name و medical_system_code غیرخالی). */
|
||||
public function import(array $data, User $importedBy): DoctorImportResult
|
||||
{
|
||||
try {
|
||||
return $this->doImport($data, $importedBy);
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
// برندهٔ همزمانی رکورد را همین الان ساخته (قید uniq_doctors_source_code).
|
||||
// EM پس از این خطا بسته است — reset و اجرای مجدد که اینبار مسیر update را میرود.
|
||||
$this->registry->resetManager();
|
||||
return $this->doImport($data, $importedBy);
|
||||
}
|
||||
}
|
||||
|
||||
private function doImport(array $data, User $importedBy): DoctorImportResult
|
||||
{
|
||||
return $this->em->wrapInTransaction(function () use ($data, $importedBy): DoctorImportResult {
|
||||
$name = trim((string) $data['name']);
|
||||
$code = trim((string) ($data['medical_system_code'] ?? $data['medicalSystemCode']));
|
||||
$source = trim((string) ($data['source'] ?? 'irimc')) ?: 'irimc';
|
||||
|
||||
$doctorRepo = $this->em->getRepository(Doctor::class);
|
||||
$userRepo = $this->em->getRepository(User::class);
|
||||
|
||||
// idempotency: همان پزشکِ منبع → بهروزرسانی، نه ساخت تکراری
|
||||
$doctor = $doctorRepo->findOneBy(['source' => $source, 'medicalSystemCode' => $code]);
|
||||
$created = false;
|
||||
|
||||
// پروفایل تصاحبشده را با ایمپورت مجدد بازنویسی نکن (مالک واقعی اولویت دارد)
|
||||
if ($doctor !== null && $doctor->getOwnerStatus() === 'claimed') {
|
||||
return new DoctorImportResult($doctor, false, 'claimed');
|
||||
}
|
||||
|
||||
if ($doctor === null) {
|
||||
// کاربر جانشینِ یکتا و غیرفعال؛ شناسهٔ مصنوعی قطعی از روی کد نظام پزشکی
|
||||
$synthetic = 'imp_' . substr(md5($source . ':' . $code), 0, 14); // ≤ ۱۸ کاراکتر، ASCII، یکتا
|
||||
$user = $userRepo->findOneBy(['mobileNumber' => $synthetic]);
|
||||
if ($user === null) {
|
||||
$user = new User($synthetic);
|
||||
$user->setRealName($name);
|
||||
$user->setStatus(0); // جانشین: هرگز لاگین نمیکند
|
||||
$user->addRole(self::ROLE_UNCLAIMED_DOCTOR);
|
||||
$this->em->persist($user);
|
||||
}
|
||||
$doctor = new Doctor($user, $name);
|
||||
$doctor->setSource($source);
|
||||
$doctor->setOwnerStatus('unclaimed');
|
||||
$doctor->setActiveDoctorAppointment(false); // تا مالک واقعی برنامهٔ کاری بسازد
|
||||
$created = true;
|
||||
}
|
||||
|
||||
// backfill: جانشینهای ایمپورتشده پیش از افزودن نقش marker، در ایمپورت مجدد نقش میگیرند
|
||||
$surrogate = $doctor->getUser();
|
||||
if (!$created
|
||||
&& str_starts_with($surrogate->getMobileNumber(), 'imp_')
|
||||
&& !$surrogate->hasRole(self::ROLE_UNCLAIMED_DOCTOR)) {
|
||||
$surrogate->addRole(self::ROLE_UNCLAIMED_DOCTOR);
|
||||
}
|
||||
|
||||
// فیلدهای مشترک
|
||||
$doctor->setName($name);
|
||||
$doctor->setMedicalSystemCode($code);
|
||||
$doctor->setManagedBy($importedBy->getId());
|
||||
if (array_key_exists('source_ref', $data) || array_key_exists('profile_url', $data)) {
|
||||
$doctor->setSourceRef($data['source_ref'] ?? $data['profile_url'] ?? null);
|
||||
}
|
||||
if (!empty($data['gender'])) $doctor->setGender($data['gender']);
|
||||
if (!empty($data['degree'])) $doctor->setDegree($data['degree']);
|
||||
if (array_key_exists('info', $data)) $doctor->setInfo($data['info']);
|
||||
|
||||
// روابط بر پایهٔ شناسههای مرجع (تخصص/استان/شهر)
|
||||
$this->syncRefCollection($doctor->getSpecialties(), $data['specialties'] ?? null, Specialty::class);
|
||||
$this->syncRefCollection($doctor->getProvinces(), $data['states'] ?? null, Province::class);
|
||||
$this->syncRefCollection($doctor->getCities(), $data['cities'] ?? null, City::class);
|
||||
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return new DoctorImportResult($doctor, $created);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* یک مجموعهٔ ManyToMany پزشک را با آرایهای از شناسههای مرجع همگام میکند.
|
||||
* اگر $ids null باشد دست نمیخورد؛ اگر آرایه باشد، پاک و از نو پر میشود.
|
||||
*/
|
||||
private function syncRefCollection(\Doctrine\Common\Collections\Collection $col, ?array $ids, string $class): void
|
||||
{
|
||||
if ($ids === null) {
|
||||
return;
|
||||
}
|
||||
$col->clear();
|
||||
foreach ($ids as $id) {
|
||||
$ref = $this->em->getRepository($class)->find((int) $id);
|
||||
if ($ref !== null && !$col->contains($ref)) {
|
||||
$col->add($ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,31 @@ class ApiIrService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* استعلام هویت شخص از روی کد ملی و تاریخ تولد (PersonInfo).
|
||||
*
|
||||
* @param string $birthDateJalali تاریخ تولد شمسی به فرمت Y/m/d (مثلاً 1371/1/1)
|
||||
* @return array{firstName: string, lastName: string, alive: bool}|null null یعنی رکوردی مطابقت نکرد.
|
||||
*/
|
||||
public function personInfo(string $nationalCode, string $birthDateJalali): ?array
|
||||
{
|
||||
$data = $this->post('/api/sw1/PersonInfo', [
|
||||
'nationalCode' => $nationalCode,
|
||||
'birthDate' => $birthDateJalali,
|
||||
]);
|
||||
|
||||
$person = $data['data'] ?? null;
|
||||
if (!is_array($person) || ($person['nationalCode'] ?? '') === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'firstName' => (string) ($person['firstName'] ?? ''),
|
||||
'lastName' => (string) ($person['lastName'] ?? ''),
|
||||
'alive' => filter_var($person['alive'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array<string,mixed>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Util;
|
||||
|
||||
/**
|
||||
* نرمالسازی متن فارسی برای مقایسهٔ نامها.
|
||||
*
|
||||
* تفاوتهای رایج را یکدست میکند: ي/ی عربی-فارسی، ك/ک، ارقام عربی/فارسی،
|
||||
* نیمفاصله و فاصلههای تکراری، فاصلهٔ ابتدا/انتها و Unicode normalization —
|
||||
* تا مقایسهٔ نام هرگز با compare خام رشته انجام نشود.
|
||||
*/
|
||||
final class PersianText
|
||||
{
|
||||
public static function normalize(string $text): string
|
||||
{
|
||||
if (class_exists(\Normalizer::class)) {
|
||||
$text = \Normalizer::normalize($text, \Normalizer::FORM_KC) ?: $text;
|
||||
}
|
||||
|
||||
$text = strtr($text, [
|
||||
"\u{064A}" => 'ی', // ي عربی
|
||||
"\u{0649}" => 'ی', // ى الف مقصوره
|
||||
"\u{0643}" => 'ک', // ك عربی
|
||||
"\u{200C}" => ' ', // نیمفاصله → فاصله
|
||||
"\u{200B}" => '', // zero-width space
|
||||
"\u{FEFF}" => '', // BOM
|
||||
"\u{0640}" => '', // کشیده ـ
|
||||
]);
|
||||
|
||||
// ارقام فارسی/عربی → لاتین
|
||||
$text = strtr($text, array_combine(
|
||||
['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹','٠','١','٢','٣','٤','٥','٦','٧','٨','٩'],
|
||||
['0','1','2','3','4','5','6','7','8','9','0','1','2','3','4','5','6','7','8','9'],
|
||||
));
|
||||
|
||||
return trim(preg_replace('/\s+/u', ' ', $text) ?? $text);
|
||||
}
|
||||
|
||||
/** مقایسهٔ دو نام فارسی پس از نرمالسازی. */
|
||||
public static function sameName(string $a, string $b): bool
|
||||
{
|
||||
return self::normalize($a) === self::normalize($b);
|
||||
}
|
||||
|
||||
/** حذف عنوان «دکتر» از ابتدای نام (برای مقایسهٔ نام پروفایل با نام ثبت احوال). */
|
||||
public static function stripDoctorTitle(string $name): string
|
||||
{
|
||||
return trim(preg_replace('/^\s*دکتر\s+/u', '', self::normalize($name)) ?? $name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Doctor;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorClaimRequest;
|
||||
use App\Shared\Service\ApiIrService;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* جریان تصاحب پروفایل پزشک ایمپورتشده. ApiIrService همیشه mock میشود —
|
||||
* تستها هرگز به سرویس واقعی api.ir درخواست نمیزنند.
|
||||
*/
|
||||
class DoctorClaimTest extends ApiTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->client->disableReboot();
|
||||
}
|
||||
|
||||
private function mockApiIr(bool $shahkar = true, ?array $person = ['firstName' => 'تست', 'lastName' => 'ایمپورت', 'alive' => true]): void
|
||||
{
|
||||
$mock = $this->createMock(ApiIrService::class);
|
||||
$mock->method('isConfigured')->willReturn(true);
|
||||
$mock->method('shahkarMatch')->willReturn($shahkar);
|
||||
$mock->method('personInfo')->willReturn($person);
|
||||
static::getContainer()->set(ApiIrService::class, $mock);
|
||||
}
|
||||
|
||||
private function importUnclaimedDoctor(): string
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
$code = 'C' . random_int(100_000, 999_999) . random_int(100, 999);
|
||||
$data = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, [
|
||||
'name' => 'دکتر تست ایمپورت',
|
||||
'medical_system_code' => $code,
|
||||
]);
|
||||
|
||||
return $data['data']['uuid'];
|
||||
}
|
||||
|
||||
private function claimBody(): array
|
||||
{
|
||||
// db_test هرگز reset نمیشود و users.national_code یکتاست → کد ملی هر تست تصادفی
|
||||
return [
|
||||
'national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT),
|
||||
'birth_date' => '1371/1/1',
|
||||
'first_name' => 'تست',
|
||||
'last_name' => 'ایمپورت',
|
||||
];
|
||||
}
|
||||
|
||||
public function testSuccessfulClaimTransfersOwnershipAndDeletesSurrogate(): void
|
||||
{
|
||||
$uuid = $this->importUnclaimedDoctor();
|
||||
$this->mockApiIr();
|
||||
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
|
||||
$surrogateId = $doctor->getUser()->getId();
|
||||
$claimer = $this->createUser(['ROLE_USER']);
|
||||
|
||||
$res = $this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, $this->claimBody());
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertSame('claimed', $res['data']['status']);
|
||||
|
||||
$this->em->clear();
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
|
||||
$this->assertSame('claimed', $doctor->getOwnerStatus());
|
||||
$this->assertSame($claimer->getId(), $doctor->getUser()->getId());
|
||||
$this->assertNull($doctor->getManagedBy());
|
||||
|
||||
$claimer = $this->em->getRepository(User::class)->find($claimer->getId());
|
||||
$this->assertTrue($claimer->hasRole('ROLE_DOCTOR'));
|
||||
$this->assertTrue($claimer->isNationalCodeVerified());
|
||||
|
||||
$this->assertNull($this->em->getRepository(User::class)->find($surrogateId), 'surrogate must be deleted');
|
||||
|
||||
$claim = $this->em->getRepository(DoctorClaimRequest::class)->findOneBy(['doctor' => $doctor]);
|
||||
$this->assertSame(DoctorClaimRequest::STATUS_COMPLETED, $claim->getStatus());
|
||||
}
|
||||
|
||||
public function testNameMismatchRevertsToUnclaimed(): void
|
||||
{
|
||||
$uuid = $this->importUnclaimedDoctor();
|
||||
$this->mockApiIr(person: ['firstName' => 'کس', 'lastName' => 'دیگری', 'alive' => true]);
|
||||
|
||||
$claimer = $this->createUser(['ROLE_USER']);
|
||||
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, $this->claimBody());
|
||||
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
|
||||
$this->assertSame('unclaimed', $doctor->getOwnerStatus(), 'must be free for the real doctor to retry');
|
||||
$this->assertStringStartsWith('imp_', $doctor->getUser()->getMobileNumber(), 'surrogate must survive');
|
||||
|
||||
$claim = $this->em->getRepository(DoctorClaimRequest::class)->findOneBy(['doctor' => $doctor]);
|
||||
$this->assertSame(DoctorClaimRequest::STATUS_FAILED, $claim->getStatus());
|
||||
$this->assertNotNull($claim->getFailureReason());
|
||||
}
|
||||
|
||||
public function testDeceasedPersonIsRejected(): void
|
||||
{
|
||||
$uuid = $this->importUnclaimedDoctor();
|
||||
$this->mockApiIr(person: ['firstName' => 'تست', 'lastName' => 'ایمپورت', 'alive' => false]);
|
||||
|
||||
$claimer = $this->createUser(['ROLE_USER']);
|
||||
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, $this->claimBody());
|
||||
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testShahkarMismatchIsRejected(): void
|
||||
{
|
||||
$uuid = $this->importUnclaimedDoctor();
|
||||
$this->mockApiIr(shahkar: false);
|
||||
|
||||
$claimer = $this->createUser(['ROLE_USER']);
|
||||
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, $this->claimBody());
|
||||
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testAlreadyClaimedIsConflict(): void
|
||||
{
|
||||
$uuid = $this->importUnclaimedDoctor();
|
||||
$this->mockApiIr();
|
||||
|
||||
$first = $this->createUser(['ROLE_USER']);
|
||||
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $first, $this->claimBody());
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
|
||||
$second = $this->createUser(['ROLE_USER']);
|
||||
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $second, $this->claimBody());
|
||||
$this->assertSame(409, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testUserWhoAlreadyOwnsADoctorCannotClaim(): void
|
||||
{
|
||||
$uuidA = $this->importUnclaimedDoctor();
|
||||
$uuidB = $this->importUnclaimedDoctor();
|
||||
$this->mockApiIr();
|
||||
|
||||
$claimer = $this->createUser(['ROLE_USER']);
|
||||
$this->authJson('POST', "/api/v1/doctor/{$uuidA}/claim", $claimer, $this->claimBody());
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
|
||||
$this->authJson('POST', "/api/v1/doctor/{$uuidB}/claim", $claimer, $this->claimBody());
|
||||
$this->assertSame(409, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
$doctorB = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuidB]);
|
||||
$this->assertSame('unclaimed', $doctorB->getOwnerStatus());
|
||||
}
|
||||
|
||||
public function testValidationErrors(): void
|
||||
{
|
||||
$uuid = $this->importUnclaimedDoctor();
|
||||
$this->mockApiIr();
|
||||
$claimer = $this->createUser(['ROLE_USER']);
|
||||
|
||||
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, ['national_code' => '123', 'birth_date' => '1371/1/1', 'first_name' => 'الف', 'last_name' => 'ب']);
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
|
||||
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, ['national_code' => '0010007700', 'birth_date' => 'invalid', 'first_name' => 'الف', 'last_name' => 'ب']);
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testClaimInfoIsPublic(): void
|
||||
{
|
||||
$uuid = $this->importUnclaimedDoctor();
|
||||
|
||||
$this->client->request('GET', "/api/v1/doctor/{$uuid}/claim-info");
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$data = json_decode($this->client->getResponse()->getContent(), true);
|
||||
$this->assertTrue($data['data']['claimable']);
|
||||
$this->assertSame('unclaimed', $data['data']['owner_status']);
|
||||
}
|
||||
|
||||
public function testAdminTransferHappyPath(): void
|
||||
{
|
||||
$uuid = $this->importUnclaimedDoctor();
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
$mobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||
|
||||
$res = $this->authJson('POST', "/api/v1/admin/doctors/{$uuid}/transfer", $admin, ['mobile' => $mobile]);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertSame('claimed', $res['data']['owner_status']);
|
||||
|
||||
$this->em->clear();
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
|
||||
$this->assertSame('claimed', $doctor->getOwnerStatus());
|
||||
$this->assertSame($mobile, $doctor->getUser()->getMobileNumber());
|
||||
$this->assertTrue($doctor->getUser()->hasRole('ROLE_DOCTOR'));
|
||||
|
||||
// transfer دوباره → 409
|
||||
$this->authJson('POST', "/api/v1/admin/doctors/{$uuid}/transfer", $admin, ['mobile' => $mobile]);
|
||||
$this->assertSame(409, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testAdminTransferRequiresAdmin(): void
|
||||
{
|
||||
$uuid = $this->importUnclaimedDoctor();
|
||||
$user = $this->createUser(['ROLE_USER']);
|
||||
$this->authJson('POST', "/api/v1/admin/doctors/{$uuid}/transfer", $user, ['mobile' => '09121234567']);
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testClaimRequiresAuthentication(): void
|
||||
{
|
||||
$uuid = $this->importUnclaimedDoctor();
|
||||
$this->client->request('POST', "/api/v1/doctor/{$uuid}/claim", server: ['CONTENT_TYPE' => 'application/json'], content: json_encode($this->claimBody()));
|
||||
$this->assertSame(401, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Doctor;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Regression contract for POST /api/v1/admin/doctors/import (IRIMC import).
|
||||
* Written BEFORE extracting the logic into DoctorImportService — the HTTP
|
||||
* contract (routes, statuses, {uuid, created, skipped} payload) must not change.
|
||||
*/
|
||||
class DoctorImportTest extends ApiTestCase
|
||||
{
|
||||
private function importPayload(string $code): array
|
||||
{
|
||||
return [
|
||||
'name' => 'دکتر تست ایمپورت',
|
||||
'medical_system_code' => $code,
|
||||
'source_ref' => 'https://membersearch.irimc.org/member/profile?id=test',
|
||||
'gender' => 'man',
|
||||
'degree' => 'general',
|
||||
'info' => 'دکترای حرفهای پزشکی',
|
||||
];
|
||||
}
|
||||
|
||||
/** db_test is never reset — randomise the natural key per run. */
|
||||
private function freshCode(): string
|
||||
{
|
||||
return 'T' . random_int(100_000, 999_999) . random_int(100, 999);
|
||||
}
|
||||
|
||||
public function testImportCreatesUnclaimedDoctorWithSurrogateUser(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
$code = $this->freshCode();
|
||||
|
||||
$data = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $this->importPayload($code));
|
||||
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
$this->assertTrue($data['success']);
|
||||
$this->assertTrue($data['data']['created']);
|
||||
$this->assertNotEmpty($data['data']['uuid']);
|
||||
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $data['data']['uuid']]);
|
||||
$this->assertSame('unclaimed', $doctor->getOwnerStatus());
|
||||
$this->assertSame('irimc', $doctor->getSource());
|
||||
$this->assertFalse($doctor->isActiveDoctorAppointment());
|
||||
|
||||
$surrogate = $doctor->getUser();
|
||||
$this->assertStringStartsWith('imp_', $surrogate->getMobileNumber());
|
||||
$this->assertSame(0, $surrogate->getStatus());
|
||||
$this->assertTrue($surrogate->hasRole('ROLE_UNCLAIMED_DOCTOR'));
|
||||
}
|
||||
|
||||
public function testReimportUpdatesInsteadOfDuplicating(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
$code = $this->freshCode();
|
||||
|
||||
$first = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $this->importPayload($code));
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
|
||||
$payload = $this->importPayload($code);
|
||||
$payload['name'] = 'دکتر تست ویرایششده';
|
||||
$second = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $payload);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertFalse($second['data']['created']);
|
||||
$this->assertSame($first['data']['uuid'], $second['data']['uuid']);
|
||||
|
||||
$count = $this->em->getRepository(Doctor::class)->count(['source' => 'irimc', 'medicalSystemCode' => $code]);
|
||||
$this->assertSame(1, $count);
|
||||
|
||||
$this->em->clear();
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $first['data']['uuid']]);
|
||||
$this->assertSame('دکتر تست ویرایششده', $doctor->getName());
|
||||
}
|
||||
|
||||
public function testClaimedDoctorIsNeverOverwritten(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
$code = $this->freshCode();
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $this->importPayload($code));
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
|
||||
$owner = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor->transferOwnershipTo($owner);
|
||||
$this->em->flush();
|
||||
$originalName = $doctor->getName();
|
||||
|
||||
$payload = $this->importPayload($code);
|
||||
$payload['name'] = 'دکتر بازنویسی ممنوع';
|
||||
$reimport = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $payload);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertSame('claimed', $reimport['data']['skipped']);
|
||||
|
||||
$this->em->clear();
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
|
||||
$this->assertSame($originalName, $doctor->getName());
|
||||
$this->assertSame('claimed', $doctor->getOwnerStatus());
|
||||
}
|
||||
|
||||
public function testValidationErrors(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
|
||||
$this->authJson('POST', '/api/v1/admin/doctors/import', $admin, ['medical_system_code' => $this->freshCode()]);
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
|
||||
$this->authJson('POST', '/api/v1/admin/doctors/import', $admin, ['name' => 'دکتر بیکد']);
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testNonAdminIsRejected(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER']);
|
||||
$this->authJson('POST', '/api/v1/admin/doctors/import', $user, $this->importPayload($this->freshCode()));
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testImporterRoleCanImportButNothingElse(): void
|
||||
{
|
||||
$importer = $this->createUser(['ROLE_USER', 'ROLE_IMPORTER']);
|
||||
|
||||
$this->authJson('POST', '/api/v1/admin/doctors/import', $importer, $this->importPayload($this->freshCode()));
|
||||
$this->assertSame(201, $this->responseCode(), 'ROLE_IMPORTER must be able to import');
|
||||
|
||||
$this->authJson('GET', '/api/v1/admin/users', $importer);
|
||||
$this->assertSame(403, $this->responseCode(), 'ROLE_IMPORTER must NOT reach other admin endpoints');
|
||||
|
||||
$this->authJson('GET', '/api/v1/admin/doctor-claims', $importer);
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Shared;
|
||||
|
||||
use App\Shared\Util\PersianText;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class PersianTextTest extends TestCase
|
||||
{
|
||||
public function testArabicYehAndKafAreUnified(): void
|
||||
{
|
||||
$this->assertTrue(PersianText::sameName("علي اكبري", 'علی اکبری'));
|
||||
}
|
||||
|
||||
public function testHalfSpaceAndExtraWhitespace(): void
|
||||
{
|
||||
$this->assertTrue(PersianText::sameName("محمد\u{200C}رضا کریمی ", 'محمد رضا کریمی'));
|
||||
}
|
||||
|
||||
public function testPersianAndArabicDigits(): void
|
||||
{
|
||||
$this->assertSame('1371/1/1', PersianText::normalize('۱۳۷۱/۱/۱'));
|
||||
$this->assertSame('0912', PersianText::normalize('٠٩١٢'));
|
||||
}
|
||||
|
||||
public function testStripDoctorTitle(): void
|
||||
{
|
||||
$this->assertSame('فرخنده حسینی', PersianText::stripDoctorTitle('دکتر فرخنده حسینی'));
|
||||
$this->assertSame('فرخنده حسینی', PersianText::stripDoctorTitle(' دکتر فرخنده حسینی '));
|
||||
$this->assertSame('فرخنده حسینی', PersianText::stripDoctorTitle('فرخنده حسینی'));
|
||||
}
|
||||
|
||||
public function testDifferentNamesStayDifferent(): void
|
||||
{
|
||||
$this->assertFalse(PersianText::sameName('علی اکبری', 'ولی اکبری'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user