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.
This commit is contained in:
hamed
2026-07-11 10:55:20 +03:30
parent 7868577c57
commit 83c872bb78
4 changed files with 608 additions and 385 deletions
+201
View File
@@ -0,0 +1,201 @@
# تکمیل فیچر ایمپورت پزشکان نظام پزشکی (قطعات باقی‌مانده)
## پروژه
`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_<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` (خط ~۵۱۴) — **بدون نقش اختصاصی**:
```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 الزامی است (قانون پروژه).