diff --git a/.claude/prompt/doctor-map-claim-captcha-delete.md b/.claude/prompt/doctor-map-claim-captcha-delete.md new file mode 100644 index 00000000..40ab570b --- /dev/null +++ b/.claude/prompt/doctor-map-claim-captcha-delete.md @@ -0,0 +1,163 @@ +# رفع زوم نقشه در افزودن آدرس + کپچا و موبایل در claim + حذف پروفایل توسط مالک + +## پروژه + +`clinicpro` (پنل ادمین React + backend) +> پرامپت همتا (سایت عمومی): `nobat724_front/.claude/prompt/doctor-map-claim-modal.md` — نقشهٔ صفحه پزشک + مودال claim + دکمهٔ حذف. این پرامپت قرارداد API را که سایت مصرف می‌کند تغییر می‌دهد. + +## زمینه + +سه موضوع مرتبط با پروفایل پزشک: +1. در فرم افزودن آدرس (پنل ادمین)، با انتخاب شهر نقشه باید روی آن شهر زوم کند؛ ولی بار اول کار نمی‌کند و کاربر مجبور است شهر را **دو بار** انتخاب کند. +2. جریان تصاحب پروفایل (claim) از قبل هست (`DoctorClaimController`) ولی طبق سناریوی جدید باید **کپچای ALTCHA** داشته باشد و **شماره موبایل** به‌صراحت در فرم گرفته و تطبیق داده شود. +3. پس از claim، **مالک** پروفایل باید بتواند پروفایل خود را حذف کند (الان حذف فقط `ROLE_ADMIN` است). + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `assets/admin/pages/DoctorDetailPage.tsx` | فرم آدرس + `MapPicker`/`MapController` (react-leaflet) + geocode شهر | +| `src/Doctor/Controller/DoctorClaimController.php` | endpoint `claim` — افزودن کپچا + فیلد mobile | +| `src/Doctor/Service/DoctorClaimService.php` | منطق claim | +| `src/Shared/Captcha/CaptchaGuard.php` | `assertValid($request)` — الگوی موجود کپچا (در `AuthController`) | +| `src/Doctor/Controller/DoctorController.php` | متد `delete` (خط ۳۷۱، الان `#[IsGranted('ROLE_ADMIN')]`) | +| `docs/api/doctor.md` + `docs/api/doctor-claim.md` | مستندسازی | + +## وظیفه ۱ — رفع زوم نقشه هنگام انتخاب شهر (نیازِ دو‌بار انتخاب) + +### وضعیت فعلی + +```tsx +// DoctorDetailPage.tsx:468 — recenter فقط با flyTo روی تغییر flyTarget +function MapController({ flyTarget }: { flyTarget: [number, number] | null }) { + const map = useMap(); + useEffect(() => { + if (flyTarget) map.flyTo(flyTarget, 12, { duration: 1.2 }); + }, [flyTarget, map]); + return null; +} + +// :935 — انتخاب شهر → geocode خارجی → setMapFlyTarget +onChange={(val, label) => { + setValue('city_id', val); + if (label) { + geocodeCityInIran(label).then(coords => { if (coords) setMapFlyTarget(coords); }); + } +}} +``` + +### ریشه‌ها + +1. **نقشهٔ تازه‌مانت‌شده اندازه‌اش ۰ است:** وقتی فرم/نقشه تازه باز می‌شود، Leaflet ابعاد کانتینر را نگرفته و `flyTo` روی نقشهٔ بدون‌اندازه بی‌اثر است؛ انتخاب دومِ شهر (که نقشه دیگر layout شده) کار می‌کند. باید `map.invalidateSize()` قبل از `flyTo` صدا زده شود. +2. **geocode خارجی (nominatim) async و rate-limited است:** اولین فراخوان ممکن است خالی/۴۰۳ برگردد (کاربر بلافاصله بعد از باز شدن انتخاب می‌کند) و `setMapFlyTarget` اجرا نشود. + +### راه‌حل + +**الف) `invalidateSize` + recenter مقاوم:** + +```tsx +function MapController({ flyTarget }: { flyTarget: [number, number] | null }) { + const map = useMap(); + useEffect(() => { + map.invalidateSize(); // ابعاد را پس از mount/تغییر layout به‌روز کن + if (flyTarget) map.flyTo(flyTarget, 12, { duration: 1.2 }); + }, [flyTarget, map]); + return null; +} +``` + +اگر نقشه داخل بخشی است که با باز/بسته شدن mount/unmount می‌شود، یک `invalidateSize` هنگام mount هم لازم است (effect بالا با `map` در deps این را پوشش می‌دهد). + +**ب) geocode مقاوم — ترجیحاً از مختصات خودِ شهر به‌جای سرویس خارجی:** + +- اول بررسی کن آیا آبجکت شهر در `cities` (یا endpoint `/api/v1/cities`) مختصات دارد؛ اگر دارد، مستقیم از همان `flyTarget` را بساز و از nominatim صرف‌نظر کن (سریع، بدون rate-limit، بدون async ناموفق). +- اگر مختصات در دیتا نیست، `geocodeCityInIran` را نگه‌دار اما با retry ساده (یک تلاش مجدد بعد از ~۱ ثانیه در صورت پاسخ خالی) و بدون بلاک‌کردن UI. + +> **edge:** اگر کاربر پیش از resolve شدن geocode شهر دیگری انتخاب کند، فقط آخرین انتخاب باید اعمال شود (نگه‌داشتن یک request id/ابطال نتیجهٔ قدیمی). + +## وظیفه ۲ — کپچا و فیلد موبایل در claim + +### وضعیت فعلی + +`DoctorClaimController::claim` پشت `IS_AUTHENTICATED_FULLY` است و کپچا ندارد؛ موبایل را از کاربر لاگین‌شده می‌گیرد (`$user->getMobileNumber()`)، فیلد جدا در بدنه ندارد. + +### راه‌حل + +**الف) کپچای ALTCHA** — الگوی موجود `CaptchaGuard::assertValid($request)` (همان که در `AuthController::sendCode` استفاده می‌شود): + +```php +// ابتدای DoctorClaimController::claim، پیش از rate limiter/منطق +$this->captcha->assertValid($request); // تزریق CaptchaGuard در constructor +``` + +- `assertValid` هنگام `ALTCHA_ENABLED=false` بی‌اثر است (dev)، و در prod payload کپچا می‌خواهد؛ خطای آن به `ERR_CAPTCHA_001` (۴۲۲) تبدیل می‌شود (ExceptionSubscriber). + +**ب) فیلد موبایل صریح** — بدنه فیلد `mobile` بگیرد و با موبایل کاربر لاگین‌شده تطبیق داده شود (طبق سناریو: «شماره موبایل ثبت‌شده در حساب کاربری باید به عنوان مالک بررسی شود»): + +```php +$mobile = \App\Shared\Util\PersianText::normalize((string) ($data['mobile'] ?? '')); +$mobile = preg_replace('/\D/', '', $mobile); +if (!preg_match('/^09\d{9}$/', $mobile)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل نامعتبر است', 422, 'mobile'); +} +if ($mobile !== $user->getMobileNumber()) { + return $this->error(ErrorCodes::ERR_CONFLICT_001, 'شماره موبایل باید با حساب کاربری شما یکی باشد', 422, 'mobile'); +} +``` + +- `DoctorClaimService::claim` بدون تغییر می‌ماند (همان موبایل کاربر برای شاهکار استفاده می‌شود). +- سند `docs/api/doctor-claim.md`: افزودن فیلد `mobile` به بدنه + ذکر کپچای ALTCHA و کد `ERR_CAPTCHA_001`. + +## وظیفه ۳ — حذف پروفایل توسط مالک + +### وضعیت فعلی + +```php +// DoctorController.php:369 +#[Route('/api/v1/doctor/{uuid}', methods: ['DELETE'])] +#[IsGranted('ROLE_ADMIN')] +public function delete(string $uuid): JsonResponse { ... } +``` + +فقط ادمین حذف می‌کند؛ مالک پزشک نمی‌تواند پروفایل خود را حذف کند. + +### راه‌حل + +مالک (`claimed` و `doctor.getUser()->getId() === user`) هم اجازهٔ حذف بگیرد: + +- `#[IsGranted('ROLE_ADMIN')]` را از متد بردار و به `#[IsGranted('IS_AUTHENTICATED_FULLY')]` تغییر بده؛ داخل متد `#[CurrentUser] User $user` را بگیر و کنترل دسترسی صریح: + +```php +public function delete(string $uuid, #[CurrentUser] User $user): JsonResponse +{ + $doctor = $this->doctorRepo->findByUuid($uuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + $isAdmin = $user->hasRole('ROLE_ADMIN'); + $isOwner = $doctor->getOwnerStatus() === 'claimed' && $doctor->getUser()->getId() === $user->getId(); + if (!$isAdmin && !$isOwner) { + return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'اجازهٔ حذف این پروفایل را ندارید', 403); + } + // ... گاردِ FK موجود (نوبت ثبت‌شده → 409) و remove فعلی بدون تغییر ... +} +``` + +- گارد FK موجود (پزشکِ دارای نوبت → ۴۰۹ `ERR_CONFLICT_001`) حفظ شود. +- امنیت: IDOR — کاربرِ لاگین‌شده فقط پروفایلِ **claimed متعلق به خودش** یا (اگر ادمین) هر پروفایلی را حذف کند؛ پروفایلِ `unclaimed` توسط کاربر عادی حذف نشود. + +## نکات مهم + +- بعد از تغییر `delete`/`claim`، `docs/api/doctor.md` و `docs/api/doctor-claim.md` را به‌روز کن (قانون پروژه). +- نقشهٔ react-leaflet فقط admin frontend است؛ تغییر backend ندارد. +- کپچا: مسیر public سایت (`nobat724`) هم باید payload ALTCHA بفرستد (پرامپت همتا)؛ در dev با `ALTCHA_ENABLED=false` بی‌اثر است. +- تست: + ```bash + ddev exec php -l src/Doctor/Controller/DoctorClaimController.php + ddev exec php bin/console cache:clear + ddev exec npx tsc --noEmit --project tsconfig.json 2>&1 | head + ddev exec yarn dev + # claim: با mobile نامطابق → 422؛ delete توسط مالک claimed → 200؛ توسط کاربر دیگر → 403 + ddev exec php bin/phpunit tests/Doctor + ``` +- تست‌های موجود `DoctorClaimTest`/`DoctorImportTest` را با فیلد `mobile` و مسیر delete مالک به‌روز/تکمیل کن؛ سبز بمانند. diff --git a/.claude/prompt/fix-doctor-name-and-purge.md b/.claude/prompt/fix-doctor-name-and-purge.md new file mode 100644 index 00000000..af0a3fe1 --- /dev/null +++ b/.claude/prompt/fix-doctor-name-and-purge.md @@ -0,0 +1,146 @@ +# رفع نام دوتایی «دکتر» در ایمپورت IRIMC + دستور پاک‌سازی کامل پزشکان + +## پروژه + +`clinicpro` (backend + پنل ادمین) + +## زمینه + +پس از ایمپورت پزشکان نظام پزشکی، نام در پنل ادمین اشتباه نمایش داده می‌شود: به‌جای +«دکتر صفورا حجازی نیا»، «دکتر دکتر صفورا حجازی نیا» و در کارت grid به‌خاطر ellipsis +بریده و «دکتر دکتر صفورا حجازی نی» دیده می‌شود. + +**ریشه (تأییدشده):** نامِ خامِ نظام پزشکی خودش پیشوند «دکتر» دارد (`دکتر صفورا حجازی نیا` +در `doctors.json` و در DB، ۲۰ کاراکتر — داده درست ذخیره شده). اما کنوانسیون پنل این است +که نام **بدون** پیشوند ذخیره شود و خودِ UI «دکتر» را جلو می‌گذارد: + +```tsx +// assets/admin/pages/DoctorsPage.tsx:326 (جدول) و :412-413 (کارت grid با ellipsis) +دکتر {doc.name} +``` + +پس وقتی `doc.name = "دکتر صفورا حجازی نیا"` باشد، خروجی «دکتر دکتر …» می‌شود و در کارت +(`whiteSpace:nowrap; overflow:hidden; textOverflow:ellipsis`) طولانی‌تر شده و «…نیا» +بریده می‌شود. یک ریشه، هر دو نشانه. + +علاوه بر این، کاربر می‌خواهد **همهٔ پزشکان و داده‌های وابسته به پزشک** پاک شوند تا یک +دیتابیس تمیز برای تست داشته باشیم (این کار با FK حذف مستقیم شکست می‌خورد — قبلاً خطای +`FK_4384ADBC87F4FB17` روی `doctor_provinces` دیدیم). + +## مشکل / هدف + +۱. ایمپورت IRIMC پیشوند «دکتر/دكتر» را از نام حذف کند تا با کنوانسیون پنل یکدست شود. +۲. ۳۴۰ رکورد IRIMC موجود (که با پیشوند ذخیره شده‌اند) اصلاح شوند. +۳. یک دستور کنسول امن برای پاک‌سازی کامل پزشکان + همهٔ داده‌های وابسته (FK-safe). + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Doctor/Service/DoctorImportService.php` | ساخت/به‌روزرسانی پزشک؛ اینجا نام normalize شود | +| `src/Shared/Util/PersianText.php` | `stripDoctorTitle()` موجود — «دکتر» ابتدای نام را حذف می‌کند | +| `assets/admin/pages/DoctorsPage.tsx` | خط ۳۲۶ و ۴۱۲-۴۱۳ — نمایش `دکتر {doc.name}` (تغییر لازم ندارد، فقط داده اصلاح شود) | +| جداول FK به `doctors` (۱۶ عدد) | `weekly_schedules, date_overrides, comments, clinic_doctor_invitations, holidays, doctor_specialties, appointments, doctor_cities, doctor_expertise, rates, doctor_provinces, doctor_insurances, clinic_doctors, doctor_claim_requests, doctor_addresses, doctor_secretaries` | + +## وضعیت فعلی + +`DoctorImportService::doImport()` نام را همان‌طور که آمده ذخیره می‌کند: + +```php +$name = trim((string) $data['name']); +// ... +$doctor->setName($name); // نام هنوز شامل «دکتر …» است +``` + +`PersianText::stripDoctorTitle()` از قبل هست و دقیقاً همین کار را می‌کند: + +```php +public static function stripDoctorTitle(string $name): string +{ + return trim(preg_replace('/^\s*دکتر\s+/u', '', self::normalize($name)) ?? $name); +} +``` + +## وظایف + +### ۱. حذف پیشوند «دکتر» هنگام ایمپورت + +در `DoctorImportService::doImport()`، نام را قبل از ذخیره normalize کن: + +```php +use App\Shared\Util\PersianText; + +$name = PersianText::stripDoctorTitle((string) ($data['name'] ?? '')); +if ($name === '') { /* همان اعتبارسنجی موجود در کنترلر — کنترلر نام خام را چک می‌کند */ } +``` + +- توجه: کنترلر (`DoctorImportController`) نام خام را برای اعتبارسنجی `!== ''` چک می‌کند؛ + strip فقط داخل سرویس برای مقدار ذخیره‌شده انجام شود تا اعتبارسنجی نشکند. +- `stripDoctorTitle` علاوه بر حذف پیشوند، `normalize` هم می‌کند (ي→ی، ك→ک، نیم‌فاصله) که + برای یکدستی نام مفید است. +- **edge:** نام‌هایی که «دکتر» ندارند بدون تغییر می‌مانند؛ نام‌های دو-پیشوندی نادر + («دکتر دکتر …») هم چون preg فقط یک بار از ابتدا حذف می‌کند، در صورت وجود باید بررسی شود + (regex را در صورت نیاز به `^(?:\s*دکتر\s+)+` تغییر بده تا همهٔ پیشوندهای متوالی برود). + +### ۲. اصلاح رکوردهای IRIMC موجود + +یک دستور یک‌بارمصرف (هم‌سبک `BackfillSurrogateRoleCommand`) به نام +`app:doctors:fix-irimc-names`: + +```php +// SELECT پزشکان source='irimc' که name با 'دکتر ' شروع می‌شود؛ +// name = stripDoctorTitle(name)؛ با --dry-run فقط گزارش. +``` + +- فقط `source='irimc'` را دست بزن (پزشکان manual/seed را تغییر نده). +- `--dry-run` داشته باشد؛ در خروجی تعداد اصلاح‌شده را بده. +- در همان دستور، اگر `name` کاربرِ جانشین (`realName`) هم پیشوند دارد اختیاری است؛ اولویت با `doctors.name`. + +### ۳. دستور پاک‌سازی کامل پزشکان (دیتابیس تمیز تست) + +دستور `app:doctors:purge` در `src/Doctor/Command/PurgeDoctorsCommand.php`: + +- **حفاظت:** فقط با `--force` اجرا شود؛ بدون آن فقط تعداد رکوردهای هر جدول را گزارش کند + (dry-run پیش‌فرض). چون مخرب است، پیام تأیید واضح بدهد. +- ترتیب FK-safe: داخل یک تراکنش، اول جداول فرزند سپس `doctors`، سپس کاربران جانشین. + ساده‌ترین و مطمئن‌ترین راه در MariaDB: + +```php +$conn = $this->em->getConnection(); +$conn->executeStatement('SET FOREIGN_KEY_CHECKS=0'); +foreach ([ + 'doctor_claim_requests','doctor_secretaries','doctor_addresses','doctor_insurances', + 'doctor_provinces','doctor_cities','doctor_specialties','doctor_expertise', + 'clinic_doctors','clinic_doctor_invitations','weekly_schedules','date_overrides', + 'holidays','comments','rates','appointments','doctors', +] as $t) { + $n = $conn->executeStatement("DELETE FROM {$t}"); // یا TRUNCATE پس از خالی‌شدن FK + $io->text("{$t}: {$n}"); +} +$conn->executeStatement('SET FOREIGN_KEY_CHECKS=1'); +``` + +- **کاربران جانشین:** پس از حذف پزشکان، کاربرانِ ایمپورت را هم پاک کن (وگرنه یتیم می‌مانند): + `DELETE FROM users WHERE mobile_number LIKE 'imp\\_%' AND status=0`. +- **هشدار داده‌های مشترک:** `appointments`, `comments`, `rates` به بیمار/پرداخت هم وصل‌اند؛ + چون این دیتابیس فقط برای تستِ ایمپورت است حذف کامل قابل‌قبول است، ولی در دستور صریح + هشدار بده که این عمل روی prod اجرا نشود (بررسی `APP_ENV !== 'prod'` یا نیاز به فلگ اضافهٔ + `--i-know` برای prod). +- بعد از اجرا: `SET FOREIGN_KEY_CHECKS=1` حتی در صورت خطا (finally) اجرا شود. + +## نکات مهم + +- بعد از وظیفهٔ ۱، فقط ایمپورت‌های جدید نام تمیز می‌گیرند؛ وظیفهٔ ۲ برای ۳۴۰ رکورد فعلی لازم است. +- تغییری در `DoctorsPage.tsx` لازم نیست — با نام تمیز، `دکتر {doc.name}` درست رندر می‌شود و + کارت grid دیگر بریده نمی‌شود. +- جریان claim (`DoctorClaimService::verifyIdentity`) از `stripDoctorTitle` روی `doctor.getName()` + استفاده می‌کند؛ با نام تمیزِ ذخیره‌شده، این strip بی‌اثر (no-op) و تطبیق نام همچنان درست است — رگرسیون نده. +- تست: + ```bash + ddev exec php bin/console app:doctors:fix-irimc-names --dry-run + ddev exec php bin/console app:doctors:purge # dry-run + ddev exec php bin/console app:doctors:purge --force # پاک‌سازی + # سپس یک ایمپورت تست و بررسی نام در /admin/doctors (باید «دکتر صفورا حجازی نیا» تک‌پیشوند باشد) + ``` +- بعد از تغییر سرویس/کنترلر ایمپورت، `docs/api/doctor-import.md` را با «نام بدون پیشوند دکتر ذخیره می‌شود» به‌روز کن. +- تست integration موجود `DoctorImportTest` را به‌روز کن: assert کند نام ذخیره‌شده پیشوند «دکتر» ندارد. diff --git a/.claude/prompt/irimc-import-complete.md b/.claude/prompt/irimc-import-complete.md index c6dc867a..32a47eb9 100644 --- a/.claude/prompt/irimc-import-complete.md +++ b/.claude/prompt/irimc-import-complete.md @@ -1,201 +1,342 @@ -# تکمیل فیچر ایمپورت پزشکان نظام پزشکی (قطعات باقی‌مانده) +# فیچر کامل ایمپورت پزشکان نظام پزشکی (IRIMC): ایمپورت، تصاحب پروفایل، کرالر State-Based -## پروژه +> نسخهٔ بازنویسی‌شده — production-grade. جایگزین نسخهٔ قبلی این فایل. +> مبنا: بررسی کامل `docs/scenarios/` (هر ۴ سند) + کد واقعی. هر ادعای این پرامپت با `file:line` تأیید شده است. -`clinicpro` (backend) + یک تغییر کوچک در `clinicpro-crawler/clinicpro_client.py` +## پروژه‌ها و برنچ -> **برنچ:** تغییرات backend روی برنچ جدید در repo خود clinicpro: `git -C clinicpro checkout -b feature/irimc-doctor-import` -> (تغییر کرالر در repo والد است — همان‌جا commit شود.) +**قانون برنچ (الزامی):** هیچ تغییری روی `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` | -سند سناریو: [docs/scenarios/ایمپورت-پزشکان-نظام-پزشکی.md](../docs/scenarios/ایمپورت-پزشکان-نظام-پزشکی.md). -بخش عمدهٔ فیچر **قبلاً پیاده شده و در کد موجود است** — دوباره نساز: +> `clinic-pro-tauri` **کاملاً خارج از scope این فیچر است** — هیچ تغییری در آن نده و آن را در نظر نگیر. -| قطعه | وضعیت | -|------|-------| -| ستون‌های مالکیت `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/` | +ترتیب اجرا (قانون workspace): backend اول → مستندات API → کلاینت‌ها. -**چهار قطعه از سند هنوز پیاده نشده** — این پرامپت فقط همان‌هاست: +--- -1. نقش `ROLE_UNCLAIMED_DOCTOR` برای کاربر جانشین (§۳ سند) — الان جانشین فقط `ROLE_USER` می‌گیرد. -2. اندپوینت انتقال مالکیت `POST /api/v1/admin/doctors/{uuid}/transfer` (§۴) — متد entity هست، کنترلر **نیست**. -3. حذف امن کاربر جانشین بعد از انتقال (§۳) — وابسته به ۱ و ۲. -4. رد شدن کپچا برای لاگین سرویسیِ کرالر (§۷) — الان لاگین headless با `ERR_CAPTCHA_001` می‌شکند. +## ۱. هدف فیچر -## فایل‌های مرتبط +پزشکان از سامانهٔ نظام پزشکی (`membersearch.irimc.org`) — که **موبایل ندارند** — به کلینیک‌پرو ایمپورت می‌شوند تا در Nobat724 نمایش داده شوند؛ سپس پزشک واقعی از طریق سایت، با **احراز هویت API.ir + OTP**، پروفایل خود را تصاحب (claim) می‌کند. یک کرالر پایتونی مستقل، با state داخلی SQLite و پنل مدیریت توکن، دادهٔ نظام پزشکی را استان‌به‌استان/شهربه‌شهر می‌خزد و از طریق API رسمی ایمپورت می‌کند. -| فایل | نقش | -|------|-----| -| `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); -} -``` +| قطعه | محل | وضعیت | +|---|---|---| +| ستون‌های مالکیت `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_`، 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` | ✅ کد جدید فقط اگر معنای موجود نبود | -متد آمادهٔ entity: +### ۲.۲ واگرایی‌های سند-با-کد که این پرامپت حل می‌کند (تصمیم‌های معماری مستند) -```php -// Doctor.php:388-396 — user_id را پر می‌کند، مدیریت سیستمی را برمی‌دارد و وضعیت را claimed می‌کند. -public function transferOwnershipTo(User $user): self -{ - ... - $this->ownerStatus = 'claimed'; - $this->claimedAt = time(); -``` +1. **گزینه 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 تمیز نگه داشته می‌شود. سند سناریو باید پس از پیاده‌سازی با این تصمیم به‌روز شود. +2. **`ROLE_UNCLAIMED_DOCTOR` در مستند هست، در کد نیست.** `docs/api/doctor-import.md` این نقش را توصیف می‌کند ولی `importDoctor` آن را نمی‌دهد (`AdminApiController.php:~514` فقط `new User + setStatus(0)`). کد باید به مستند برسد (وظیفهٔ ۳.۲). +3. **تأیید ادمین در برابر انتقال خودکار.** سند قدیمی‌تر approve دستی ادمین را برای فاز اول الزامی کرده بود؛ سند جدیدتر `docs/scenarios/climed.md` (مؤخر و صریح) claim را پس از موفقیت PersonInfo **خودکار نهایی** می‌کند. + **تصمیم: climed.md حاکم است** — claim پس از تطبیق هویت خودکار نهایی می‌شود؛ ادمین به‌جای approve، **visibility** می‌گیرد (لاگ ادعاها + انتقال دستی برای پشتیبانی، وظیفهٔ ۳.۴ و ۵). +4. **ایندکس `(source, medical_system_code)` یکتا نیست.** `Version20260711120000` فقط `INDEX` ساخته؛ dedup فقط application-level است → با دو درخواست هم‌زمان (دو worker کرالر یا retry شبکه) رکورد تکراری ممکن است. باید UNIQUE شود (وظیفهٔ ۳.۳). +5. **کرالر NestJS؟** `docs/scenarios/crawler.md` در انتها NestJS را «پیشنهاد» می‌کند؛ کرالر موجود Python/Flask بالغ است (rate-limit، mapping، resumable). **تصمیم: Python می‌ماند**؛ الزامات crawler.md (SQLite state، پنل توکن، ترتیب استان→شهر) روی همین پایه پیاده می‌شود (وظیفهٔ ۶). +6. **کپچا.** `PasswordAuthenticator::authenticate()` خط ۴۹ بی‌قید `$this->captcha->assertValid($request)` را صدا می‌زند → لاگین headless کرالر با `ERR_CAPTCHA_001` می‌شکند (سند سناریو §۷). راه‌حل هدر سرویسی محدود (وظیفهٔ ۴.۲). -کپچا (بدون استثنا): +--- -```php -// PasswordAuthenticator.php:49 — ابتدای authenticate() -$this->captcha->assertValid($request); -``` +## ۳. Workstream A — بک‌اند clinicpro -## وظایف +### ۳.۱ استخراج منطق ایمپورت از کنترلر (thin controller) -### ۱. نقش `ROLE_UNCLAIMED_DOCTOR` برای کاربر جانشین +`importDoctor` الان ~۱۰۰ خط منطق دامنه داخل کنترلر دارد (ساخت جانشین، idempotency، sync روابط). استخراج به سرویس: -در `importDoctor`، هنگام ساخت کاربر جانشین: +- فایل جدید `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` (پس از استخراج): ```php $user = new User($synthetic); $user->setRealName($name); $user->setStatus(0); -$user->addRole('ROLE_UNCLAIMED_DOCTOR'); -$this->em->persist($user); +$user->addRole('ROLE_UNCLAIMED_DOCTOR'); // User.php:105 ``` -- نقش را به‌صورت رشته اضافه کن (الگوی موجود `addRole('ROLE_DOCTOR')` در پروژه). -- **ایمپورت‌های قبلی** (جانشین‌های موجود بدون این نقش): چون idempotent است، در همان `importDoctor` وقتی `$doctor !== null && unclaimed` است هم نقش را به کاربر فعلی‌اش تضمین کن (`if (!$user->hasRole(...)) addRole(...)`) — کاربر جانشین از `$doctor->getUser()` در دسترس است. +- **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) -در `AdminApiController` (کنار `importDoctor`، همان الگوی OA + `$this->success/error`): +Migration جدید: -```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'); - } +```sql +-- پیش‌شرط (در همان 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 باید جدا چک شود. - $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, - ]); -} +DROP INDEX idx_doctors_source ON doctors; +CREATE UNIQUE INDEX uniq_doctors_source_code ON doctors (source, medical_system_code); ``` -نکته‌ها: -- **ترتیب**: اول `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. +- MariaDB چند `NULL` را در ایندکس یکتا مجاز می‌داند → پزشکان manual بدون کد می‌مانند، مشکلی نیست. رکوردهای manual با کد تکراری اگر در prod وجود داشتند، migration باید **متوقف شود نه اینکه داده حذف کند** — گزارش بده، پاک‌سازی دستی/جداگانه. +- در `DoctorImportService`، `UniqueConstraintViolationException` را بگیر و به‌عنوان «برندهٔ هم‌زمانی، رکورد موجود را آپدیت کن» retry کن (یک بار) — این کنار قید DB، مسیر concurrent-import را قطعی می‌کند. -### ۳. رد شدن کپچا برای لاگین سرویسی کرالر +### ۳.۴ جریان Claim (تصاحب پروفایل توسط پزشک واقعی) — طبق `climed.md` -راه انتخابی سند (§۷، گزینهٔ «هدر سرّی مورد اعتماد»)، امن‌تر از خاموش‌کردن ALTCHA: +**سرویس:** `src/Doctor/Service/DoctorClaimService.php`. **کنترلر:** `src/Doctor/Controller/DoctorClaimController.php` (thin، extends `BaseController`). -در `PasswordAuthenticator::authenticate()`، قبل از `assertValid`: +**موجودیت audit جدید:** `DoctorClaimRequest` (جدول `doctor_claim_requests` + migration): -```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); -} +``` +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) ``` -- **فقط کپچا** دور زده می‌شود؛ rate-limit و اعتبارسنجی رمز سر جای خود می‌مانند. -- اگر env خالی باشد هیچ bypass وجود ندارد (پیش‌فرض امن). -- env جدید را به `.env` (خالی) و `.env.example` اضافه کن + ذکر در مستند. -- ترجیحاً env را از طریق constructor bind کن (الگوی `services.yaml` مثل `$appUrl: '%env(APP_BASE_URL)%'`) نه `$_ENV` مستقیم — با الگوی موجود فایل هماهنگ شو. +**API (قرارداد کامل):** -### ۴. کرالر: ارسال هدر سرویس +``` +GET /api/v1/doctor/{uuid}/claim-info [PUBLIC — در الگوی public_endpoints فعلی `api/v1/doctors` نیست؛ به pattern اضافه شود] + → 200 { success, data: { claimable: bool, owner_status } } + فقط برای رندر دکمهٔ «آیا شما این پزشک هستید؟». هیچ دادهٔ هویتی برنمی‌گرداند. -در `clinicpro-crawler/clinicpro_client.py`، متد لاگین: اگر env `CLINICPRO_SERVICE_TOKEN` ست بود، هدر `X-Service-Token` را به درخواست لاگین اضافه کن (فقط لاگین کافی است). به `.env.example` کرالر هم اضافه کن. +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()` (ترتیب دقیق):** -- `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 انتقال را کنارش اضافه کن. +1. **قفل و گارد وضعیت** — داخل تراکنش، `SELECT ... FOR UPDATE` روی ردیف پزشک (`$em->find(Doctor::class, $id, LockMode::PESSIMISTIC_WRITE)`)؛ اگر `owner_status !== 'unclaimed'` → 409. این + قید منطقی «کاربر فقط یک پزشک» (`findOneBy(['user' => $target])`) شرط race در climed.md را برآورده می‌کند: یک Doctor هرگز به دو User وصل نمی‌شود. +2. وضعیت → `pending_transfer` + ساخت `DoctorClaimRequest(pending)` + flush + **پایان تراکنش کوتاه** (قفل آزاد؛ فراخوان خارجی داخل قفل ممنوع). +3. **تطبیق موبایل↔کدملی:** `ApiIrService::shahkarMatch($nationalCode, $user->getMobileNumber())` — سرویس و الگوی مصرفش موجود است (Representation:103). climed.md می‌گوید «اگر API.ir تطبیق موبایل دارد از آن استفاده کن» → دارد. اگر `isConfigured() === false` (env نبود)، fallback: موبایل کاربر لاگین‌شده قبلاً با OTP تأیید شده (مسیر ورود موجود) — کافی شمرده می‌شود، در `verification_method` ثبت شود. +4. **PersonInfo:** متد جدید `ApiIrService::personInfo(string $nationalCode, string $birthDateJalali): ?array` — همان الگوی `post()` موجود (`/api/sw1/PersonInfo`)؛ timeout موجود سرویس؛ `alive === false` → توقف با `ERR_IDENTITY_001`. +5. **تطبیق نام:** normalize فارسی سپس compare: + - `firstName+lastName` برگشتی API.ir ↔ نام پزشک ایمپورت‌شده (`Doctor::name` — پیشوند «دکتر» را strip کن) + - ورودی کاربر ↔ دادهٔ تأییدشدهٔ API.ir + - **Normalizer مشترک:** اول `src/Shared/` را برای util موجود بگرد (`User::setNationalCode` normalize ارقام دارد — ببین از کجا)؛ اگر normalizer نام فارسی نبود، `src/Shared/Util/PersianText.php` بساز: ي→ی، ك→ک، حذف نیم‌فاصله/فاصله‌های تکراری، trim، `Normalizer::normalize(..., FORM_KC)`. تست واحد جدا دارد. +6. **نهایی‌سازی (تراکنش دوم، اتمیک):** re-check `owner_status === 'pending_transfer'` و همین claim فعال → `$user->setNationalCode(...)->setNationalCodeVerified(true)` → `$user->addRole('ROLE_DOCTOR')` → `$doctor->transferOwnershipTo($user)` (موجود، Doctor.php:390) → claim `completed` → flush → **حذف امن جانشین در flush دوم**: فقط اگر `hasRole('ROLE_UNCLAIMED_DOCTOR')` && هیچ Doctor دیگری به او وصل نیست && غیر از کاربر هدف (شمارش بعد از flush اول تا UnitOfWork گمراه نکند). +7. شکست در هر مرحلهٔ ۳-۵: claim → `failed` + `failure_reason`، پزشک → **برگشت به `unclaimed`** (تا برای تلاش مجدد/شخص واقعی آزاد بماند). خطای API.ir → `retryable` است؛ خطای تطبیق → permanent، retry بی‌معنا. +8. **اطلاع‌رسانی:** پیامک خوش‌آمد با `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. -- `transferOwnershipTo` از قبل `claimed_at`/`owner_status`/`managed_by` را هندل می‌کند — منطق را در کنترلر تکرار نکن. -- پروفایل `pending_transfer` در این فاز فقط یک مقدار enum است؛ جریان درخواست تصاحب از سمت Nobat724 فاز بعدی است (§۹) — نساز. -- حذف جانشین باید **دقیقاً** سه شرط سند را داشته باشد: نقش `ROLE_UNCLAIMED_DOCTOR` + هیچ پزشک متصل + غیر از کاربر هدف. کاربر واقعی را هرگز حذف نکن. -- `activeDoctorAppointment` بعد از transfer دست نزن — روشن‌کردن نوبت‌دهی با مالک جدید است. -- بعد از تغییر API، به‌روزرسانی `docs/api/doctor-import.md` در همین session الزامی است (قانون پروژه). +**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 — باید اصلاح شود:** + +1. نقش جدید `ROLE_IMPORTER`. `SystemOwnerCommand` را طوری تغییر بده که کاربر سیستمی `['ROLE_USER','ROLE_IMPORTER']` بگیرد (نه ADMIN)؛ برای کاربر موجود در DBها یک پاس migration دیتایی/اجرای مجدد دستور. +2. اندپوینت 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` سلسله‌مراتب نقش دست نخورد. +3. **کپچا (لاگین headless):** در `PasswordAuthenticator::authenticate()` قبل از `assertValid` (خط ۴۹): + ```php + 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 لاگین و اعتبارسنجی رمز دست‌نخورده می‌مانند. + ``` + env از طریق bind در `services.yaml` (الگوی `$appUrl: '%env(APP_BASE_URL)%'`) تزریق شود، نه `$_ENV` مستقیم. مقدار خالی = هیچ bypass (secure by default). به `.env`/`.env.example` اضافه شود. +4. چرخهٔ توکن: 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). + +1. **صفحهٔ پزشک** (`app/doctor/[slug]/page.js`): اگر `owner_status === 'unclaimed'`: + - برچسب وضعیت روی پروفایل: «این پروفایل هنوز توسط پزشک مدیریت نمی‌شود» + - دقیقاً زیر بخش نوبت‌دهی: بلوک «آیا شما این پزشک هستید؟» + دکمهٔ «تأیید و مدیریت این پروفایل» + - نوبت‌دهی آنلاین غیرفعال می‌ماند (از قبل `active=false` چون برنامهٔ کاری ندارد — رفتار موجود، تغییر نده). +2. **کامپوننت مشترک** `components/doctor/ClaimProfileModal.jsx` — یک کامپوننت برای دامنهٔ اصلی + همهٔ subdomainها + دامنه‌های نماینده (multi-domain از قبل با `ProvinceProvider`/`getStateInfo` حل است؛ منطق claim به دامنه وابسته نیست، duplicate نکن). +3. **جریان داخل Modal:** + - کاربر لاگین نیست → مسیر OTP موجود (send-code/verify-code) داخل همان modal یا redirect به فلوی ورود موجود — از الگوی auth موجود سایت استفاده کن، فرم OTP جدید نساز. + - فرم: موبایل (پیش‌پرشده از کاربر لاگین)، کد ملی، تاریخ تولد شمسی (**date picker موجود پروژه**)، نام، نام خانوادگی. + - `request.post('doctor/{uuid}/claim', body, { requireAuth: true })` از `services/response.js`. +4. **stateهای الزامی UI:** loading (دکمه disable + spinner)، خطای validation فیلدبه‌فیلد، خطای هویت (پیام عمومی)، 409 (قبلاً تصاحب‌شده)، 429، خطای شبکه با دکمهٔ تلاش مجدد، جلوگیری از double-submit (disable در حین flight)، success. +5. **پیام موفقیت (متن دقیق climed.md):** + «دکتر [نام پزشک]، به نوبت ۷۲۴ خوش آمدید 🎉 پروفایل شما با موفقیت تأیید شد و اکنون می‌توانید اطلاعات پروفایل و تنظیمات نوبت‌دهی خود را مدیریت کنید.» سپس هدایت طبق فلوی auth موجود به پنل. +6. هیچ درخواست مستقیمی از فرانت به API.ir نمی‌رود؛ هیچ توکنی به فرانت نمی‌رسد (همه backend، §۳.۴). +7. قواعد کسب‌وکار در فرانت تکرار نشود — دکمه با `owner_status` رندر می‌شود ولی مرجع نهایی backend است (403/409 هندل شود). + +--- + +## ۵. Workstream C — پنل ادمین clinicpro (visibility عملیاتی) + +صفحهٔ جدید `assets/admin/pages/DoctorClaimsPage.tsx` (الگوی موجود: `PaginatedResponse` + 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-پایدار). جداول: + +```sql +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) + +1. کرالر با پنل خودش به کلینیک‌پرو لاگین می‌کند (بدون توکن دستی)، استان→شهر ترتیبی می‌خزد، پس از kill/restart از همان نقطه ادامه می‌دهد، و پزشکان در کلینیک‌پرو `unclaimed` ظاهر می‌شوند — کاربر سیستمی فقط `ROLE_IMPORTER` دارد و به هیچ endpoint ادمین دیگری دسترسی ندارد (تست 403). +2. اجرای دوبارهٔ ایمپورت روی همان دیتاست: صفر رکورد تکراری (قید DB) و پروفایل‌های claimed دست‌نخورده. +3. در Nobat724 (دامنهٔ اصلی + یک subdomain نماینده) پروفایل unclaimed برچسب و دکمهٔ claim دارد؛ جریان کامل claim با API.ir mockنشده در staging طی می‌شود؛ پس از claim: پیام خوش‌آمد، `ROLE_DOCTOR`، جانشین حذف، ویرایش پروفایل توسط پزشک ممکن، نوبت‌دهی همچنان خاموش تا برنامهٔ کاری تعریف شود. +4. ادمین در پنل: لیست claimها با علت شکست + انتقال دستی کارا. +5. هیچ کد ملی/تاریخ تولد/موبایل کامل/توکنی در هیچ لاگی (app_log و لاگ کرالر) ظاهر نمی‌شود — با grep روی لاگ staging تأیید شود. +6. کل 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 موجود است. diff --git a/.env.example b/.env.example index 13af84d2..8285fe4e 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,6 @@ ALLOWED_FRONTEND_HOSTS=clinic-pro.ir,yasuj-nobat.ir,yazd-nobat.ir APP_BASE_URL=https://clinic-pro.ir # کلیدهای درگاه (mellat/sep) از پنل «تنظیمات سایت» (DB) خوانده می‌شوند؛ env فقط fallback اختیاری است. ###< Payment ### + +# لاگین سرویسی کرالر: مقدار غیرخالی، هدر X-Service-Token را برای دورزدن کپچای لاگین فعال می‌کند (فقط کپچا) +CRAWLER_SERVICE_TOKEN= diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 0769cfb2..04093dd5 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -39,6 +39,7 @@ import MyPatientsPage from './pages/MyPatientsPage'; import NewSessionPage from './pages/NewSessionPage'; import InsurancePricingPage from './pages/InsurancePricingPage'; import ClaimsPage from './pages/ClaimsPage'; +import DoctorClaimsPage from './pages/DoctorClaimsPage'; import MyFinancialPage from './pages/MyFinancialPage'; import ClinicFormPage from './pages/ClinicFormPage'; import PreRegistrationsPage from './pages/PreRegistrationsPage'; @@ -177,6 +178,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 9e30a2c1..cf147b92 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -73,6 +73,7 @@ function buildSections( label: "کاربران", }, { to: "/admin/doctors", icon: HeartIcon, label: "پزشکان" }, + { to: "/admin/doctor-claims", icon: HeartIcon, label: "تصاحب پروفایل" }, { to: "/admin/clinics", icon: BuildingOffice2Icon, diff --git a/assets/admin/pages/DoctorClaimsPage.tsx b/assets/admin/pages/DoctorClaimsPage.tsx new file mode 100644 index 00000000..3d6eee32 --- /dev/null +++ b/assets/admin/pages/DoctorClaimsPage.tsx @@ -0,0 +1,186 @@ +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { ArrowPathIcon, UserPlusIcon } from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import type { ApiResponse, PaginatedResponse } from '../lib/api'; +import { formatDate } from '../lib/utils'; +import DataTable, { Column } from '../components/ui/DataTable'; +import Pagination from '../components/ui/Pagination'; +import Modal from '../components/ui/Modal'; + +interface DoctorClaim { + uuid: string; + status: 'pending' | 'completed' | 'failed'; + doctor: { uuid: string; name: string }; + mobile_masked: string; + verification_method: string; + failure_reason: string | null; + created_at: number; + completed_at: number | null; +} + +const FILTERS = [ + { value: '', label: 'همه' }, + { value: 'completed', label: 'موفق' }, + { value: 'failed', label: 'ناموفق' }, + { value: 'pending', label: 'در جریان' }, +]; + +const STATUS_BADGE: Record = { + completed: { cls: 'green', label: 'موفق' }, + failed: { cls: 'red', label: 'ناموفق' }, + pending: { cls: 'amber', label: 'در جریان' }, +}; + +const METHOD_LABEL: Record = { + 'apiir_personinfo+shahkar': 'استعلام هویت + شاهکار', + 'apiir_personinfo': 'استعلام هویت', + 'admin_manual': 'انتقال دستی ادمین', +}; + +export default function DoctorClaimsPage() { + const qc = useQueryClient(); + const [page, setPage] = useState(1); + const [status, setStatus] = useState(''); + const [transferTarget, setTransferTarget] = useState(null); + const [transferMobile, setTransferMobile] = useState(''); + const limit = 20; + + const { data, isLoading, refetch, isFetching } = useQuery({ + queryKey: ['doctor-claims', page, status], + queryFn: () => { + const p = new URLSearchParams({ page: String(page), limit: String(limit) }); + if (status) p.set('status', status); + return api.get>(`/api/v1/admin/doctor-claims?${p}`); + }, + }); + + const transferMut = useMutation({ + mutationFn: ({ doctorUuid, mobile }: { doctorUuid: string; mobile: string }) => + api.post>(`/api/v1/admin/doctors/${doctorUuid}/transfer`, { mobile }), + onSuccess: () => { + toast.success('پروفایل با موفقیت منتقل شد'); + setTransferTarget(null); + setTransferMobile(''); + qc.invalidateQueries({ queryKey: ['doctor-claims'] }); + qc.invalidateQueries({ queryKey: ['admin-doctors'] }); + }, + onError: (err: Error) => toast.error(err.message), + }); + + const columns: Column[] = [ + { key: 'doctor', header: 'پزشک', render: (c) => {c.doctor?.name} }, + { + key: 'status', + header: 'وضعیت', + render: (c) => {STATUS_BADGE[c.status].label}, + }, + { key: 'mobile_masked', header: 'موبایل', render: (c) => {c.mobile_masked} }, + { + key: 'verification_method', + header: 'روش احراز', + render: (c) => METHOD_LABEL[c.verification_method] ?? c.verification_method, + }, + { + key: 'failure_reason', + header: 'علت شکست', + render: (c) => c.failure_reason + ? {c.failure_reason} + : '—', + }, + { key: 'created_at', header: 'تاریخ درخواست', render: (c) => formatDate(c.created_at) }, + { + key: 'completed_at', + header: 'پایان', + render: (c) => (c.completed_at ? formatDate(c.completed_at) : '—'), + }, + ]; + + const items = data?.data ?? []; + const total = data?.meta?.totalRecords ?? 0; + + return ( +
+
+
+

