# فیچر کامل ایمپورت پزشکان نظام پزشکی (IRIMC): ایمپورت، تصاحب پروفایل، کرالر State-Based

> نسخهٔ بازنویسی‌شده — production-grade. جایگزین نسخهٔ قبلی این فایل.
> مبنا: بررسی کامل `docs/scenarios/` (هر ۴ سند) + کد واقعی. هر ادعای این پرامپت با `file:line` تأیید شده است.

## پروژه‌ها و برنچ

**قانون برنچ (الزامی):** هیچ تغییری روی `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` |

> `clinic-pro-tauri` **کاملاً خارج از scope این فیچر است** — هیچ تغییری در آن نده و آن را در نظر نگیر.

ترتیب اجرا (قانون workspace): backend اول → مستندات API → کلاینت‌ها.

---

## ۱. هدف فیچر

پزشکان از سامانهٔ نظام پزشکی (`membersearch.irimc.org`) — که **موبایل ندارند** — به کلینیک‌پرو ایمپورت می‌شوند تا در Nobat724 نمایش داده شوند؛ سپس پزشک واقعی از طریق سایت، با **احراز هویت API.ir + OTP**، پروفایل خود را تصاحب (claim) می‌کند. یک کرالر پایتونی مستقل، با state داخلی SQLite و پنل مدیریت توکن، دادهٔ نظام پزشکی را استان‌به‌استان/شهربه‌شهر می‌خزد و از طریق API رسمی ایمپورت می‌کند.

---

## ۲. تحلیل معماری موجود — حقایق تأییدشده (دوباره کشف نکن، دوباره نساز)

### ۲.۱ آنچه از قبل پیاده شده و کار می‌کند

| قطعه | محل | وضعیت |
|---|---|---|
| ستون‌های مالکیت `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` | ✅ کد جدید فقط اگر معنای موجود نبود |

### ۲.۲ واگرایی‌های سند-با-کد که این پرامپت حل می‌کند (تصمیم‌های معماری مستند)

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` می‌شکند (سند سناریو §۷). راه‌حل هدر سرویسی محدود (وظیفهٔ ۴.۲).

---

## ۳. Workstream A — بک‌اند clinicpro

### ۳.۱ استخراج منطق ایمپورت از کنترلر (thin controller)

`importDoctor` الان ~۱۰۰ خط منطق دامنه داخل کنترلر دارد (ساخت جانشین، idempotency، sync روابط). استخراج به سرویس:

- فایل جدید `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');   // User.php:105
```

- **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)

Migration جدید:

```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 باید جدا چک شود.

DROP INDEX idx_doctors_source ON doctors;
CREATE UNIQUE INDEX uniq_doctors_source_code ON doctors (source, medical_system_code);
```

- MariaDB چند `NULL` را در ایندکس یکتا مجاز می‌داند → پزشکان manual بدون کد می‌مانند، مشکلی نیست. رکوردهای manual با کد تکراری اگر در prod وجود داشتند، migration باید **متوقف شود نه اینکه داده حذف کند** — گزارش بده، پاک‌سازی دستی/جداگانه.
- در `DoctorImportService`، `UniqueConstraintViolationException` را بگیر و به‌عنوان «برندهٔ هم‌زمانی، رکورد موجود را آپدیت کن» retry کن (یک بار) — این کنار قید DB، مسیر concurrent-import را قطعی می‌کند.

### ۳.۴ جریان Claim (تصاحب پروفایل توسط پزشک واقعی) — طبق `climed.md`

**سرویس:** `src/Doctor/Service/DoctorClaimService.php`. **کنترلر:** `src/Doctor/Controller/DoctorClaimController.php` (thin، extends `BaseController`).

**موجودیت audit جدید:** `DoctorClaimRequest` (جدول `doctor_claim_requests` + migration):

```
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)
```

**API (قرارداد کامل):**

```
GET /api/v1/doctor/{uuid}/claim-info                [PUBLIC — در الگوی public_endpoints فعلی `api/v1/doctors` نیست؛ به pattern اضافه شود]
  → 200 { success, data: { claimable: bool, owner_status } }
  فقط برای رندر دکمهٔ «آیا شما این پزشک هستید؟». هیچ دادهٔ هویتی برنمی‌گرداند.

