# تکمیل فیچر ایمپورت پزشکان نظام پزشکی (قطعات باقی‌مانده) ## پروژه `clinicpro` (backend) + یک تغییر کوچک در `clinicpro-crawler/clinicpro_client.py` > **برنچ:** تغییرات backend روی برنچ جدید در repo خود clinicpro: `git -C clinicpro checkout -b feature/irimc-doctor-import` > (تغییر کرالر در repo والد است — همان‌جا commit شود.) ## زمینه سند سناریو: [docs/scenarios/ایمپورت-پزشکان-نظام-پزشکی.md](../docs/scenarios/ایمپورت-پزشکان-نظام-پزشکی.md). بخش عمدهٔ فیچر **قبلاً پیاده شده و در کد موجود است** — دوباره نساز: | قطعه | وضعیت | |------|-------| | ستون‌های مالکیت `doctors` (`owner_status`, `source`, `source_ref`, `managed_by`, `claimed_at`) + متد `transferOwnershipTo()` | ✅ `src/Doctor/Entity/Doctor.php:81-98,390` | | Migration | ✅ `migrations/Version20260711120000.php` (اعمال‌شده) | | `POST /api/v1/admin/doctors/import` — idempotent، کاربر جانشین `imp_`، skip روی claimed | ✅ `src/Admin/Controller/AdminApiController.php:483` | | دستور `app:system-owner` | ✅ `src/Auth/Command/SystemOwnerCommand.php` | | مستند | ✅ `docs/api/doctor-import.md` | | کرالر (`clinicpro_client.py`, `pipeline.py`) | ✅ `clinicpro-crawler/` | **چهار قطعه از سند هنوز پیاده نشده** — این پرامپت فقط همان‌هاست: 1. نقش `ROLE_UNCLAIMED_DOCTOR` برای کاربر جانشین (§۳ سند) — الان جانشین فقط `ROLE_USER` می‌گیرد. 2. اندپوینت انتقال مالکیت `POST /api/v1/admin/doctors/{uuid}/transfer` (§۴) — متد entity هست، کنترلر **نیست**. 3. حذف امن کاربر جانشین بعد از انتقال (§۳) — وابسته به ۱ و ۲. 4. رد شدن کپچا برای لاگین سرویسیِ کرالر (§۷) — الان لاگین headless با `ERR_CAPTCHA_001` می‌شکند. ## فایل‌های مرتبط | فایل | نقش | |------|-----| | `src/Admin/Controller/AdminApiController.php` | `importDoctor` خط ۴۸۳ (اصلاح نقش) + اندپوینت transfer جدید | | `src/Doctor/Entity/Doctor.php` | `transferOwnershipTo(User)` خط ۳۹۰ — آماده، فقط صدا بزن | | `src/Auth/Entity/User.php` | `addRole()` خط ۱۰۵، `hasRole()` خط ۱۱۴، `setStatus()` | | `src/Auth/Security/PasswordAuthenticator.php` | خط ۴۹: `$this->captcha->assertValid($request)` — نقطهٔ bypass | | `src/Shared/Captcha/CaptchaGuard.php` | گارد کپچا (برای فهم امضا) | | `docs/api/doctor-import.md` | باید transfer + هدر سرویس مستند شود | | `clinicpro-crawler/clinicpro_client.py` | افزودن هدر سرویس به لاگین | ## وضعیت فعلی ساخت کاربر جانشین در `importDoctor` (خط ~۵۱۴) — **بدون نقش اختصاصی**: ```php $synthetic = 'imp_' . substr(md5($source . ':' . $code), 0, 14); $user = $userRepo->findOneBy(['mobileNumber' => $synthetic]); if ($user === null) { $user = new User($synthetic); $user->setRealName($name); $user->setStatus(0); // جانشین: هرگز لاگین نمی‌کند $this->em->persist($user); } ``` متد آمادهٔ entity: ```php // Doctor.php:388-396 — user_id را پر می‌کند، مدیریت سیستمی را برمی‌دارد و وضعیت را claimed می‌کند. public function transferOwnershipTo(User $user): self { ... $this->ownerStatus = 'claimed'; $this->claimedAt = time(); ``` کپچا (بدون استثنا): ```php // PasswordAuthenticator.php:49 — ابتدای authenticate() $this->captcha->assertValid($request); ``` ## وظایف ### ۱. نقش `ROLE_UNCLAIMED_DOCTOR` برای کاربر جانشین در `importDoctor`، هنگام ساخت کاربر جانشین: ```php $user = new User($synthetic); $user->setRealName($name); $user->setStatus(0); $user->addRole('ROLE_UNCLAIMED_DOCTOR'); $this->em->persist($user); ``` - نقش را به‌صورت رشته اضافه کن (الگوی موجود `addRole('ROLE_DOCTOR')` در پروژه). - **ایمپورت‌های قبلی** (جانشین‌های موجود بدون این نقش): چون idempotent است، در همان `importDoctor` وقتی `$doctor !== null && unclaimed` است هم نقش را به کاربر فعلی‌اش تضمین کن (`if (!$user->hasRole(...)) addRole(...)`) — کاربر جانشین از `$doctor->getUser()` در دسترس است. ### ۲. اندپوینت انتقال مالکیت در `AdminApiController` (کنار `importDoctor`، همان الگوی OA + `$this->success/error`): ```php #[Route('/api/v1/admin/doctors/{uuid}/transfer', methods: ['POST'])] public function transferDoctor(string $uuid, Request $request): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; $mobile = trim((string) ($data['mobile'] ?? '')); if (!preg_match('/^09\d{9}$/', $mobile)) { return $this->error(ErrorCodes::VALIDATION, 'شماره موبایل نامعتبر است', 422, 'mobile'); } $doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]); if ($doctor === null) { return $this->error(ErrorCodes::NOT_FOUND, 'پزشک یافت نشد', 404); } if ($doctor->getOwnerStatus() === 'claimed') { return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این پروفایل قبلاً تصاحب شده است', 409); } $userRepo = $this->em->getRepository(User::class); $target = $userRepo->findOneBy(['mobileNumber' => $mobile]); if ($target === null) { $target = new User($mobile); $target->setRealName($doctor->getName()); $target->setStatus(1); $this->em->persist($target); } // قید یکتای user_id: کاربر هدف نباید از قبل پزشک دیگری داشته باشد $already = $this->em->getRepository(Doctor::class)->findOneBy(['user' => $target]); if ($already !== null && $already->getId() !== $doctor->getId()) { return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این کاربر قبلاً پروفایل پزشک دیگری دارد', 409); } $surrogate = $doctor->getUser(); $target->addRole('ROLE_DOCTOR'); $doctor->transferOwnershipTo($target); // حذف امن جانشین: فقط اگر واقعاً جانشین است و هیچ پزشک دیگری به او وصل نیست if ($surrogate !== null && $surrogate->getId() !== $target->getId() && $surrogate->hasRole('ROLE_UNCLAIMED_DOCTOR') && $this->em->getRepository(Doctor::class)->count(['user' => $surrogate]) === 0) { $this->em->remove($surrogate); } $this->em->flush(); return $this->success([ 'uuid' => $doctor->getUuid(), 'owner_status' => $doctor->getOwnerStatus(), 'user_mobile' => $mobile, ]); } ``` نکته‌ها: - **ترتیب**: اول `transferOwnershipTo` (که `user_id` را عوض می‌کند)، بعد شمارش پزشکانِ جانشین — دقت کن Doctrine تا `flush` تغییر را به DB نمی‌برد، پس `count(['user' => $surrogate])` ممکن است هنوز همین پزشک را بشمارد. یا اول flush کن بعد حذف در flush دوم، یا شرط را `count === 0 || (count === 1 && همین doctor)` بگذار. سناریوی سادهٔ امن: دو مرحله — `flush()` بعد از transfer، سپس شمارش و `remove($surrogate)` و `flush()` دوم. - امضای دقیق فیلد رابطهٔ `Doctor::user` را قبل از `findOneBy(['user' => ...])` از entity تأیید کن. - ثابت‌های `ErrorCodes` موجود (`VALIDATION`, `NOT_FOUND`, `ERR_CONFLICT_001`) — چیز جدید نساز. - بلوک OA (سواگر) مثل `importDoctor` بنویس: body `{ mobile }`، پاسخ‌های 200/404/409/422. ### ۳. رد شدن کپچا برای لاگین سرویسی کرالر راه انتخابی سند (§۷، گزینهٔ «هدر سرّی مورد اعتماد»)، امن‌تر از خاموش‌کردن ALTCHA: در `PasswordAuthenticator::authenticate()`، قبل از `assertValid`: ```php $serviceToken = $_ENV['CRAWLER_SERVICE_TOKEN'] ?? ''; $sentToken = (string) $request->headers->get('X-Service-Token', ''); $isServiceLogin = $serviceToken !== '' && hash_equals($serviceToken, $sentToken); if (!$isServiceLogin) { $this->captcha->assertValid($request); } ``` - **فقط کپچا** دور زده می‌شود؛ rate-limit و اعتبارسنجی رمز سر جای خود می‌مانند. - اگر env خالی باشد هیچ bypass وجود ندارد (پیش‌فرض امن). - env جدید را به `.env` (خالی) و `.env.example` اضافه کن + ذکر در مستند. - ترجیحاً env را از طریق constructor bind کن (الگوی `services.yaml` مثل `$appUrl: '%env(APP_BASE_URL)%'`) نه `$_ENV` مستقیم — با الگوی موجود فایل هماهنگ شو. ### ۴. کرالر: ارسال هدر سرویس در `clinicpro-crawler/clinicpro_client.py`، متد لاگین: اگر env `CLINICPRO_SERVICE_TOKEN` ست بود، هدر `X-Service-Token` را به درخواست لاگین اضافه کن (فقط لاگین کافی است). به `.env.example` کرالر هم اضافه کن. ### ۵. مستند + تست - `docs/api/doctor-import.md`: بخش transfer (method/path/permission/body/پاسخ‌ها/خطاها با مثال JSON) + توضیح `X-Service-Token` برای لاگین سرویسی + نقش `ROLE_UNCLAIMED_DOCTOR`. - تست e2e مطابق §۹ سند: ```bash ddev exec php bin/console app:system-owner 0000000000 --password=test123 --activate cd ../clinicpro-crawler && CLINICPRO_PASSWORD=test123 .venv/bin/python pipeline.py \ --mode file --file output/یاسوج/doctors.json --no-photos --limit 2 --interval 2 ``` سپس یک transfer دستی با curl و بررسی: `owner_status=claimed`، کاربر جانشین حذف‌شده، کاربر واقعی `ROLE_DOCTOR` دارد. - تست‌های موجود `tests/Admin`/`tests/Doctor` را اجرا کن؛ اگر تست importDoctor وجود دارد، case انتقال را کنارش اضافه کن. ## نکات مهم - `transferOwnershipTo` از قبل `claimed_at`/`owner_status`/`managed_by` را هندل می‌کند — منطق را در کنترلر تکرار نکن. - پروفایل `pending_transfer` در این فاز فقط یک مقدار enum است؛ جریان درخواست تصاحب از سمت Nobat724 فاز بعدی است (§۹) — نساز. - حذف جانشین باید **دقیقاً** سه شرط سند را داشته باشد: نقش `ROLE_UNCLAIMED_DOCTOR` + هیچ پزشک متصل + غیر از کاربر هدف. کاربر واقعی را هرگز حذف نکن. - `activeDoctorAppointment` بعد از transfer دست نزن — روشن‌کردن نوبت‌دهی با مالک جدید است. - بعد از تغییر API، به‌روزرسانی `docs/api/doctor-import.md` در همین session الزامی است (قانون پروژه).