- 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>
343 lines
36 KiB
Markdown
343 lines
36 KiB
Markdown
# فیچر کامل ایمپورت پزشکان نظام پزشکی (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 موجود است.
|