POST /api/v1/doctor/{uuid}/claim                    [IS_AUTHENTICATED_FULLY — کاربر با OTP لاگین شده]
  body: { national_code, birth_date_jalali, first_name, last_name }
  RateLimiter جدید 'doctor_claim': sliding_window, limit 5 / 1h — کلید: user_id + doctor uuid؛ و یک limiter ثانویه روی IP.
  خطاها (همه از ErrorCodes موجود؛ فرمت BaseController):
    401  بدون لاگین
    404  ERR_NOT_FOUND_001    پزشک یافت نشد
    409  ERR_CONFLICT_001     پروفایل claimable نیست (claimed یا pending_transfer فعالِ دیگری)
    409  ERR_CONFLICT_001     کاربر از قبل پزشک دیگری دارد
    422  ERR_IDENTITY_001     عدم تطبیق هویت (پیام عمومی — نگو دقیقاً کدام فیلد؛ ضد enumeration)
    422  ERR_VALIDATION_001   ورودی نامعتبر (کد ملی/تاریخ)
    429  ERR_RATE_LIMIT_001
    502/503 ERR_EXTERNAL_001  API.ir خطا/تایم‌اوت — پیام: «خطا در استعلام. لطفاً بعداً تلاش کنید»
  → 200 { success, data: { status: 'claimed', doctor: { uuid }, message } }
