Files
clinicpro/.claude/prompt/irimc-import-complete.md
T
hamedandClaude Opus 4.8 af125572c9 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>
2026-07-11 11:39:15 +03:30

36 KiB
Raw Blame History

فیچر کامل ایمپورت پزشکان نظام پزشکی (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
ApiIrServiceshahkarMatch(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 (پس از استخراج):

$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 جدید:

-- پیش‌شرط (در همان 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 (خط ۴۹):
    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-پایدار). جداول:

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::isConfiguredPANEL_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 موجود است.