Files
clinicpro/.claude/prompt/irimc-import-complete.md
T
hamed 83c872bb78 feat: implement doctor import completion feature and crawler enhancements
- Added the remaining components for the doctor import feature in the backend, including role management, ownership transfer endpoint, and captcha bypass for crawler service login.
- Created detailed scenarios for the doctor claim process, ensuring proper identity verification and mobile validation.
- Established a crawler interface for token management and state tracking using SQLite, enabling a resume capability for the crawling process.
2026-07-11 10:55:20 +03:30

11 KiB
Raw Blame History

تکمیل فیچر ایمپورت پزشکان نظام پزشکی (قطعات باقی‌مانده)

پروژه

clinicpro (backend) + یک تغییر کوچک در clinicpro-crawler/clinicpro_client.py

برنچ: تغییرات backend روی برنچ جدید در repo خود clinicpro: git -C clinicpro checkout -b feature/irimc-doctor-import (تغییر کرالر در repo والد است — همان‌جا commit شود.)

زمینه

سند سناریو: 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_<hash>، 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 (خط ~۵۱۴) — بدون نقش اختصاصی:

$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:

// Doctor.php:388-396 — user_id را پر می‌کند، مدیریت سیستمی را برمی‌دارد و وضعیت را claimed می‌کند.
public function transferOwnershipTo(User $user): self
{
    ...
    $this->ownerStatus = 'claimed';
    $this->claimedAt   = time();

کپچا (بدون استثنا):

// PasswordAuthenticator.php:49 — ابتدای authenticate()
$this->captcha->assertValid($request);

وظایف

۱. نقش ROLE_UNCLAIMED_DOCTOR برای کاربر جانشین

در importDoctor، هنگام ساخت کاربر جانشین:

$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):

#[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:

$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 مطابق §۹ سند:
    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 الزامی است (قانون پروژه).