```

**الگوریتم `DoctorClaimService::claim()` (ترتیب دقیق):**

1. **قفل و گارد وضعیت** — داخل تراکنش، `SELECT ... FOR UPDATE` روی ردیف پزشک (`$em->find(Doctor::class, $id, LockMode::PESSIMISTIC_WRITE)`)؛ اگر `owner_status !== 'unclaimed'` → 409. این + قید منطقی «کاربر فقط یک پزشک» (`findOneBy(['user' => $target])`) شرط race در climed.md را برآورده می‌کند: یک Doctor هرگز به دو User وصل نمی‌شود.
2. وضعیت → `pending_transfer` + ساخت `DoctorClaimRequest(pending)` + flush + **پایان تراکنش کوتاه** (قفل آزاد؛ فراخوان خارجی داخل قفل ممنوع).
3. **تطبیق موبایل↔کدملی:** `ApiIrService::shahkarMatch($nationalCode, $user->getMobileNumber())` — سرویس و الگوی مصرفش موجود است (Representation:103). climed.md می‌گوید «اگر API.ir تطبیق موبایل دارد از آن استفاده کن» → دارد. اگر `isConfigured() === false` (env نبود)، fallback: موبایل کاربر لاگین‌شده قبلاً با OTP تأیید شده (مسیر ورود موجود) — کافی شمرده می‌شود، در `verification_method` ثبت شود.
4. **PersonInfo:** متد جدید `ApiIrService::personInfo(string $nationalCode, string $birthDateJalali): ?array` — همان الگوی `post()` موجود (`/api/sw1/PersonInfo`)؛ timeout موجود سرویس؛ `alive === false` → توقف با `ERR_IDENTITY_001`.
5. **تطبیق نام:** normalize فارسی سپس compare:
   - `firstName+lastName` برگشتی API.ir ↔ نام پزشک ایمپورت‌شده (`Doctor::name` — پیشوند «دکتر» را strip کن)
   - ورودی کاربر ↔ دادهٔ تأییدشدهٔ API.ir
   - **Normalizer مشترک:** اول `src/Shared/` را برای util موجود بگرد (`User::setNationalCode` normalize ارقام دارد — ببین از کجا)؛ اگر normalizer نام فارسی نبود، `src/Shared/Util/PersianText.php` بساز: ي→ی، ك→ک، حذف نیم‌فاصله/فاصله‌های تکراری، trim، `Normalizer::normalize(..., FORM_KC)`. تست واحد جدا دارد.
6. **نهایی‌سازی (تراکنش دوم، اتمیک):** re-check `owner_status === 'pending_transfer'` و همین claim فعال → `$user->setNationalCode(...)->setNationalCodeVerified(true)` → `$user->addRole('ROLE_DOCTOR')` → `$doctor->transferOwnershipTo($user)` (موجود، Doctor.php:390) → claim `completed` → flush → **حذف امن جانشین در flush دوم**: فقط اگر `hasRole('ROLE_UNCLAIMED_DOCTOR')` && هیچ Doctor دیگری به او وصل نیست && غیر از کاربر هدف (شمارش بعد از flush اول تا UnitOfWork گمراه نکند).
7. شکست در هر مرحلهٔ ۳-۵: claim → `failed` + `failure_reason`، پزشک → **برگشت به `unclaimed`** (تا برای تلاش مجدد/شخص واقعی آزاد بماند). خطای API.ir → `retryable` است؛ خطای تطبیق → permanent، retry بی‌معنا.
8. **اطلاع‌رسانی:** پیامک خوش‌آمد با `SmsService::dispatchTemplate` موجود (async از قبل Messenger است).

**Logging/Audit:** context ساخت‌یافته `{claim_uuid, doctor_uuid, user_id, verification_method}`. **هرگز لاگ نشود:** کد ملی خام، تاریخ تولد، موبایل کامل، توکن API.ir، request/response کامل API.ir (قانون صریح climed.md). فقط hash/mask.

**ALTCHA:** endpoint claim پشت `IS_AUTHENTICATED_FULLY` است (کاربر قبلاً از مسیر OTP+کپچای موجود گذشته) → کپچای مجزا لازم نیست؛ rate limiter کفایت می‌کند. مستند کن.

### ۳.۵ انتقال دستی ادمین (ابزار پشتیبانی)

`POST /api/v1/admin/doctors/{uuid}/transfer` body `{ mobile }` — برای موارد پشتیبانی (پزشک بدون دسترسی به claim آنلاین). همان منطق نهایی‌سازی ۳.۴/۶ را از **همان `DoctorClaimService`** صدا بزن (متد `transferByAdmin`) — منطق را در کنترلر ادمین تکرار نکن. قواعد: 404/409 (claimed)/409 (کاربر پزشک دارد)/422 (موبایل نامعتبر `^09\d{9}$`). کاربر هدف اگر نبود ساخته می‌شود (status=1). `DoctorClaimRequest` با `verification_method='admin_manual'` ثبت شود.

### ۳.۶ فیلتر ادمین + نمایش وضعیت

- `GET /api/v1/admin/doctors` (لیست موجود در AdminApiController): پارامتر `owner_status` + ستون در خروجی (الگوی موجود: DQL/SQL array hydration — `getArrayResult`).
- `GET /api/v1/admin/doctor-claims?status=&page=&limit=` [ROLE_ADMIN]: paginated از `doctor_claim_requests` (join نام پزشک) — برای صفحه‌ی ادمین (§۵).
- خروجی عمومی پزشک (`toListArray`/`toArray`): فیلد `owner_status` اضافه شود تا Nobat724 دکمهٔ claim را رندر کند. **فیلد اضافه کن، هیچ فیلد موجودی را تغییر نده/حذف نکن** (سازگاری قرارداد؛ سایت عمومی از همین می‌خواند).

### ۳.۷ احراز هویت کرالر — کمینه‌سازی دسترسی

وضع فعلی: کاربر سیستمی `0000000000` با `ROLE_ADMIN` لاگین می‌کند و `AdminApiController` کلاً `#[IsGranted('ROLE_ADMIN')]` است (`AdminApiController.php:36`) → کرالر عملاً به کل پنل ادمین دسترسی دارد. **نقض least privilege — باید اصلاح شود:**

1. نقش جدید `ROLE_IMPORTER`. `SystemOwnerCommand` را طوری تغییر بده که کاربر سیستمی `['ROLE_USER','ROLE_IMPORTER']` بگیرد (نه ADMIN)؛ برای کاربر موجود در DBها یک پاس migration دیتایی/اجرای مجدد دستور.
2. اندپوینت import از `AdminApiController` (class-level ADMIN) به کنترلر ایمپورت اختصاصی منتقل شود: `src/Doctor/Controller/DoctorImportController.php` با `#[IsGranted(new Expression("is_granted('ROLE_ADMIN') or is_granted('ROLE_IMPORTER')"))]` — **همان path فعلی `/api/v1/admin/doctors/import` حفظ شود** (قرارداد کرالر/مستند نشکند). در `security.yaml` سلسله‌مراتب نقش دست نخورد.
3. **کپچا (لاگین headless):** در `PasswordAuthenticator::authenticate()` قبل از `assertValid` (خط ۴۹):
   ```php
   if (!$this->isTrustedServiceLogin($request)) {
       $this->captcha->assertValid($request);
   }
   // 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 موجود است.
