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 الزامی است (قانون پروژه).
+346
View File
@@ -0,0 +1,346 @@
حتماً، متن کامل پرامپت را در محیط قابل کپی می‌گذارم:
یک قابلیت کامل برای Claim Doctor Profile یا «احراز مالکیت پروفایل پزشک» در سیستم Clinic Pro و سایت نوبت 724 پیاده‌سازی کن.
قبل از هرگونه تغییر، ابتدا ساختار فعلی Backend و Frontend را کامل بررسی کن و مشخص کن وضعیت پزشکان ایمپورت‌شده در حال حاضر چگونه مدیریت می‌شود.
در سیستم، پزشک ایمپورت‌شده می‌تواند یکی از وضعیت‌های زیر را داشته باشد:
- unclaimed
- pending_transform
- claimed
نام دقیق enum، field و statusهای موجود در پروژه را بررسی کن و بدون ایجاد ساختار تکراری، از معماری فعلی استفاده کن.
سناریوی اصلی
زمانی که یک پزشک از دیتای سازمان نظام پزشکی Import می‌شود و هنوز به کاربر یا پزشک واقعی در Clinic Pro متصل نشده است، وضعیت آن باید unclaimed باشد.
تا زمانی که پروفایل پزشک Claim نشده است:
- نوبت‌دهی آنلاین پزشک باید غیرفعال باشد.
- در سایت اصلی نوبت 724 و تمام سایت‌های زیرمجموعه و دامنه‌های نمایندگان، وضعیت پروفایل باید مشخص باشد.
- کاربر باید متوجه شود که این پروفایل هنوز توسط پزشک تأیید و مدیریت نشده است.
در صفحه پروفایل پزشک، دقیقاً زیر بخش «نوبت‌دهی»، یک بخش برای احراز مالکیت پروفایل نمایش داده شود.
متن مناسب فارسی مانند زیر نمایش داده شود:
«آیا شما این پزشک هستید؟»
و یک دکمه با عنوان:
«تأیید و مدیریت این پروفایل»
این بخش فقط برای پزشکانی نمایش داده شود که وضعیت آن‌ها unclaimed است.
Modal احراز مالکیت پروفایل
با کلیک روی دکمه، یک Modal باز شود.
فرم باید با UI فعلی پروژه، RTL، فارسی و کاملاً Responsive طراحی شود.
اطلاعات موردنیاز فرم:
- شماره موبایل
- کد ملی
- تاریخ تولد به شمسی
- نام
- نام خانوادگی
ارتباط با API.ir
تمام فرآیند احراز هویت باید از Backend انجام شود.
هیچ Request مستقیمی از Frontend به API.ir ارسال نشود.
Token مربوط به API.ir باید در Environment Variable نگهداری شود و هرگز به Frontend ارسال نشود.
از API زیر برای دریافت اطلاعات هویتی شخص استفاده کن:
curl https://s.api.ir/api/sw1/PersonInfo
request POST
header Content-Type: application/json
header Authorization: Bearer [API_TOKEN]
data {
“nationalCode”: “0010007700”,
“birthDate”: “1371/1/1”
}
نمونه Response:
{
“data”: {
“nationalCode”: “0010007700”,
“firstName”: “محسن”,
“lastName”: “اکبری”,
“fatherName”: “علی”,
“gender”: 1,
“alive”: true
},
“success”: false,
“code”: 0,
“message”: null
}
طبق نمونه API، ورودی PersonInfo شامل nationalCode و birthDate است.
ابتدا بررسی کن که آیا API.ir Endpoint دیگری برای تطبیق شماره موبایل و کد ملی دارد یا خیر.
اگر چنین Endpointی در ساختار فعلی پروژه یا مستندات موجود تعریف شده است، از آن برای تأیید مالکیت شماره موبایل استفاده کن.
در غیر این صورت، شماره موبایل باید با OTP تأیید شود.
OTP به‌تنهایی به معنی مالکیت پروفایل پزشک نیست.
Claim فقط زمانی موفق باشد که هویت شخص با اطلاعات پزشک Import‌شده تطبیق داده شده باشد.
فرآیند احراز هویت
کاربر فرم را تکمیل و ارسال می‌کند.
Backend باید اطلاعات را دریافت و Validation کند.
سپس با استفاده از nationalCode و birthDate به API.ir درخواست PersonInfo ارسال شود.
اطلاعات برگشتی شامل موارد زیر بررسی شود:
- nationalCode
- firstName
- lastName
- alive
اگر alive برابر false بود، فرآیند Claim متوقف شود.
نام و نام خانوادگی برگشتی از API.ir باید با اطلاعات پزشک Import‌شده از سازمان نظام پزشکی مقایسه شود.
همچنین نام و نام خانوادگی واردشده توسط کاربر باید با اطلاعات تأییدشده مقایسه شود.
قبل از Compare نام‌های فارسی، عملیات Normalize انجام شود.
موارد زیر مدیریت شوند:
- تفاوت ي و ی
- تفاوت ك و ک
- فاصله‌های اضافه
- نیم‌فاصله
- فاصله ابتدا و انتهای متن
- Unicode normalization
از Compare ساده و مستقیم String استفاده نکن.
در صورت امکان، یک Service مشترک برای Persian Name Normalization ایجاد یا از Service موجود استفاده کن.
تأیید شماره موبایل
شماره موبایل کاربر باید تأیید شود.
اگر API.ir امکان تطبیق شماره موبایل با کد ملی را دارد، از همان API استفاده کن.
در غیر این صورت، از OTP موجود در سیستم استفاده کن.
اگر سیستم در حال حاضر OTP Service دارد، Service جدید و تکراری ایجاد نکن.
از زیرساخت فعلی Authentication و OTP استفاده کن.
پس از تأیید OTP، شماره موبایل Verified در نظر گرفته شود.
اما Claim نهایی فقط بعد از موفقیت احراز هویت PersonInfo انجام شود.
اتصال پروفایل پزشک به کاربر
اگر احراز هویت موفق بود:
- User مربوط به پزشک را بر اساس معماری فعلی سیستم پیدا کن.
- اگر Flow فعلی سیستم اجازه ایجاد User را می‌دهد، User مناسب ایجاد شود.
- پروفایل Doctor ایمپورت‌شده به User مربوطه متصل شود.
- وضعیت Doctor از unclaimed به claimed تغییر کند.
- زمان Claim ذخیره شود.
- روش احراز هویت ذخیره شود.
- در صورت وجود Audit Log، عملیات Claim ثبت شود.
- از Claim مجدد Doctor جلوگیری شود.
تمام عملیات نهایی Claim باید داخل Transaction انجام شود.
اگر سیستم فعلی از وضعیت pending_transform در این Flow استفاده می‌کند، ابتدا منطق آن را بررسی کن.
مشخص کن pending_transform دقیقاً در چه شرایطی استفاده می‌شود و Flow جدید را با همان معماری هماهنگ کن.
Status جدید و تکراری ایجاد نکن مگر اینکه واقعاً ضروری باشد.
جلوگیری از Race Condition
ممکن است دو Request هم‌زمان برای Claim یک Doctor ارسال شوند.
Backend باید از Race Condition جلوگیری کند.
قبل از نهایی کردن Claim، وضعیت Doctor مجدداً بررسی شود.
در صورت نیاز از Transaction، Database Lock یا مکانیزم مناسب معماری فعلی استفاده کن.
یک Doctor تحت هیچ شرایطی نباید به دو User مختلف متصل شود.
پیام موفقیت
بعد از Claim موفق، پیام خوش‌آمدگویی نمایش داده شود:
«دکتر [نام پزشک]، به نوبت 724 خوش آمدید 🎉
پروفایل شما با موفقیت تأیید شد و اکنون می‌توانید اطلاعات پروفایل و تنظیمات نوبت‌دهی خود را مدیریت کنید.»
سپس کاربر بر اساس Flow فعلی Authentication سیستم به پنل مناسب هدایت شود.
مدیریت خطاها
برای حالت‌های زیر Error Handling مناسب ایجاد کن:
- اطلاعات هویتی اشتباه است.
- تاریخ تولد اشتباه است.
- نام شخص با پزشک تطبیق ندارد.
- کد ملی نامعتبر است.
- شماره موبایل تأیید نشده است.
- OTP نامعتبر یا منقضی شده است.
- API.ir در دسترس نیست.
- API.ir Timeout شده است.
- Response API نامعتبر است.
- Doctor قبلاً Claim شده است.
- Doctor در وضعیت قابل Claim نیست.
- User دیگری هم‌زمان Doctor را Claim کرده است.
- Rate Limit رد شده است.
پیام‌های Frontend باید فارسی، واضح و قابل فهم باشند.
اطلاعات حساس API یا جزئیات Internal Error در Frontend نمایش داده نشود.
امنیت
Endpoint مربوط به Claim باید Rate Limit داشته باشد.
برای جلوگیری از Brute Force، محدودیت تعداد تلاش بر اساس موارد مناسب مانند موارد زیر اعمال شود:
- IP
- Doctor ID
- National Code Hash
- User ID
- Mobile Number Hash
کد ملی و تاریخ تولد را در Logهای معمولی به‌صورت Plain Text ثبت نکن.
شماره موبایل کامل را در Log ثبت نکن.
API Token را Log نکن.
Request و Response کامل API.ir را در Production Log ذخیره نکن.
در صورت نیاز برای Audit، فقط اطلاعات ضروری و Mask شده ذخیره شود.
در صورت وجود CAPTCHA یا ALTCHA در پروژه، بررسی کن که Claim Endpoint باید تحت محافظت ALTCHA قرار بگیرد.
از زیرساخت فعلی ALTCHA استفاده کن و سیستم CAPTCHA جدید ایجاد نکن.
Frontend
این قابلیت باید در موارد زیر کار کند:
- سایت اصلی نوبت 724
- تمام دامنه‌های زیرمجموعه
- سایت‌های نمایندگان
از Component مشترک استفاده کن.
منطق Claim را برای هر Domain جداگانه Duplicate نکن.
Tenant و Domain Logic فعلی پروژه را بررسی کن و از همان ساختار استفاده کن.
UI باید با Design System فعلی پروژه هماهنگ باشد.
Modal باید:
- RTL باشد.
- فارسی باشد.
- Responsive باشد.
- روی موبایل UX مناسبی داشته باشد.
- Loading State داشته باشد.
- Error State داشته باشد.
- Success State داشته باشد.
- از Double Submit جلوگیری کند.
برای تاریخ تولد از Date Picker شمسی موجود در پروژه استفاده کن.
Library جدید برای تاریخ شمسی نصب نکن، مگر اینکه پروژه هیچ راهکار فعلی برای تاریخ شمسی نداشته باشد.
تست
تست‌های Backend و Frontend لازم را اضافه کن.
حداقل سناریوهای زیر تست شوند:
- Claim موفق
- کد ملی اشتباه
- تاریخ تولد اشتباه
- عدم تطبیق نام پزشک
- Doctor قبلاً Claim شده
- Doctor با Status غیرمجاز
- API.ir Timeout
- API.ir Error
- Response نامعتبر API
- تلاش هم‌زمان برای Claim یک Doctor
- Normalize صحیح نام فارسی
- OTP نامعتبر
- OTP منقضی‌شده
- Rate Limit
- نمایش Claim Form فقط برای unclaimed
- عدم نمایش Claim Form برای claimed
- عدم امکان Claim یک Doctor توسط دو User
API.ir را در تست‌ها Mock کن.
تست‌ها تحت هیچ شرایطی نباید به سرویس واقعی API.ir Request ارسال کنند.
خروجی مورد انتظار
قابلیت Claim Doctor Profile را به‌صورت End-to-End پیاده‌سازی کن.
ابتدا معماری موجود پروژه را کامل بررسی کن.
سپس موارد زیر را پیاده‌سازی کن:
- Backend Claim Flow
- API.ir Integration
- Identity Verification
- Mobile Verification
- OTP Integration
- Security
- Rate Limiting
- Doctor/User Relation
- Transaction Handling
- Race Condition Protection
- Frontend Claim Section
- Claim Modal
- Success Flow
- Error Handling
- Tests
از ایجاد کد تکراری یا معماری موازی خودداری کن.
در پایان گزارشی ارائه بده که شامل موارد زیر باشد:
- فایل‌های تغییرکرده
- Flow نهایی Claim
- نحوه ارتباط با API.ir
- نحوه تطبیق هویت
- نحوه تأیید شماره موبایل
- نحوه اتصال Doctor به User
- کاربرد دقیق pending_transform در Flow
- تغییرات Database
- Environment Variableهای جدید
- تست‌های اضافه‌شده
- نکات امنیتی پیاده‌سازی‌شده
مهم:
بدون بررسی کد فعلی پروژه، درباره Entityها، Tableها، Fieldها، Routeها، Authentication Flow، OTP Service یا معماری سیستم فرض نساز.
ابتدا ساختار موجود را بررسی کن و قابلیت را کاملاً با معماری فعلی پروژه هماهنگ کن.
اگر خواستی، نسخه مخصوص Claude Code / Cursor Agent همین پرامپت را هم با دستورهای اجرایی دقیق‌تر برات تنظیم می‌کنم.
+61
View File
@@ -0,0 +1,61 @@
در Crawler، برای اینکه بتواند اطلاعات را Crawl کرده و در Clinic DataYar یک Doctor ایجاد کند، نیاز به Token احراز هویت دارد.
در حال حاضر می‌توانیم Token را به‌صورت دستی ایجاد کرده و در اختیار Crawler قرار دهیم، اما ترجیح من این است که خود Crawler یک Web Interface ساده داشته باشد.
این وب‌سایت باید احراز هویت داخلی ساده‌ای داشته باشد. احراز هویت می‌تواند Static باشد، چون این پنل استفاده عمومی ندارد و قرار نیست سیستم پیچیده‌ای برای مدیریت کاربران داشته باشیم.
من باید بتوانم وارد پنل Crawler شوم و سپس Username و Password مربوط به Clinic DataYar را وارد کنم.
Crawler باید با استفاده از این Username و Password به Clinic DataYar درخواست Login ارسال کند، Token را دریافت کند و آن را به‌صورت امن ذخیره کند.
در نتیجه، نیازی به ساخت و وارد کردن دستی Token نباشد و بتوانم Token مورد استفاده Crawler را از طریق پنل مدیریت کنم.
همچنین Crawler باید یک Database داخلی داشته باشد.
من نمی‌خواهم برای Crawler یک Database Server مانند MySQL، MariaDB یا PostgreSQL نصب کنم.
ترجیح من استفاده از یک دیتابیس Local و File-Based مانند SQLite است.
هدف Database فقط این است که Crawler بتواند State و Progress عملیات Crawl را نگهداری کند و بداند:
- چه استان‌هایی پردازش شده‌اند.
- چه شهرهایی پردازش شده‌اند.
- در حال حاضر کدام شهر در حال پردازش است.
- چه Doctorهایی Crawl شده‌اند.
- چه Doctorهایی با موفقیت به Clinic DataYar ارسال شده‌اند.
- چه مواردی Failed شده‌اند.
- چه کارهایی هنوز باقی مانده است.
Crawler باید فرآیند Crawl را بر اساس لیست استان‌ها و شهرهای موجود در Clinic DataYar انجام دهد.
ابتدا باید لیست استان‌ها و شهرها را از Clinic DataYar دریافت کند.
سپس پردازش باید به‌صورت مرحله‌ای و ترتیبی انجام شود.
برای مثال:
ابتدا استان کهگیلویه و بویراحمد انتخاب شود.
سپس شهرهای این استان یکی‌یکی پردازش شوند.
اگر شهر یاسوج در حال Crawl شدن است، Crawler باید تمام فرآیند مربوط به شهر یاسوج را کامل کند.
تا زمانی که Crawl و پردازش شهر یاسوج به‌طور کامل تمام نشده است، Crawler نباید به شهر بعدی برود.
بعد از اتمام کامل یک شهر، وضعیت آن در Database به‌عنوان Completed ذخیره شود و سپس پردازش شهر بعدی آغاز شود.
همین Flow برای تمام شهرهای یک استان انجام شود و بعد از اتمام کامل استان، Crawler به استان بعدی برود.
ترتیب کلی پردازش باید به شکل زیر باشد:
Province → City → Crawl Doctors → Process Doctors → Send Doctors to Clinic DataYar → Complete City → Next City → Complete Province → Next Province
Crawler باید قابلیت Resume داشته باشد.
یعنی اگر Process متوقف شد، Server Restart شد یا Crawler Crash کرد، بعد از اجرای مجدد نباید عملیات را از ابتدا شروع کند.
Crawler باید State ذخیره‌شده در Database را بررسی کند و دقیقاً از آخرین مرحله‌ای که متوقف شده است، ادامه دهد.
هدف این است که Crawler یک سیستم State-Based و قابل Resume باشد و همیشه مشخص باشد چه کاری انجام شده، چه کاری در حال انجام است و چه کاری هنوز باقی مانده است.
اگر بخواهی، می‌توانم همین متن را به یک پرامپت فنی دقیق برای Agent جهت پیاده‌سازی Crawler با NestJS + SQLite تبدیل کنم.
@@ -1,385 +0,0 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>سناریو: ایمپورت پزشکان نظام پزشکی و مدیریت مالکیت پروفایل</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root{
--bg:#f7f8fa; --card:#ffffff; --ink:#1f2933; --muted:#5b6673;
--line:#e3e8ee; --accent:#0f6fbf; --accent-soft:#eaf3fb; --code:#f2f4f7;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{
font-family:'Vazirmatn',system-ui,sans-serif;
background:var(--bg); color:var(--ink);
direction:rtl; text-align:right;
line-height:1.95; font-size:16px; font-weight:400;
}
.wrap{max-width:900px;margin:0 auto;padding:40px 22px 80px}
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:38px 42px;box-shadow:0 1px 3px rgba(16,24,40,.04)}
h1{font-weight:800;font-size:1.9rem;line-height:1.5;margin:.2em 0 .6em;border-bottom:3px solid var(--accent);padding-bottom:.4em}
h2{font-weight:700;font-size:1.35rem;margin:2em 0 .7em;color:var(--accent);border-right:4px solid var(--accent);padding-right:12px}
h3{font-weight:600;font-size:1.1rem;margin:1.5em 0 .5em}
p{margin:.7em 0}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline}
strong{font-weight:700;color:#0b3d5c}
blockquote{margin:1.2em 0;padding:.6em 16px;background:var(--accent-soft);border-right:4px solid var(--accent);border-radius:8px;color:#124a6b}
blockquote p{margin:.3em 0}
ul,ol{padding-right:1.4em;margin:.6em 0}
li{margin:.35em 0}
hr{border:none;border-top:1px solid var(--line);margin:2em 0}
table{border-collapse:collapse;width:100%;margin:1.2em 0;font-size:.94rem}
th,td{border:1px solid var(--line);padding:9px 12px;text-align:right;vertical-align:top}
th{background:var(--accent-soft);font-weight:700;color:#0b3d5c}
tr:nth-child(even) td{background:#fafbfc}
code{font-family:'Vazirmatn',ui-monospace,monospace;background:var(--code);padding:2px 6px;border-radius:5px;font-size:.9em;direction:ltr;unicode-bidi:embed}
pre{background:#0f1b26;color:#e6edf3;padding:18px 20px;border-radius:12px;overflow-x:auto;direction:ltr;text-align:left;line-height:1.7}
pre code{background:transparent;color:inherit;padding:0;font-size:.88rem}
</style>
</head>
<body>
<div class="wrap"><div class="card">
<h1 id="_1">سناریو: ایمپورت پزشکان سازمان نظام پزشکی و مدیریت مالکیت پروفایل</h1>
<blockquote>
<p>نسخه: ۱.۰ — تاریخ: ۱۴۰۵/۰۴/۱۹ (۲۰۲۶-۰۷-۱۰)
دامنه: <code>clinicpro</code> (بک‌اند + پنل ادمین) · <code>nobat724_front</code> (سایت عمومی) · <code>clinic-pro-tauri</code> (اپ دسکتاپ)
وضعیت: پیش‌نویس طراحی برای پیاده‌سازی</p>
</blockquote>
<hr />
<h2 id="_2">۱. خلاصه اجرایی</h2>
<p>یک دیتاست ۱۶۰ نفره از پزشکان از سامانه استعلام اعضای سازمان نظام پزشکی (<code>membersearch.irimc.org</code>) استخراج شده است. هدف، وارد کردن این پزشکان به Clinic Pro است تا در سایت عمومی Nobat724 نمایش داده شوند، <strong>پیش از آنکه پزشک واقعی در سیستم ثبت‌نام کرده باشد</strong>.</p>
<p>مشکل محوری: در مدل داده‌ی فعلی، هر پزشک (<code>Doctor</code>) به‌صورت اجباری و <strong>یک‌به‌یک و یکتا</strong> به یک کاربر (<code>User</code>) متصل است، و هر کاربر نیز الزاماً یک <strong>شماره موبایل یکتا و غیرتهی</strong> دارد. اما رکوردهای سازمان نظام پزشکی فاقد شماره موبایل هستند (<code>mobileNumber: null</code>). بنابراین نه می‌توان کاربر ساخت (چون موبایل لازم است) و نه می‌توان یک پزشک را بدون کاربر ذخیره کرد.</p>
<p>این سند یک مدل «مالکیت پروفایل» (Profile Ownership) طراحی می‌کند که در آن پزشکان ایمپورت‌شده در حالت <strong>«بدون‌مالک» (unclaimed)</strong> ذخیره و مدیریت می‌شوند، و بعداً از طریق یک فرایند احراز هویت‌شده در Nobat724 به پزشک واقعی <strong>منتقل (claim/transfer)</strong> می‌شوند.</p>
<hr />
<h2 id="_3">۲. مسئله و محدودیت‌های سیستم فعلی</h2>
<p>پیش از طراحی راه‌حل، محدودیت‌های واقعی کد فعلی مستند می‌شوند (منبع: <code>src/Doctor/Entity/Doctor.php</code>, <code>src/Auth/Entity/User.php</code>, <code>src/Doctor/Controller/DoctorController.php</code>).</p>
<table>
<thead>
<tr>
<th>محدودیت</th>
<th>جزئیات کد فعلی</th>
<th>پیامد برای ایمپورت</th>
</tr>
</thead>
<tbody>
<tr>
<td>کاربر برای پزشک اجباری است</td>
<td><code>Doctor::$user</code><code>OneToOne</code>، <code>JoinColumn(nullable: false, onDelete: RESTRICT)</code></td>
<td>نمی‌توان پزشک بدون کاربر ذخیره کرد.</td>
</tr>
<tr>
<td>رابطه پزشک↔کاربر یکتاست</td>
<td><code>UniqueConstraint idx_doctors_user (user_id)</code></td>
<td><strong>نمی‌توان چند پزشک را به یک کاربر مشترک وصل کرد</strong> — ایده‌ی «همه به یک کاربر سیستمی» با این قید نقض می‌شود.</td>
</tr>
<tr>
<td>موبایل کاربر اجباری و یکتاست</td>
<td><code>User::$mobileNumber</code><code>NOT NULL</code>، <code>UniqueConstraint uniq_mobile</code></td>
<td>بدون موبایل نمی‌توان <code>User</code> ساخت؛ داده‌ی نظام پزشکی موبایل ندارد.</td>
</tr>
<tr>
<td>ساخت پزشک به کاربر لاگین‌شده گره خورده</td>
<td><code>DoctorController::create()</code> از <code>#[CurrentUser] User $user</code> استفاده می‌کند و اگر همان کاربر پزشک داشته باشد خطای ۴۰۹ می‌دهد</td>
<td>مسیر فعلی ساخت پزشک برای ایمپورت انبوه مناسب نیست.</td>
</tr>
<tr>
<td><code>medical_system_code</code> یکتا نیست</td>
<td><code>Doctor::$medicalSystemCode</code><code>nullable</code>, بدون <code>unique</code></td>
<td>برای جلوگیری از ایمپورت تکراری و برای تطبیق هنگام claim، باید کلید طبیعی یکتا شود.</td>
</tr>
</tbody>
</table>
<h3 id="_4">نتیجه‌گیری کلیدی طراحی</h3>
<p>خواسته‌ی اولیه («همه‌ی پزشکان ایمپورت‌شده به یک کاربر سیستمی اختصاص یابند») به‌دلیل قید یکتای <code>user_id</code> روی جدول <code>doctors</code> <strong>مستقیماً قابل اجرا نیست</strong>. بنابراین یکی از دو مسیر زیر لازم است، و این سند <strong>گزینه A</strong> را توصیه می‌کند:</p>
<ul>
<li><strong>گزینه A (توصیه‌شده): جداسازی «مالکیت» از «کاربر».</strong> ستون <code>Doctor.user</code> اختیاری (<code>nullable</code>) می‌شود. یک کاربر سیستمی به‌نام «مالک سیستمی» (System Owner) صرفاً به‌عنوان <strong>مدیرِ منطقیِ</strong> پزشکان بدون‌مالک عمل می‌کند (نه از طریق ستون <code>user_id</code>، بلکه از طریق فیلد جدید <code>managed_by</code>). این کار قید یکتا را نقض نمی‌کند و مدل تمیزتری می‌سازد.</li>
<li><strong>گزینه B (جایگزین کم‌تغییر): کاربر جانشین (Placeholder User) به‌ازای هر پزشک.</strong> برای هر پزشک یک <code>User</code> غیرفعال با شناسه‌ی مصنوعی (مثلاً موبایل رزروشده‌ی <code>IRIMC-&lt;code&gt;</code>) ساخته می‌شود. اسکیمای <code>doctors</code> تقریباً دست‌نخورده می‌ماند اما جدول <code>users</code> با ۱۶۰ کاربر جعلی شلوغ می‌شود و هنگام claim باید ادغام (merge) انجام شود.</li>
</ul>
<p>مقایسه و تصمیم نهایی در بخش ۱۳ آمده است.</p>
<hr />
<h2 id="ownership-model">۳. مدل مفهومی مالکیت (Ownership Model)</h2>
<p>هر پروفایل پزشک یکی از این وضعیت‌های مالکیت را دارد:</p>
<ul>
<li><strong><code>unclaimed</code> (بدون‌مالک):</strong> ایمپورت‌شده از نظام پزشکی، هنوز به پزشک واقعی وصل نشده. توسط «مالک سیستمی» مدیریت می‌شود. در Nobat724 نمایش داده می‌شود اما قابل ویرایش توسط عموم نیست و نوبت‌دهی آنلاین آن پیش‌فرض <strong>غیرفعال</strong> است.</li>
<li><strong><code>pending_transfer</code> (در انتظار انتقال):</strong> پزشک واقعی درخواست تصاحب داده و در حال احراز هویت / انتظار تأیید ادمین است.</li>
<li><strong><code>claimed</code> (تصاحب‌شده):</strong> مالکیت به پزشک واقعی منتقل شده؛ پروفایل به کاربر واقعی او متصل است و او کنترل کامل دارد.</li>
</ul>
<p>منبع پروفایل نیز ثبت می‌شود:</p>
<ul>
<li><strong><code>source</code></strong>: <code>irimc</code> (نظام پزشکی) یا <code>manual</code> (ساخت دستی/ثبت‌نام عادی — رفتار فعلی).</li>
<li><strong><code>source_ref</code></strong>: شناسه‌ی یکتای رکورد مبدأ (<code>profile_url</code> id یا <code>medicalSystemCode</code>) برای idempotency و ممیزی.</li>
</ul>
<hr />
<h2 id="_5">۴. بخش اول — ایمپورت پزشکان نظام پزشکی</h2>
<h3 id="_6">۴.۱ کاربر «مالک سیستمی»</h3>
<p>یک کاربر ویژه یک‌بار ساخته می‌شود (از طریق دستور کنسول، هم‌سبک <code>CreateAdminCommand</code>):</p>
<ul>
<li>موبایل رزروشده و ثابت، مثلاً <code>0000000000</code> (خارج از فضای شماره‌های واقعی ایران، ۱۱ رقمی نامعتبر).</li>
<li>نقش‌ها: <code>['ROLE_USER', 'ROLE_ADMIN']</code> یا نقش اختصاصی <code>ROLE_SYSTEM_OWNER</code>.</li>
<li><code>status = 0</code> (غیرفعال برای لاگین) تا امکان ورود با آن وجود نداشته باشد.</li>
<li><code>real_name = 'مالک سیستمی نوبت۷۲۴'</code>.</li>
</ul>
<p>این کاربر <strong>صاحب <code>user_id</code> پزشکان نیست</strong> (چون یکتاست)؛ بلکه شناسه‌اش در ستون جدید <code>Doctor.managed_by</code> قرار می‌گیرد تا مشخص باشد این پزشکان توسط پلتفرم مدیریت می‌شوند و بعداً قابل واگذاری‌اند.</p>
<h3 id="doctorsjson">۴.۲ نگاشت فیلدها از <code>doctors.json</code></h3>
<p>هر رکورد ورودی به این شکل به موجودیت <code>Doctor</code> نگاشت می‌شود:</p>
<table>
<thead>
<tr>
<th>فیلد ورودی (JSON)</th>
<th>مقصد در <code>Doctor</code></th>
<th>توضیح</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>name</code></td>
<td><code>name</code></td>
<td>مثلاً «دکتر فرخنده حسینی»</td>
</tr>
<tr>
<td><code>gender</code> (<code>woman</code>/<code>man</code>)</td>
<td><code>gender</code></td>
<td>با <code>Doctor::GENDERS</code> سازگار است</td>
</tr>
<tr>
<td><code>medicalSystemCode</code></td>
<td><code>medical_system_code</code> + <code>source_ref</code></td>
<td>کلید طبیعی یکتا برای dedup</td>
</tr>
<tr>
<td><code>mobileNumber</code> (<code>null</code>)</td>
<td><code>mobile_number</code> = <code>null</code></td>
<td>اجازه دارد null بماند</td>
</tr>
<tr>
<td><code>degree</code> (<code>general</code>)</td>
<td><code>degree</code></td>
<td>با <code>Doctor::DEGREES</code> سازگار است</td>
</tr>
<tr>
<td><code>info</code></td>
<td><code>info</code></td>
<td>«دکترای حرفه‌ای پزشکی»</td>
</tr>
<tr>
<td><code>specialty_id</code> / <code>specialty_uuid</code></td>
<td>رابطه <code>specialties</code></td>
<td>تطبیق با جدول <code>specialties</code> (fallback با uuid)</td>
</tr>
<tr>
<td><code>state_id</code> / <code>state_uuid</code></td>
<td>رابطه <code>provinces</code></td>
<td>استان محل فعالیت</td>
</tr>
<tr>
<td><code>city_id</code> / <code>city_uuid</code></td>
<td>رابطه <code>cities</code></td>
<td>شهر محل فعالیت</td>
</tr>
<tr>
<td><code>images</code> (<code>[]</code>)</td>
<td><code>images</code></td>
<td>خالی → <code>null</code></td>
</tr>
<tr>
<td><code>socialMedia</code></td>
<td><code>social_media</code></td>
<td>نگاشت به کلیدهای مجاز</td>
</tr>
<tr>
<td><code>profile_url</code> / <code>source_url</code></td>
<td>متادیتای ایمپورت</td>
<td>برای ممیزی و لینک بازبینی</td>
</tr>
</tbody>
</table>
<p>فیلدهای ثابت هنگام ایمپورت: <code>owner_status = 'unclaimed'</code>، <code>source = 'irimc'</code>، <code>managed_by = &lt;systemOwnerId&gt;</code>، <code>active_doctor_appointment = false</code> (تا وقتی مالک واقعی برنامه‌ی کاری تعریف کند نوبت‌دهی روشن نشود).</p>
<h3 id="idempotency">۴.۳ قواعد Idempotency و اعتبارسنجی</h3>
<ul>
<li>کلید یکتای ایمپورت: <code>(source = 'irimc', medical_system_code)</code>. اجرای مجدد ایمپورت رکورد موجود را <strong>به‌روزرسانی</strong> می‌کند نه تکراری‌سازی.</li>
<li>رکوردهای بدون <code>medicalSystemCode</code> رد و در گزارش ایمپورت لاگ می‌شوند.</li>
<li>تطبیق تخصص/استان/شهر ابتدا با <code>*_id</code> و در صورت نبود، با <code>*_uuid</code> انجام می‌شود؛ عدم تطبیق باعث رد کل رکورد نمی‌شود بلکه فقط آن رابطه خالی می‌ماند و در گزارش ثبت می‌شود.</li>
<li>خروجی دستور ایمپورت: تعداد ساخته‌شده / به‌روزشده / ردشده + مسیر فایل گزارش.</li>
</ul>
<h3 id="_7">۴.۴ روش اجرا</h3>
<p>دستور کنسول اختصاصی (هم‌سبک <code>SeedDemoDataCommand</code> و <code>SeedCategoriesCommand</code>):</p>
<pre><code class="language-bash">ddev exec php bin/console app:doctors:import-irimc var/import/doctors.json --dry-run
ddev exec php bin/console app:doctors:import-irimc var/import/doctors.json
</code></pre>
<p><code>--dry-run</code> فقط گزارش می‌دهد و چیزی ذخیره نمی‌کند. ایمپورت درون یک تراکنش دیتابیس و به‌صورت دسته‌ای (batch/flush هر ۵۰ رکورد) انجام می‌شود.</p>
<hr />
<h2 id="_8">۵. طراحی فنی دیتابیس</h2>
<p>تغییرات روی موجودیت <code>Doctor</code> (به‌همراه یک migration در <code>migrations/</code>):</p>
<pre><code>doctors:
user_id INT NULL -- تغییر از NOT NULL به NULL (گزینه A)
managed_by INT NULL -- FK به users.id؛ کاربر «مالک سیستمی»
owner_status VARCHAR(20) NOT NULL DEFAULT 'claimed'
-- unclaimed | pending_transfer | claimed
source VARCHAR(20) NOT NULL DEFAULT 'manual' -- irimc | manual
source_ref VARCHAR(100) NULL -- شناسه رکورد مبدأ
claimed_at INT NULL
medical_system_code VARCHAR(25) NULL -- (موجود) + ایندکس یکتای جزئی
</code></pre>
<p>قیود و ایندکس‌ها:</p>
<ul>
<li>حذف/تعدیل <code>UniqueConstraint idx_doctors_user</code>: یکتایی فقط باید برای پزشکانِ <strong>دارای کاربر</strong> اعمال شود. چون MariaDB از partial unique index پشتیبانی مستقیم ندارد، یکتایی <code>user_id</code> در سطح اپلیکیشن (هنگام claim) تضمین می‌شود و ایندکس دیتابیس به <code>INDEX</code> ساده تبدیل می‌شود.</li>
<li>ایندکس یکتای طبیعی: <code>UNIQUE (source, medical_system_code)</code> برای idempotency ایمپورت.</li>
<li>ایندکس <code>owner_status</code> برای فیلتر سریع «پزشکان بدون‌مالک».</li>
</ul>
<p>سازگاری با داده‌ی موجود: تمام پزشکان فعلی هنگام migration مقدار <code>owner_status = 'claimed'</code> و <code>source = 'manual'</code> می‌گیرند تا رفتارشان تغییر نکند.</p>
<blockquote>
<p>نکته سازگاری: طبق <code>CLAUDE.md</code>، <code>medical_system_code</code> تا الان <code>nullable</code> و بدون یکتایی بوده؛ پیش از افزودن ایندکس یکتا باید داده‌ی موجود از نظر تکراری بودن پاک‌سازی شود.</p>
</blockquote>
<hr />
<h2 id="api-clinicpro">۶. طراحی API (بک‌اند clinicpro)</h2>
<p>پاسخ‌ها از پوشش <code>BaseController</code> پیروی می‌کنند: <code>{ success, data }</code> / <code>{ success, errors }</code> / صفحه‌بندی <code>{ data, meta }</code>. مطابق قانون پروژه، هر تغییر کنترلر باید در <code>docs/api/*</code> هم مستند شود.</p>
<h3 id="_9">۶.۱ ایمپورت (داخلی / ادمین)</h3>
<p>معمولاً از طریق دستور کنسول انجام می‌شود؛ در صورت نیاز به تریگر از پنل ادمین:</p>
<pre><code>POST /api/v1/admin/doctors/import-irimc [ROLE_ADMIN]
body: { source_url?, dry_run?: bool, records: [...] }
→ 200 { success, data: { created, updated, skipped, report_url } }
</code></pre>
<h3 id="_10">۶.۲ فهرست پزشکان بدون‌مالک</h3>
<p>اندپوینت موجود <code>GET /api/v1/doctors</code> با فیلتر جدید <code>owner_status</code> توسعه می‌یابد تا هم برای پنل ادمین و هم برای صفحه‌ی «تصاحب پروفایل» در Nobat724 قابل‌استفاده باشد:</p>
<pre><code>GET /api/v1/doctors?owner_status=unclaimed&amp;search=&amp;city_id=&amp;specialty_id=
→ 200 { success, data: [...], meta }
</code></pre>
<h3 id="claim">۶.۳ درخواست تصاحب (Claim) — عمومی و احراز هویت‌شده</h3>
<pre><code>POST /api/v1/doctor/{uuid}/claim [IS_AUTHENTICATED_FULLY]
body: { national_code, medical_system_code, activity_time? }
قواعد:
- پروفایل باید owner_status = 'unclaimed' باشد، وگرنه 409.
- medical_system_code ورودی باید با رکورد پزشک مطابقت کند، وگرنه 422.
- کاربر لاگین‌شده (که موبایلش قبلاً با OTP تأیید شده) نباید از قبل پزشکِ دیگری داشته باشد.
- رکورد به pending_transfer می‌رود و یک ClaimRequest ثبت می‌شود.
→ 202 { success, data: { claim_id, status: 'pending_transfer' } }
</code></pre>
<h3 id="_11">۶.۴ تأیید/رد توسط ادمین و نهایی‌سازی انتقال</h3>
<pre><code>GET /api/v1/admin/doctor-claims?status=pending [ROLE_ADMIN]
POST /api/v1/admin/doctor-claims/{claimId}/approve [ROLE_ADMIN]
POST /api/v1/admin/doctor-claims/{claimId}/reject [ROLE_ADMIN] { reason }
</code></pre>
<p>هنگام approve، عملیات انتقال مالکیت (بخش ۷) به‌صورت اتمیک اجرا می‌شود.</p>
<blockquote>
<p>امکان «انتقال خودکار» (بدون ادمین) نیز قابل تعریف است: اگر <code>national_code</code> کاربر تأییدشده باشد و <code>medical_system_code</code> و نام کاملاً منطبق باشند، سیستم می‌تواند مستقیماً claim را تأیید کند. تصمیم پیش‌فرض این سند: <strong>تأیید ادمین اجباری برای فاز اول</strong> (بخش ۱۳).</p>
</blockquote>
<hr />
<h2 id="_12">۷. بخش دوم — مکانیزم انتقال مالکیت</h2>
<p>جریان کامل تصاحب پروفایل توسط پزشک واقعی:</p>
<ol>
<li><strong>کشف:</strong> پزشک در Nobat724 نام خود را می‌بیند (پروفایل <code>unclaimed</code>) و روی «این پروفایل من است» کلیک می‌کند.</li>
<li><strong>احراز هویت پایه:</strong> اگر لاگین نیست، با موبایل + OTP ثبت‌نام/ورود می‌کند. در این مرحله یک <code>User</code> واقعی با موبایل واقعی ساخته می‌شود (مسیر عادی auth موجود).</li>
<li><strong>تطبیق هویت:</strong> فرم تصاحب، <code>national_code</code> و <code>medical_system_code</code> را می‌گیرد و با رکورد پزشک تطبیق می‌دهد (<code>POST .../claim</code>). پروفایل به <code>pending_transfer</code> می‌رود.</li>
<li><strong>بازبینی:</strong> ادمین در پنل، درخواست را با <code>profile_url</code> سازمان نظام پزشکی بازبینی و approve/reject می‌کند.</li>
<li><strong>نهایی‌سازی انتقال (اتمیک):</strong></li>
<li><code>Doctor.user</code> = کاربر واقعی پزشک (پر شدن ستونی که تا الان null بود).</li>
<li><code>Doctor.managed_by</code> = <code>null</code>؛ <code>owner_status = 'claimed'</code>؛ <code>claimed_at = time()</code>.</li>
<li>افزودن <code>ROLE_DOCTOR</code> به کاربر واقعی (همان منطق موجود در <code>DoctorController::create</code>).</li>
<li>از این پس ویرایش پروفایل توسط خود پزشک از طریق <code>PATCH /api/v1/doctor/{uuid}</code> مجاز است (چک مالکیت فعلی <code>getUser()-&gt;getId() === user</code> اکنون درست کار می‌کند).</li>
<li><strong>اطلاع‌رسانی:</strong> پیامک/نوتیف تأیید به پزشک (هم‌سبک <code>Sms</code> موجود).</li>
</ol>
<p>قواعد یکتایی هنگام انتقال: چون یک کاربر واقعی نباید صاحب دو پزشک شود، پیش از اتصال باید بررسی شود که <code>user_id</code> مقصد در جدول <code>doctors</code> تکراری نشود (تضمین در سطح اپلیکیشن، جایگزین قید یکتای حذف‌شده).</p>
<hr />
<h2 id="nobat724-nobat724_front">۸. اتصال با Nobat724 (<code>nobat724_front</code>)</h2>
<p>مصرف‌کننده‌ی API از طریق <code>services/response.js</code> است (که هم‌اکنون <code>getDoctors</code>, <code>postDoctor</code>, ... را دارد). تغییرات لازم:</p>
<ul>
<li><strong>نمایش پزشکان بدون‌مالک:</strong> فهرست فعلی پزشکان (<code>api/v1/doctors</code>) به‌طور خودکار پزشکان <code>unclaimed</code> را هم شامل می‌شود؛ در کارت پزشک، به‌جای دکمه‌ی «رزرو نوبت»، دکمه‌ی «این پروفایل من است / تکمیل پروفایل» نمایش داده می‌شود چون <code>active=false</code> است.</li>
<li><strong>صفحه/فرم تصاحب:</strong> فراخوانی <code>POST api/v1/doctor/{uuid}/claim</code> پس از ورود با OTP. نگهداری <code>access_token</code>/<code>refresh_token</code> در کوکی (مطابق الگوی فعلی Nobat724).</li>
<li><strong>زبان/تقویم:</strong> تمام رشته‌های جدید فارسی و تاریخ‌ها جلالی (شمسی) بمانند.</li>
<li>رعایت قرارداد: تغییر قالب پاسخ در بک‌اند باید در <code>nobat724_front/services/response.js</code> هم منعکس شود، چون در زمان build خطا نمی‌دهد.</li>
</ul>
<hr />
<h2 id="clinic-pro-tauri">۹. اثر بر <code>clinic-pro-tauri</code></h2>
<p>اپ دسکتاپ نیز کلاینت همان API است (<code>src/service/response.js</code>) و از CASL برای نقش‌ها استفاده می‌کند (<code>clinic</code>, <code>doctor</code>, <code>clinic_doctor</code>, <code>secretary</code>).</p>
<ul>
<li>اگر لیست پزشکان در اپ نمایش داده می‌شود، باید فیلد <code>owner_status</code> و رفتار <code>active=false</code> را مدیریت کند (پزشک بدون‌مالک قابل رزرو آنلاین نیست).</li>
<li>مرز sync آفلاین/آنلاین این اپ هنوز کامل نگاشت نشده؛ پیش از فرض هم‌ترازی، رفتار <code>owner_status</code> در دیتابیس محلی SQLite باید بررسی شود (طبق هشدار <code>AGENTS.md</code>).</li>
<li>برای فاز اول، تغییر در Tauri <strong>اختیاری</strong> است؛ فقط در صورتی که این اپ پزشکان <code>unclaimed</code> را نشان دهد لازم می‌شود.</li>
</ul>
<hr />
<h2 id="_13">۱۰. جریان کاربری (خلاصه‌ی گام‌به‌گام)</h2>
<pre><code>[نظام پزشکی JSON] → دستور ایمپورت → پزشکِ unclaimed (managed_by = System Owner)
نمایش در Nobat724 (active=false، بدون نوبت آنلاین)
│ پزشک واقعی: «این پروفایل من است»
ورود با موبایل + OTP → ساخت User واقعی
فرم تصاحب (کد ملی + کد نظام پزشکی) → POST /claim → pending_transfer
بازبینی ادمین (approve) → انتقال اتمیک:
user_id=واقعی، owner_status=claimed، +ROLE_DOCTOR
پزشک پروفایل و برنامه‌ی کاری را کامل می‌کند → active=true → نوبت‌دهی آنلاین فعال
</code></pre>
<hr />
<h2 id="_14">۱۱. حالات مرزی و قواعد کسب‌وکار</h2>
<ul>
<li><strong>درخواست تصاحب هم‌زمان دو نفر برای یک پروفایل:</strong> فقط اولین <code>pending_transfer</code> پذیرفته می‌شود؛ بقیه با ۴۰۹ رد می‌شوند تا تعیین تکلیف قبلی روشن شود.</li>
<li><strong>کاربری که قبلاً پزشک دارد:</strong> نمی‌تواند پروفایل دوم را تصاحب کند (قید یکتای منطقی <code>user_id</code>).</li>
<li><strong>عدم تطابق کد نظام پزشکی:</strong> رد با ۴۲۲ و بدون تغییر وضعیت.</li>
<li><strong>رد توسط ادمین:</strong> پروفایل به <code>unclaimed</code> بازمی‌گردد و برای تصاحب مجدد آزاد می‌شود.</li>
<li><strong>حذف پزشک بدون‌مالک:</strong> مجاز برای ادمین (مسیر فعلی <code>DELETE</code>); اما پزشکِ <code>claimed</code> طبق رفتار فعلی محافظت می‌شود.</li>
<li><strong>ایمپورت مجدد یک پزشکِ از قبل claimed:</strong> فیلدهای هویتی به‌روز نمی‌شوند (مالک واقعی اولویت دارد)؛ فقط در گزارش «skipped/claimed» ثبت می‌شود.</li>
<li><strong>نوبت‌دهی:</strong> تا زمانی که پروفایل <code>unclaimed</code> است، <code>active_doctor_appointment=false</code> و برنامه‌ی کاری وجود ندارد؛ لذا در <code>toListArray</code> مقدار <code>active=false</code> می‌شود و رزرو ممکن نیست.</li>
</ul>
<hr />
<h2 id="_15">۱۲. مراحل پیاده‌سازی (به‌ترتیب و به‌تفکیک ریپو)</h2>
<p>مطابق <code>CLAUDE.md</code>: ابتدا بک‌اند <code>clinicpro</code>، سپس مستندسازی API، سپس کلاینت‌ها.</p>
<p><strong>الف) <code>clinicpro</code> (بک‌اند):</strong>
1. افزودن فیلدهای <code>managed_by</code>, <code>owner_status</code>, <code>source</code>, <code>source_ref</code>, <code>claimed_at</code> و nullable کردن <code>user_id</code> در <code>Doctor</code> + migration در <code>migrations/</code>.
2. پاک‌سازی داده و افزودن ایندکس یکتای <code>(source, medical_system_code)</code>.
3. دستور کنسول <code>app:doctors:import-irimc</code> (با <code>--dry-run</code>، گزارش، تراکنش).
4. دستور/سیدر ساخت کاربر «مالک سیستمی».
5. موجودیت/جدول <code>DoctorClaim</code> + اندپوینت‌های claim و approve/reject.
6. توسعه‌ی فیلتر <code>owner_status</code> در <code>GET /api/v1/doctors</code> و به‌روزرسانی چک‌های مالکیت.
7. به‌روزرسانی <code>docs/api/*</code> (طبق قانون استاندارد پروژه) و افزودن این سند به مستندات.</p>
<p><strong>ب) <code>nobat724_front</code>:</strong>
8. هم‌ترازی <code>services/response.js</code> با قالب‌های جدید.
9. دکمه‌ی «این پروفایل من است» روی کارت پزشکِ <code>unclaimed</code> + صفحه‌ی فرم تصاحب (فارسی، جلالی، RTL).</p>
<p><strong>ج) <code>clinic-pro-tauri</code> (در صورت نیاز):</strong>
10. مدیریت <code>owner_status</code>/<code>active=false</code> در لیست پزشکان و بررسی مرز sync محلی.</p>
<p><strong>د) بازبینی نهایی:</strong>
11. تست ایمپورت روی نمونه‌ی ۱۶۰ رکورد، تست جریان claim سرتاسری، و بازسازی کاربران تست (<code>ddev exec php create_test_users.php</code>).</p>
<hr />
<h2 id="_16">۱۳. تصمیمات باز و ریسک‌ها</h2>
<ul>
<li><strong>گزینه A در برابر B:</strong> این سند گزینه A (nullable کردن <code>user_id</code> + <code>managed_by</code>) را توصیه می‌کند چون جدول <code>users</code> را با کاربران جعلی آلوده نمی‌کند و مدل مالکیت را صریح می‌سازد. هزینه‌اش: از دست رفتن قید یکتای دیتابیسی روی <code>user_id</code> و انتقال آن به سطح اپلیکیشن.</li>
<li><strong>تأیید ادمین در برابر انتقال خودکار:</strong> پیش‌فرض فاز اول تأیید دستی ادمین است (امن‌تر برای هویت پزشک). خودکارسازی بعداً با اتکا به تأیید کد ملی افزوده می‌شود.</li>
<li><strong>کیفیت داده‌ی نظام پزشکی:</strong> برخی رکوردها ممکن است فاقد <code>specialty_id</code>/<code>city_id</code> معتبر باشند؛ گزارش ایمپورت باید این‌ها را شفاف کند.</li>
<li><strong>حریم خصوصی:</strong> نمایش عمومی نام و کد نظام پزشکی پیش از رضایت پزشک، ملاحظه‌ی حقوقی دارد و باید با سیاست پلتفرم بررسی شود.</li>
<li><strong>یکتایی موبایل مالک سیستمی:</strong> مقدار رزروشده باید تضمیناً هرگز با موبایل واقعی کاربر تداخل نکند.</li>
</ul>
<hr />
<h2 id="_17">۱۴. مرجع نمونه‌ی داده</h2>
<p>نمونه‌ی یک رکورد ورودی از <code>doctors.json</code> (۱۶۰ رکورد، همگی <code>mobileNumber: null</code>):</p>
<pre><code class="language-json">{
&quot;name&quot;: &quot;دکتر فرخنده حسینی&quot;,
&quot;gender&quot;: &quot;woman&quot;,
&quot;medicalSystemCode&quot;: &quot;145657&quot;,
&quot;mobileNumber&quot;: null,
&quot;degree&quot;: &quot;general&quot;,
&quot;info&quot;: &quot;دکترای حرفه‌ای پزشکی&quot;,
&quot;specialty_id&quot;: 1,
&quot;specialty_name&quot;: &quot;پزشک عمومی&quot;,
&quot;state_id&quot;: 23,
&quot;state_name&quot;: &quot;کهگیلویه و بویراحمد&quot;,
&quot;city_id&quot;: 123,
&quot;city_name&quot;: &quot;یاسوج&quot;,
&quot;profile_url&quot;: &quot;https://membersearch.irimc.org/member/profile?id=02058131-...&quot;,
&quot;source_url&quot;: &quot;https://membersearch.irimc.org&quot;
}
</code></pre>
</div></div>
</body>
</html>