تصاحب پروفایل پزشکان

+
{total} درخواست — پروفایل‌های ایمپورت‌شده از نظام پزشکی
+
+
+ +
+
+
+
+ {FILTERS.map((f) => ( + + ))} +
+
+ +
+
+ + + columns={columns} + data={items} + loading={isLoading} + emptyMessage="هیچ درخواست تصاحبی ثبت نشده است" + actions={(claim) => ( + claim.status !== 'completed' ? ( + + ) : null + )} + /> + +
+ + setTransferTarget(null)} + title={`انتقال دستی پروفایل ${transferTarget?.doctor?.name ?? ''}`} + > +
+

+ مالکیت پروفایل بدون استعلام هویت به کاربرِ این شماره منتقل می‌شود + (اگر کاربری با این موبایل نباشد، ساخته می‌شود). فقط برای پشتیبانی استفاده کنید. +

+
+ setTransferMobile(e.target.value)} + placeholder="09xxxxxxxxx" + maxLength={11} + /> +
+
+ + +
+
+
+
+ ); +} diff --git a/assets/admin/pages/DoctorDetailPage.tsx b/assets/admin/pages/DoctorDetailPage.tsx index a5feea5f..0db95596 100644 --- a/assets/admin/pages/DoctorDetailPage.tsx +++ b/assets/admin/pages/DoctorDetailPage.tsx @@ -449,15 +449,19 @@ function StarRating({ rate }: { rate: number }) { const IRAN_CENTER: [number, number] = [32.4279, 53.6880]; async function geocodeCityInIran(cityName: string): Promise<[number, number] | null> { - try { - const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(cityName + ',ایران')}&format=json&countrycodes=ir&limit=1`; - const res = await fetch(url, { headers: { 'Accept-Language': 'fa' } }); - const data = await res.json(); - if (data?.[0]) return [parseFloat(data[0].lat), parseFloat(data[0].lon)]; - return null; - } catch { - return null; + const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(cityName + ',ایران')}&format=json&countrycodes=ir&limit=1`; + // nominatim گاهی روی اولین فراخوان خالی/۴۲۹ برمی‌گرداند؛ یک retry تا انتخاب اول هم کار کند. + for (let attempt = 0; attempt < 2; attempt++) { + try { + const res = await fetch(url, { headers: { 'Accept-Language': 'fa' } }); + const data = await res.json(); + if (data?.[0]) return [parseFloat(data[0].lat), parseFloat(data[0].lon)]; + } catch { + /* تلاش بعدی */ + } + if (attempt === 0) await new Promise(r => setTimeout(r, 900)); } + return null; } function MapClickHandler({ onPick }: { onPick: (lat: number, lng: number) => void }) { @@ -467,8 +471,14 @@ function MapClickHandler({ onPick }: { onPick: (lat: number, lng: number) => voi function MapController({ flyTarget }: { flyTarget: [number, number] | null }) { const map = useMap(); + // نقشهٔ تازه‌مانت‌شده ابعادش را نگرفته؛ flyTo بی‌اثر می‌ماند تا invalidateSize صدا زده شود. useEffect(() => { - if (flyTarget) map.flyTo(flyTarget, 12, { duration: 1.2 }); + map.invalidateSize(); + }, [map]); + useEffect(() => { + if (!flyTarget) return; + map.invalidateSize(); + map.flyTo(flyTarget, 12, { duration: 1.2 }); }, [flyTarget, map]); return null; } diff --git a/assets/admin/pages/DoctorsPage.tsx b/assets/admin/pages/DoctorsPage.tsx index ea5cc2af..210dd650 100644 --- a/assets/admin/pages/DoctorsPage.tsx +++ b/assets/admin/pages/DoctorsPage.tsx @@ -27,6 +27,8 @@ interface AdminDoctor { mobile: string | null; email: string | null; is_active: boolean; + owner_status?: string; + source?: string; rate: number; specialties: { id: number; name: string }[]; profile_image: string | null; @@ -100,6 +102,7 @@ export default function DoctorsPage() { const [searchInput, setSearchInput] = useState(''); const [search, setSearch] = useState(''); const [status, setStatus] = useState(''); + const [ownerStatus, setOwnerStatus] = useState(''); const [specialtyId, setSpecialty] = useState(''); const [view, setView] = useState<'table' | 'grid'>('table'); const [deleteTarget, setDeleteTarget] = useState(null); @@ -109,7 +112,7 @@ export default function DoctorsPage() { return () => clearTimeout(t); }, [searchInput]); - useEffect(() => { setPage(1); }, [status, specialtyId]); + useEffect(() => { setPage(1); }, [status, ownerStatus, specialtyId]); // ── Queries ── @@ -131,11 +134,12 @@ export default function DoctorsPage() { }); const doctorsQ = useQuery({ - queryKey: ['admin-doctors', page, limit, search, status, specialtyId], + queryKey: ['admin-doctors', page, limit, search, status, ownerStatus, specialtyId], queryFn: () => { const p = new URLSearchParams({ page: String(page), limit: String(limit) }); if (search) p.set('search', search); if (status) p.set('status', status); + if (ownerStatus && !isRepresentation) p.set('owner_status', ownerStatus); if (specialtyId) p.set('specialty_id', specialtyId); const base = isRepresentation ? '/api/v1/representation/doctors' : '/api/v1/admin/doctors'; return api.get>(`${base}?${p}`); @@ -257,6 +261,13 @@ export default function DoctorsPage() {
+ {!isRepresentation && ( +
+ + + +
+ )}