- 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>
36 KiB
فیچر کامل ایمپورت پزشکان نظام پزشکی (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 |
✅ کد جدید فقط اگر معنای موجود نبود |
۲.۲ واگراییهای سند-با-کد که این پرامپت حل میکند (تصمیمهای معماری مستند)
- گزینه 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 تمیز نگه داشته میشود. سند سناریو باید پس از پیادهسازی با این تصمیم بهروز شود. ROLE_UNCLAIMED_DOCTORدر مستند هست، در کد نیست.docs/api/doctor-import.mdاین نقش را توصیف میکند ولیimportDoctorآن را نمیدهد (AdminApiController.php:~514فقطnew User + setStatus(0)). کد باید به مستند برسد (وظیفهٔ ۳.۲).- تأیید ادمین در برابر انتقال خودکار. سند قدیمیتر approve دستی ادمین را برای فاز اول الزامی کرده بود؛ سند جدیدتر
docs/scenarios/climed.md(مؤخر و صریح) claim را پس از موفقیت PersonInfo خودکار نهایی میکند. تصمیم: climed.md حاکم است — claim پس از تطبیق هویت خودکار نهایی میشود؛ ادمین بهجای approve، visibility میگیرد (لاگ ادعاها + انتقال دستی برای پشتیبانی، وظیفهٔ ۳.۴ و ۵). - ایندکس
(source, medical_system_code)یکتا نیست.Version20260711120000فقطINDEXساخته؛ dedup فقط application-level است → با دو درخواست همزمان (دو worker کرالر یا retry شبکه) رکورد تکراری ممکن است. باید UNIQUE شود (وظیفهٔ ۳.۳). - کرالر NestJS؟
docs/scenarios/crawler.mdدر انتها NestJS را «پیشنهاد» میکند؛ کرالر موجود Python/Flask بالغ است (rate-limit، mapping، resumable). تصمیم: Python میماند؛ الزامات crawler.md (SQLite state، پنل توکن، ترتیب استان→شهر) روی همین پایه پیاده میشود (وظیفهٔ ۶). - کپچا.
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() (ترتیب دقیق):
- قفل و گارد وضعیت — داخل تراکنش،
SELECT ... FOR UPDATEروی ردیف پزشک ($em->find(Doctor::class, $id, LockMode::PESSIMISTIC_WRITE))؛ اگرowner_status !== 'unclaimed'→ 409. این + قید منطقی «کاربر فقط یک پزشک» (findOneBy(['user' => $target])) شرط race در climed.md را برآورده میکند: یک Doctor هرگز به دو User وصل نمیشود. - وضعیت →
pending_transfer+ ساختDoctorClaimRequest(pending)+ flush + پایان تراکنش کوتاه (قفل آزاد؛ فراخوان خارجی داخل قفل ممنوع). - تطبیق موبایل↔کدملی:
ApiIrService::shahkarMatch($nationalCode, $user->getMobileNumber())— سرویس و الگوی مصرفش موجود است (Representation:103). climed.md میگوید «اگر API.ir تطبیق موبایل دارد از آن استفاده کن» → دارد. اگرisConfigured() === false(env نبود)، fallback: موبایل کاربر لاگینشده قبلاً با OTP تأیید شده (مسیر ورود موجود) — کافی شمرده میشود، درverification_methodثبت شود. - PersonInfo: متد جدید
ApiIrService::personInfo(string $nationalCode, string $birthDateJalali): ?array— همان الگویpost()موجود (/api/sw1/PersonInfo)؛ timeout موجود سرویس؛alive === false→ توقف باERR_IDENTITY_001. - تطبیق نام: normalize فارسی سپس compare:
firstName+lastNameبرگشتی API.ir ↔ نام پزشک ایمپورتشده (Doctor::name— پیشوند «دکتر» را strip کن)- ورودی کاربر ↔ دادهٔ تأییدشدهٔ API.ir
- Normalizer مشترک: اول
src/Shared/را برای util موجود بگرد (User::setNationalCodenormalize ارقام دارد — ببین از کجا)؛ اگر normalizer نام فارسی نبود،src/Shared/Util/PersianText.phpبساز: ي→ی، ك→ک، حذف نیمفاصله/فاصلههای تکراری، trim،Normalizer::normalize(..., FORM_KC). تست واحد جدا دارد.
- نهاییسازی (تراکنش دوم، اتمیک): re-check
owner_status === 'pending_transfer'و همین claim فعال →$user->setNationalCode(...)->setNationalCodeVerified(true)→$user->addRole('ROLE_DOCTOR')→$doctor->transferOwnershipTo($user)(موجود، Doctor.php:390) → claimcompleted→ flush → حذف امن جانشین در flush دوم: فقط اگرhasRole('ROLE_UNCLAIMED_DOCTOR')&& هیچ Doctor دیگری به او وصل نیست && غیر از کاربر هدف (شمارش بعد از flush اول تا UnitOfWork گمراه نکند). - شکست در هر مرحلهٔ ۳-۵: claim →
failed+failure_reason، پزشک → برگشت بهunclaimed(تا برای تلاش مجدد/شخص واقعی آزاد بماند). خطای API.ir →retryableاست؛ خطای تطبیق → permanent، retry بیمعنا. - اطلاعرسانی: پیامک خوشآمد با
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 — باید اصلاح شود:
- نقش جدید
ROLE_IMPORTER.SystemOwnerCommandرا طوری تغییر بده که کاربر سیستمی['ROLE_USER','ROLE_IMPORTER']بگیرد (نه ADMIN)؛ برای کاربر موجود در DBها یک پاس migration دیتایی/اجرای مجدد دستور. - اندپوینت 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سلسلهمراتب نقش دست نخورد. - کپچا (لاگین headless): در
PasswordAuthenticator::authenticate()قبل ازassertValid(خط ۴۹):env از طریق bind در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 لاگین و اعتبارسنجی رمز دستنخورده میمانند.services.yaml(الگوی$appUrl: '%env(APP_BASE_URL)%') تزریق شود، نه$_ENVمستقیم. مقدار خالی = هیچ bypass (secure by default). به.env/.env.exampleاضافه شود. - چرخهٔ توکن: 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).
- صفحهٔ پزشک (
app/doctor/[slug]/page.js): اگرowner_status === 'unclaimed':- برچسب وضعیت روی پروفایل: «این پروفایل هنوز توسط پزشک مدیریت نمیشود»
- دقیقاً زیر بخش نوبتدهی: بلوک «آیا شما این پزشک هستید؟» + دکمهٔ «تأیید و مدیریت این پروفایل»
- نوبتدهی آنلاین غیرفعال میماند (از قبل
active=falseچون برنامهٔ کاری ندارد — رفتار موجود، تغییر نده).
- کامپوننت مشترک
components/doctor/ClaimProfileModal.jsx— یک کامپوننت برای دامنهٔ اصلی + همهٔ subdomainها + دامنههای نماینده (multi-domain از قبل باProvinceProvider/getStateInfoحل است؛ منطق claim به دامنه وابسته نیست، duplicate نکن). - جریان داخل Modal:
- کاربر لاگین نیست → مسیر OTP موجود (send-code/verify-code) داخل همان modal یا redirect به فلوی ورود موجود — از الگوی auth موجود سایت استفاده کن، فرم OTP جدید نساز.
- فرم: موبایل (پیشپرشده از کاربر لاگین)، کد ملی، تاریخ تولد شمسی (date picker موجود پروژه)، نام، نام خانوادگی.
request.post('doctor/{uuid}/claim', body, { requireAuth: true })ازservices/response.js.
- stateهای الزامی UI: loading (دکمه disable + spinner)، خطای validation فیلدبهفیلد، خطای هویت (پیام عمومی)، 409 (قبلاً تصاحبشده)، 429، خطای شبکه با دکمهٔ تلاش مجدد، جلوگیری از double-submit (disable در حین flight)، success.
- پیام موفقیت (متن دقیق climed.md): «دکتر [نام پزشک]، به نوبت ۷۲۴ خوش آمدید 🎉 پروفایل شما با موفقیت تأیید شد و اکنون میتوانید اطلاعات پروفایل و تنظیمات نوبتدهی خود را مدیریت کنید.» سپس هدایت طبق فلوی auth موجود به پنل.
- هیچ درخواست مستقیمی از فرانت به API.ir نمیرود؛ هیچ توکنی به فرانت نمیرسد (همه backend، §۳.۴).
- قواعد کسبوکار در فرانت تکرار نشود — دکمه با
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::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)
- کرالر با پنل خودش به کلینیکپرو لاگین میکند (بدون توکن دستی)، استان→شهر ترتیبی میخزد، پس از kill/restart از همان نقطه ادامه میدهد، و پزشکان در کلینیکپرو
unclaimedظاهر میشوند — کاربر سیستمی فقطROLE_IMPORTERدارد و به هیچ endpoint ادمین دیگری دسترسی ندارد (تست 403). - اجرای دوبارهٔ ایمپورت روی همان دیتاست: صفر رکورد تکراری (قید DB) و پروفایلهای claimed دستنخورده.
- در Nobat724 (دامنهٔ اصلی + یک subdomain نماینده) پروفایل unclaimed برچسب و دکمهٔ claim دارد؛ جریان کامل claim با API.ir mockنشده در staging طی میشود؛ پس از claim: پیام خوشآمد،
ROLE_DOCTOR، جانشین حذف، ویرایش پروفایل توسط پزشک ممکن، نوبتدهی همچنان خاموش تا برنامهٔ کاری تعریف شود. - ادمین در پنل: لیست claimها با علت شکست + انتقال دستی کارا.
- هیچ کد ملی/تاریخ تولد/موبایل کامل/توکنی در هیچ لاگی (app_log و لاگ کرالر) ظاهر نمیشود — با grep روی لاگ staging تأیید شود.
- کل 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 موجود است.