Merge branch 'feature/irimc-doctor-import'

This commit is contained in:
hamed
2026-07-11 20:12:06 +03:30
89 changed files with 10829 additions and 2077 deletions
@@ -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 مالک به‌روز/تکمیل کن؛ سبز بمانند.
+146
View File
@@ -0,0 +1,146 @@
# رفع نام دوتایی «دکتر» در ایمپورت IRIMC + دستور پاک‌سازی کامل پزشکان
## پروژه
`clinicpro` (backend + پنل ادمین)
## زمینه
پس از ایمپورت پزشکان نظام پزشکی، نام در پنل ادمین اشتباه نمایش داده می‌شود: به‌جای
«دکتر صفورا حجازی نیا»، «دکتر دکتر صفورا حجازی نیا» و در کارت grid به‌خاطر ellipsis
بریده و «دکتر دکتر صفورا حجازی نی» دیده می‌شود.
**ریشه (تأییدشده):** نامِ خامِ نظام پزشکی خودش پیشوند «دکتر» دارد (`دکتر صفورا حجازی نیا`
در `doctors.json` و در DB، ۲۰ کاراکتر — داده درست ذخیره شده). اما کنوانسیون پنل این است
که نام **بدون** پیشوند ذخیره شود و خودِ UI «دکتر» را جلو می‌گذارد:
```tsx
// assets/admin/pages/DoctorsPage.tsx:326 (جدول) و :412-413 (کارت grid با ellipsis)
<b>دکتر {doc.name}</b>
```
پس وقتی `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 کند نام ذخیره‌شده پیشوند «دکتر» ندارد.
+297 -156
View File
@@ -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_<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/` |
ترتیب اجرا (قانون 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_<md5-14>`، 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<T>` + 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 موجود است.
+3
View File
@@ -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=
+2
View File
@@ -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() {
<Route path="representation-finance" element={<RoleRoute roles={['representation']}><RepresentationFinancePage /></RoleRoute>} />
<Route path="representation-profile" element={<RoleRoute roles={['representation']}><RepresentationProfilePage /></RoleRoute>} />
<Route path="doctors" element={<RoleRoute roles={['admin', 'representation']}><DoctorsPage /></RoleRoute>} />
<Route path="doctor-claims" element={<RoleRoute roles={['admin']}><DoctorClaimsPage /></RoleRoute>} />
<Route path="doctors/new" element={<RoleRoute roles={['admin', 'representation']}><DoctorFormPage /></RoleRoute>} />
<Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'doctor', 'clinic', 'representation']}><DoctorDetailPage /></RoleRoute>} />
<Route path="profile" element={<RoleRoute roles={['doctor']} blockClinicScope><DoctorProfilePage /></RoleRoute>} />
@@ -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,
+186
View File
@@ -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<DoctorClaim['status'], { cls: string; label: string }> = {
completed: { cls: 'green', label: 'موفق' },
failed: { cls: 'red', label: 'ناموفق' },
pending: { cls: 'amber', label: 'در جریان' },
};
const METHOD_LABEL: Record<string, string> = {
'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<DoctorClaim | null>(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<PaginatedResponse<DoctorClaim>>(`/api/v1/admin/doctor-claims?${p}`);
},
});
const transferMut = useMutation({
mutationFn: ({ doctorUuid, mobile }: { doctorUuid: string; mobile: string }) =>
api.post<ApiResponse<unknown>>(`/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<DoctorClaim>[] = [
{ key: 'doctor', header: 'پزشک', render: (c) => <b>{c.doctor?.name}</b> },
{
key: 'status',
header: 'وضعیت',
render: (c) => <span className={`badge ${STATUS_BADGE[c.status].cls}`}>{STATUS_BADGE[c.status].label}</span>,
},
{ key: 'mobile_masked', header: 'موبایل', render: (c) => <span dir="ltr">{c.mobile_masked}</span> },
{
key: 'verification_method',
header: 'روش احراز',
render: (c) => METHOD_LABEL[c.verification_method] ?? c.verification_method,
},
{
key: 'failure_reason',
header: 'علت شکست',
render: (c) => c.failure_reason
? <span className="muted" style={{ fontSize: 12 }}>{c.failure_reason}</span>
: '—',
},
{ 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 (
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div>
<h1 className="section-title">تصاحب پروفایل پزشکان</h1>
<div className="muted">{total} درخواست پروفایلهای ایمپورتشده از نظام پزشکی</div>
</div>
</div>
<div className="card">
<div className="card-pad" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="seg">
{FILTERS.map((f) => (
<button
key={f.value}
className={status === f.value ? 'on' : ''}
onClick={() => { setStatus(f.value); setPage(1); }}
>
{f.label}
</button>
))}
</div>
<div className="spacer" />
<button className="btn ghost sm" onClick={() => refetch()} disabled={isFetching}>
<ArrowPathIcon style={{ width: 15, height: 15, animation: isFetching ? 'spin 1s linear infinite' : undefined }} />
</button>
</div>
</div>
<DataTable<DoctorClaim>
columns={columns}
data={items}
loading={isLoading}
emptyMessage="هیچ درخواست تصاحبی ثبت نشده است"
actions={(claim) => (
claim.status !== 'completed' ? (
<button
className="mini-btn"
title="انتقال دستی به موبایل"
onClick={() => { setTransferTarget(claim); setTransferMobile(''); }}
>
<UserPlusIcon style={{ width: 15, height: 15 }} />
</button>
) : null
)}
/>
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
</div>
<Modal
open={!!transferTarget}
onClose={() => setTransferTarget(null)}
title={`انتقال دستی پروفایل ${transferTarget?.doctor?.name ?? ''}`}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<p className="muted" style={{ fontSize: 13, lineHeight: 2 }}>
مالکیت پروفایل بدون استعلام هویت به کاربرِ این شماره منتقل میشود
(اگر کاربری با این موبایل نباشد، ساخته میشود). فقط برای پشتیبانی استفاده کنید.
</p>
<div className="field">
<input
dir="ltr"
value={transferMobile}
onChange={(e) => setTransferMobile(e.target.value)}
placeholder="09xxxxxxxxx"
maxLength={11}
/>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn ghost sm" onClick={() => setTransferTarget(null)}>انصراف</button>
<button
className="btn primary sm"
disabled={!/^09\d{9}$/.test(transferMobile) || transferMut.isPending}
onClick={() => transferTarget && transferMut.mutate({ doctorUuid: transferTarget.doctor.uuid, mobile: transferMobile })}
>
{transferMut.isPending ? 'در حال انتقال…' : 'انتقال مالکیت'}
</button>
</div>
</div>
</Modal>
</div>
);
}
+19 -9
View File
@@ -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;
}
+15 -2
View File
@@ -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<AdminDoctor | null>(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<PaginatedResponse<AdminDoctor>>(`${base}?${p}`);
@@ -257,6 +261,13 @@ export default function DoctorsPage() {
<button className={status === 'active' ? 'on' : ''} onClick={() => setStatus('active')}>فعال</button>
<button className={status === 'inactive' ? 'on' : ''} onClick={() => setStatus('inactive')}>غیرفعال</button>
</div>
{!isRepresentation && (
<div className="seg" title="مالکیت پروفایل (ایمپورت نظام پزشکی)">
<button className={!ownerStatus ? 'on' : ''} onClick={() => setOwnerStatus('')}>همه</button>
<button className={ownerStatus === 'unclaimed' ? 'on' : ''} onClick={() => setOwnerStatus('unclaimed')}>بدونمالک</button>
<button className={ownerStatus === 'claimed' ? 'on' : ''} onClick={() => setOwnerStatus('claimed')}>تصاحبشده</button>
</div>
)}
<div className="spacer" />
<div className="seg">
<button className={view === 'table' ? 'on' : ''} onClick={() => setView('table')} title="جدول">
@@ -338,6 +349,8 @@ export default function DoctorsPage() {
{doc.mobile ?? '—'}
</td>
<td>
{doc.owner_status === 'unclaimed' && <span className="badge amber" style={{ marginLeft: 6 }}>بدونمالک</span>}
{doc.owner_status === 'pending_transfer' && <span className="badge violet" style={{ marginLeft: 6 }}>در انتظار انتقال</span>}
<span className={`badge ${doc.is_active ? 'green' : 'gray'}`}>
<span className="bdot" />
{doc.is_active ? 'فعال' : 'غیرفعال'}
@@ -17,6 +17,35 @@ function toJalali(iso: string): string {
return y && m && d ? `${y}/${m}/${d}` : '';
}
// ارقام فارسی/عربی → لاتین (کیبورد انگلیسی؛ ورودی چسبانده‌شده هم نرمال شود)
function toLatinDigits(s: string): string {
return s.replace(/[۰-۹٠-٩]/g, (d) =>
String('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩'.indexOf(d) % 10),
);
}
// اعتبارسنجی کد ملی ایران (طول ۱۰ + رقم کنترلی)
function isValidIranNationalCode(code: string): boolean {
if (!/^\d{10}$/.test(code)) return false;
if (/^(\d)\1{9}$/.test(code)) return false; // ارقام یکسان نامعتبر
const check = +code[9];
let sum = 0;
for (let i = 0; i < 9; i++) sum += +code[i] * (10 - i);
const r = sum % 11;
return r < 2 ? check === r : check === 11 - r;
}
// اعتبارسنجی شبای ایران: IR + ۲۴ رقم + کنترل mod-97
function isValidIranIban(raw: string): boolean {
const iban = toLatinDigits(raw).replace(/\s/g, '').toUpperCase();
if (!/^IR\d{24}$/.test(iban)) return false;
const rearranged = iban.slice(4) + iban.slice(0, 4);
const numeric = rearranged.replace(/[A-Z]/g, (c) => String(c.charCodeAt(0) - 55));
let rem = 0;
for (const ch of numeric) rem = (rem * 10 + +ch) % 97;
return rem === 1;
}
interface IbanItem {
id: string;
iban: string;
@@ -86,16 +115,17 @@ export default function RepresentationProfilePage() {
});
const submitNationalCode = () => {
const code = nationalCode.replace(/\D/g, '');
if (code.length !== 10) { toast.error('کد ملی باید ۱۰ رقم باشد'); return; }
const code = toLatinDigits(nationalCode).replace(/\D/g, '');
if (!isValidIranNationalCode(code)) { toast.error('کد ملی نامعتبر است'); return; }
verifyMut.mutate(code);
};
const submitIban = () => {
if (!iban.trim()) { toast.error('شماره شبا را وارد کنید'); return; }
const clean = toLatinDigits(iban).replace(/\s/g, '').toUpperCase();
if (!isValidIranIban(clean)) { toast.error('شماره شبا نامعتبر است (IR + ۲۴ رقم)'); return; }
const jalali = toJalali(birthDate);
if (!jalali) { toast.error('تاریخ تولد را انتخاب کنید'); return; }
addIbanMut.mutate({ iban: iban.trim(), birth_date: jalali });
addIbanMut.mutate({ iban: clean, birth_date: jalali });
};
return (
@@ -127,7 +157,7 @@ export default function RepresentationProfilePage() {
<input
type="text" inputMode="numeric" dir="ltr" maxLength={10} value={nationalCode}
placeholder="کد ملی ۱۰ رقمی"
onChange={(e) => setNationalCode(e.target.value.replace(/\D/g, ''))}
onChange={(e) => setNationalCode(toLatinDigits(e.target.value).replace(/\D/g, ''))}
style={{ width: 220, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5, boxSizing: 'border-box', textAlign: 'center' }}
/>
<button className="btn primary" onClick={submitNationalCode} disabled={verifyMut.isPending}>
@@ -178,8 +208,9 @@ export default function RepresentationProfilePage() {
{verified && ibans.length < 2 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
<input
type="text" dir="ltr" value={iban} placeholder="IR000000000000000000000000"
onChange={(e) => setIban(e.target.value)}
type="text" inputMode="numeric" dir="ltr" maxLength={26} value={iban}
placeholder="IR000000000000000000000000"
onChange={(e) => setIban(toLatinDigits(e.target.value).toUpperCase().replace(/[^IR0-9]/g, ''))}
style={{ width: 320, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13, boxSizing: 'border-box', fontFamily: 'monospace' }}
/>
<PersianDatePicker
+6
View File
@@ -29,3 +29,9 @@ framework:
policy: 'sliding_window'
limit: 5
interval: '60 minutes'
# Doctor profile claim: max 5 attempts per hour per (user, doctor) — ضد brute-force هویت
doctor_claim:
policy: 'sliding_window'
limit: 5
interval: '60 minutes'
+1 -1
View File
@@ -33,7 +33,7 @@ security:
provider: api_doc_provider
public_endpoints:
pattern: ^/(api/v1/altcha/(challenge|config)$|api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic/[^/]+/addresses$|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/appointment-settings/month-availability/|api/v1/comments/|api/v1/rate/[^/]+$|api/v1/specialties|api/v1/blogs$|api/v1/tags$|api/v1/clinic-invitation/|api/v1/pre-registration$)
pattern: ^/(api/v1/altcha/(challenge|config)$|api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic/[^/]+/addresses$|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/appointment-settings/month-availability/|api/v1/comments/|api/v1/rate/[^/]+$|api/v1/specialties|api/v1/blogs$|api/v1/tags$|api/v1/clinic-invitation/|api/v1/pre-registration$|api/v1/doctor/[^/]+/claim-info$)
stateless: true
security: false
+1
View File
@@ -76,6 +76,7 @@ services:
App\Auth\Security\PasswordAuthenticator:
arguments:
$refreshTokenTtl: '%env(int:REFRESH_TOKEN_TTL)%'
$crawlerServiceToken: '%env(default::CRAWLER_SERVICE_TOKEN)%'
$loginLimiter: '@limiter.login'
App\Auth\Controller\AuthController:
+1
View File
@@ -343,6 +343,7 @@ List all doctors with pagination.
| `status` | string | ❌ | `"active"` or `"inactive"` |
| `gender` | string | ❌ | `"male"` or `"female"` |
| `specialty_id` | integer | ❌ | Filter by specialty |
| `owner_status` | string | ❌ | `claimed` \| `unclaimed` \| `pending_transfer` — پروفایل‌های ایمپورت IRIMC (خروجی هم `owner_status` و `source` دارد) |
| `sort` | string | ❌ | Sort field |
### Response `200`
+156
View File
@@ -0,0 +1,156 @@
# Doctor Profile Claim API (تصاحب پروفایل پزشک ایمپورت‌شده)
> **Controller:** `App\Doctor\Controller\DoctorClaimController` — منطق در `App\Doctor\Service\DoctorClaimService`
> **مصرف‌کننده:** سایت عمومی Nobat724 (همهٔ دامنه‌ها) + پنل ادمین
پزشکِ ایمپورت‌شده از نظام پزشکی (`owner_status = unclaimed`) توسط پزشک واقعی تصاحب می‌شود.
احراز هویت سمت سرور با **API.ir** انجام می‌شود (شاهکار: تطبیق موبایل↔کدملی؛ PersonInfo: تطبیق
کدملی+تاریخ تولد و نام). هیچ درخواستی از فرانت به API.ir نمی‌رود و توکن API.ir هرگز به کلاینت
نمی‌رسد. claim پس از تطبیق موفق **خودکار** نهایی می‌شود (بدون approve ادمین — تصمیم مستند در
`.claude/prompt/irimc-import-complete.md` §۲.۲).
## چرخهٔ وضعیت
```
unclaimed ──claim/transfer شروع──▶ pending_transfer ──موفق──▶ claimed
▲ │شکست تطبیق/خطای استعلام
└───────────────────────────────────┘ (برگشت، قابل تلاش مجدد)
```
هر تلاش یک رکورد ممیزی در `doctor_claim_requests` می‌سازد — کد ملی فقط **hash sha256** و
موبایل فقط **mask شده** ذخیره می‌شود؛ هیچ دادهٔ هویتی خام در DB یا لاگ نمی‌ماند.
---
## GET `/api/v1/doctor/{uuid}/claim-info`
**Permission:** عمومی (بدون JWT) — فقط برای رندر دکمهٔ «آیا شما این پزشک هستید؟»
### Response `200`
```json
{ "success": true, "data": { "claimable": true, "owner_status": "unclaimed" } }
```
| کد | حالت |
|---|---|
| `404` | پزشک یافت نشد |
---
## POST `/api/v1/doctor/{uuid}/claim`
**Permission:** `IS_AUTHENTICATED_FULLY` — کاربر با OTP لاگین شده (موبایلش تأییدشده است)
**Rate limit:** limiter `doctor_claim` — ۵ تلاش در ساعت به‌ازای هر (کاربر، پزشک)
**Captcha:** ALTCHA — بدنه باید payload کپچا بفرستد (`CaptchaGuard::assertValid`)؛ در dev با `ALTCHA_ENABLED=false` بی‌اثر است، در prod اجباری. خطا → `ERR_CAPTCHA_001` (۴۲۲).
### Request
```json
{
"national_code": "0010007700",
"birth_date": "1371/1/1",
"first_name": "فرخنده",
"last_name": "حسینی",
"mobile": "09121234567",
"altcha": "<payload کپچا>"
}
```
| فیلد | الزامی | قاعده |
|---|:---:|---|
| `national_code` | ✅ | ۱۰ رقم (ارقام فارسی پذیرفته و نرمال می‌شوند) |
| `birth_date` | ✅ | شمسی `Y/m/d` |
| `first_name` / `last_name` | ✅ | با هویت ثبت احوال و نام پروفایل تطبیق داده می‌شود (نرمال‌سازی ي/ی، ك/ک، نیم‌فاصله — `PersianText`) |
| `mobile` | ❌ | اگر داده شود، باید با موبایل حساب کاربری یکی باشد وگرنه `422 ERR_CONFLICT_001`؛ اگر خالی باشد از موبایل کاربر لاگین‌شده استفاده می‌شود |
| `altcha` | prod | payload کپچای ALTCHA |
### مراحل سرور (اتمیک/ضد race)
1. قفل `PESSIMISTIC_WRITE` روی ردیف پزشک → اگر `unclaimed` نبود `409`؛ اگر کاربر از قبل پزشکی دارد `409`؛ اگر کد ملی متعلق به کاربر دیگری است `409` — سپس `pending_transfer` + رکورد ممیزی (تراکنش کوتاه، بدون فراخوان خارجی داخل قفل).
2. شاهکار (`ApiIrService::shahkarMatch`) — تطبیق موبایل کاربر با کد ملی. اگر API.ir پیکربندی نشده باشد، این گام skip و مبنا موبایلِ OTP-تأییدشده است.
3. `PersonInfo` — تطبیق کدملی+تاریخ تولد؛ `alive=false` → رد.
4. تطبیق نام: ورودی کاربر ↔ هویت تأییدشده ↔ نام پروفایل (بدون پیشوند «دکتر»).
5. نهایی‌سازی اتمیک: `user_id` → کاربر واقعی، `ROLE_DOCTOR`، `national_code_verified=true`، `owner_status=claimed`، حذف امنِ کاربر جانشین (فقط با `ROLE_UNCLAIMED_DOCTOR` و بدون پزشک دیگر).
6. پیامک خوش‌آمد (تمپلیت `welcome`، async).
> شکست در گام‌های ۲-۴ پروفایل را به `unclaimed` برمی‌گرداند تا پزشک واقعی بتواند دوباره تلاش کند.
> نوبت‌دهی (`active_doctor_appointment`) خاموش می‌ماند تا مالک جدید برنامهٔ کاری تعریف کند.
### Response `200`
```json
{
"success": true,
"data": {
"status": "claimed",
"claim": { "uuid": "…" },
"doctor": { "uuid": "…", "name": "دکتر فرخنده حسینی" }
}
}
```
### Errors
| کد | HTTP | حالت |
|---|---|---|
| `ERR_AUTH_001` | 401 | بدون لاگین |
| `ERR_NOT_FOUND_001` | 404 | پزشک یافت نشد |
| `ERR_CONFLICT_001` | 409 | پروفایل قابل تصاحب نیست / کاربر پزشک دیگری دارد / درخواست هم‌زمان دیگری در جریان است |
| `ERR_PROFILE_001` | 409 | کد ملی قبلاً برای کاربر دیگری ثبت شده است |
| `ERR_VALIDATION_001/002` | 422 | کد ملی/تاریخ/نام نامعتبر |
| `ERR_IDENTITY_001` | 422 | عدم تطبیق هویت (شاهکار/ثبت احوال/نام) — پیام عمومی، ضد enumeration |
| `ERR_RATE_LIMIT_001` | 429 | عبور از سقف تلاش |
| `ERR_EXTERNAL_001` | 502 | خطا/تایم‌اوت API.ir |
| `ERR_EXTERNAL_002` | 503 | API.ir پیکربندی نشده (env) |
---
## POST `/api/v1/admin/doctors/{uuid}/transfer`
انتقال دستی (ابزار پشتیبانی ادمین) — مستند کامل در `docs/api/doctor-import.md`.
**Permission:** `ROLE_ADMIN`. بدنه `{ "mobile": "09…" }`. همان نهایی‌سازی claim را اجرا می‌کند
و رکورد ممیزی با `verification_method="admin_manual"` می‌سازد.
---
## GET `/api/v1/admin/doctor-claims`
**Permission:** `ROLE_ADMIN` — لیست ممیزی درخواست‌های claim برای پشتیبانی عملیاتی.
| Query | پیش‌فرض | توضیح |
|---|---|---|
| `status` | همه | `pending` \| `completed` \| `failed` |
| `page` / `limit` | 1 / 20 (سقف 50) | صفحه‌بندی استاندارد |
### Response `200` (paginated)
```json
{
"success": true,
"data": [
{
"uuid": "…",
"status": "completed",
"doctor": { "uuid": "…", "name": "دکتر فرخنده حسینی" },
"mobile_masked": "0912***4567",
"verification_method": "apiir_personinfo+shahkar",
"failure_reason": null,
"created_at": 1783750000,
"completed_at": 1783750040
}
],
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
}
```
`failure_reason` فارسی و انسانی‌خوان است تا ادمین بدون خواندن لاگ سرور علت شکست را ببیند.
---
## env های مرتبط
| متغیر | نقش |
|---|---|
| `APIIR_*` (baseUrl/token موجود `ApiIrService`) | استعلام شاهکار و PersonInfo |
| `CRAWLER_SERVICE_TOKEN` | فقط برای لاگین سرویسی کرالر (`doctor-import.md`) — ربطی به claim ندارد |
## تست‌ها
`tests/Doctor/DoctorClaimTest.php` (۱۱ سناریو، API.ir همیشه mock — هیچ تستی به سرویس واقعی
درخواست نمی‌زند) و `tests/Shared/PersianTextTest.php` (نرمال‌سازی نام).
+84 -14
View File
@@ -1,8 +1,8 @@
# Doctor Import (IRIMC) API
> **Endpoint:** `POST /api/v1/admin/doctors/import`
> **Permission:** `ROLE_ADMIN`
> **Controller:** `App\Admin\Controller\AdminApiController::importDoctor`
> **Permission:** `ROLE_ADMIN` **یا** `ROLE_IMPORTER` (نقش حداقلی کاربر سیستمی کرالر)
> **Controller:** `App\Doctor\Controller\DoctorImportController::import` — منطق دامنه در `App\Doctor\Service\DoctorImportService`
وارد کردن یک پزشک از سازمان نظام پزشکی (`membersearch.irimc.org`) **بدون شماره موبایل**.
برخلاف `POST /api/v1/admin/doctors` (که موبایل معتبر ایرانی می‌خواهد)، این اندپوینت برای
@@ -31,12 +31,18 @@
## idempotency
کلید یکتای منطقی: `(source, medical_system_code)`.
کلید یکتای `(source, medical_system_code)` — از این نسخه **در سطح دیتابیس** هم unique است
(`uniq_doctors_source_code`، migration `Version20260711150000`)؛ درخواست هم‌زمانِ همان پزشک
با retry داخلی به مسیر update می‌رود و هرگز رکورد تکراری نمی‌سازد.
- اگر پزشکی با همان `source`+`medical_system_code` وجود نداشته باشد → **ساخته** می‌شود (`201`).
- اگر وجود داشته باشد و `owner_status != claimed`**به‌روزرسانی** می‌شود (`200`).
- اگر وجود داشته باشد و `owner_status == claimed`**رد** می‌شود (`200`, `skipped: "claimed"`)
تا دادهٔ مالک واقعی بازنویسی نشود.
- **تکرار بین منابع:** اگر پزشکی با همان `medical_system_code` ولی `source` متفاوت
(مثلاً ثبت دستی در پنل) وجود داشته باشد → **رد** می‌شود (`200`, `skipped: "duplicate"`
نه رکورد جدیدی ساخته می‌شود و نه رکورد موجود بازنویسی می‌شود. `uuid` همان رکورد موجود
برگردانده می‌شود (تست: `DoctorImportTest::testManualDoctorWithSameCodeIsNeverDuplicated`).
---
@@ -59,7 +65,7 @@
| فیلد | الزامی | توضیح |
|---|:---:|---|
| `name` | ✅ | نام کامل پزشک |
| `name` | ✅ | نام کامل پزشک — پیشوند «دکتر» **هنگام ذخیره حذف** می‌شود (کنوانسیون: نام بدون عنوان؛ UI خودش «دکتر» را جلو می‌گذارد). ورودی می‌تواند با یا بدون «دکتر» باشد. |
| `medical_system_code` | ✅ | کد نظام پزشکی (کلید idempotency) |
| `source` | — | پیش‌فرض `irimc` |
| `source_ref` | — | `profile_url` یا شناسهٔ مبدأ |
@@ -73,6 +79,22 @@
---
## آدرس پیش‌فرض (مطب)
هر پزشک ایمپورت‌شده **همیشه دست‌کم یک آدرس** (`doctor_addresses`، type=`personal`) دارد:
- اگر پزشک هیچ آدرسی نداشته باشد، یک آدرس با نام **«مطب دکتر {نام}»** ساخته می‌شود و
شهر/استان آن از **اولین** عنصر آرایه‌های `cities`/`states` همان درخواست پر می‌شود.
- در ایمپورت مجدد آدرس تکراری ساخته نمی‌شود؛ فقط فیلدهای **خالیِ** آدرس موجود
(نام/شهر/استان) backfill می‌شوند — مقادیر ویرایش‌شده توسط کاربر بازنویسی نمی‌شوند.
- اگر `cities`/`states` در درخواست نباشند، آدرس فقط با نام ساخته می‌شود (کرالر در حالت
auto همیشه هر دو را می‌فرستد).
(تست‌ها: `DoctorImportTest::testImportCreatesDefaultOfficeAddressWithCityAndProvince`،
`testImportWithoutLocationStillCreatesOfficeAddress`)
---
## Response
### `201 Created` (ساخته شد)
@@ -90,6 +112,11 @@
{ "success": true, "data": { "uuid": "…", "created": false, "skipped": "claimed" } }
```
### `200 OK` (رد به‌دلیل کد نظام پزشکی تکراری با منبع دیگر)
```json
{ "success": true, "data": { "uuid": "…", "created": false, "skipped": "duplicate" } }
```
### `422` (اعتبارسنجی)
```json
{ "success": false, "data": null, "errors": [ { "code": "…", "message": "کد نظام پزشکی الزامی است", "field": "medical_system_code" } ] }
@@ -132,10 +159,16 @@
// Response 200
{ "success": true, "data": {
"uuid": "…", "owner_status": "claimed",
"transferred_to": "09120000000", "placeholder_deleted": true
"user_mobile": "09120000000",
"claim": { "uuid": "…" }
} }
```
> این اندپوینت در `App\Doctor\Controller\DoctorClaimController::transfer` است و همان
> `DoctorClaimService::transferByAdmin` را صدا می‌زند؛ هر انتقال یک رکورد ممیزی در
> `doctor_claim_requests` با `verification_method = "admin_manual"` می‌سازد.
> جریان self-claim پزشک (با احراز هویت API.ir) در `docs/api/doctor-claim.md` مستند است.
| کد | حالت |
|---|---|
| `404` | پزشک یافت نشد |
@@ -154,13 +187,50 @@ php bin/console app:system-owner 0000000000 --password=<secret> --activate
php bin/console app:system-owner 0000000000 --deactivate
```
کاربر باید `ROLE_ADMIN` و `status=1` داشته باشد تا لاگینِ رمزی (`POST /api/v1/user/login`)
و فراخوانی این اندپوینت ممکن باشد.
کاربر سیستمی **least privilege** است: فقط `ROLE_USER,ROLE_IMPORTER` می‌گیرد (اجرای مجدد
دستور، `ROLE_ADMIN` قدیمی را هم حذف می‌کند). `ROLE_IMPORTER` فقط به همین اندپوینت ایمپورت
دسترسی دارد و به هیچ اندپوینت `/api/v1/admin/*` دیگری راه ندارد (تست: `DoctorImportTest::testImporterRoleCanImportButNothingElse`).
> ⚠️ **captcha:** مسیر `/api/v1/user/login` از `CaptchaGuard` رد می‌شود و این guard وقتی
> `ALTCHA_ENABLED=true` باشد (مقدار فعلی `.env`) یک payloadِ altcha می‌خواهد. برای اجرای
> بدونِ‌مرورگرِ کرالر یکی از این‌ها لازم است:
> ۱) روی همان سرور `ALTCHA_ENABLED=false` در `.env.local` (ساده‌ترین برای dev)، یا
> ۲) افزودن یک استثنا در `PasswordAuthenticator` که برای کاربر مالک سیستمی captcha را رد کند،
> یا ۳) لاگین سرویس با یک هدر سرّیِ مورد اعتماد. تا وقتی این حل نشود، لاگین کرالر با ۴۲۲
> (`ERR_CAPTCHA_001`) رد می‌شود.
### لاگین سرویسی (captcha)
مسیر `/api/v1/user/login` کپچای ALTCHA دارد. برای لاگین headless کرالر، هدر سرّی
تعریف شده است:
```
X-Service-Token: <مقدار env CRAWLER_SERVICE_TOKEN>
```
- فقط **کپچا** دور زده می‌شود؛ rate limit و اعتبارسنجی رمز دست‌نخورده می‌مانند.
- اگر env خالی/تعریف‌نشده باشد هیچ bypass وجود ندارد (secure by default).
- چرخش credential: تغییر `CRAWLER_SERVICE_TOKEN` + تغییر رمز با `app:system-owner … --password=…`؛
ابطال فوری: `--deactivate`.
### backfill نقش جانشین‌های قدیمی
جانشین‌های ساخته‌شده قبل از افزودن نقش marker:
```bash
php bin/console app:doctors:backfill-surrogate-role --dry-run # فقط گزارش
php bin/console app:doctors:backfill-surrogate-role # اعمال
```
### اصلاح نام رکوردهای قدیمی (حذف پیشوند «دکتر»)
رکوردهای IRIMC که پیش از این تغییر با پیشوند «دکتر» ذخیره شده بودند:
```bash
php bin/console app:doctors:fix-irimc-names --dry-run # فقط گزارش
php bin/console app:doctors:fix-irimc-names # اعمال (فقط source='irimc')
```
### پاک‌سازی کامل برای دیتابیس تست
حذف همهٔ پزشکان + داده‌های وابسته (FK-safe) برای شروع تمیز:
```bash
php bin/console app:doctors:purge # dry-run: فقط گزارش تعداد هر جدول
php bin/console app:doctors:purge --force # حذف واقعی + کاربران جانشین یتیم
```
> ⚠️ مخرب — `appointments`/`comments`/`rates` را هم پاک می‌کند. روی prod نیازمند
> `--i-know-this-is-prod` است و پیش‌فرض متوقف می‌شود.
+10 -4
View File
@@ -1,5 +1,9 @@
# Doctor API
> فیلد `owner_status` (`claimed` | `unclaimed` | `pending_transfer`) به خروجی لیست و جزئیات پزشک
> اضافه شده است — پروفایل `unclaimed` (ایمپورت نظام پزشکی) در سایت دکمهٔ «تصاحب پروفایل» می‌گیرد
> (`docs/api/doctor-claim.md`) و نوبت‌دهی آنلاینش غیرفعال است.
> **Prefix:** `/api/v1/doctor`, `/api/v1/doctors`, `/api/v1/clinic-pro/doctor-address*`
>
> Numeric path params on the address routes (`doctor-address/{id}`, `doctor-addresses/{doctorId}`) require `\d+`; a non-numeric value returns a clean `404` instead of a `500`.
@@ -101,6 +105,7 @@ Get doctor detail with clinics.
"free_turn": "دوشنبه 09:0013:00",
"hours_of_work": "شنبه: 09:0013:00 و 14:0018:00 | یکشنبه: 09:0013:00",
"active": true,
"owner_status": "claimed",
"specialties": [{ "uuid": "...", "id": "1", "name": "قلب و عروق", "parent_id": null }],
"expertise": [{ "uuid": "...", "id": "3", "name": "نوار قلب" }],
"address": [],
@@ -204,7 +209,8 @@ List doctors with pagination and filters.
"point": "3.5",
"free_turn": "دوشنبه 09:0013:00",
"hours_of_work": "شنبه: 09:0013:00 و 14:0018:00 | یکشنبه: 09:0013:00",
"active": true
"active": true,
"owner_status": "claimed"
}
],
"meta": {
@@ -263,7 +269,7 @@ Updated doctor object (same structure as GET single).
Delete a doctor profile.
**Permission:** `ROLE_ADMIN`
**Permission:** `IS_AUTHENTICATED_FULLY`**admin** (any profile) **or the owner** of a `claimed` profile (`doctor.user === current user`). Other users get `403`.
> **Side effect:** the doctor's insurance configuration (`tenant_insurances`, `entity_insurance_pricing`, and their `tenant_service_coverages`) is purged in the same request — these reference the doctor via a polymorphic `entity_id` with no DB FK, so the cleanup is enforced in the application.
@@ -286,8 +292,8 @@ Delete a doctor profile.
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
| `ERR_FORBIDDEN_001` | 403 | Not admin and not the owner of this claimed profile |
| `ERR_VALIDATION_002` | 404 | Doctor not found |
| `ERR_CONFLICT_001` | 409 | Doctor has existing appointments and cannot be deleted |
---
+21 -8
View File
@@ -713,7 +713,6 @@
"711": "Community 711",
"712": "Community 712",
"713": "Community 713",
"714": "Community 714",
"715": "Community 715",
"716": "Community 716",
"717": "Community 717",
@@ -727,8 +726,6 @@
"725": "Community 725",
"726": "Community 726",
"727": "Community 727",
"728": "Community 728",
"729": "Community 729",
"730": "Community 730",
"731": "Community 731",
"732": "Community 732",
@@ -736,16 +733,32 @@
"734": "Community 734",
"735": "Community 735",
"736": "Community 736",
"737": "Community 737",
"738": "Community 738",
"739": "Community 739",
"740": "Community 740",
"741": "Community 741",
"742": "Community 742",
"743": "Community 743",
"744": "Community 744",
"745": "Community 745",
"746": "Community 746",
"747": "Community 747",
"748": "Community 748"
"748": "Community 748",
"749": "Community 749",
"750": "Community 750",
"751": "Community 751",
"752": "Community 752",
"753": "Community 753",
"754": "Community 754",
"755": "Community 755",
"756": "Community 756",
"757": "Community 757",
"759": "Community 759",
"760": "Community 760",
"761": "Community 761",
"763": "Community 763",
"764": "Community 764",
"766": "Community 766",
"767": "Community 767",
"768": "Community 768",
"769": "Community 769",
"770": "Community 770",
"771": "Community 771"
}
+210 -211
View File
@@ -1,16 +1,16 @@
# Graph Report - clinicpro (2026-07-11)
## Corpus Check
- 743 files · ~552,971 words
- 767 files · ~566,250 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 9384 nodes · 12986 edges · 749 communities (603 shown, 146 thin omitted)
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 281 edges (avg confidence: 0.8)
- 9614 nodes · 13338 edges · 762 communities (602 shown, 160 thin omitted)
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 294 edges (avg confidence: 0.8)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `a5e3408e`
- Built from commit: `3dcd4f3b`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@@ -715,7 +715,6 @@
- [[_COMMUNITY_Community 711|Community 711]]
- [[_COMMUNITY_Community 712|Community 712]]
- [[_COMMUNITY_Community 713|Community 713]]
- [[_COMMUNITY_Community 714|Community 714]]
- [[_COMMUNITY_Community 715|Community 715]]
- [[_COMMUNITY_Community 716|Community 716]]
- [[_COMMUNITY_Community 717|Community 717]]
@@ -729,8 +728,6 @@
- [[_COMMUNITY_Community 725|Community 725]]
- [[_COMMUNITY_Community 726|Community 726]]
- [[_COMMUNITY_Community 727|Community 727]]
- [[_COMMUNITY_Community 728|Community 728]]
- [[_COMMUNITY_Community 729|Community 729]]
- [[_COMMUNITY_Community 730|Community 730]]
- [[_COMMUNITY_Community 731|Community 731]]
- [[_COMMUNITY_Community 732|Community 732]]
@@ -738,51 +735,65 @@
- [[_COMMUNITY_Community 734|Community 734]]
- [[_COMMUNITY_Community 735|Community 735]]
- [[_COMMUNITY_Community 736|Community 736]]
- [[_COMMUNITY_Community 737|Community 737]]
- [[_COMMUNITY_Community 738|Community 738]]
- [[_COMMUNITY_Community 739|Community 739]]
- [[_COMMUNITY_Community 740|Community 740]]
- [[_COMMUNITY_Community 741|Community 741]]
- [[_COMMUNITY_Community 742|Community 742]]
- [[_COMMUNITY_Community 743|Community 743]]
- [[_COMMUNITY_Community 744|Community 744]]
- [[_COMMUNITY_Community 745|Community 745]]
- [[_COMMUNITY_Community 746|Community 746]]
- [[_COMMUNITY_Community 747|Community 747]]
- [[_COMMUNITY_Community 748|Community 748]]
- [[_COMMUNITY_Community 749|Community 749]]
- [[_COMMUNITY_Community 750|Community 750]]
- [[_COMMUNITY_Community 751|Community 751]]
- [[_COMMUNITY_Community 752|Community 752]]
- [[_COMMUNITY_Community 753|Community 753]]
- [[_COMMUNITY_Community 754|Community 754]]
- [[_COMMUNITY_Community 757|Community 757]]
- [[_COMMUNITY_Community 759|Community 759]]
- [[_COMMUNITY_Community 760|Community 760]]
- [[_COMMUNITY_Community 761|Community 761]]
- [[_COMMUNITY_Community 763|Community 763]]
- [[_COMMUNITY_Community 764|Community 764]]
- [[_COMMUNITY_Community 766|Community 766]]
- [[_COMMUNITY_Community 767|Community 767]]
- [[_COMMUNITY_Community 768|Community 768]]
- [[_COMMUNITY_Community 769|Community 769]]
- [[_COMMUNITY_Community 770|Community 770]]
- [[_COMMUNITY_Community 771|Community 771]]
## God Nodes (most connected - your core abstractions)
1. `BaseController` - 82 edges
2. `ApiTestCase` - 76 edges
1. `BaseController` - 86 edges
2. `ApiTestCase` - 80 edges
3. `Doctor` - 59 edges
4. `api` - 55 edges
4. `api` - 56 edges
5. `UserProfile` - 52 edges
6. `Clinic` - 50 edges
7. `useAuthStore` - 43 edges
8. `AdminApiController` - 43 edges
9. `ApiResponse` - 41 edges
10. `formatDate()` - 39 edges
8. `ApiResponse` - 42 edges
9. `AdminApiController` - 41 edges
10. `formatDate()` - 40 edges
## Surprising Connections (you probably didn't know these)
- `ServiceTariffModal()` --calls--> `formatNumber()` [EXTRACTED]
assets/admin/components/ServiceTariffModal.tsx → assets/admin/lib/utils.ts
- `Pagination()` --calls--> `formatNumber()` [EXTRACTED]
assets/admin/components/ui/Pagination.tsx → assets/admin/lib/utils.ts
- `MyFinancialPage()` --calls--> `formatRial()` [EXTRACTED]
assets/admin/pages/MyFinancialPage.tsx → assets/admin/lib/utils.ts
- `SettlementsPage()` --calls--> `formatRial()` [EXTRACTED]
assets/admin/pages/SettlementsPage.tsx → assets/admin/lib/utils.ts
- `NewAppointmentModal()` --calls--> `useAuthStore` [EXTRACTED]
assets/admin/pages/AppointmentsPage.tsx → assets/admin/stores/authStore.ts
- `LogoUploadField()` --calls--> `useAuthStore` [EXTRACTED]
assets/admin/pages/CategoriesPage.tsx → assets/admin/stores/authStore.ts
- `LogsPage()` --calls--> `formatDateTime()` [EXTRACTED]
assets/admin/pages/LogsPage.tsx → assets/admin/lib/utils.ts
## Import Cycles
- None detected.
## Communities (749 total, 146 thin omitted)
## Communities (762 total, 160 thin omitted)
### Community 0 - "Community 0"
Cohesion: 0.05
Nodes (41): ALLOWED_ROLES, PrivateRoute(), PublicRoute(), RoleRoute(), queryClient, toastStyle, DEGREE_OPTIONS, DoctorFormPage() (+33 more)
Nodes (39): ALLOWED_ROLES, PrivateRoute(), PublicRoute(), RoleRoute(), queryClient, toastStyle, DEGREE_OPTIONS, DoctorFormPage() (+31 more)
### Community 1 - "Community 1"
Cohesion: 0.03
@@ -805,12 +816,12 @@ Cohesion: 0.07
Nodes (3): UserProfile, self, User
### Community 6 - "Community 6"
Cohesion: 0.12
Nodes (12): SettlementController, SettlementRepository, WalletTransactionRepository, Settlement, JsonResponse, Request, User, ManagerRegistry (+4 more)
Cohesion: 0.09
Nodes (15): SettlementController, SettlementRepository, WalletTransactionRepository, CommissionService, Settlement, JsonResponse, Request, User (+7 more)
### Community 7 - "Community 7"
Cohesion: 0.06
Nodes (8): DoctorService, Clinic, DoctorServiceRepository, Collection, Doctor, self, User, ManagerRegistry
Cohesion: 0.05
Nodes (11): DoctorServiceController, DoctorService, Clinic, DoctorServiceRepository, Collection, Doctor, self, User (+3 more)
### Community 8 - "Community 8"
Cohesion: 0.08
@@ -829,24 +840,24 @@ Cohesion: 0.50
Nodes (4): UserActiveContextRepository, ManagerRegistry, User, UserActiveContext
### Community 12 - "Community 12"
Cohesion: 0.04
Nodes (47): Clinic Doctor Invitation API, DELETE `/api/v1/admin/clinic/invitation/{invUuid}`, Errors, Errors, Errors, Errors, Errors, Errors (+39 more)
Cohesion: 0.05
Nodes (42): Clinic Doctor Invitation API, DELETE `/api/v1/admin/clinic/invitation/{invUuid}`, Errors, Errors, Errors, Errors, Errors, Errors (+34 more)
### Community 13 - "Community 13"
Cohesion: 0.05
Nodes (36): get, PaymentConfig, PaymentGatewayInfo, usePaymentConfig(), api, ApiError, ApiResponse, downloadFile() (+28 more)
Nodes (35): get, PaymentConfig, PaymentGatewayInfo, api, ApiError, ApiResponse, downloadFile(), getToken() (+27 more)
### Community 14 - "Community 14"
Cohesion: 0.10
Nodes (12): AppointmentSettingsController, DateOverride, Holiday, DateOverrideRepository, HolidayRepository, JsonResponse, Request, User (+4 more)
Cohesion: 0.15
Nodes (8): AppointmentSettingsController, Holiday, HolidayRepository, JsonResponse, Request, User, Doctor, ManagerRegistry
### Community 15 - "Community 15"
Cohesion: 0.25
Nodes (7): PaymentController, Appointment, JsonResponse, Payment, Request, Response, User
### Community 16 - "Community 16"
Cohesion: 0.09
Nodes (6): PatientSession, Appointment, Collection, PatientRecord, self, SessionService
Cohesion: 0.07
Nodes (7): PatientSession, SmsWallet, Appointment, Collection, PatientRecord, self, SessionService
### Community 17 - "Community 17"
Cohesion: 0.05
@@ -858,19 +869,19 @@ Nodes (38): API, API, API, API, API, Route, Route, Route (+30 more)
### Community 19 - "Community 19"
Cohesion: 0.03
Nodes (56): CityForm, citySchema, ImportError, InsuranceForm, insuranceSchema, LogoUploadField(), ProvinceForm, provinceSchema (+48 more)
Nodes (57): usePaymentConfig(), CityForm, citySchema, ImportError, InsuranceForm, insuranceSchema, LogoUploadField(), ProvinceForm (+49 more)
### Community 20 - "Community 20"
Cohesion: 0.12
Nodes (6): AdminApiController, Collection, JsonResponse, Request, User, StreamedResponse
Cohesion: 0.13
Nodes (4): AdminApiController, JsonResponse, Request, StreamedResponse
### Community 21 - "Community 21"
Cohesion: 0.03
Nodes (48): formatNumber(), ClinicAddress, ClinicDetailPage(), ClinicDoctorItem, ClinicInvitation, EditForm, editSchema, HUES_LIST (+40 more)
### Community 22 - "Community 22"
Cohesion: 0.15
Nodes (9): RatingController, Like, LikeRepository, JsonResponse, Request, User, Comment, ManagerRegistry (+1 more)
Cohesion: 0.29
Nodes (4): RatingController, JsonResponse, Request, User
### Community 23 - "Community 23"
Cohesion: 0.05
@@ -889,8 +900,8 @@ Cohesion: 0.24
Nodes (4): InsuranceController, JsonResponse, Request, User
### Community 27 - "Community 27"
Cohesion: 0.06
Nodes (35): 55. 🟢 `GET` all tag, 56. 🟢 `GET` supplementary_insurance, 57. 🟢 `GET` categories list, 58. 🟢 `GET` all state, 59. 🟢 `GET` all city, 60. 🟢 `GET` all specially doctor, 62. 🟡 `PATCH` patch, 63. 🔴 `DELETE` DELETE (+27 more)
Cohesion: 0.05
Nodes (40): 55. 🟢 `GET` all tag, 56. 🟢 `GET` supplementary_insurance, 57. 🟢 `GET` categories list, 58. 🟢 `GET` all state, 59. 🟢 `GET` all city, 60. 🟢 `GET` all specially doctor, 61. 🔵 `POST` post, 62. 🟡 `PATCH` patch (+32 more)
### Community 28 - "Community 28"
Cohesion: 0.08
@@ -910,7 +921,7 @@ Nodes (32): 10. Modal / Dialog, 11. Toast Notifications, 12. Empty States & Load
### Community 33 - "Community 33"
Cohesion: 0.07
Nodes (25): CoverageRow, Draft, KIND, TenantInsurance, ServiceTariffModal(), TariffResponse, TariffRow, EMPTY_ITEMS (+17 more)
Nodes (22): CoverageRow, Draft, KIND, TenantInsurance, ServiceTariffModal(), TariffResponse, TariffRow, Contract (+14 more)
### Community 34 - "Community 34"
Cohesion: 0.06
@@ -989,8 +1000,8 @@ Cohesion: 0.07
Nodes (26): الزامات UI, باگ‌فیکس صفحه نوبت‌ها, باگ ۱ — کرش تقویم, باگ ۲ — روز هفته در DateNavigator, باگ ۳ — پیام «slot نیست», باگ ۴ — نوبت جدید: نام اجباری + find-or-create patient, باگ ۵ — patient_mobile نشان می‌دهد موبایل پزشک, باگ ۶ — نوبت‌های رزرو شده در نمایش زمانبندی (+18 more)
### Community 53 - "Community 53"
Cohesion: 0.08
Nodes (15): AppLogRepository, PaymentLog, ClaimItemRepository, PaymentLogRepository, PreRegistrationRepository, SiteConfigRepository, SmsSettingsRepository, ServiceEntityRepository (+7 more)
Cohesion: 0.09
Nodes (15): AppLogRepository, ClaimItemRepository, DoctorClaimRequestRepository, DoctorInsuranceRepository, InvoiceRepository, PreRegistrationRepository, ServiceEntityRepository, ManagerRegistry (+7 more)
### Community 54 - "Community 54"
Cohesion: 0.10
@@ -1009,8 +1020,8 @@ Cohesion: 0.08
Nodes (24): Blog API, DELETE `/api/v1/blog/{uuid}`, Errors, Errors, Errors, Errors, Errors, GET `/api/v1/blog/{slug}` (+16 more)
### Community 58 - "Community 58"
Cohesion: 0.28
Nodes (4): Blog, BlogRepository, ManagerRegistry, QueryBuilder
Cohesion: 0.15
Nodes (8): Blog, BlogController, BlogRepository, JsonResponse, Request, User, ManagerRegistry, QueryBuilder
### Community 59 - "Community 59"
Cohesion: 0.22
@@ -1029,24 +1040,20 @@ Cohesion: 0.08
Nodes (25): Bulk import / export, DELETE `/api/v1/admin/specialty/{id}`, Errors, Errors, Errors, Errors, GET `/api/v1/admin/specialties`, GET `/api/v1/specialties` (+17 more)
### Community 63 - "Community 63"
Cohesion: 0.06
Nodes (41): FreeVisitPrice(), Pricing, cn(), formatRial(), iranMobileOptionalSchema, iranMobileSchema, isValidIranMobile(), maskMobile() (+33 more)
Cohesion: 0.05
Nodes (50): FreeVisitPrice(), Pricing, cn(), formatDate(), formatDateTime(), formatRial(), iranMobileOptionalSchema, iranMobileSchema (+42 more)
### Community 64 - "Community 64"
Cohesion: 0.29
Nodes (4): SmsWalletController, JsonResponse, Request, User
Cohesion: 0.19
Nodes (7): SmsWalletController, SmsSettingsRepository, SmsSettings, JsonResponse, Request, User, ManagerRegistry
### Community 65 - "Community 65"
Cohesion: 0.08
Nodes (24): 2. کاربر (User), 4. 🔵 `POST` verify code, 5. 🔵 `POST` send code, 6. 🔵 `POST` register, 7. 🔴 `DELETE` delete user, 8. 🟡 `PATCH` patch, 9. 🟢 `GET` list secretary, Request Body (+16 more)
### Community 66 - "Community 66"
Cohesion: 0.06
Nodes (6): ClinicStaff, SmsWallet, AppLog, LogPruneService, AppointmentExpiryService, self
### Community 67 - "Community 67"
Cohesion: 0.08
Nodes (7): FinancialBreakdown, Invoice, Collection, InvoiceItem, self, Payment, User
Cohesion: 0.11
Nodes (4): Invoice, Collection, InvoiceItem, self
### Community 68 - "Community 68"
Cohesion: 0.12
@@ -1096,10 +1103,6 @@ Nodes (3): SubscriptionPlan, Collection, self
Cohesion: 0.09
Nodes (23): dependencies, @ckeditor/ckeditor5-build-classic, @ckeditor/ckeditor5-react, @fontsource/vazirmatn, @heroicons/react, @hookform/resolvers, jalaali-js, leaflet (+15 more)
### Community 82 - "Community 82"
Cohesion: 0.07
Nodes (8): CategoryImportTest, Connection, RepositoryClassMappingTest, KernelTestCase, DbLogger, CategoryImporter, DbLoggerTest, Stringable
### Community 83 - "Community 83"
Cohesion: 0.09
Nodes (21): Backend, CSS / UI, Frontend, روند اجرای هر قابلیت, قبل از شروع — تحلیل پرامپت و ساخت Todo, قوانین اجرا (اجباری — هیچ استثنایی ندارد), قوانین خاص این پروژه, مثال اجرا (+13 more)
@@ -1113,8 +1116,8 @@ Cohesion: 0.07
Nodes (30): devDependencies, @babel/core, @babel/preset-env, @babel/preset-react, @babel/preset-typescript, core-js, @csstools/postcss-oklab-function, @hotwired/stimulus (+22 more)
### Community 86 - "Community 86"
Cohesion: 0.05
Nodes (17): AppointmentExpiryServiceTest, DateOverrideOwnershipTest, LowTierFixesTest, SendCodeMobileRateLimitTest, ClaimsListPaginationTest, CaptchaFlowTest, ServiceItemDeleteCleanupTest, ServiceItemStaffOwnershipTest (+9 more)
Cohesion: 0.06
Nodes (16): AppointmentExpiryServiceTest, LowTierFixesTest, SendCodeMobileRateLimitTest, ClaimsListPaginationTest, CaptchaFlowTest, ServiceItemStaffOwnershipTest, EntityManagerInterface, KernelBrowser (+8 more)
### Community 87 - "Community 87"
Cohesion: 0.10
@@ -1201,8 +1204,8 @@ Cohesion: 0.33
Nodes (6): AppointmentController, Appointment, Doctor, JsonResponse, Request, User
### Community 108 - "Community 108"
Cohesion: 0.14
Nodes (9): CaptchaController, BaseController, CategoryController, SiteContextController, JsonResponse, JsonResponse, Request, JsonResponse (+1 more)
Cohesion: 0.11
Nodes (13): CaptchaController, BaseController, CategoryController, DoctorImportController, SiteContextController, JsonResponse, JsonResponse, Request (+5 more)
### Community 109 - "Community 109"
Cohesion: 0.29
@@ -1397,8 +1400,8 @@ Cohesion: 0.12
Nodes (15): Endpoint ها, GET /api/v1/payment/{uuid}, POST /api/v1/payment, POST /api/v1/payment/callback/mellat, Strategy Pattern برای درگاه‌ها, Subscription Payment — POST /api/v1/subscription-payment, ⚠ امنیت: IP Whitelist برای Callback, ⚠ امنیت: جلوگیری از Open Redirect (+7 more)
### Community 160 - "Community 160"
Cohesion: 0.09
Nodes (21): Appointment Settings API, Available Locations, Date Overrides, DELETE `/api/v1/appointment-settings/date-override/{uuid}`, Errors, Errors, Errors, `GET /api/v1/appointment-settings/available-locations/{doctorUuid}` (+13 more)
Cohesion: 0.13
Nodes (15): Date Overrides, DELETE `/api/v1/appointment-settings/date-override/{uuid}`, Errors, Errors, GET `/api/v1/appointment-settings/date-override/list/{doctorUuid}`, GET `/api/v1/appointment-settings/date-override/{uuid}`, PATCH `/api/v1/appointment-settings/date-override/{uuid}`, POST `/api/v1/appointment-settings/date-override` (+7 more)
### Community 161 - "Community 161"
Cohesion: 0.24
@@ -1409,8 +1412,8 @@ Cohesion: 0.13
Nodes (13): Architecture, Auth, Backend (PHP/Symfony), Backend — `src/`, Category / Bundle system, Commands, Database, First-time setup (+5 more)
### Community 163 - "Community 163"
Cohesion: 0.13
Nodes (14): autoload, autoload-dev, psr-4, psr-4, conflict, symfony/symfony, description, license (+6 more)
Cohesion: 0.11
Nodes (18): autoload, autoload-dev, psr-4, psr-4, conflict, symfony/symfony, description, extra (+10 more)
### Community 164 - "Community 164"
Cohesion: 0.29
@@ -1433,8 +1436,8 @@ Cohesion: 0.10
Nodes (20): api.ir (استعلام هویت — Shahkar / IbanMatch), اتصال به دیتابیس‌های مستقل (الزامی), اسرار (الزامی — قبل از اولین دیپلوی), امنیت و منابع, بررسی سلامت, دامنه‌ها و CORS, دیپلوی‌های بعدی, راهنمای دیپلوی ClinicPro (Coolify + Docker Compose) (+12 more)
### Community 169 - "Community 169"
Cohesion: 0.13
Nodes (7): EntityInsurancePricing, EntityInsurancePricingRepository, TenantServiceCoverageRepository, TenantInsuranceCleanupService, ManagerRegistry, ManagerRegistry, TenantServiceCoverage
Cohesion: 0.15
Nodes (5): EntityInsurancePricing, TenantInsuranceCleanupTest, EntityInsurancePricingRepository, TenantInsuranceCleanupService, ManagerRegistry
### Community 170 - "Community 170"
Cohesion: 0.13
@@ -1553,8 +1556,8 @@ Cohesion: 0.14
Nodes (13): Endpoint ها, PATCH /api/v1/secretary/{uuid}, POST /api/v1/secretary, تسک ۱۴: ماژول منشی, توضیح, زمان تخمینی, ساختار JSON, سیستم مجوزها — Resource-Based Permissions (مقیاس‌پذیر) (+5 more)
### Community 201 - "Community 201"
Cohesion: 0.17
Nodes (12): Admin API, Clinic Invitation Management, Dashboard, GET `/api/v1/admin/dashboard/charts`, GET `/api/v1/admin/dashboard/recent`, GET `/api/v1/admin/dashboard/stats`, GET /api/v1/admin/settings, PATCH /api/v1/admin/settings (+4 more)
Cohesion: 0.12
Nodes (16): Admin API, Clinic Invitation Management, Dashboard, GET `/api/v1/admin/dashboard/charts`, GET `/api/v1/admin/dashboard/recent`, GET `/api/v1/admin/dashboard/stats`, GET `/api/v1/admin/pre-registrations`, GET /api/v1/admin/settings (+8 more)
### Community 202 - "Community 202"
Cohesion: 0.15
@@ -1569,8 +1572,8 @@ Cohesion: 0.21
Nodes (6): AuthenticationException, ExceptionSubscriber, SecurityHeadersSubscriber, EventSubscriberInterface, ExceptionEvent, ResponseEvent
### Community 205 - "Community 205"
Cohesion: 0.06
Nodes (23): Command, CancelExpiredAppointmentsCommand, CreateAdminCommand, PruneLogsCommand, SeedCategoriesCommand, SeedDemoDataCommand, SeedSmsMessageTemplatesCommand, SystemOwnerCommand (+15 more)
Cohesion: 0.21
Nodes (4): SeedDemoDataCommand, InputInterface, OutputInterface, SymfonyStyle
### Community 206 - "Community 206"
Cohesion: 0.08
@@ -1633,8 +1636,8 @@ Cohesion: 0.23
Nodes (5): PaymentRepository, Appointment, ManagerRegistry, Payment, User
### Community 225 - "Community 225"
Cohesion: 0.05
Nodes (36): formatDate(), formatDateTime(), toDate(), ALL_STATUSES, AppointmentDetailPage(), timeOf(), Claim, ClaimItem (+28 more)
Cohesion: 0.13
Nodes (12): AdminUserDetail, AVATAR_COLORS, EditForm, editSchema, GENDER_LABELS, getPrimaryRole(), MARITAL_LABELS, MEDICAL_SECTIONS (+4 more)
### Community 226 - "Community 226"
Cohesion: 0.15
@@ -1753,8 +1756,8 @@ Cohesion: 0.18
Nodes (11): GET /api/v1/doctors/{id}, GET /api/v1/doctors/{id}/insurances, GET /api/v1/representations/{id}, GET /oauth/userinfo — اطلاعات کاربر (سازگار با دروپال), POST /api/v1/representations/{id}/bank-accounts, POST /oauth/token — تجدید توکن (Refresh), POST /oauth/token — ورود به سیستم, Task-02: احراز هویت (Authentication) (+3 more)
### Community 256 - "Community 256"
Cohesion: 0.17
Nodes (11): `200 OK` (به‌روزرسانی شد), `200 OK` (رد به‌دلیل تصاحب‌شده), `201 Created` (ساخته شد), `422` (اعتبارسنجی), Doctor Import (IRIMC) API, idempotency, Request, Response (+3 more)
Cohesion: 0.12
Nodes (16): `200 OK` (به‌روزرسانی شد), `200 OK` (رد به‌دلیل تصاحب‌شده), `201 Created` (ساخته شد), `422` (اعتبارسنجی), backfill نقش جانشین‌های قدیمی, Doctor Import (IRIMC) API, idempotency, Request (+8 more)
### Community 258 - "Community 258"
Cohesion: 0.15
@@ -1785,8 +1788,8 @@ Cohesion: 0.30
Nodes (4): RepresentationRepository, ManagerRegistry, Representation, User
### Community 265 - "Community 265"
Cohesion: 0.08
Nodes (27): calcFinalPrice(), EMPTY_RECORDS, EMPTY_SESSIONS, fileNumber(), getPatientName(), getPatientPhone(), InsurancePricing, MyPatientsPageInner() (+19 more)
Cohesion: 0.15
Nodes (17): calcFinalPrice(), EMPTY_RECORDS, EMPTY_SESSIONS, fileNumber(), getPatientName(), getPatientPhone(), InsurancePricing, MyPatientsPageInner() (+9 more)
### Community 266 - "Community 266"
Cohesion: 0.33
@@ -1825,8 +1828,8 @@ Cohesion: 0.17
Nodes (5): Authentication, ClinicPro — API Documentation Index, Error Code Reference, Modules, Standard Response Envelope
### Community 275 - "Community 275"
Cohesion: 0.19
Nodes (5): MellatGatewayTest, ErrorCodesTest, KavehNegarProviderTest, TestCase, KavehNegarProvider
Cohesion: 0.17
Nodes (6): MellatGateway, MellatGatewayTest, ErrorCodesTest, KavehNegarProviderTest, TestCase, KavehNegarProvider
### Community 276 - "Community 276"
Cohesion: 0.20
@@ -1897,12 +1900,12 @@ Cohesion: 0.20
Nodes (9): AdminApiController — dashboardCharts با بازه زمانی, DashboardController — اضافه کردن from/to, date range selector component:, تغییر Backend, تغییر Frontend — DashboardPage.tsx, فایل‌هایی که تغییر می‌کنند, معماری — تسک ۱۶: داشبورد هوشمند, نصب dependency: (+1 more)
### Community 294 - "Community 294"
Cohesion: 0.30
Cohesion: 0.29
Nodes (6): AbstractAuthenticator, Passport, PasswordAuthenticator, Request, Response, TokenInterface
### Community 295 - "Community 295"
Cohesion: 0.29
Nodes (5): AbstractController, AdminController, HomeController, Response, Response
Cohesion: 0.20
Nodes (8): AbstractController, AdminController, HomeController, SeoController, Response, Response, Request, Response
### Community 296 - "Community 296"
Cohesion: 0.22
@@ -1925,8 +1928,8 @@ Cohesion: 0.42
Nodes (3): PreRegistrationController, JsonResponse, Request
### Community 301 - "Community 301"
Cohesion: 0.05
Nodes (41): PaginatedResponse, STATUS_FILTERS, AddForm, addSchema, ClinicsPage(), HUES_LIST, FILTERS, Breakdown (+33 more)
Cohesion: 0.04
Nodes (47): PaginatedResponse, STATUS_FILTERS, AddForm, addSchema, ClinicsPage(), HUES_LIST, FILTERS, Breakdown (+39 more)
### Community 302 - "Community 302"
Cohesion: 0.12
@@ -1937,8 +1940,8 @@ Cohesion: 0.12
Nodes (16): آماده‌سازی پروژه ClinicPro برای دیپلوی روی Coolify با Docker Compose, زمینه, فایل‌های مرتبط, نکات مهم (محدودیت‌ها و edge caseها), هدف, وظایف, ۱. ساخت `Dockerfile` چندمرحله‌ای, ۱۰. ساخت راهنمای `docs/deploy/coolify.md` (+8 more)
### Community 304 - "Community 304"
Cohesion: 0.22
Nodes (9): 29. 🟢 `GET` clinic list 🆕, 30. 🟢 `GET` get 🆕, 33. 🔵 `POST` image_clinic, 6. کلینیک (Clinic), هدرهای اضافی, پارامترهای Query, پاسخ‌ها, پاسخ‌ها (+1 more)
Cohesion: 0.05
Nodes (42): 29. 🟢 `GET` clinic list 🆕, 30. 🟢 `GET` get 🆕, 33. 🔵 `POST` image_clinic, 34. 🔵 `POST` image logo, 36. 🟢 `GET` get my rate, 37. 🔵 `POST` post, 39. 🟡 `PATCH` Comment confirmation, 40. 🔵 `POST` post (+34 more)
### Community 306 - "Community 306"
Cohesion: 0.07
@@ -2037,8 +2040,8 @@ Cohesion: 0.22
Nodes (8): Query های جدید, بیماران منحصربه‌فرد در بازه, درآمد بر اساس روز (از patient_sessions), فروش اشتراک بر اساس پنل (admin), نوبت‌ها بر اساس روز (admin chart), نکات مهم, هیچ migration لازم نیست, پایگاه داده — تسک ۱۶: داشبورد هوشمند
### Community 333 - "Community 333"
Cohesion: 0.25
Nodes (7): DELETE `/api/v1/clinic-pro/doctor-address/{id}`, Doctor API, Errors, GET `/api/v1/doctors`, Query Parameters, Response `200`, Response `200`
Cohesion: 0.05
Nodes (39): DELETE `/api/v1/clinic-pro/doctor-address/{id}`, DELETE `/api/v1/doctor/{uuid}`, Doctor API, Errors, Errors, Errors, Errors, Errors (+31 more)
### Community 334 - "Community 334"
Cohesion: 0.25
@@ -2061,8 +2064,8 @@ Cohesion: 0.39
Nodes (5): JsonContains, FunctionNode, Node, Parser, SqlWalker
### Community 340 - "Community 340"
Cohesion: 0.25
Nodes (8): ثبت‌نام دکتر — از طریق نماینده, ثبت‌نام دکتر — از طریق کلینیک, ثبت‌نام دکتر — مستقل, ۱. معرفی محصول, ۱.۱ نقش‌های سیستم, ۱.۲ فلوهای عملیاتی اصلی, ۱.۳ پلن‌های اشتراک, ۱.۴ سیستم پیامک
Cohesion: 0.04
Nodes (38): Claim, ClaimItem, DebtRow, InsuranceOption, KIND_LABEL, STATUS_FILTERS, STATUS_META, DoctorClaim (+30 more)
### Community 341 - "Community 341"
Cohesion: 0.12
@@ -2109,8 +2112,8 @@ Cohesion: 0.11
Nodes (18): `Appointment.php`, `PaymentController::pay` (نوبت را اصلاً چک نمی‌کند), `PaymentController::startOrderPayment` (فقط status، بدون چک زمان), `renderPaymentResult` labels (برچسب `expired` ندارد), الگوی موجود انقضا (`AppointmentExpiryService`) — برای مرجع, زمینه, فایل‌های مرتبط, مشکل / هدف (+10 more)
### Community 352 - "Community 352"
Cohesion: 0.25
Nodes (8): ۲.۲ انواع دسته‌بندی (Category Types), ۲.۲.۱ تگ (Tag), ۲.۲.۲ استان (State), ۲.۲.۳ شهر (City), ۲.۲.۴ بیمه پایه (Basic Insurance), ۲.۲.۵ بیمه مکمل (Supplementary Insurance), ۲.۲.۶ تخصص دکتر (Doctor Specialty), ۲.۲.۷ خدمات دکتر (Doctor Services)
Cohesion: 0.07
Nodes (27): فرضیات صریح (فقط جایی که اطلاعات وجود نداشت), فیچر کامل ایمپورت پزشکان نظام پزشکی (IRIMC): ایمپورت، تصاحب پروفایل، کرالر State-Based, پروژه‌ها و برنچ, ۱. هدف فیچر, ۱۰. معیار پذیرش (Definition of Done), ۲. تحلیل معماری موجود — حقایق تأییدشده (دوباره کشف نکن، دوباره نساز), ۲.۱ آنچه از قبل پیاده شده و کار می‌کند, ۲.۲ واگرایی‌های سند-با-کد که این پرامپت حل می‌کند (تصمیم‌های معماری مستند) (+19 more)
### Community 354 - "Community 354"
Cohesion: 0.36
@@ -2181,8 +2184,8 @@ Cohesion: 0.29
Nodes (7): Authentication, Authorization, Input Validation, Logging Security, Rate Limiting, Secrets Management, ۷. تحلیل امنیت
### Community 371 - "Community 371"
Cohesion: 0.15
Nodes (6): UniqueConstraintsTest, FinancialBreakdown, FinancialBreakdownRepository, FinancialBreakdownIntegrityTest, ManagerRegistry, Payment
Cohesion: 0.13
Nodes (6): DateOverrideOwnershipTest, UniqueConstraintsTest, DateOverride, DateOverrideRepository, Doctor, ManagerRegistry
### Community 372 - "Community 372"
Cohesion: 0.11
@@ -2286,7 +2289,7 @@ Nodes (7): initiate(), refund(), reverse(), verify(), PaymentInitResult, Payment
### Community 399 - "Community 399"
Cohesion: 0.23
Nodes (5): AbstractMigration, Schema, Version20260609130407, Schema, Version20260628133044
Nodes (5): AbstractMigration, Schema, Version20260609130407, Schema, Version20260610175105
### Community 401 - "Community 401"
Cohesion: 0.12
@@ -2297,8 +2300,8 @@ Cohesion: 0.11
Nodes (17): بازطراحی معماری پرداخت — سرویس‌محور، امن، توسعه‌پذیر (Backend), تست دستی (ddev، در حالت `payment_test_mode=1`), خروجی نهایی (طبق spec — در گزارش اجرا ارائه شود), زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی (+9 more)
### Community 407 - "Community 407"
Cohesion: 0.16
Nodes (11): MessageBusInterface, MockObject, SmsService, SmsServiceLookupOnlyTest, SmsLogRepository, SmsMessageTemplateRepository, SmsService, SmsTextResolver (+3 more)
Cohesion: 0.27
Nodes (8): MessageBusInterface, MockObject, SmsServiceLookupOnlyTest, SmsLogRepository, SmsMessageTemplateRepository, SmsService, SmsTextResolver, KavehNegarProvider
### Community 414 - "Community 414"
Cohesion: 0.10
@@ -2308,6 +2311,10 @@ Nodes (20): edge cases, خلاصهٔ خطاها و اولویت, راه‌حل,
Cohesion: 0.17
Nodes (11): دیپلوی ClinicPro روی Coolify (Docker Compose), رفع اشکال, مراحل دیپلوی, معماری دیپلوی, نکات عملیاتی, چند دامنه فرانت‌اند (مهم), ۱. ساخت Resource در Coolify, ۲. اختصاص دامنه (+3 more)
### Community 422 - "Community 422"
Cohesion: 0.12
Nodes (15): راه‌حل, راه‌حل, راه‌حل, رفع زوم نقشه در افزودن آدرس + کپچا و موبایل در claim + حذف پروفایل توسط مالک, ریشه‌ها, زمینه, فایل‌های مرتبط, نکات مهم (+7 more)
### Community 424 - "Community 424"
Cohesion: 0.33
Nodes (5): SecretaryController, DoctorSecretary, JsonResponse, Request, User
@@ -2325,8 +2332,8 @@ Cohesion: 0.35
Nodes (5): PatientController, JsonResponse, PatientSession, Request, User
### Community 435 - "Community 435"
Cohesion: 0.33
Nodes (4): BlogController, JsonResponse, Request, User
Cohesion: 0.18
Nodes (10): Command, CancelExpiredAppointmentsCommand, PruneLogsCommand, SeedSmsMessageTemplatesCommand, InputInterface, OutputInterface, InputInterface, OutputInterface (+2 more)
### Community 439 - "Community 439"
Cohesion: 0.17
@@ -2336,13 +2343,9 @@ Nodes (11): زمینه, صفحه Twig دعوت پزشک + کوتاه‌کردن
Cohesion: 0.11
Nodes (18): [F10] راهنمای کهنه در `CLAUDE.md`: endpoint `categorys/{bundle}` منتقل شده, [F11] داشبورد دکتر `GET /api/v1/dashboard/doctor` همیشه 500 (فیلد ناموجود در DQL) — ✅ رفع شد, [F1] phpstan: مقایسهٔ همیشه‌درست در محاسبهٔ estimated SMS — ✅ رفع شد, [F2] تست‌های PHPUnit به API خارجی Kavenegar درخواست واقعی می‌زنند, [F3] دیتابیس تست seed نشده — فقط کاربر ادمین وجود دارد, [F4] اسکریپت seeder `create_test_users.php` وجود ندارد, [F5] ادمین با JWT معتبر به `/api/doc` (Swagger UI) دسترسی ندارد (401), [F6] ناسازگاری کدهای خطا بین دامنه‌ها (+10 more)
### Community 451 - "Community 451"
Cohesion: 0.08
Nodes (22): Contract, InsuranceOption, KIND_LABEL, ChargeForm, chargeSchema, EMPTY_LOGS, POST_VISIT_VARS, REMINDER_HOUR_OPTIONS (+14 more)
### Community 452 - "Community 452"
Cohesion: 0.33
Nodes (6): GET /api/v1/sms/balance — موجودی حساب پیامک, POST /api/v1/sms/queue — افزودن به صف, ۴. سیستم حساب و صف پیامک, ۴.۱ حساب پیامک (SMS Account), ۴.۲ صف پیامک (SMS Queue), ۴.۳ API پیامک
Cohesion: 0.15
Nodes (3): DoctorClaimRequest, Doctor, User
### Community 456 - "Community 456"
Cohesion: 0.15
@@ -2433,20 +2436,16 @@ Cohesion: 0.36
Nodes (5): StaffController, ClinicStaff, JsonResponse, Request, User
### Community 481 - "Community 481"
Cohesion: 0.38
Nodes (5): Rate, RateRepository, Doctor, ManagerRegistry, User
Cohesion: 0.17
Nodes (11): رفع نام دوتایی «دکتر» در ایمپورت IRIMC + دستور پاک‌سازی کامل پزشکان, زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی, وظایف, پروژه (+3 more)
### Community 482 - "Community 482"
Cohesion: 0.43
Nodes (5): BeforeInstallPromptEvent, usePwaInstall(), PwaInstallBanner(), detectIOS(), PwaLoginCard()
### Community 483 - "Community 483"
Cohesion: 0.43
Nodes (3): InvoiceRepository, Invoice, ManagerRegistry
### Community 486 - "Community 486"
Cohesion: 0.40
Nodes (5): ۲.۸ تنظیمات نوبت (Appointment Settings), ۲.۸.۱ برنامه هفتگی (Weekly Schedule), ۲.۸.۲ تعطیلات (Holidays), ۲.۸.۳ لغو تعطیل (Date Override), ۲.۸.۴ الگوریتم محاسبه اسلات‌های خالی
Cohesion: 0.33
Nodes (5): Like, LikeRepository, Comment, ManagerRegistry, User
### Community 487 - "Community 487"
Cohesion: 0.40
@@ -2457,13 +2456,17 @@ Cohesion: 0.40
Nodes (5): Admin Endpoints, GET /api/v1/admin/sms/settings/review, GET /api/v1/admin/sms/wallet-report, POST /api/v1/admin/sms/settings/{id}/approve, POST /api/v1/admin/sms/settings/{id}/reject
### Community 490 - "Community 490"
Cohesion: 0.36
Nodes (3): DoctorServiceController, JsonResponse, Request
Cohesion: 0.38
Nodes (5): Rate, RateRepository, Doctor, ManagerRegistry, User
### Community 491 - "Community 491"
Cohesion: 0.12
Nodes (15): `Modal.tsx` (بدون Portal), `PersianCalendar.tsx` (buttonها بدون `type`) — نمونه‌ها, باگ ۱ — علت, باگ ۲ — علت, رفع دو باگ Modal و تقویم شمسی در پنل ادمین, زمینه, فایل‌های مرتبط, مشکل / هدف (+7 more)
### Community 492 - "Community 492"
Cohesion: 0.18
Nodes (4): KavehNegarProvider, SmsService, SendSmsMessage, SmsProviderInterface
### Community 493 - "Community 493"
Cohesion: 0.20
Nodes (9): Backend Audit Backlog — ClinicPro, ☐ CRITICAL, ✅ DONE (committed on backend-audit), ☐ EPICS (design debt — cross-repo, defer; do NOT quick-fix), ☐ HIGH, ☐ LOW, ☐ MEDIUM, Progress (this audit session) (+1 more)
@@ -2476,10 +2479,6 @@ Nodes (3): InvoiceItemRepository, InvoiceItem, ManagerRegistry
Cohesion: 0.12
Nodes (15): CORS — `config/packages/nelmio_cors.yaml`, MellatGateway — تشخیص فعال‌بودن, payment/config — `PaymentController::config()` (خط ۵۴۶), resolveGateway + callback (کد واقعی), رفع باگ‌های پروداکشن: CORS + payment/config 500 + درگاه‌های فعال و callback ملت, زمینه, فایل‌های مرتبط, فرانت — `SubscriptionPage.tsx` (+7 more)
### Community 496 - "Community 496"
Cohesion: 0.40
Nodes (5): Errors, PATCH `/api/v1/clinic-pro/doctor-address/{id}`, Path Parameters, Request Body, Response `200`
### Community 497 - "Community 497"
Cohesion: 0.53
Nodes (3): SlotUniquenessTest, Appointment, Doctor
@@ -2581,24 +2580,20 @@ Cohesion: 0.15
Nodes (12): ارسال پیامک OTP فقط از طریق Kavenegar VerifyLookup (پترن), اسپک Kavenegar VerifyLookup (از داکیومنت رسمی), زمینه, فایل‌های مرتبط, نکات مهم, وضعیت فعلی, وظایف, پروژه (+4 more)
### Community 526 - "Community 526"
Cohesion: 0.50
Nodes (4): GET /api/v1/appointment-settings/slots — دریافت اسلات‌های خالی, POST /api/v1/appointment-settings/holidays — ثبت تعطیلی (فقط ادمین), POST /api/v1/appointment-settings/overrides — ثبت Override توسط دکتر, Task-09: API تنظیمات نوبت
Cohesion: 0.13
Nodes (14): Doctor Profile Claim API (تصاحب پروفایل پزشک ایمپورت‌شده), env های مرتبط, Errors, GET `/api/v1/admin/doctor-claims`, GET `/api/v1/doctor/{uuid}/claim-info`, POST `/api/v1/admin/doctors/{uuid}/transfer`, POST `/api/v1/doctor/{uuid}/claim`, Request (+6 more)
### Community 528 - "Community 528"
Cohesion: 0.50
Nodes (4): GET /api/v1/categories/cities — شهرها, GET /api/v1/categories/states — استان‌ها, Task-08: API دسته‌بندی‌ها, سایر Endpoint های دسته‌بندی — الزامی
Cohesion: 0.17
Nodes (12): buildKavenegarPattern(), KavenegarGuide(), SmsPage(), STATUS_LOG_META, Tab, TAG_FILTER_OPTIONS, TAG_LABELS, TemplateFormData (+4 more)
### Community 529 - "Community 529"
Cohesion: 0.50
Nodes (4): Errors, POST `/oauth/token/refresh`, Request Body, Response `200`
### Community 530 - "Community 530"
Cohesion: 0.33
Nodes (3): GatewayFactory, MellatGateway, SepGateway
### Community 531 - "Community 531"
Cohesion: 0.50
Nodes (4): ۲.۱۳ نظرات، لایک و امتیازدهی, ۲.۱۳.۱ نظر (Comment), ۲.۱۳.۲ لایک (Like), ۲.۱۳.۳ امتیاز (Rate)
Cohesion: 0.42
Nodes (4): DoctorClaimRequest, DoctorClaimService, Doctor, User
### Community 532 - "Community 532"
Cohesion: 0.33
@@ -2625,8 +2620,8 @@ Cohesion: 0.22
Nodes (3): AppException, SlotTakenException, RuntimeException
### Community 543 - "Community 543"
Cohesion: 0.21
Nodes (6): HealthController, EntityManagerInterface, CommissionService, Payment, Representation, JsonResponse
Cohesion: 0.25
Nodes (8): ثبت‌نام دکتر — از طریق نماینده, ثبت‌نام دکتر — از طریق کلینیک, ثبت‌نام دکتر — مستقل, ۱. معرفی محصول, ۱.۱ نقش‌های سیستم, ۱.۲ فلوهای عملیاتی اصلی, ۱.۳ پلن‌های اشتراک, ۱.۴ سیستم پیامک
### Community 544 - "Community 544"
Cohesion: 0.15
@@ -2733,8 +2728,8 @@ Cohesion: 0.40
Nodes (5): Errors, Notes, POST `/api/v1/notification-mobile/request-otp`, Request Body, Response `200`
### Community 576 - "Community 576"
Cohesion: 0.43
Nodes (3): CategoryImportController, JsonResponse, Request
Cohesion: 0.25
Nodes (8): ۲.۲ انواع دسته‌بندی (Category Types), ۲.۲.۱ تگ (Tag), ۲.۲.۲ استان (State), ۲.۲.۳ شهر (City), ۲.۲.۴ بیمه پایه (Basic Insurance), ۲.۲.۵ بیمه مکمل (Supplementary Insurance), ۲.۲.۶ تخصص دکتر (Doctor Specialty), ۲.۲.۷ خدمات دکتر (Doctor Services)
### Community 577 - "Community 577"
Cohesion: 0.43
@@ -2781,8 +2776,8 @@ Cohesion: 0.43
Nodes (3): SmsLogRepository, SmsLog, ManagerRegistry
### Community 596 - "Community 596"
Cohesion: 0.57
Nodes (3): SeoController, Request, Response
Cohesion: 0.29
Nodes (3): FinancialBreakdown, Payment, User
### Community 597 - "Community 597"
Cohesion: 0.29
@@ -2804,6 +2799,10 @@ Nodes (3): ماژول‌های شناسایی‌شده در PRD, موارد پو
Cohesion: 0.67
Nodes (3): نقاط ضعف, نقاط قوت, ۶. تحلیل API Design
### Community 606 - "Community 606"
Cohesion: 0.18
Nodes (10): ایمپورت پزشکان نظام پزشکی به کلینیک‌پرو و مدیریت مالکیت پروفایل, ۱. مسئله و محدودیت‌ها, ۲. مدل مالکیت پروفایل, ۳. راه‌حل انتخاب‌شده و دلیل آن, ۴. تغییرات بک‌اند (clinicpro), ۵. جریان کرالر (pipeline.py), ۶. نحوه‌ی اجرا, ۷. نکته‌ی مهم: captcha در لاگین (+2 more)
### Community 607 - "Community 607"
Cohesion: 0.34
Nodes (5): RepresentationController, JsonResponse, Representation, Request, User
@@ -2812,6 +2811,10 @@ Nodes (5): RepresentationController, JsonResponse, Representation, Request, User
Cohesion: 0.67
Nodes (3): بک‌اند, فرانت‌اند, وضعیت فعلی کد (مهم — قبل از تغییر بخوان)
### Community 618 - "Community 618"
Cohesion: 0.16
Nodes (5): ServiceItemDeleteCleanupTest, ServiceCoverageNPlusOneTest, TenantServiceCoverageRepository, ManagerRegistry, TenantServiceCoverage
### Community 625 - "Community 625"
Cohesion: 0.40
Nodes (5): 31. 🟡 `PATCH` patch, Request Body, مثال Request, هدرهای اضافی, پاسخ‌ها
@@ -2821,8 +2824,8 @@ Cohesion: 0.23
Nodes (7): Money, BillingCalculator, InvoiceService, CoverageRule, ShareBreakdown, Invoice, PatientSession
### Community 631 - "Community 631"
Cohesion: 0.48
Nodes (3): DoctorInsuranceRepository, DoctorInsurance, ManagerRegistry
Cohesion: 0.39
Nodes (4): FinancialBreakdown, FinancialBreakdownRepository, ManagerRegistry, Payment
### Community 632 - "Community 632"
Cohesion: 0.40
@@ -2917,8 +2920,8 @@ Cohesion: 0.40
Nodes (5): addMinutes(), calcSlotCount(), hasOverlap(), parseMinutes(), SessionEditor()
### Community 677 - "Community 677"
Cohesion: 0.40
Nodes (5): 37. 🔵 `POST` post, Request Body, مثال Request, هدرهای اضافی, پاسخ‌ها
Cohesion: 0.29
Nodes (6): Appointment Settings API, Available Locations, Errors, `GET /api/v1/appointment-settings/available-locations/{doctorUuid}`, Response `200`, Slot Calculation Logic (Reference)
### Community 678 - "Community 678"
Cohesion: 0.53
@@ -2937,25 +2940,21 @@ Cohesion: 0.40
Nodes (5): GET `/api/v1/admin/comments`, GET `/api/v1/admin/rates`, Query Parameters, Query Parameters, Rating & Comment Management
### Community 687 - "Community 687"
Cohesion: 0.50
Nodes (4): DELETE `/api/v1/doctor/{uuid}`, Errors, Path Parameters, Response `200`
Cohesion: 0.38
Nodes (3): PurgeDoctorsCommand, InputInterface, OutputInterface
### Community 689 - "Community 689"
Cohesion: 0.40
Nodes (5): 41. 🟡 `PATCH` patch, Request Body, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 690 - "Community 690"
Cohesion: 0.50
Nodes (4): Errors, POST `/api/v1/doctor`, Request Body (`application/json`), Response `201`
Cohesion: 0.43
Nodes (3): CategoryImportController, JsonResponse, Request
### Community 692 - "Community 692"
Cohesion: 0.50
Nodes (4): GET `/api/v1/admin/representations`, Query Parameters, Representation Management, Response `200`
### Community 693 - "Community 693"
Cohesion: 0.50
Nodes (4): Errors, GET `/api/v1/doctor/{uuid}`, Path Parameters, Response `200`
### Community 694 - "Community 694"
Cohesion: 0.50
Nodes (4): Error Codes, POST `/api/v1/user/reset-password`, Request Body, Response `200`
@@ -2973,8 +2972,8 @@ Cohesion: 0.17
Nodes (11): رفع خطاهای ارسال پیامک کاوه‌نگار در سرور prod (431 + Idle timeout), زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی, وظایف, پروژه (+3 more)
### Community 700 - "Community 700"
Cohesion: 0.50
Nodes (4): Errors, POST `/file/upload/clinic_pro/doctor/field_image`, Request, Response `200`
Cohesion: 0.53
Nodes (3): SessionServiceRepository, ManagerRegistry, SessionService
### Community 701 - "Community 701"
Cohesion: 0.17
@@ -2984,13 +2983,9 @@ Nodes (11): رفع خطای `Class "SoapClient" not found` در پرداخت م
Cohesion: 0.50
Nodes (4): Errors, POST `/api/v1/user/send-code`, Request Body, Response `200`
### Community 705 - "Community 705"
Cohesion: 0.50
Nodes (4): Errors, POST `/api/v1/clinic-pro/doctor-address`, Request Body, Response `201`
### Community 706 - "Community 706"
Cohesion: 0.50
Nodes (4): Errors, Path Parameters, POST `/api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}`, Response `201`
Cohesion: 0.40
Nodes (5): Errors, GET `/api/v1/admin/clinic/{uuid}/invitations`, Path Parameters, Query Parameters, Response `200`
### Community 707 - "Community 707"
Cohesion: 0.50
@@ -3004,22 +2999,6 @@ Nodes (4): Errors, POST `/api/v1/user/login`, Request Body, Response `200`
Cohesion: 0.50
Nodes (4): Errors, POST `/oauth/token`, Request Body, Response `200`
### Community 712 - "Community 712"
Cohesion: 0.67
Nodes (3): GET `/api/v1/clinic-pro/doctor-addresses/{doctorId}`, Path Parameters, Response `200`
### Community 713 - "Community 713"
Cohesion: 0.50
Nodes (4): 39. 🟡 `PATCH` Comment confirmation, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 714 - "Community 714"
Cohesion: 0.50
Nodes (4): 45. 🔵 `POST` post, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 715 - "Community 715"
Cohesion: 0.67
Nodes (3): POST `/api/v1/admin/sms/template/{uuid}/approve`, Request Body (`application/json`), Response `200`
### Community 716 - "Community 716"
Cohesion: 0.50
Nodes (4): Errors, GET `/oauth/userinfo`, Headers, Response `200`
@@ -3064,14 +3043,6 @@ Nodes (4): Errors, GET `/api/v1/representation/doctors`, Query Parameters, Respo
Cohesion: 0.50
Nodes (4): 38. 🟢 `GET` Unapproved comments, هدرهای اضافی, پارامترهای Query, پاسخ‌ها
### Community 728 - "Community 728"
Cohesion: 0.50
Nodes (4): 40. 🔵 `POST` post, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 729 - "Community 729"
Cohesion: 0.50
Nodes (4): 44. 🟢 `GET` list comment, هدرهای اضافی, پارامترهای Query, پاسخ‌ها
### Community 730 - "Community 730"
Cohesion: 0.50
Nodes (4): 46. 🟡 `PATCH` patch, مثال Request, هدرهای اضافی, پاسخ‌ها
@@ -3100,60 +3071,88 @@ Nodes (3): POST `/api/v1/admin/insurance/{id}/upload-logo`, Request, Response `2
Cohesion: 0.67
Nodes (3): GET `/api/v1/representation/{uuid}/dashboard/yearly`, Query Parameters, Response `200`
### Community 737 - "Community 737"
Cohesion: 0.67
Nodes (3): 34. 🔵 `POST` image logo, هدرهای اضافی, پاسخ‌ها
### Community 738 - "Community 738"
Cohesion: 0.50
Nodes (4): Errors, GET `/api/v1/representation/{uuid}`, Path Parameters, Response `200`
### Community 739 - "Community 739"
Cohesion: 0.67
Nodes (3): 36. 🟢 `GET` get my rate, هدرهای اضافی, پاسخ‌ها
Cohesion: 0.38
Nodes (4): DoctorClaimController, JsonResponse, Request, User
### Community 740 - "Community 740"
Cohesion: 0.67
Nodes (3): 42. 🔴 `DELETE` delete, هدرهای اضافی, پاسخ‌ها
### Community 744 - "Community 744"
Cohesion: 0.36
Nodes (4): DoctorImportResult, DoctorImportService, Collection, User
### Community 741 - "Community 741"
Cohesion: 0.67
Nodes (3): 43. 🟢 `GET` get, هدرهای اضافی, پاسخ‌ها
### Community 748 - "Community 748"
Cohesion: 0.38
Nodes (3): BackfillSurrogateRoleCommand, InputInterface, OutputInterface
### Community 743 - "Community 743"
### Community 750 - "Community 750"
Cohesion: 0.38
Nodes (3): CreateAdminCommand, InputInterface, OutputInterface
### Community 753 - "Community 753"
Cohesion: 0.38
Nodes (3): FixIrimcNamesCommand, InputInterface, OutputInterface
### Community 757 - "Community 757"
Cohesion: 0.38
Nodes (3): SystemOwnerCommand, InputInterface, OutputInterface
### Community 759 - "Community 759"
Cohesion: 0.38
Nodes (3): RepositoryClassMappingTest, KernelTestCase, DbLoggerTest
### Community 761 - "Community 761"
Cohesion: 0.47
Nodes (3): SeedCategoriesCommand, InputInterface, OutputInterface
### Community 763 - "Community 763"
Cohesion: 0.33
Nodes (6): GET /api/v1/sms/balance — موجودی حساب پیامک, POST /api/v1/sms/queue — افزودن به صف, ۴. سیستم حساب و صف پیامک, ۴.۱ حساب پیامک (SMS Account), ۴.۲ صف پیامک (SMS Queue), ۴.۳ API پیامک
### Community 764 - "Community 764"
Cohesion: 0.53
Nodes (3): SessionServiceRepository, ManagerRegistry, SessionService
Nodes (3): PaymentLog, PaymentLogRepository, ManagerRegistry
### Community 745 - "Community 745"
### Community 766 - "Community 766"
Cohesion: 0.40
Nodes (5): 61. 🔵 `POST` post, Request Body, مثال Request, هدرهای اضافی, پاسخ‌ها
Nodes (5): ۲.۸ تنظیمات نوبت (Appointment Settings), ۲.۸.۱ برنامه هفتگی (Weekly Schedule), ۲.۸.۲ تعطیلات (Holidays), ۲.۸.۳ لغو تعطیل (Date Override), ۲.۸.۴ الگوریتم محاسبه اسلات‌های خالی
### Community 746 - "Community 746"
### Community 768 - "Community 768"
Cohesion: 0.50
Nodes (4): GET `/api/v1/admin/pre-registrations`, POST `/api/v1/admin/pre-registrations/{uuid}/approve`, POST `/api/v1/admin/pre-registrations/{uuid}/reject`, Pre-Registration Management
Nodes (4): GET /api/v1/appointment-settings/slots — دریافت اسلات‌های خالی, POST /api/v1/appointment-settings/holidays — ثبت تعطیلی (فقط ادمین), POST /api/v1/appointment-settings/overrides — ثبت Override توسط دکتر, Task-09: API تنظیمات نوبت
### Community 747 - "Community 747"
### Community 769 - "Community 769"
Cohesion: 0.50
Nodes (4): extra, symfony, allow-contrib, require
Nodes (4): GET /api/v1/categories/cities — شهرها, GET /api/v1/categories/states — استان‌ها, Task-08: API دسته‌بندی‌ها, سایر Endpoint های دسته‌بندی — الزامی
### Community 770 - "Community 770"
Cohesion: 0.50
Nodes (4): ۲.۱۳ نظرات، لایک و امتیازدهی, ۲.۱۳.۱ نظر (Comment), ۲.۱۳.۲ لایک (Like), ۲.۱۳.۳ امتیاز (Rate)
### Community 771 - "Community 771"
Cohesion: 0.67
Nodes (3): POST `/api/v1/admin/sms/template/{uuid}/approve`, Request Body (`application/json`), Response `200`
## Knowledge Gaps
- **4087 isolated node(s):** `ALLOWED_ROLES`, `Pricing`, `ImageCropModalProps`, `TenantInsurance`, `CoverageRow` (+4082 more)
- **4157 isolated node(s):** `ALLOWED_ROLES`, `Pricing`, `ImageCropModalProps`, `TenantInsurance`, `CoverageRow` (+4152 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **146 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
- **160 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `Altcha` connect `Community 367` to `Community 0`, `Community 485`?**
_High betweenness centrality (0.070) - this node is a cross-community bridge._
- **Why does `BaseController` connect `Community 108` to `Community 6`, `Community 138`, `Community 139`, `Community 14`, `Community 654`, `Community 655`, `Community 15`, `Community 20`, `Community 22`, `Community 26`, `Community 164`, `Community 295`, `Community 424`, `Community 300`, `Community 433`, `Community 435`, `Community 308`, `Community 59`, `Community 318`, `Community 576`, `Community 577`, `Community 64`, `Community 75`, `Community 77`, `Community 607`, `Community 480`, `Community 230`, `Community 104`, `Community 490`, `Community 107`, `Community 109`, `Community 499`, `Community 380`, `Community 121`, `Community 122`, `Community 252`?**
_High betweenness centrality (0.044) - this node is a cross-community bridge._
- **Why does `ApiTestCase` connect `Community 86` to `Community 640`, `Community 397`, `Community 534`, `Community 535`, `Community 541`, `Community 543`, `Community 562`, `Community 565`, `Community 574`, `Community 575`, `Community 594`, `Community 82`, `Community 609`, `Community 484`, `Community 744`, `Community 618`, `Community 748`, `Community 497`, `Community 371`?**
_High betweenness centrality (0.030) - this node is a cross-community bridge._
_High betweenness centrality (0.065) - this node is a cross-community bridge._
- **Why does `BaseController` connect `Community 108` to `Community 6`, `Community 7`, `Community 138`, `Community 139`, `Community 14`, `Community 654`, `Community 655`, `Community 15`, `Community 20`, `Community 22`, `Community 26`, `Community 164`, `Community 295`, `Community 424`, `Community 300`, `Community 433`, `Community 690`, `Community 308`, `Community 58`, `Community 59`, `Community 318`, `Community 64`, `Community 577`, `Community 75`, `Community 77`, `Community 607`, `Community 480`, `Community 739`, `Community 230`, `Community 104`, `Community 107`, `Community 109`, `Community 499`, `Community 252`, `Community 121`, `Community 122`, `Community 380`?**
_High betweenness centrality (0.038) - this node is a cross-community bridge._
- **Why does `Version20260705070546` connect `Community 646` to `Community 399`?**
_High betweenness centrality (0.024) - this node is a cross-community bridge._
- **What connects `ALLOWED_ROLES`, `Pricing`, `ImageCropModalProps` to the rest of the system?**
_4087 weakly-connected nodes found - possible documentation gaps or missing edges._
_4157 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `Community 0` be split into smaller, more focused modules?**
_Cohesion score 0.048087431693989074 - nodes in this community are weakly interconnected._
_Cohesion score 0.05075187969924812 - nodes in this community are weakly interconnected._
- **Should `Community 1` be split into smaller, more focused modules?**
_Cohesion score 0.028985507246376812 - nodes in this community are weakly interconnected._
- **Should `Community 2` be split into smaller, more focused modules?**
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_doctor_service_doctorimportresult_php", "label": "DoctorImportResult.php", "file_type": "code", "source_file": "src/Doctor/Service/DoctorImportResult.php", "source_location": "L1"}, {"id": "service_doctorimportresult_doctorimportresult", "label": "DoctorImportResult", "file_type": "code", "source_file": "src/Doctor/Service/DoctorImportResult.php", "source_location": "L7"}, {"id": "service_doctorimportresult_doctorimportresult_construct", "label": ".__construct()", "file_type": "code", "source_file": "src/Doctor/Service/DoctorImportResult.php", "source_location": "L9"}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_doctor_service_doctorimportresult_php", "target": "doctor", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "src/Doctor/Service/DoctorImportResult.php", "source_location": "L5", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_doctor_service_doctorimportresult_php", "target": "service_doctorimportresult_doctorimportresult", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src/Doctor/Service/DoctorImportResult.php", "source_location": "L7", "weight": 1.0}, {"source": "service_doctorimportresult_doctorimportresult", "target": "service_doctorimportresult_doctorimportresult_construct", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Doctor/Service/DoctorImportResult.php", "source_location": "L9", "weight": 1.0}], "raw_calls": []}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_util_persiantext_php", "label": "PersianText.php", "file_type": "code", "source_file": "src/Shared/Util/PersianText.php", "source_location": "L1"}, {"id": "util_persiantext_persiantext", "label": "PersianText", "file_type": "code", "source_file": "src/Shared/Util/PersianText.php", "source_location": "L12"}, {"id": "util_persiantext_persiantext_normalize", "label": ".normalize()", "file_type": "code", "source_file": "src/Shared/Util/PersianText.php", "source_location": "L14"}, {"id": "util_persiantext_persiantext_samename", "label": ".sameName()", "file_type": "code", "source_file": "src/Shared/Util/PersianText.php", "source_location": "L40"}, {"id": "util_persiantext_persiantext_stripdoctortitle", "label": ".stripDoctorTitle()", "file_type": "code", "source_file": "src/Shared/Util/PersianText.php", "source_location": "L46"}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_util_persiantext_php", "target": "util_persiantext_persiantext", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src/Shared/Util/PersianText.php", "source_location": "L12", "weight": 1.0}, {"source": "util_persiantext_persiantext", "target": "util_persiantext_persiantext_normalize", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Shared/Util/PersianText.php", "source_location": "L14", "weight": 1.0}, {"source": "util_persiantext_persiantext", "target": "util_persiantext_persiantext_samename", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Shared/Util/PersianText.php", "source_location": "L40", "weight": 1.0}, {"source": "util_persiantext_persiantext", "target": "util_persiantext_persiantext_stripdoctortitle", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Shared/Util/PersianText.php", "source_location": "L46", "weight": 1.0}], "raw_calls": [{"caller_nid": "util_persiantext_persiantext_normalize", "callee": "class_exists", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L16", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_normalize", "callee": "\\Normalizer", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L17", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_normalize", "callee": "strtr", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L20", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_normalize", "callee": "strtr", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L31", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_normalize", "callee": "array_combine", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L31", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_normalize", "callee": "trim", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L36", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_normalize", "callee": "preg_replace", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L36", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_samename", "callee": "self", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L42", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_samename", "callee": "self", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L42", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_stripdoctortitle", "callee": "trim", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L48", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_stripdoctortitle", "callee": "preg_replace", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L48", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_stripdoctortitle", "callee": "self", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L48", "receiver": null}]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_doctor_repository_doctorclaimrequestrepository_php", "label": "DoctorClaimRequestRepository.php", "file_type": "code", "source_file": "src/Doctor/Repository/DoctorClaimRequestRepository.php", "source_location": "L1"}, {"id": "repository_doctorclaimrequestrepository_doctorclaimrequestrepository", "label": "DoctorClaimRequestRepository", "file_type": "code", "source_file": "src/Doctor/Repository/DoctorClaimRequestRepository.php", "source_location": "L9"}, {"id": "serviceentityrepository", "label": "ServiceEntityRepository", "file_type": "code", "source_file": "", "source_location": ""}, {"id": "repository_doctorclaimrequestrepository_doctorclaimrequestrepository_construct", "label": ".__construct()", "file_type": "code", "source_file": "src/Doctor/Repository/DoctorClaimRequestRepository.php", "source_location": "L11"}, {"id": "managerregistry", "label": "ManagerRegistry", "file_type": "code", "source_file": "src/Doctor/Repository/DoctorClaimRequestRepository.php", "source_location": "L11"}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_doctor_repository_doctorclaimrequestrepository_php", "target": "doctorclaimrequest", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "src/Doctor/Repository/DoctorClaimRequestRepository.php", "source_location": "L5", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_doctor_repository_doctorclaimrequestrepository_php", "target": "serviceentityrepository", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "src/Doctor/Repository/DoctorClaimRequestRepository.php", "source_location": "L6", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_doctor_repository_doctorclaimrequestrepository_php", "target": "managerregistry", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "src/Doctor/Repository/DoctorClaimRequestRepository.php", "source_location": "L7", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_doctor_repository_doctorclaimrequestrepository_php", "target": "repository_doctorclaimrequestrepository_doctorclaimrequestrepository", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src/Doctor/Repository/DoctorClaimRequestRepository.php", "source_location": "L9", "weight": 1.0}, {"source": "repository_doctorclaimrequestrepository_doctorclaimrequestrepository", "target": "serviceentityrepository", "relation": "inherits", "confidence": "EXTRACTED", "source_file": "src/Doctor/Repository/DoctorClaimRequestRepository.php", "source_location": "L9", "weight": 1.0}, {"source": "repository_doctorclaimrequestrepository_doctorclaimrequestrepository", "target": "repository_doctorclaimrequestrepository_doctorclaimrequestrepository_construct", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Doctor/Repository/DoctorClaimRequestRepository.php", "source_location": "L11", "weight": 1.0}, {"source": "repository_doctorclaimrequestrepository_doctorclaimrequestrepository_construct", "target": "managerregistry", "relation": "references", "confidence": "EXTRACTED", "source_file": "src/Doctor/Repository/DoctorClaimRequestRepository.php", "source_location": "L11", "weight": 1.0, "context": "parameter_type"}], "raw_calls": [{"caller_nid": "repository_doctorclaimrequestrepository_doctorclaimrequestrepository_construct", "callee": "parent", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Doctor/Repository/DoctorClaimRequestRepository.php", "source_location": "L13", "receiver": null}]}
@@ -0,0 +1 @@
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_migrations_version20260711151000_php", "label": "Version20260711151000.php", "file_type": "code", "source_file": "migrations/Version20260711151000.php", "source_location": "L1"}, {"id": "migrations_version20260711151000_version20260711151000", "label": "Version20260711151000", "file_type": "code", "source_file": "migrations/Version20260711151000.php", "source_location": "L14"}, {"id": "abstractmigration", "label": "AbstractMigration", "file_type": "code", "source_file": "", "source_location": ""}, {"id": "migrations_version20260711151000_version20260711151000_getdescription", "label": ".getDescription()", "file_type": "code", "source_file": "migrations/Version20260711151000.php", "source_location": "L16"}, {"id": "migrations_version20260711151000_version20260711151000_up", "label": ".up()", "file_type": "code", "source_file": "migrations/Version20260711151000.php", "source_location": "L21"}, {"id": "schema", "label": "Schema", "file_type": "code", "source_file": "migrations/Version20260711151000.php", "source_location": "L21"}, {"id": "migrations_version20260711151000_version20260711151000_down", "label": ".down()", "file_type": "code", "source_file": "migrations/Version20260711151000.php", "source_location": "L46"}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_migrations_version20260711151000_php", "target": "schema", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "migrations/Version20260711151000.php", "source_location": "L7", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_migrations_version20260711151000_php", "target": "abstractmigration", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "migrations/Version20260711151000.php", "source_location": "L8", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_migrations_version20260711151000_php", "target": "migrations_version20260711151000_version20260711151000", "relation": "contains", "confidence": "EXTRACTED", "source_file": "migrations/Version20260711151000.php", "source_location": "L14", "weight": 1.0}, {"source": "migrations_version20260711151000_version20260711151000", "target": "abstractmigration", "relation": "inherits", "confidence": "EXTRACTED", "source_file": "migrations/Version20260711151000.php", "source_location": "L14", "weight": 1.0}, {"source": "migrations_version20260711151000_version20260711151000", "target": "migrations_version20260711151000_version20260711151000_getdescription", "relation": "method", "confidence": "EXTRACTED", "source_file": "migrations/Version20260711151000.php", "source_location": "L16", "weight": 1.0}, {"source": "migrations_version20260711151000_version20260711151000", "target": "migrations_version20260711151000_version20260711151000_up", "relation": "method", "confidence": "EXTRACTED", "source_file": "migrations/Version20260711151000.php", "source_location": "L21", "weight": 1.0}, {"source": "migrations_version20260711151000_version20260711151000_up", "target": "schema", "relation": "references", "confidence": "EXTRACTED", "source_file": "migrations/Version20260711151000.php", "source_location": "L21", "weight": 1.0, "context": "parameter_type"}, {"source": "migrations_version20260711151000_version20260711151000", "target": "migrations_version20260711151000_version20260711151000_down", "relation": "method", "confidence": "EXTRACTED", "source_file": "migrations/Version20260711151000.php", "source_location": "L46", "weight": 1.0}, {"source": "migrations_version20260711151000_version20260711151000_down", "target": "schema", "relation": "references", "confidence": "EXTRACTED", "source_file": "migrations/Version20260711151000.php", "source_location": "L46", "weight": 1.0, "context": "parameter_type"}], "raw_calls": [{"caller_nid": "migrations_version20260711151000_version20260711151000_up", "callee": "addSql", "is_member_call": true, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/migrations/Version20260711151000.php", "source_location": "L23", "receiver": null}, {"caller_nid": "migrations_version20260711151000_version20260711151000_up", "callee": "addSql", "is_member_call": true, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/migrations/Version20260711151000.php", "source_location": "L42", "receiver": null}, {"caller_nid": "migrations_version20260711151000_version20260711151000_up", "callee": "addSql", "is_member_call": true, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/migrations/Version20260711151000.php", "source_location": "L43", "receiver": null}, {"caller_nid": "migrations_version20260711151000_version20260711151000_down", "callee": "addSql", "is_member_call": true, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/migrations/Version20260711151000.php", "source_location": "L48", "receiver": null}]}
@@ -0,0 +1 @@
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_scenarios_crawler_md", "label": "crawler.md", "file_type": "document", "source_file": "docs/scenarios/crawler.md", "source_location": "L1"}], "edges": [], "input_tokens": 0, "output_tokens": 0}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_util_persiantext_php", "label": "PersianText.php", "file_type": "code", "source_file": "src/Shared/Util/PersianText.php", "source_location": "L1"}, {"id": "util_persiantext_persiantext", "label": "PersianText", "file_type": "code", "source_file": "src/Shared/Util/PersianText.php", "source_location": "L12"}, {"id": "util_persiantext_persiantext_normalize", "label": ".normalize()", "file_type": "code", "source_file": "src/Shared/Util/PersianText.php", "source_location": "L14"}, {"id": "util_persiantext_persiantext_samename", "label": ".sameName()", "file_type": "code", "source_file": "src/Shared/Util/PersianText.php", "source_location": "L40"}, {"id": "util_persiantext_persiantext_stripdoctortitle", "label": ".stripDoctorTitle()", "file_type": "code", "source_file": "src/Shared/Util/PersianText.php", "source_location": "L46"}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_util_persiantext_php", "target": "util_persiantext_persiantext", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src/Shared/Util/PersianText.php", "source_location": "L12", "weight": 1.0}, {"source": "util_persiantext_persiantext", "target": "util_persiantext_persiantext_normalize", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Shared/Util/PersianText.php", "source_location": "L14", "weight": 1.0}, {"source": "util_persiantext_persiantext", "target": "util_persiantext_persiantext_samename", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Shared/Util/PersianText.php", "source_location": "L40", "weight": 1.0}, {"source": "util_persiantext_persiantext", "target": "util_persiantext_persiantext_stripdoctortitle", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Shared/Util/PersianText.php", "source_location": "L46", "weight": 1.0}], "raw_calls": [{"caller_nid": "util_persiantext_persiantext_normalize", "callee": "class_exists", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L16", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_normalize", "callee": "\\Normalizer", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L17", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_normalize", "callee": "strtr", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L20", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_normalize", "callee": "strtr", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L31", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_normalize", "callee": "array_combine", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L31", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_normalize", "callee": "trim", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L36", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_normalize", "callee": "preg_replace", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L36", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_samename", "callee": "self", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L42", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_samename", "callee": "self", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L42", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_stripdoctortitle", "callee": "self", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L48", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_stripdoctortitle", "callee": "trim", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L50", "receiver": null}, {"caller_nid": "util_persiantext_persiantext_stripdoctortitle", "callee": "preg_replace", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Util/PersianText.php", "source_location": "L50", "receiver": null}]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_scenarios_climed_md", "label": "climed.md", "file_type": "document", "source_file": "docs/scenarios/climed.md", "source_location": "L1"}], "edges": [], "input_tokens": 0, "output_tokens": 0}
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+7474 -1491
View File
File diff suppressed because it is too large Load Diff
+154 -39
View File
@@ -1,7 +1,7 @@
{
"assets/admin/App.tsx": {
"mtime": 1782750328.975341,
"ast_hash": "496200a3a353f2057d3a37621f80aa07",
"mtime": 1783757885.6255667,
"ast_hash": "69fd74865eefe0cb7c1bfc78a71a0a1e",
"semantic_hash": ""
},
"assets/admin/components/FreeVisitPrice.tsx": {
@@ -30,8 +30,8 @@
"semantic_hash": ""
},
"assets/admin/components/layout/Sidebar.tsx": {
"mtime": 1783067421.0219867,
"ast_hash": "61ff8f2939c5c0d3d1aa9c23b75bcdb6",
"mtime": 1783757885.626085,
"ast_hash": "2f0f7e9608d40b9cda0a0b9412782cca",
"semantic_hash": ""
},
"assets/admin/components/layout/Topbar.tsx": {
@@ -230,8 +230,8 @@
"semantic_hash": ""
},
"assets/admin/pages/DoctorDetailPage.tsx": {
"mtime": 1783189217.065477,
"ast_hash": "d10e0ec287b6d42011d384e3e8d8583b",
"mtime": 1783768892.4257572,
"ast_hash": "9e14c9cc70e4707bddaab0a5267e54c0",
"semantic_hash": ""
},
"assets/admin/pages/DoctorFormPage.tsx": {
@@ -245,8 +245,8 @@
"semantic_hash": ""
},
"assets/admin/pages/DoctorsPage.tsx": {
"mtime": 1783014538.7828207,
"ast_hash": "6cf2c963ef00401631e8d519b5f9b0ed",
"mtime": 1783757825.0106773,
"ast_hash": "d2092c284b360169efff2f5405469893",
"semantic_hash": ""
},
"assets/admin/pages/FinancialReportPage.tsx": {
@@ -810,8 +810,8 @@
"semantic_hash": ""
},
"src/Admin/Controller/AdminApiController.php": {
"mtime": 1783747617.3699927,
"ast_hash": "920703ac00621ed56bd030a02c077016",
"mtime": 1783757803.148958,
"ast_hash": "73acc68912b526d1c608c10b92fac3f0",
"semantic_hash": ""
},
"src/Admin/Controller/AdminController.php": {
@@ -930,8 +930,8 @@
"semantic_hash": ""
},
"src/Auth/Entity/User.php": {
"mtime": 1782728407.1921442,
"ast_hash": "c21a256d856199dfa5d76b7c4193306d",
"mtime": 1783757329.0757134,
"ast_hash": "85cd7c4923a991aab2f1003d8996fc32",
"semantic_hash": ""
},
"src/Auth/Entity/UserActiveContext.php": {
@@ -955,8 +955,8 @@
"semantic_hash": ""
},
"src/Auth/Security/PasswordAuthenticator.php": {
"mtime": 1783668692.4381845,
"ast_hash": "e4107ad152265492e7427334d4534b10",
"mtime": 1783757329.0759397,
"ast_hash": "1c385c3916608095ea2512ac434aca19",
"semantic_hash": ""
},
"src/Auth/Service/OtpService.php": {
@@ -1180,13 +1180,13 @@
"semantic_hash": ""
},
"src/Doctor/Controller/DoctorController.php": {
"mtime": 1783747336.6328127,
"ast_hash": "7828b41ebfdf61e887bf38d360c8a8e1",
"mtime": 1783769018.613043,
"ast_hash": "57c2d4ba148aa5084683dbfd08f8e2b0",
"semantic_hash": ""
},
"src/Doctor/Entity/Doctor.php": {
"mtime": 1783747550.8134816,
"ast_hash": "ee77b315870c0299bc02b08b3e3a8726",
"mtime": 1783757329.076088,
"ast_hash": "e2334ea52505d1b8cc2b69b838fe17ca",
"semantic_hash": ""
},
"src/Doctor/Entity/DoctorAddress.php": {
@@ -1580,8 +1580,8 @@
"semantic_hash": ""
},
"src/Shared/Service/ApiIrService.php": {
"mtime": 1782750061.8790064,
"ast_hash": "5fb80e5fce2191b2ece9385cf295b983",
"mtime": 1783757329.076324,
"ast_hash": "7ee2ccaad6b77b3845258440accf99ee",
"semantic_hash": ""
},
"src/Shared/Service/FileValidatorService.php": {
@@ -2250,8 +2250,8 @@
"semantic_hash": ""
},
"config/packages/rate_limiter.yaml": {
"mtime": 1781946641.5599158,
"ast_hash": "9069ae4509af48c36a3905a2070465f9",
"mtime": 1783757329.0740077,
"ast_hash": "625dc1a0d852d36afd05b4ac60abd6d9",
"semantic_hash": ""
},
"config/packages/routing.yaml": {
@@ -2260,8 +2260,8 @@
"semantic_hash": ""
},
"config/packages/security.yaml": {
"mtime": 1783669243.3956637,
"ast_hash": "aa39bbbcb9476e4663f7f7e8e513528c",
"mtime": 1783757329.074222,
"ast_hash": "d6adafd1b3cf1b7ec18d3c729b70dcb8",
"semantic_hash": ""
},
"config/packages/twig.yaml": {
@@ -2300,8 +2300,8 @@
"semantic_hash": ""
},
"config/services.yaml": {
"mtime": 1783748202.290563,
"ast_hash": "cb30b75b2aa9eb8bd44ee757491d35d9",
"mtime": 1783757329.0744464,
"ast_hash": "feb62c479381915746246b38817892e3",
"semantic_hash": ""
},
"docs/Architecture_Audit.md": {
@@ -2340,8 +2340,8 @@
"semantic_hash": ""
},
"docs/api/admin.md": {
"mtime": 1783671102.5622253,
"ast_hash": "878c59042b8a5873afa8346b07830a35",
"mtime": 1783757329.0746298,
"ast_hash": "1fe7f1b55de999bb218ab72f44360021",
"semantic_hash": ""
},
"docs/api/appointment-settings.md": {
@@ -2395,8 +2395,8 @@
"semantic_hash": ""
},
"docs/api/doctor.md": {
"mtime": 1783747505.4473846,
"ast_hash": "59b4a19e6e646fcde2b31cf8cd6ccc46",
"mtime": 1783769157.105152,
"ast_hash": "572576e30db5b049cb62ba8d54bdf8a5",
"semantic_hash": ""
},
"docs/api/insurance.md": {
@@ -3850,8 +3850,8 @@
"semantic_hash": ""
},
"src/Auth/Command/SystemOwnerCommand.php": {
"mtime": 1783747644.3113935,
"ast_hash": "ba2cafb8bf075489371912af24b34757",
"mtime": 1783757329.0755038,
"ast_hash": "e26afd83ac2085644152496ebc79fbd3",
"semantic_hash": ""
},
".claude/prompt/fix-server-error-logs-20260711.md": {
@@ -3859,11 +3859,6 @@
"ast_hash": "07ba38da8be11ea753c616a29a133070",
"semantic_hash": ""
},
"docs/scenarios/irimc-doctor-import-ownership.html": {
"mtime": 1783692949.7169843,
"ast_hash": "65046faf3fcf2859db97a594fd66aeb1",
"semantic_hash": ""
},
"docs/scenarios/irimc-doctor-import-ownership.md": {
"mtime": 1783692850.4494128,
"ast_hash": "94803933a144c487e107395efcf6d764",
@@ -3880,8 +3875,128 @@
"semantic_hash": ""
},
"docs/api/doctor-import.md": {
"mtime": 1783747979.9276593,
"ast_hash": "0a30a6d1d4fd4f2584d6315ccf94ef35",
"mtime": 1783766160.064325,
"ast_hash": "28bb80aa78e7650d0b3e7be75bfc12d0",
"semantic_hash": ""
},
"assets/admin/pages/DoctorClaimsPage.tsx": {
"mtime": 1783757859.3423393,
"ast_hash": "bf42d9bcc670c4632bb7078fba919b41",
"semantic_hash": ""
},
"migrations/Version20260711150000.php": {
"mtime": 1783757329.0904677,
"ast_hash": "a4735e00ad698d7b1908c1565f56101b",
"semantic_hash": ""
},
"migrations/Version20260711151000.php": {
"mtime": 1783757329.0905693,
"ast_hash": "4f19e830c75d8958e6ccac6fa0212d57",
"semantic_hash": ""
},
"src/Doctor/Command/BackfillSurrogateRoleCommand.php": {
"mtime": 1783757329.0907202,
"ast_hash": "795960477fc1d7bc59527e74777066d8",
"semantic_hash": ""
},
"src/Doctor/Controller/DoctorClaimController.php": {
"mtime": 1783768981.0086484,
"ast_hash": "997576824b9039e977fd58efb417cf70",
"semantic_hash": ""
},
"src/Doctor/Controller/DoctorImportController.php": {
"mtime": 1783757329.0911074,
"ast_hash": "78db47cbe57f35d5c5baf17bcc3e3cca",
"semantic_hash": ""
},
"src/Doctor/Entity/DoctorClaimRequest.php": {
"mtime": 1783757329.0913296,
"ast_hash": "26c3d96b19594bb127aebb1e74df876e",
"semantic_hash": ""
},
"src/Doctor/Repository/DoctorClaimRequestRepository.php": {
"mtime": 1783757329.0914319,
"ast_hash": "9321165d5ce64d77ea1c20da6f08dc2e",
"semantic_hash": ""
},
"src/Doctor/Service/DoctorClaimService.php": {
"mtime": 1783757329.0916033,
"ast_hash": "577da8fd4de111aa71e0bdc1c98952ec",
"semantic_hash": ""
},
"src/Doctor/Service/DoctorImportResult.php": {
"mtime": 1783757329.0917,
"ast_hash": "4300a0647f76835d6913a031faa0e9f2",
"semantic_hash": ""
},
"src/Doctor/Service/DoctorImportService.php": {
"mtime": 1783766986.8738186,
"ast_hash": "a675ebe7e1acac7da14cc8de3364757e",
"semantic_hash": ""
},
"src/Shared/Util/PersianText.php": {
"mtime": 1783765891.9874525,
"ast_hash": "477051e58ba2a5a5e9cbf25f3a6992d6",
"semantic_hash": ""
},
"tests/Doctor/DoctorClaimTest.php": {
"mtime": 1783769087.937134,
"ast_hash": "50ea89c2d246faf98dc843152d51a0c5",
"semantic_hash": ""
},
"tests/Doctor/DoctorImportTest.php": {
"mtime": 1783766996.494613,
"ast_hash": "997b8d698c8eb4bbf69652bdcead514d",
"semantic_hash": ""
},
"tests/Shared/PersianTextTest.php": {
"mtime": 1783765905.1030002,
"ast_hash": "c852d71e5f7ab87a0dc9250f9e848bd6",
"semantic_hash": ""
},
".claude/prompt/irimc-import-complete.md": {
"mtime": 1783757329.0736878,
"ast_hash": "2d5029751c9bd813a0410e46aebe5eba",
"semantic_hash": ""
},
"docs/api/doctor-claim.md": {
"mtime": 1783769130.2951994,
"ast_hash": "2f4e095e4e0cac673062cc48b0577d5d",
"semantic_hash": ""
},
"docs/scenarios/climed.md": {
"mtime": 1783753889.7651906,
"ast_hash": "36deff8609362d99217e415d322df1ac",
"semantic_hash": ""
},
"docs/scenarios/crawler.md": {
"mtime": 1783754133.317661,
"ast_hash": "86c9447fe38ac680873de5cae4f1db50",
"semantic_hash": ""
},
"docs/scenarios/\u0627\u06cc\u0645\u067e\u0648\u0631\u062a-\u067e\u0632\u0634\u06a9\u0627\u0646-\u0646\u0638\u0627\u0645-\u067e\u0632\u0634\u06a9\u06cc.md": {
"mtime": 1783751951.8968952,
"ast_hash": "692f883978aaad1f269b5357ba10a5f6",
"semantic_hash": ""
},
"src/Doctor/Command/FixIrimcNamesCommand.php": {
"mtime": 1783766017.2526865,
"ast_hash": "fb0cad757bf1405c6919159a374e9154",
"semantic_hash": ""
},
"src/Doctor/Command/PurgeDoctorsCommand.php": {
"mtime": 1783766065.697639,
"ast_hash": "8ba5c9742c92bbf2a0d01d97ee903dd3",
"semantic_hash": ""
},
".claude/prompt/fix-doctor-name-and-purge.md": {
"mtime": 1783765695.1482882,
"ast_hash": "b03ab1640dc46aa51114fbc3eba51870",
"semantic_hash": ""
},
".claude/prompt/doctor-map-claim-captcha-delete.md": {
"mtime": 1783768547.5967684,
"ast_hash": "d4531629fe5bf2befc994f1a3f2a34ff",
"semantic_hash": ""
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* کلید طبیعی ایمپورت IRIMC را در سطح دیتابیس یکتا می‌کند.
*
* ایندکس قبلی (source, medical_system_code) ساده بود؛ dedup فقط application-level
* بود و دو درخواست هم‌زمان می‌توانست رکورد تکراری بسازد. MariaDB چند NULL را در
* ایندکس یکتا مجاز می‌داند، پس پزشکان manual بدون کد تحت تأثیر نیستند.
*
* غیرمخرب: اگر دادهٔ تکراری وجود داشته باشد migration متوقف می‌شود (هیچ حذفی
* انجام نمی‌دهد) تکراری‌ها باید جداگانه و دستی تعیین تکلیف شوند.
*/
final class Version20260711150000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Unique index on doctors (source, medical_system_code) for concurrency-safe IRIMC import';
}
public function up(Schema $schema): void
{
$dupes = $this->connection->fetchAllAssociative(<<<'SQL'
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
SQL);
$this->abortIf(
$dupes !== [],
'Duplicate (source, medical_system_code) rows exist — resolve them manually before adding the unique index: '
. json_encode($dupes, JSON_UNESCAPED_UNICODE)
);
$this->addSql('DROP INDEX idx_doctors_source ON doctors');
$this->addSql('CREATE UNIQUE INDEX uniq_doctors_source_code ON doctors (source, medical_system_code)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX uniq_doctors_source_code ON doctors');
$this->addSql('CREATE INDEX idx_doctors_source ON doctors (source, medical_system_code)');
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* جدول ممیزی درخواست‌های تصاحب پروفایل پزشک (claim).
* دادهٔ حساس خام ذخیره نمی‌شود: کد ملی hash (sha256) و موبایل mask.
*/
final class Version20260711151000 extends AbstractMigration
{
public function getDescription(): string
{
return 'doctor_claim_requests audit table for the profile-claim flow';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE doctor_claim_requests (
id INT AUTO_INCREMENT NOT NULL,
uuid VARCHAR(36) NOT NULL,
doctor_id INT NOT NULL,
user_id INT DEFAULT NULL,
status VARCHAR(20) NOT NULL,
national_code_hash VARCHAR(64) NOT NULL,
mobile_masked VARCHAR(15) NOT NULL,
verification_method VARCHAR(40) NOT NULL,
failure_reason VARCHAR(100) DEFAULT NULL,
created_at INT NOT NULL,
completed_at INT DEFAULT NULL,
UNIQUE INDEX UNIQ_claim_uuid (uuid),
INDEX idx_claim_doctor_status (doctor_id, status),
INDEX IDX_claim_user (user_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql('ALTER TABLE doctor_claim_requests ADD CONSTRAINT FK_claim_doctor FOREIGN KEY (doctor_id) REFERENCES doctors (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE doctor_claim_requests ADD CONSTRAINT FK_claim_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE doctor_claim_requests');
}
}
+8 -125
View File
@@ -7,10 +7,8 @@ use App\Shared\Constant\ErrorCodes;
use App\Appointment\Repository\SlotTakenException;
use App\Auth\Entity\User;
use App\Shared\Service\InputValidator;
use App\Location\Entity\City;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Location\Entity\Province;
use App\Specialty\Entity\Specialty;
use App\Payment\Entity\Payment;
use App\Rating\Entity\Comment;
@@ -326,6 +324,11 @@ class AdminApiController extends BaseController
$where[] = 'd.gender = :gender';
$params['gender'] = $gender;
}
$ownerStatus = trim((string) $request->query->get('owner_status', ''));
if (in_array($ownerStatus, ['claimed', 'unclaimed', 'pending_transfer'], true)) {
$where[] = 'd.owner_status = :ownerStatus';
$params['ownerStatus'] = $ownerStatus;
}
if ($specId > 0) {
$where[] = 'EXISTS (SELECT 1 FROM doctor_specialties ds2 WHERE ds2.doctor_id = d.id AND ds2.specialty_id = :specId)';
$params['specId'] = $specId;
@@ -348,6 +351,7 @@ class AdminApiController extends BaseController
"SELECT d.id, d.uuid, d.name, d.gender, d.degree, d.medical_system_code,
d.mobile_number as doctor_mobile, d.active_doctor_appointment,
d.doctor_rate, d.doctor_rate_percentage, d.images, d.created_at,
d.owner_status, d.source,
u.mobile_number as user_mobile, u.email
FROM doctors d JOIN users u ON u.id = d.user_id
WHERE $whereStr ORDER BY $orderBy LIMIT $limit OFFSET $offset",
@@ -379,6 +383,8 @@ class AdminApiController extends BaseController
'mobile' => $d['doctor_mobile'] ?: $d['user_mobile'],
'email' => $d['email'],
'is_active' => (bool) $d['active_doctor_appointment'],
'owner_status' => $d['owner_status'],
'source' => $d['source'],
'rate' => (float) $d['doctor_rate'],
'specialties' => $specMap[(int) $d['id']] ?? [],
'profile_image' => !empty($images) ? ($images[0]['url'] ?? null) : null,
@@ -444,129 +450,6 @@ class AdminApiController extends BaseController
return $this->success(['uuid' => $doctor->getUuid()], 201);
}
/**
* ایمپورت یک پزشک از سازمان نظام پزشکی (بدون شماره موبایل).
*
* برخلاف createDoctor، این اندپوینت موبایل نمی‌خواهد: برای هر پزشک یک «کاربر
* جانشین» غیرفعال با شناسهٔ مصنوعی ساخته می‌شود و پروفایل در وضعیت unclaimed
* ذخیره می‌گردد تا بعداً به پزشک واقعی منتقل شود. idempotent بر پایهٔ
* (source, medical_system_code): اجرای مجدد، رکورد موجود را به‌روزرسانی می‌کند.
*/
#[OA\Post(
path: '/api/v1/admin/doctors/import',
summary: 'Import an IRIMC doctor without a mobile number (unclaimed profile)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['name', 'medical_system_code'],
properties: [
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'medical_system_code', type: 'string'),
new OA\Property(property: 'source', type: 'string', default: 'irimc'),
new OA\Property(property: 'source_ref', type: 'string', nullable: true, description: 'profile_url یا شناسهٔ مبدأ'),
new OA\Property(property: 'gender', type: 'string', nullable: true),
new OA\Property(property: 'degree', type: 'string', nullable: true),
new OA\Property(property: 'info', type: 'string', nullable: true),
new OA\Property(property: 'specialties', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'states', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'cities', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
]
)
),
responses: [
new OA\Response(response: 201, description: 'Doctor imported (created)'),
new OA\Response(response: 200, description: 'Doctor already existed (updated or skipped)'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
#[Route('/api/v1/admin/doctors/import', methods: ['POST'])]
public function importDoctor(Request $request, #[CurrentUser] User $admin): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$name = trim((string) ($data['name'] ?? ''));
$code = trim((string) ($data['medical_system_code'] ?? $data['medicalSystemCode'] ?? ''));
$source = trim((string) ($data['source'] ?? 'irimc')) ?: 'irimc';
if ($name === '') {
return $this->error(ErrorCodes::VALIDATION, 'نام الزامی است', 422, 'name');
}
if ($code === '') {
return $this->error(ErrorCodes::VALIDATION, 'کد نظام پزشکی الزامی است', 422, 'medical_system_code');
}
$doctorRepo = $this->em->getRepository(Doctor::class);
$userRepo = $this->em->getRepository(User::class);
// idempotency: همان پزشکِ منبع → به‌روزرسانی، نه ساخت تکراری
$doctor = $doctorRepo->findOneBy(['source' => $source, 'medicalSystemCode' => $code]);
$created = false;
// پروفایل تصاحب‌شده را با ایمپورت مجدد بازنویسی نکن (مالک واقعی اولویت دارد)
if ($doctor !== null && $doctor->getOwnerStatus() === 'claimed') {
return $this->success(['uuid' => $doctor->getUuid(), 'created' => false, 'skipped' => 'claimed']);
}
if ($doctor === null) {
// کاربر جانشینِ یکتا و غیرفعال؛ شناسهٔ مصنوعی قطعی از روی کد نظام پزشکی
$synthetic = 'imp_' . substr(md5($source . ':' . $code), 0, 14); // ≤ ۱۸ کاراکتر، ASCII، یکتا
$user = $userRepo->findOneBy(['mobileNumber' => $synthetic]);
if ($user === null) {
$user = new User($synthetic);
$user->setRealName($name);
$user->setStatus(0); // جانشین: هرگز لاگین نمی‌کند
$this->em->persist($user);
}
$doctor = new Doctor($user, $name);
$doctor->setSource($source);
$doctor->setOwnerStatus('unclaimed');
$doctor->setActiveDoctorAppointment(false); // تا مالک واقعی برنامهٔ کاری بسازد
$created = true;
}
// فیلدهای مشترک
$doctor->setName($name);
$doctor->setMedicalSystemCode($code);
$doctor->setManagedBy($admin->getId());
if (array_key_exists('source_ref', $data) || array_key_exists('profile_url', $data)) {
$doctor->setSourceRef($data['source_ref'] ?? $data['profile_url'] ?? null);
}
if (!empty($data['gender'])) $doctor->setGender($data['gender']);
if (!empty($data['degree'])) $doctor->setDegree($data['degree']);
if (array_key_exists('info', $data)) $doctor->setInfo($data['info']);
// روابط بر پایهٔ شناسه‌های مرجع (تخصص/استان/شهر)
$this->syncRefCollection($doctor->getSpecialties(), $data['specialties'] ?? null, Specialty::class);
$this->syncRefCollection($doctor->getProvinces(), $data['states'] ?? null, Province::class);
$this->syncRefCollection($doctor->getCities(), $data['cities'] ?? null, City::class);
$this->em->persist($doctor);
$this->em->flush();
return $this->success(
['uuid' => $doctor->getUuid(), 'created' => $created],
$created ? 201 : 200
);
}
/**
* یک مجموعهٔ ManyToMany پزشک را با آرایه‌ای از شناسه‌های مرجع همگام می‌کند.
* اگر $ids null باشد دست نمی‌خورد؛ اگر آرایه باشد، پاک و از نو پر می‌شود.
*/
private function syncRefCollection(\Doctrine\Common\Collections\Collection $col, ?array $ids, string $class): void
{
if ($ids === null) {
return;
}
$col->clear();
foreach ($ids as $id) {
$ref = $this->em->getRepository($class)->find((int) $id);
if ($ref !== null && !$col->contains($ref)) {
$col->add($ref);
}
}
}
// ── Clinics ───────────────────────────────────────────────────────────────
#[Route('/api/v1/admin/clinics', methods: ['GET'])]
+9 -3
View File
@@ -70,11 +70,17 @@ class SystemOwnerCommand extends Command
}
}
// least privilege: کاربر سیستمی فقط ROLE_IMPORTER می‌گیرد (دسترسی فقط به اندپوینت
// ایمپورت پزشک). اگر از نسخه‌های قبلی ROLE_ADMIN دارد، حذف می‌شود.
$roles = $user->getRoles();
if (!in_array('ROLE_ADMIN', $roles, true)) {
$roles[] = 'ROLE_ADMIN';
$user->setRoles(array_values(array_unique($roles)));
if (in_array('ROLE_ADMIN', $roles, true)) {
$roles = array_values(array_diff($roles, ['ROLE_ADMIN']));
$io->note('ROLE_ADMIN از کاربر سیستمی حذف شد (least privilege).');
}
if (!in_array('ROLE_IMPORTER', $roles, true)) {
$roles[] = 'ROLE_IMPORTER';
}
$user->setRoles(array_values(array_unique($roles)));
if ($password !== null) {
$user->setPasswordHash($this->hasher->hashPassword($user, (string) $password));
+2 -1
View File
@@ -121,6 +121,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this->hasRole('ROLE_DOCTOR')
|| $this->hasRole('ROLE_CLINIC')
|| $this->hasRole('ROLE_SECRETARY')
|| $this->hasRole('ROLE_ADMIN');
|| $this->hasRole('ROLE_ADMIN')
|| $this->hasRole('ROLE_IMPORTER'); // کاربر سیستمی کرالر — لاگین با رمز؛ دسترسی فقط اندپوینت ایمپورت
}
}
+15 -1
View File
@@ -30,8 +30,20 @@ class PasswordAuthenticator extends AbstractAuthenticator
private readonly RateLimiterFactory $loginLimiter,
private readonly CaptchaGuard $captcha,
private readonly int $refreshTokenTtl = 2592000,
private readonly ?string $crawlerServiceToken = null,
) {}
/**
* لاگین سرویسی کرالر: هدر X-Service-Token با مقدار env CRAWLER_SERVICE_TOKEN
* فقط کپچا را دور می‌زند rate limit و اعتبارسنجی رمز دست‌نخورده می‌مانند.
* env خالی = هیچ bypass (secure by default).
*/
private function isTrustedServiceLogin(Request $request): bool
{
return ($this->crawlerServiceToken ?? '') !== ''
&& hash_equals($this->crawlerServiceToken, (string) $request->headers->get('X-Service-Token', ''));
}
public function supports(Request $request): ?bool
{
return $request->getPathInfo() === '/api/v1/user/login'
@@ -46,7 +58,9 @@ class PasswordAuthenticator extends AbstractAuthenticator
}
// AppException را ExceptionSubscriber به پاسخ 422 با ERR_CAPTCHA_001 تبدیل می‌کند.
$this->captcha->assertValid($request);
if (!$this->isTrustedServiceLogin($request)) {
$this->captcha->assertValid($request);
}
$data = json_decode($request->getContent(), true) ?? [];
$mobile = trim($data['mobile_number'] ?? '');
@@ -0,0 +1,76 @@
<?php
namespace App\Doctor\Command;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Doctor\Service\DoctorImportService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* backfill یک‌بارمصرف: نقش ROLE_UNCLAIMED_DOCTOR برای کاربران جانشینِ ایمپورت
* (mobile با پیشوند imp_، غیرفعال، متصل به پزشک unclaimed) که پیش از افزودن
* این نقش ساخته شده‌اند. غیرمخرب؛ با --dry-run فقط گزارش می‌دهد.
*
* php bin/console app:doctors:backfill-surrogate-role --dry-run
* php bin/console app:doctors:backfill-surrogate-role
*/
#[AsCommand(
name: 'app:doctors:backfill-surrogate-role',
description: 'Add ROLE_UNCLAIMED_DOCTOR to legacy IRIMC surrogate users (idempotent, supports --dry-run)',
)]
class BackfillSurrogateRoleCommand extends Command
{
public function __construct(private readonly EntityManagerInterface $em)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only, change nothing');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
/** @var User[] $surrogates */
$surrogates = $this->em->createQueryBuilder()
->select('u')
->from(User::class, 'u')
->join(Doctor::class, 'd', 'WITH', 'd.user = u')
->where("u.mobileNumber LIKE 'imp\\_%'")
->andWhere('u.status = 0')
->andWhere("d.ownerStatus = 'unclaimed'")
->getQuery()
->getResult();
$updated = 0;
foreach ($surrogates as $user) {
if ($user->hasRole(DoctorImportService::ROLE_UNCLAIMED_DOCTOR)) {
continue;
}
$updated++;
$io->text(sprintf('%s %s', $dryRun ? '[dry-run]' : '[update]', $user->getMobileNumber()));
if (!$dryRun) {
$user->addRole(DoctorImportService::ROLE_UNCLAIMED_DOCTOR);
}
}
if (!$dryRun && $updated > 0) {
$this->em->flush();
}
$io->success(sprintf('%d surrogate(s) %s (of %d scanned)', $updated, $dryRun ? 'would be updated' : 'updated', count($surrogates)));
return Command::SUCCESS;
}
}
@@ -0,0 +1,70 @@
<?php
namespace App\Doctor\Command;
use App\Doctor\Entity\Doctor;
use App\Shared\Util\PersianText;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* اصلاح یک‌بارمصرف نامِ پزشکانِ ایمپورت‌شدهٔ IRIMC که با پیشوند «دکتر» ذخیره شده‌اند.
* فقط source='irimc' را دست می‌زند؛ پزشکان manual/seed را تغییر نمی‌دهد.
*
* php bin/console app:doctors:fix-irimc-names --dry-run
* php bin/console app:doctors:fix-irimc-names
*/
#[AsCommand(
name: 'app:doctors:fix-irimc-names',
description: 'Strip the leading «دکتر» title from existing IRIMC doctor names (idempotent, supports --dry-run)',
)]
class FixIrimcNamesCommand extends Command
{
public function __construct(private readonly EntityManagerInterface $em)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only, change nothing');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
/** @var Doctor[] $doctors */
$doctors = $this->em->getRepository(Doctor::class)->createQueryBuilder('d')
->where('d.source = :src')
->setParameter('src', 'irimc')
->getQuery()
->getResult();
$fixed = 0;
foreach ($doctors as $doctor) {
$clean = PersianText::stripDoctorTitle((string) $doctor->getName());
if ($clean !== '' && $clean !== $doctor->getName()) {
$io->text(sprintf('%s «%s» → «%s»', $dryRun ? '[dry-run]' : '[fix]', $doctor->getName(), $clean));
if (!$dryRun) {
$doctor->setName($clean);
}
$fixed++;
}
}
if (!$dryRun && $fixed > 0) {
$this->em->flush();
}
$io->success(sprintf('%d نام %s (از %d پزشک IRIMC).', $fixed, $dryRun ? 'قابل اصلاح' : 'اصلاح شد', count($doctors)));
return Command::SUCCESS;
}
}
@@ -0,0 +1,89 @@
<?php
namespace App\Doctor\Command;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* پاک‌سازی کامل همهٔ پزشکان و داده‌های وابسته برای ساختِ یک دیتابیس تمیزِ تست.
*
* مخرب است: بدون --force فقط تعداد رکوردهای هر جدول را گزارش می‌دهد (dry-run).
* روی prod نیازمند --i-know-this-is-prod است.
*
* php bin/console app:doctors:purge # فقط گزارش
* php bin/console app:doctors:purge --force # پاک‌سازی
*/
#[AsCommand(
name: 'app:doctors:purge',
description: 'Delete ALL doctors and doctor-related data for a clean test DB (dry-run by default; --force to apply)',
)]
class PurgeDoctorsCommand extends Command
{
/** ترتیب: فرزندان اول، سپس doctors. FK_CHECKS خاموش می‌شود پس ترتیب فقط برای خوانایی است. */
private const TABLES = [
'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',
];
public function __construct(private readonly Connection $conn)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('force', null, InputOption::VALUE_NONE, 'Actually delete (otherwise dry-run report)');
$this->addOption('i-know-this-is-prod', null, InputOption::VALUE_NONE, 'Required to run against APP_ENV=prod');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$force = (bool) $input->getOption('force');
if (($_ENV['APP_ENV'] ?? 'dev') === 'prod' && !$input->getOption('i-know-this-is-prod')) {
$io->error('روی prod بدون --i-know-this-is-prod اجرا نمی‌شود. این عمل داده‌های واقعی (نوبت/نظر/پرداخت) را حذف می‌کند.');
return Command::FAILURE;
}
$io->section($force ? 'پاک‌سازی پزشکان و داده‌های وابسته' : 'گزارش (dry-run — چیزی حذف نمی‌شود)');
$rows = [];
foreach (self::TABLES as $t) {
$rows[] = [$t, (int) $this->conn->fetchOne("SELECT COUNT(*) FROM {$t}")];
}
$io->table(['جدول', 'رکورد'], $rows);
if (!$force) {
$io->warning('برای حذف واقعی، دوباره با --force اجرا کن.');
return Command::SUCCESS;
}
$this->conn->executeStatement('SET FOREIGN_KEY_CHECKS=0');
try {
foreach (self::TABLES as $t) {
$n = $this->conn->executeStatement("DELETE FROM {$t}");
$io->text(sprintf('%s: %d حذف شد', $t, $n));
}
// کاربران جانشینِ ایمپورت (imp_..., غیرفعال) که اکنون یتیم‌اند
$surrogates = $this->conn->executeStatement(
"DELETE FROM users WHERE mobile_number LIKE 'imp\\_%' AND status = 0"
);
$io->text(sprintf('کاربران جانشین: %d حذف شد', $surrogates));
} finally {
$this->conn->executeStatement('SET FOREIGN_KEY_CHECKS=1');
}
$io->success('دیتابیس پزشکان پاک شد.');
return Command::SUCCESS;
}
}
@@ -0,0 +1,206 @@
<?php
namespace App\Doctor\Controller;
use App\Auth\Entity\User;
use App\Doctor\Repository\DoctorRepository;
use App\Doctor\Service\DoctorClaimService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Doctors')]
class DoctorClaimController extends BaseController
{
public function __construct(
private readonly DoctorRepository $doctorRepo,
private readonly DoctorClaimService $claimService,
private readonly RateLimiterFactory $doctorClaimLimiter,
private readonly \App\Doctor\Repository\DoctorClaimRequestRepository $claimRepo,
private readonly \App\Shared\Captcha\CaptchaGuard $captcha,
) {}
#[OA\Get(
path: '/api/v1/doctor/{uuid}/claim-info',
summary: 'Whether this doctor profile can be claimed (public, renders the claim button)',
responses: [
new OA\Response(response: 200, description: '{ claimable, owner_status }'),
new OA\Response(response: 404, description: 'Doctor not found'),
]
)]
#[Route('/api/v1/doctor/{uuid}/claim-info', methods: ['GET'])]
public function claimInfo(string $uuid): JsonResponse
{
$doctor = $this->doctorRepo->findByUuid($uuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
}
return $this->success([
'claimable' => $doctor->getOwnerStatus() === 'unclaimed',
'owner_status' => $doctor->getOwnerStatus(),
]);
}
#[OA\Post(
path: '/api/v1/doctor/{uuid}/claim',
summary: 'Claim an unclaimed (IRIMC-imported) doctor profile after identity verification',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['national_code', 'birth_date', 'first_name', 'last_name'],
properties: [
new OA\Property(property: 'national_code', type: 'string', example: '0010007700'),
new OA\Property(property: 'birth_date', type: 'string', description: 'شمسی Y/m/d', example: '1371/1/1'),
new OA\Property(property: 'first_name', type: 'string'),
new OA\Property(property: 'last_name', type: 'string'),
]
)
),
responses: [
new OA\Response(response: 200, description: 'Claimed'),
new OA\Response(response: 409, description: 'Not claimable / user already owns a doctor'),
new OA\Response(response: 422, description: 'Validation or identity mismatch'),
new OA\Response(response: 429, description: 'Rate limited'),
new OA\Response(response: 502, description: 'Identity provider unavailable'),
]
)]
#[Route('/api/v1/doctor/{uuid}/claim', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function claim(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$limiter = $this->doctorClaimLimiter->create('claim_' . $user->getId() . '_' . $uuid);
$limit = $limiter->consume();
if (!$limit->isAccepted()) {
throw new TooManyRequestsHttpException($limit->getRetryAfter()->getTimestamp() - time());
}
// کپچای ALTCHA (در dev با ALTCHA_ENABLED=false بی‌اثر) — خطا → ERR_CAPTCHA_001 (422)
$this->captcha->assertValid($request);
$doctor = $this->doctorRepo->findByUuid($uuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
$nationalCode = preg_replace('/\D/', '', \App\Shared\Util\PersianText::normalize((string) ($data['national_code'] ?? '')));
$birthDate = trim(\App\Shared\Util\PersianText::normalize((string) ($data['birth_date'] ?? '')));
$firstName = trim((string) ($data['first_name'] ?? ''));
$lastName = trim((string) ($data['last_name'] ?? ''));
$mobile = preg_replace('/\D/', '', \App\Shared\Util\PersianText::normalize((string) ($data['mobile'] ?? '')));
if ($mobile !== '' && $mobile !== $user->getMobileNumber()) {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'شماره موبایل باید با حساب کاربری شما یکی باشد', 422, 'mobile');
}
if (!preg_match('/^\d{10}$/', $nationalCode)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی نامعتبر است', 422, 'national_code');
}
if (!preg_match('~^1[34]\d{2}/\d{1,2}/\d{1,2}$~', $birthDate)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'تاریخ تولد نامعتبر است (مثال: 1371/1/1)', 422, 'birth_date');
}
if ($firstName === '' || $lastName === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام و نام خانوادگی الزامی است', 422);
}
$claim = $this->claimService->claim($doctor, $user, $nationalCode, $birthDate, $firstName, $lastName);
return $this->success([
'status' => 'claimed',
'claim' => ['uuid' => $claim->getUuid()],
'doctor' => ['uuid' => $doctor->getUuid(), 'name' => $doctor->getName()],
]);
}
#[OA\Post(
path: '/api/v1/admin/doctors/{uuid}/transfer',
summary: 'Manually transfer an unclaimed doctor profile to a real user (admin support tool)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['mobile'],
properties: [new OA\Property(property: 'mobile', type: 'string', example: '09121234567')]
)
),
responses: [
new OA\Response(response: 200, description: 'Transferred'),
new OA\Response(response: 404, description: 'Doctor not found'),
new OA\Response(response: 409, description: 'Already claimed / target user owns another doctor'),
new OA\Response(response: 422, description: 'Invalid mobile'),
]
)]
#[Route('/api/v1/admin/doctors/{uuid}/transfer', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function transfer(string $uuid, Request $request): JsonResponse
{
$doctor = $this->doctorRepo->findByUuid($uuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
$mobile = trim((string) ($data['mobile'] ?? ''));
if (!preg_match('/^09\d{9}$/', $mobile)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل نامعتبر است', 422, 'mobile');
}
$claim = $this->claimService->transferByAdmin($doctor, $mobile);
return $this->success([
'uuid' => $doctor->getUuid(),
'owner_status' => $doctor->getOwnerStatus(),
'user_mobile' => $mobile,
'claim' => ['uuid' => $claim->getUuid()],
]);
}
#[OA\Get(
path: '/api/v1/admin/doctor-claims',
summary: 'Paginated audit list of doctor profile claim requests',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'status', in: 'query', schema: new OA\Schema(type: 'string', enum: ['pending', 'completed', 'failed'])),
new OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', schema: new OA\Schema(type: 'integer', default: 20)),
],
responses: [new OA\Response(response: 200, description: 'Paginated claim requests')]
)]
#[Route('/api/v1/admin/doctor-claims', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function claimsList(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
$status = trim((string) $request->query->get('status', ''));
$qb = $this->claimRepo->createQueryBuilder('c')
->orderBy('c.createdAt', 'DESC');
if (in_array($status, [\App\Doctor\Entity\DoctorClaimRequest::STATUS_PENDING, \App\Doctor\Entity\DoctorClaimRequest::STATUS_COMPLETED, \App\Doctor\Entity\DoctorClaimRequest::STATUS_FAILED], true)) {
$qb->andWhere('c.status = :status')->setParameter('status', $status);
}
$total = (int) (clone $qb)->select('COUNT(c.id)')->getQuery()->getSingleScalarResult();
$items = $qb->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit)
->getQuery()
->getResult();
return $this->paginated(
array_map(fn(\App\Doctor\Entity\DoctorClaimRequest $c) => $c->toArray(), $items),
$total,
$page,
$limit
);
}
}
+8 -2
View File
@@ -368,14 +368,20 @@ class DoctorController extends BaseController
]
)]
#[Route('/api/v1/doctor/{uuid}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')]
public function delete(string $uuid): JsonResponse
#[IsGranted('IS_AUTHENTICATED_FULLY')]
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);
}
// ادمین هر پروفایلی را؛ مالک فقط پروفایلِ claimedِ خودش را حذف می‌کند (ضد IDOR)
$isOwner = $doctor->getOwnerStatus() === 'claimed' && $doctor->getUser()->getId() === $user->getId();
if (!$user->hasRole('ROLE_ADMIN') && !$isOwner) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'اجازهٔ حذف این پروفایل را ندارید', 403);
}
if ($this->appointmentRepo->count(['doctor' => $doctor]) > 0) {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این پزشک نوبت ثبت‌شده دارد و قابل حذف نیست', 409);
}
@@ -0,0 +1,86 @@
<?php
namespace App\Doctor\Controller;
use App\Auth\Entity\User;
use App\Doctor\Service\DoctorImportService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* اندپوینت ایمپورت پزشک IRIMC جدا از AdminApiController تا کاربر سیستمیِ
* کرالر با نقش حداقلی ROLE_IMPORTER (بدون دسترسی به بقیهٔ پنل ادمین) بتواند
* فقط همین عمل را انجام دهد (least privilege). مسیر برای سازگاری با کرالر و
* مستندات، همان مسیر قبلی مانده است.
*/
#[OA\Tag(name: 'Doctors')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class DoctorImportController extends BaseController
{
public function __construct(
private readonly DoctorImportService $importService,
) {}
#[OA\Post(
path: '/api/v1/admin/doctors/import',
summary: 'Import an IRIMC doctor without a mobile number (unclaimed profile)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['name', 'medical_system_code'],
properties: [
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'medical_system_code', type: 'string'),
new OA\Property(property: 'source', type: 'string', default: 'irimc'),
new OA\Property(property: 'source_ref', type: 'string', nullable: true, description: 'profile_url یا شناسهٔ مبدأ'),
new OA\Property(property: 'gender', type: 'string', nullable: true),
new OA\Property(property: 'degree', type: 'string', nullable: true),
new OA\Property(property: 'info', type: 'string', nullable: true),
new OA\Property(property: 'specialties', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'states', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'cities', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
]
)
),
responses: [
new OA\Response(response: 201, description: 'Doctor imported (created)'),
new OA\Response(response: 200, description: 'Doctor already existed (updated or skipped)'),
new OA\Response(response: 403, description: 'Requires ROLE_ADMIN or ROLE_IMPORTER'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
#[Route('/api/v1/admin/doctors/import', methods: ['POST'])]
public function import(Request $request, #[CurrentUser] User $user): JsonResponse
{
if (!$this->isGranted('ROLE_ADMIN') && !$this->isGranted('ROLE_IMPORTER')) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی به این منبع مجاز نیست', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$name = trim((string) ($data['name'] ?? ''));
$code = trim((string) ($data['medical_system_code'] ?? $data['medicalSystemCode'] ?? ''));
if ($name === '') {
return $this->error(ErrorCodes::VALIDATION, 'نام الزامی است', 422, 'name');
}
if ($code === '') {
return $this->error(ErrorCodes::VALIDATION, 'کد نظام پزشکی الزامی است', 422, 'medical_system_code');
}
$result = $this->importService->import($data, $user);
$payload = ['uuid' => $result->doctor->getUuid(), 'created' => $result->created];
if ($result->skipped !== null) {
$payload['skipped'] = $result->skipped;
}
return $this->success($payload, $result->created ? 201 : 200);
}
}
+6 -2
View File
@@ -17,7 +17,9 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: DoctorRepository::class)]
#[ORM\Table(name: 'doctors')]
#[ORM\UniqueConstraint(name: 'idx_doctors_user', columns: ['user_id'])]
#[ORM\UniqueConstraint(name: 'uniq_doctors_source_code', columns: ['source', 'medical_system_code'])]
#[ORM\Index(columns: ['active_doctor_appointment'], name: 'idx_doctors_active')]
#[ORM\Index(columns: ['owner_status'], name: 'idx_doctors_owner')]
class Doctor
{
public const DEGREES = ['expert', 'general', 'specialist', 'subspecialistplus'];
@@ -79,11 +81,11 @@ class Doctor
// ── Profile ownership (IRIMC import) ───────────────────────────────────────
// owner_status: claimed | unclaimed | pending_transfer
#[ORM\Column(name: 'owner_status', type: 'string', length: 20)]
#[ORM\Column(name: 'owner_status', type: 'string', length: 20, options: ['default' => 'claimed'])]
private string $ownerStatus = 'claimed';
// source: manual | irimc
#[ORM\Column(type: 'string', length: 20)]
#[ORM\Column(type: 'string', length: 20, options: ['default' => 'manual'])]
private string $source = 'manual';
// شناسه رکورد مبدأ (profile_url یا کد نظام پزشکی) برای idempotency و ممیزی
@@ -521,6 +523,7 @@ class Doctor
'free_turn' => $sf['free_turn'],
'hours_of_work' => $sf['hours_of_work'],
'active' => $this->activeDoctorAppointment && $sf['has_schedule'],
'owner_status' => $this->ownerStatus,
];
}
@@ -553,6 +556,7 @@ class Doctor
], $this->services->toArray()),
'satisfaction' => (string) $this->doctorRatePercentage,
'point' => (string) $this->doctorRate,
'owner_status' => $this->ownerStatus,
'free_turn' => $sf['free_turn'],
'hours_of_work' => $sf['hours_of_work'],
'address' => array_map(fn(DoctorAddress $a) => $a->toArray(), $this->addresses->toArray()),
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Doctor\Entity;
use App\Auth\Entity\User;
use App\Doctor\Repository\DoctorClaimRequestRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* رکورد ممیزی درخواست تصاحب پروفایل پزشک (claim).
*
* دادهٔ حساس خام ذخیره نمی‌شود: کد ملی فقط hash و موبایل فقط mask.
*/
#[ORM\Entity(repositoryClass: DoctorClaimRequestRepository::class)]
#[ORM\Table(name: 'doctor_claim_requests')]
#[ORM\Index(columns: ['doctor_id', 'status'], name: 'idx_claim_doctor_status')]
class DoctorClaimRequest
{
public const STATUS_PENDING = 'pending';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: Doctor::class)]
#[ORM\JoinColumn(name: 'doctor_id', nullable: false, onDelete: 'CASCADE')]
private Doctor $doctor;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'user_id', nullable: true, onDelete: 'SET NULL')]
private ?User $user;
#[ORM\Column(type: 'string', length: 20)]
private string $status = self::STATUS_PENDING;
#[ORM\Column(name: 'national_code_hash', type: 'string', length: 64)]
private string $nationalCodeHash;
#[ORM\Column(name: 'mobile_masked', type: 'string', length: 15)]
private string $mobileMasked;
#[ORM\Column(name: 'verification_method', type: 'string', length: 40)]
private string $verificationMethod;
#[ORM\Column(name: 'failure_reason', type: 'string', length: 100, nullable: true)]
private ?string $failureReason = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'completed_at', type: 'integer', nullable: true)]
private ?int $completedAt = null;
public function __construct(Doctor $doctor, ?User $user, string $nationalCode, string $mobile, string $verificationMethod)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->doctor = $doctor;
$this->user = $user;
$this->nationalCodeHash = hash('sha256', $nationalCode);
$this->mobileMasked = self::maskMobile($mobile);
$this->verificationMethod = $verificationMethod;
$this->createdAt = time();
}
public static function maskMobile(string $mobile): string
{
return strlen($mobile) >= 7
? substr($mobile, 0, 4) . '***' . substr($mobile, -4)
: '***';
}
public function markCompleted(): void
{
$this->status = self::STATUS_COMPLETED;
$this->completedAt = time();
}
public function markFailed(string $reason): void
{
$this->status = self::STATUS_FAILED;
$this->failureReason = mb_substr($reason, 0, 100);
$this->completedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getDoctor(): Doctor { return $this->doctor; }
public function getUser(): ?User { return $this->user; }
public function getStatus(): string { return $this->status; }
public function getFailureReason(): ?string { return $this->failureReason; }
public function getVerificationMethod(): string { return $this->verificationMethod; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getCompletedAt(): ?int { return $this->completedAt; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'status' => $this->status,
'doctor' => ['uuid' => $this->doctor->getUuid(), 'name' => $this->doctor->getName()],
'mobile_masked' => $this->mobileMasked,
'verification_method' => $this->verificationMethod,
'failure_reason' => $this->failureReason,
'created_at' => $this->createdAt,
'completed_at' => $this->completedAt,
];
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\Doctor\Repository;
use App\Doctor\Entity\DoctorClaimRequest;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class DoctorClaimRequestRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, DoctorClaimRequest::class);
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
namespace App\Doctor\Service;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorClaimRequest;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Shared\Service\ApiIrService;
use App\Shared\Util\PersianText;
use App\Sms\Entity\SmsLog;
use App\Sms\Service\SmsService;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* تصاحب پروفایل پزشک ایمپورت‌شده (unclaimed) توسط پزشک واقعی.
*
* دو مسیر: self-claim (احراز هویت API.ir: شاهکار + PersonInfo + تطبیق نام) و
* انتقال دستی ادمین. نهایی‌سازی هر دو مسیر یکی است: اتصال کاربر واقعی، نقش
* ROLE_DOCTOR، حذف امن کاربر جانشین، و ثبت رکورد ممیزی DoctorClaimRequest.
*
* ضد race: تغییر وضعیت unclaimed→pending_transfer زیر قفل PESSIMISTIC_WRITE
* انجام می‌شود؛ درخواست هم‌زمان دوم 409 می‌گیرد. فراخوانی خارجی هرگز داخل قفل نیست.
*/
class DoctorClaimService
{
public const METHOD_APIIR = 'apiir_personinfo';
public const METHOD_APIIR_SHAHKAR = 'apiir_personinfo+shahkar';
public const METHOD_ADMIN = 'admin_manual';
public function __construct(
private readonly EntityManagerInterface $em,
private readonly ApiIrService $apiIr,
private readonly SmsService $smsService,
private readonly LoggerInterface $logger,
) {}
public function claim(Doctor $doctor, User $user, string $nationalCode, string $birthDateJalali, string $firstName, string $lastName): DoctorClaimRequest
{
$method = $this->apiIr->isConfigured() ? self::METHOD_APIIR_SHAHKAR : self::METHOD_APIIR;
// مرحلهٔ ۱ — رزرو اتمیک پروفایل زیر قفل (تراکنش کوتاه، بدون فراخوان خارجی)
$claim = $this->em->wrapInTransaction(function () use ($doctor, $user, $nationalCode, $method): DoctorClaimRequest {
$locked = $this->em->find(Doctor::class, $doctor->getId(), LockMode::PESSIMISTIC_WRITE);
if ($locked->getOwnerStatus() !== 'unclaimed') {
throw new AppException(ErrorCodes::ERR_CONFLICT_001, 'این پروفایل قابل تصاحب نیست یا درخواست دیگری در جریان است', 409);
}
$existing = $this->em->getRepository(Doctor::class)->findOneBy(['user' => $user]);
if ($existing !== null) {
throw new AppException(ErrorCodes::ERR_CONFLICT_001, 'شما از قبل یک پروفایل پزشک دارید', 409);
}
$codeOwner = $this->em->getRepository(User::class)->findOneBy(['nationalCode' => $nationalCode]);
if ($codeOwner !== null && $codeOwner->getId() !== $user->getId()) {
throw new AppException(ErrorCodes::ERR_PROFILE_NATIONAL_CODE_TAKEN, null, 409);
}
$locked->setOwnerStatus('pending_transfer');
$claim = new DoctorClaimRequest($locked, $user, $nationalCode, $user->getMobileNumber(), $method);
$this->em->persist($claim);
return $claim;
});
// مرحلهٔ ۲ — احراز هویت (خارج از قفل)
try {
$this->verifyIdentity($doctor, $user, $nationalCode, $birthDateJalali, $firstName, $lastName);
} catch (AppException $e) {
$this->revert($doctor, $claim, $e->getMessage());
throw $e;
}
// مرحلهٔ ۳ — نهایی‌سازی اتمیک
$this->finalize($doctor, $user, $claim, $nationalCode);
// مرحلهٔ ۴ — پیامک خوش‌آمد (غیر بحرانی؛ شکستش claim را باطل نمی‌کند)
$this->smsService->dispatchTemplate(SmsLog::TAG_WELCOME, $user->getMobileNumber(), [
'name' => PersianText::stripDoctorTitle($doctor->getName()),
'site' => 'نوبت۷۲۴',
]);
return $claim;
}
/** انتقال دستی توسط ادمین (پشتیبانی) — بدون استعلام هویت؛ کاربر هدف با موبایل پیدا/ساخته می‌شود. */
public function transferByAdmin(Doctor $doctor, string $mobile): DoctorClaimRequest
{
$claim = $this->em->wrapInTransaction(function () use ($doctor, $mobile): DoctorClaimRequest {
$locked = $this->em->find(Doctor::class, $doctor->getId(), LockMode::PESSIMISTIC_WRITE);
if ($locked->getOwnerStatus() === 'claimed') {
throw new AppException(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(PersianText::stripDoctorTitle($locked->getName()));
$target->setStatus(1);
$this->em->persist($target);
}
$existing = $this->em->getRepository(Doctor::class)->findOneBy(['user' => $target]);
if ($existing !== null && $existing->getId() !== $locked->getId()) {
throw new AppException(ErrorCodes::ERR_CONFLICT_001, 'این کاربر قبلاً پروفایل پزشک دیگری دارد', 409);
}
$locked->setOwnerStatus('pending_transfer');
$claim = new DoctorClaimRequest($locked, $target, '', $mobile, self::METHOD_ADMIN);
$this->em->persist($claim);
return $claim;
});
$this->finalize($doctor, $claim->getUser(), $claim, null);
return $claim;
}
private function verifyIdentity(Doctor $doctor, User $user, string $nationalCode, string $birthDateJalali, string $firstName, string $lastName): void
{
// تطبیق موبایل ↔ کد ملی (شاهکار). بدون پیکربندی api.ir استعلام ممکن نیست →
// موبایلِ OTP-تأییدشدهٔ کاربر لاگین‌شده مبنا می‌ماند و فقط PersonInfo چک می‌شود.
if ($this->apiIr->isConfigured() && !$this->apiIr->shahkarMatch($nationalCode, $user->getMobileNumber())) {
throw new AppException(ErrorCodes::ERR_NATIONAL_CODE_MISMATCH, null, 422);
}
$person = $this->apiIr->personInfo($nationalCode, $birthDateJalali);
if ($person === null || !$person['alive']) {
throw new AppException(ErrorCodes::ERR_NATIONAL_CODE_MISMATCH, 'اطلاعات هویتی با سامانهٔ ثبت احوال مطابقت ندارد', 422);
}
// ورودی کاربر ↔ هویت تأییدشده
if (!PersianText::sameName($firstName . ' ' . $lastName, $person['firstName'] . ' ' . $person['lastName'])) {
throw new AppException(ErrorCodes::ERR_NATIONAL_CODE_MISMATCH, 'نام واردشده با اطلاعات هویتی مطابقت ندارد', 422);
}
// هویت تأییدشده ↔ نام پروفایل ایمپورت‌شده از نظام پزشکی
$profileName = PersianText::stripDoctorTitle($doctor->getName());
$verifiedName = PersianText::normalize($person['firstName'] . ' ' . $person['lastName']);
if ($profileName !== $verifiedName) {
throw new AppException(ErrorCodes::ERR_NATIONAL_CODE_MISMATCH, 'نام شما با نام این پروفایل پزشک مطابقت ندارد', 422);
}
}
private function finalize(Doctor $doctor, User $target, DoctorClaimRequest $claim, ?string $nationalCode): void
{
$surrogate = $this->em->wrapInTransaction(function () use ($doctor, $target, $claim, $nationalCode): ?User {
$locked = $this->em->find(Doctor::class, $doctor->getId(), LockMode::PESSIMISTIC_WRITE);
if ($locked->getOwnerStatus() !== 'pending_transfer') {
throw new AppException(ErrorCodes::ERR_CONFLICT_001, 'وضعیت پروفایل در این میان تغییر کرده است', 409);
}
$surrogate = $locked->getUser();
if ($nationalCode !== null && $nationalCode !== '') {
$target->setNationalCode($nationalCode);
$target->setNationalCodeVerified(true);
}
$target->addRole('ROLE_DOCTOR');
$locked->transferOwnershipTo($target);
$claim->markCompleted();
return $surrogate;
});
// حذف امن جانشین — پس از flush انتقال، تا شمارش پزشکانِ متصل قطعی باشد
if ($surrogate !== null
&& $surrogate->getId() !== $target->getId()
&& $surrogate->hasRole(DoctorImportService::ROLE_UNCLAIMED_DOCTOR)
&& $this->em->getRepository(Doctor::class)->count(['user' => $surrogate]) === 0) {
$this->em->remove($surrogate);
$this->em->flush();
}
$this->logger->info('doctor profile claimed', [
'claim_uuid' => $claim->getUuid(),
'doctor_uuid' => $doctor->getUuid(),
'user_id' => $target->getId(),
'method' => $claim->getVerificationMethod(),
]);
}
private function revert(Doctor $doctor, DoctorClaimRequest $claim, string $reason): void
{
$this->em->wrapInTransaction(function () use ($doctor, $claim, $reason): void {
$locked = $this->em->find(Doctor::class, $doctor->getId(), LockMode::PESSIMISTIC_WRITE);
if ($locked->getOwnerStatus() === 'pending_transfer') {
$locked->setOwnerStatus('unclaimed');
}
$claim->markFailed($reason);
});
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Doctor\Service;
use App\Doctor\Entity\Doctor;
final class DoctorImportResult
{
public function __construct(
public readonly Doctor $doctor,
public readonly bool $created,
public readonly ?string $skipped = null,
) {}
}
+177
View File
@@ -0,0 +1,177 @@
<?php
namespace App\Doctor\Service;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Location\Entity\City;
use App\Location\Entity\Province;
use App\Specialty\Entity\Specialty;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
/**
* ایمپورت پزشک از سازمان نظام پزشکی (IRIMC) منطق دامنه، جدا از کنترلر.
*
* برخلاف ساخت عادی پزشک، موبایل لازم نیست: برای هر پزشک یک «کاربر جانشین»
* غیرفعال با شناسهٔ مصنوعی ساخته می‌شود و پروفایل در وضعیت unclaimed ذخیره
* می‌گردد تا بعداً به پزشک واقعی منتقل شود. idempotent بر پایهٔ
* (source, medical_system_code): اجرای مجدد، رکورد موجود را به‌روزرسانی می‌کند
* و پروفایل claimed هرگز بازنویسی نمی‌شود (مالک واقعی اولویت دارد).
*/
class DoctorImportService
{
/** نقش marker کاربر جانشین — permission نمی‌دهد؛ مبنای شناسایی و حذف امن پس از claim است. */
public const ROLE_UNCLAIMED_DOCTOR = 'ROLE_UNCLAIMED_DOCTOR';
public function __construct(
private readonly EntityManagerInterface $em,
private readonly ManagerRegistry $registry,
) {}
/** @param array $data بدنهٔ validated (name و medical_system_code غیرخالی). */
public function import(array $data, User $importedBy): DoctorImportResult
{
try {
return $this->doImport($data, $importedBy);
} catch (UniqueConstraintViolationException) {
// برندهٔ هم‌زمانی رکورد را همین الان ساخته (قید uniq_doctors_source_code).
// EM پس از این خطا بسته است — reset و اجرای مجدد که این‌بار مسیر update را می‌رود.
$this->registry->resetManager();
return $this->doImport($data, $importedBy);
}
}
private function doImport(array $data, User $importedBy): DoctorImportResult
{
return $this->em->wrapInTransaction(function () use ($data, $importedBy): DoctorImportResult {
// نامِ نظام پزشکی پیشوند «دکتر» دارد؛ کنوانسیون پنل نام بدون پیشوند است
// (UI خودش «دکتر» را جلو می‌گذارد). حذف پیشوند + normalize فارسی.
$name = \App\Shared\Util\PersianText::stripDoctorTitle((string) $data['name']);
$code = trim((string) ($data['medical_system_code'] ?? $data['medicalSystemCode']));
$source = trim((string) ($data['source'] ?? 'irimc')) ?: 'irimc';
$doctorRepo = $this->em->getRepository(Doctor::class);
$userRepo = $this->em->getRepository(User::class);
// idempotency: همان پزشکِ منبع → به‌روزرسانی، نه ساخت تکراری
$doctor = $doctorRepo->findOneBy(['source' => $source, 'medicalSystemCode' => $code]);
$created = false;
// پروفایل تصاحب‌شده را با ایمپورت مجدد بازنویسی نکن (مالک واقعی اولویت دارد)
if ($doctor !== null && $doctor->getOwnerStatus() === 'claimed') {
return new DoctorImportResult($doctor, false, 'claimed');
}
if ($doctor === null) {
// جلوگیری از تکرار بین منابع: اگر همین کد نظام پزشکی با هر source دیگری
// (مثلاً ثبت دستی) وجود دارد، ایمپورت نه می‌سازد و نه بازنویسی می‌کند.
$existing = $doctorRepo->findOneBy(['medicalSystemCode' => $code]);
if ($existing !== null) {
return new DoctorImportResult($existing, false, 'duplicate');
}
}
if ($doctor === null) {
// کاربر جانشینِ یکتا و غیرفعال؛ شناسهٔ مصنوعی قطعی از روی کد نظام پزشکی
$synthetic = 'imp_' . substr(md5($source . ':' . $code), 0, 14); // ≤ ۱۸ کاراکتر، ASCII، یکتا
$user = $userRepo->findOneBy(['mobileNumber' => $synthetic]);
if ($user === null) {
$user = new User($synthetic);
$user->setRealName($name);
$user->setStatus(0); // جانشین: هرگز لاگین نمی‌کند
$user->addRole(self::ROLE_UNCLAIMED_DOCTOR);
$this->em->persist($user);
}
$doctor = new Doctor($user, $name);
$doctor->setSource($source);
$doctor->setOwnerStatus('unclaimed');
$doctor->setActiveDoctorAppointment(true); // پزشک ایمپورت‌شده فعال باشد
$created = true;
}
// backfill: جانشین‌های ایمپورت‌شده پیش از افزودن نقش marker، در ایمپورت مجدد نقش می‌گیرند
$surrogate = $doctor->getUser();
if (!$created
&& str_starts_with($surrogate->getMobileNumber(), 'imp_')
&& !$surrogate->hasRole(self::ROLE_UNCLAIMED_DOCTOR)) {
$surrogate->addRole(self::ROLE_UNCLAIMED_DOCTOR);
}
// فیلدهای مشترک
$doctor->setName($name);
$doctor->setMedicalSystemCode($code);
$doctor->setManagedBy($importedBy->getId());
if (array_key_exists('source_ref', $data) || array_key_exists('profile_url', $data)) {
$doctor->setSourceRef($data['source_ref'] ?? $data['profile_url'] ?? null);
}
if (!empty($data['gender'])) $doctor->setGender($data['gender']);
if (!empty($data['degree'])) $doctor->setDegree($data['degree']);
if (array_key_exists('info', $data)) $doctor->setInfo($data['info']);
// روابط بر پایهٔ شناسه‌های مرجع (تخصص/استان/شهر)
$this->syncRefCollection($doctor->getSpecialties(), $data['specialties'] ?? null, Specialty::class);
$this->syncRefCollection($doctor->getProvinces(), $data['states'] ?? null, Province::class);
$this->syncRefCollection($doctor->getCities(), $data['cities'] ?? null, City::class);
$this->ensureDefaultAddress($doctor, $name, $data);
$this->em->persist($doctor);
$this->em->flush();
return new DoctorImportResult($doctor, $created);
});
}
/**
* هر پزشک ایمپورت‌شده باید دست‌کم یک آدرس «مطب دکتر …» با شهر و استان داشته باشد.
* اگر آدرسی ندارد می‌سازد؛ فیلدهای خالیِ آدرس موجود را backfill می‌کند و
* مقادیر واردشده توسط کاربر را بازنویسی نمی‌کند.
*/
private function ensureDefaultAddress(Doctor $doctor, string $name, array $data): void
{
$city = !empty($data['cities'])
? $this->em->getRepository(City::class)->find((int) $data['cities'][0])
: null;
$province = !empty($data['states'])
? $this->em->getRepository(Province::class)->find((int) $data['states'][0])
: null;
$address = $doctor->getAddresses()->first() ?: null;
if ($address === null) {
$address = DoctorAddress::forDoctor($doctor);
$doctor->getAddresses()->add($address);
$this->em->persist($address);
}
if ($address->getName() === null || $address->getName() === '') {
$address->setName('مطب دکتر ' . $name);
}
if ($address->getCity() === null && $city !== null) {
$address->setCity($city);
}
if ($address->getProvince() === null && $province !== null) {
$address->setProvince($province);
}
}
/**
* یک مجموعهٔ ManyToMany پزشک را با آرایه‌ای از شناسه‌های مرجع همگام می‌کند.
* اگر $ids null باشد دست نمی‌خورد؛ اگر آرایه باشد، پاک و از نو پر می‌شود.
*/
private function syncRefCollection(\Doctrine\Common\Collections\Collection $col, ?array $ids, string $class): void
{
if ($ids === null) {
return;
}
$col->clear();
foreach ($ids as $id) {
$ref = $this->em->getRepository($class)->find((int) $id);
if ($ref !== null && !$col->contains($ref)) {
$col->add($ref);
}
}
}
}
+25
View File
@@ -70,6 +70,31 @@ class ApiIrService
];
}
/**
* استعلام هویت شخص از روی کد ملی و تاریخ تولد (PersonInfo).
*
* @param string $birthDateJalali تاریخ تولد شمسی به فرمت Y/m/d (مثلاً 1371/1/1)
* @return array{firstName: string, lastName: string, alive: bool}|null null یعنی رکوردی مطابقت نکرد.
*/
public function personInfo(string $nationalCode, string $birthDateJalali): ?array
{
$data = $this->post('/api/sw1/PersonInfo', [
'nationalCode' => $nationalCode,
'birthDate' => $birthDateJalali,
]);
$person = $data['data'] ?? null;
if (!is_array($person) || ($person['nationalCode'] ?? '') === '') {
return null;
}
return [
'firstName' => (string) ($person['firstName'] ?? ''),
'lastName' => (string) ($person['lastName'] ?? ''),
'alive' => filter_var($person['alive'] ?? false, FILTER_VALIDATE_BOOLEAN),
];
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Shared\Util;
/**
* نرمال‌سازی متن فارسی برای مقایسهٔ نام‌ها.
*
* تفاوت‌های رایج را یکدست می‌کند: ي/ی عربی-فارسی، ك/ک، ارقام عربی/فارسی،
* نیم‌فاصله و فاصله‌های تکراری، فاصلهٔ ابتدا/انتها و Unicode normalization
* تا مقایسهٔ نام هرگز با compare خام رشته انجام نشود.
*/
final class PersianText
{
public static function normalize(string $text): string
{
if (class_exists(\Normalizer::class)) {
$text = \Normalizer::normalize($text, \Normalizer::FORM_KC) ?: $text;
}
$text = strtr($text, [
"\u{064A}" => 'ی', // ي عربی
"\u{0649}" => 'ی', // ى الف مقصوره
"\u{0643}" => 'ک', // ك عربی
"\u{200C}" => ' ', // نیم‌فاصله → فاصله
"\u{200B}" => '', // zero-width space
"\u{FEFF}" => '', // BOM
"\u{0640}" => '', // کشیده ـ
]);
// ارقام فارسی/عربی → لاتین
$text = strtr($text, array_combine(
['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹','٠','١','٢','٣','٤','٥','٦','٧','٨','٩'],
['0','1','2','3','4','5','6','7','8','9','0','1','2','3','4','5','6','7','8','9'],
));
return trim(preg_replace('/\s+/u', ' ', $text) ?? $text);
}
/** مقایسهٔ دو نام فارسی پس از نرمال‌سازی. */
public static function sameName(string $a, string $b): bool
{
return self::normalize($a) === self::normalize($b);
}
/** حذف عنوان «دکتر» از ابتدای نام (برای مقایسهٔ نام پروفایل با نام ثبت احوال). */
public static function stripDoctorTitle(string $name): string
{
$normalized = self::normalize($name);
// پیشوندهای متوالی «دکتر دکتر …» را هم کامل حذف می‌کند.
return trim(preg_replace('/^(?:دکتر\s+)+/u', '', $normalized) ?? $normalized);
}
}
+260
View File
@@ -0,0 +1,260 @@
<?php
namespace App\Tests\Doctor;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorClaimRequest;
use App\Shared\Service\ApiIrService;
use App\Tests\ApiTestCase;
/**
* جریان تصاحب پروفایل پزشک ایمپورت‌شده. ApiIrService همیشه mock می‌شود
* تست‌ها هرگز به سرویس واقعی api.ir درخواست نمی‌زنند.
*/
class DoctorClaimTest extends ApiTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->client->disableReboot();
}
private function mockApiIr(bool $shahkar = true, ?array $person = ['firstName' => 'تست', 'lastName' => 'ایمپورت', 'alive' => true]): void
{
$mock = $this->createMock(ApiIrService::class);
$mock->method('isConfigured')->willReturn(true);
$mock->method('shahkarMatch')->willReturn($shahkar);
$mock->method('personInfo')->willReturn($person);
static::getContainer()->set(ApiIrService::class, $mock);
}
private function importUnclaimedDoctor(): string
{
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$code = 'C' . random_int(100_000, 999_999) . random_int(100, 999);
$data = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, [
'name' => 'دکتر تست ایمپورت',
'medical_system_code' => $code,
]);
return $data['data']['uuid'];
}
private function claimBody(): array
{
// db_test هرگز reset نمی‌شود و users.national_code یکتاست → کد ملی هر تست تصادفی
return [
'national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT),
'birth_date' => '1371/1/1',
'first_name' => 'تست',
'last_name' => 'ایمپورت',
];
}
public function testSuccessfulClaimTransfersOwnershipAndDeletesSurrogate(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
$surrogateId = $doctor->getUser()->getId();
$claimer = $this->createUser(['ROLE_USER']);
$res = $this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, $this->claimBody());
$this->assertSame(200, $this->responseCode());
$this->assertSame('claimed', $res['data']['status']);
$this->em->clear();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
$this->assertSame('claimed', $doctor->getOwnerStatus());
$this->assertSame($claimer->getId(), $doctor->getUser()->getId());
$this->assertNull($doctor->getManagedBy());
$claimer = $this->em->getRepository(User::class)->find($claimer->getId());
$this->assertTrue($claimer->hasRole('ROLE_DOCTOR'));
$this->assertTrue($claimer->isNationalCodeVerified());
$this->assertNull($this->em->getRepository(User::class)->find($surrogateId), 'surrogate must be deleted');
$claim = $this->em->getRepository(DoctorClaimRequest::class)->findOneBy(['doctor' => $doctor]);
$this->assertSame(DoctorClaimRequest::STATUS_COMPLETED, $claim->getStatus());
}
public function testNameMismatchRevertsToUnclaimed(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr(person: ['firstName' => 'کس', 'lastName' => 'دیگری', 'alive' => true]);
$claimer = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, $this->claimBody());
$this->assertSame(422, $this->responseCode());
$this->em->clear();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
$this->assertSame('unclaimed', $doctor->getOwnerStatus(), 'must be free for the real doctor to retry');
$this->assertStringStartsWith('imp_', $doctor->getUser()->getMobileNumber(), 'surrogate must survive');
$claim = $this->em->getRepository(DoctorClaimRequest::class)->findOneBy(['doctor' => $doctor]);
$this->assertSame(DoctorClaimRequest::STATUS_FAILED, $claim->getStatus());
$this->assertNotNull($claim->getFailureReason());
}
public function testDeceasedPersonIsRejected(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr(person: ['firstName' => 'تست', 'lastName' => 'ایمپورت', 'alive' => false]);
$claimer = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, $this->claimBody());
$this->assertSame(422, $this->responseCode());
}
public function testShahkarMismatchIsRejected(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr(shahkar: false);
$claimer = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, $this->claimBody());
$this->assertSame(422, $this->responseCode());
}
public function testAlreadyClaimedIsConflict(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr();
$first = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $first, $this->claimBody());
$this->assertSame(200, $this->responseCode());
$second = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $second, $this->claimBody());
$this->assertSame(409, $this->responseCode());
}
public function testUserWhoAlreadyOwnsADoctorCannotClaim(): void
{
$uuidA = $this->importUnclaimedDoctor();
$uuidB = $this->importUnclaimedDoctor();
$this->mockApiIr();
$claimer = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuidA}/claim", $claimer, $this->claimBody());
$this->assertSame(200, $this->responseCode());
$this->authJson('POST', "/api/v1/doctor/{$uuidB}/claim", $claimer, $this->claimBody());
$this->assertSame(409, $this->responseCode());
$this->em->clear();
$doctorB = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuidB]);
$this->assertSame('unclaimed', $doctorB->getOwnerStatus());
}
public function testValidationErrors(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr();
$claimer = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, ['national_code' => '123', 'birth_date' => '1371/1/1', 'first_name' => 'الف', 'last_name' => 'ب']);
$this->assertSame(422, $this->responseCode());
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, ['national_code' => '0010007700', 'birth_date' => 'invalid', 'first_name' => 'الف', 'last_name' => 'ب']);
$this->assertSame(422, $this->responseCode());
}
public function testMobileMismatchIsRejected(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr();
$claimer = $this->createUser(['ROLE_USER']);
$body = $this->claimBody();
$body['mobile'] = '09990000000'; // متفاوت با موبایل کاربر
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, $body);
$this->assertSame(422, $this->responseCode());
}
public function testOwnerCanDeleteOwnProfileButOthersCannot(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr();
$claimer = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, $this->claimBody());
$this->assertSame(200, $this->responseCode());
// کاربر دیگر → 403
$other = $this->createUser(['ROLE_USER']);
$this->authJson('DELETE', "/api/v1/doctor/{$uuid}", $other);
$this->assertSame(403, $this->responseCode());
// مالک → 200
$this->authJson('DELETE', "/api/v1/doctor/{$uuid}", $claimer);
$this->assertSame(200, $this->responseCode());
$this->em->clear();
$this->assertNull($this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]));
}
public function testUnclaimedProfileNotDeletableByRandomUser(): void
{
$uuid = $this->importUnclaimedDoctor();
$user = $this->createUser(['ROLE_USER']);
$this->authJson('DELETE', "/api/v1/doctor/{$uuid}", $user);
$this->assertSame(403, $this->responseCode());
}
public function testClaimInfoIsPublic(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->client->request('GET', "/api/v1/doctor/{$uuid}/claim-info");
$this->assertSame(200, $this->responseCode());
$data = json_decode($this->client->getResponse()->getContent(), true);
$this->assertTrue($data['data']['claimable']);
$this->assertSame('unclaimed', $data['data']['owner_status']);
}
public function testAdminTransferHappyPath(): void
{
$uuid = $this->importUnclaimedDoctor();
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$mobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
$res = $this->authJson('POST', "/api/v1/admin/doctors/{$uuid}/transfer", $admin, ['mobile' => $mobile]);
$this->assertSame(200, $this->responseCode());
$this->assertSame('claimed', $res['data']['owner_status']);
$this->em->clear();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
$this->assertSame('claimed', $doctor->getOwnerStatus());
$this->assertSame($mobile, $doctor->getUser()->getMobileNumber());
$this->assertTrue($doctor->getUser()->hasRole('ROLE_DOCTOR'));
// transfer دوباره → 409
$this->authJson('POST', "/api/v1/admin/doctors/{$uuid}/transfer", $admin, ['mobile' => $mobile]);
$this->assertSame(409, $this->responseCode());
}
public function testAdminTransferRequiresAdmin(): void
{
$uuid = $this->importUnclaimedDoctor();
$user = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/admin/doctors/{$uuid}/transfer", $user, ['mobile' => '09121234567']);
$this->assertSame(403, $this->responseCode());
}
public function testClaimRequiresAuthentication(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->client->request('POST', "/api/v1/doctor/{$uuid}/claim", server: ['CONTENT_TYPE' => 'application/json'], content: json_encode($this->claimBody()));
$this->assertSame(401, $this->responseCode());
}
}
+224
View File
@@ -0,0 +1,224 @@
<?php
namespace App\Tests\Doctor;
use App\Doctor\Entity\Doctor;
use App\Location\Entity\City;
use App\Location\Entity\Province;
use App\Tests\ApiTestCase;
/**
* Regression contract for POST /api/v1/admin/doctors/import (IRIMC import).
* Written BEFORE extracting the logic into DoctorImportService the HTTP
* contract (routes, statuses, {uuid, created, skipped} payload) must not change.
*/
class DoctorImportTest extends ApiTestCase
{
private function importPayload(string $code): array
{
return [
'name' => 'دکتر صفورا حجازی نیا',
'medical_system_code' => $code,
'source_ref' => 'https://membersearch.irimc.org/member/profile?id=test',
'gender' => 'man',
'degree' => 'general',
'info' => 'دکترای حرفه‌ای پزشکی',
];
}
/** db_test is never reset — randomise the natural key per run. */
private function freshCode(): string
{
return 'T' . random_int(100_000, 999_999) . random_int(100, 999);
}
public function testImportCreatesUnclaimedDoctorWithSurrogateUser(): void
{
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$code = $this->freshCode();
$data = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $this->importPayload($code));
$this->assertSame(201, $this->responseCode());
$this->assertTrue($data['success']);
$this->assertTrue($data['data']['created']);
$this->assertNotEmpty($data['data']['uuid']);
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $data['data']['uuid']]);
$this->assertSame('unclaimed', $doctor->getOwnerStatus());
$this->assertSame('irimc', $doctor->getSource());
$this->assertTrue($doctor->isActiveDoctorAppointment());
// نام بدون پیشوند «دکتر» ذخیره می‌شود (UI خودش «دکتر» را جلو می‌گذارد)
$this->assertSame('صفورا حجازی نیا', $doctor->getName());
$surrogate = $doctor->getUser();
$this->assertStringStartsWith('imp_', $surrogate->getMobileNumber());
$this->assertSame(0, $surrogate->getStatus());
$this->assertTrue($surrogate->hasRole('ROLE_UNCLAIMED_DOCTOR'));
}
public function testReimportUpdatesInsteadOfDuplicating(): void
{
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$code = $this->freshCode();
$first = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $this->importPayload($code));
$this->assertSame(201, $this->responseCode());
$payload = $this->importPayload($code);
$payload['name'] = 'دکتر تست ویرایش‌شده';
$second = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $payload);
$this->assertSame(200, $this->responseCode());
$this->assertFalse($second['data']['created']);
$this->assertSame($first['data']['uuid'], $second['data']['uuid']);
$count = $this->em->getRepository(Doctor::class)->count(['source' => 'irimc', 'medicalSystemCode' => $code]);
$this->assertSame(1, $count);
$this->em->clear();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $first['data']['uuid']]);
// نیم‌فاصله در normalize به فاصله تبدیل می‌شود
$this->assertSame('تست ویرایش شده', $doctor->getName());
}
public function testClaimedDoctorIsNeverOverwritten(): void
{
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$code = $this->freshCode();
$created = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $this->importPayload($code));
$uuid = $created['data']['uuid'];
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
$owner = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor->transferOwnershipTo($owner);
$this->em->flush();
$originalName = $doctor->getName();
$payload = $this->importPayload($code);
$payload['name'] = 'دکتر بازنویسی ممنوع';
$reimport = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $payload);
$this->assertSame(200, $this->responseCode());
$this->assertSame('claimed', $reimport['data']['skipped']);
$this->em->clear();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
$this->assertSame($originalName, $doctor->getName());
$this->assertSame('claimed', $doctor->getOwnerStatus());
}
public function testImportCreatesDefaultOfficeAddressWithCityAndProvince(): void
{
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$code = $this->freshCode();
$province = new Province('استان تست ' . $code);
$city = new City('شهر تست ' . $code, $province);
$this->em->persist($province);
$this->em->persist($city);
$this->em->flush();
$payload = $this->importPayload($code);
$payload['states'] = [$province->getId()];
$payload['cities'] = [$city->getId()];
$data = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $payload);
$this->assertSame(201, $this->responseCode());
$cityId = $city->getId();
$provinceId = $province->getId();
$this->em->clear();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $data['data']['uuid']]);
$this->assertCount(1, $doctor->getAddresses());
$address = $doctor->getAddresses()->first();
$this->assertSame('مطب دکتر صفورا حجازی نیا', $address->getName());
$this->assertSame($cityId, $address->getCity()?->getId());
$this->assertSame($provinceId, $address->getProvince()?->getId());
// ایمپورت مجدد نباید آدرس تکراری بسازد یا نام آدرس را بازنویسی کند
$address->setName('مطب ویرایش‌شده توسط کاربر');
$this->em->flush();
$this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $payload);
$this->assertSame(200, $this->responseCode());
$this->em->clear();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $data['data']['uuid']]);
$this->assertCount(1, $doctor->getAddresses());
$this->assertSame('مطب ویرایش‌شده توسط کاربر', $doctor->getAddresses()->first()->getName());
}
public function testImportWithoutLocationStillCreatesOfficeAddress(): void
{
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$code = $this->freshCode();
$data = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $this->importPayload($code));
$this->assertSame(201, $this->responseCode());
$this->em->clear();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $data['data']['uuid']]);
$this->assertCount(1, $doctor->getAddresses());
$this->assertSame('مطب دکتر صفورا حجازی نیا', $doctor->getAddresses()->first()->getName());
}
public function testManualDoctorWithSameCodeIsNeverDuplicated(): void
{
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$code = $this->freshCode();
$owner = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$manual = new Doctor($owner, 'پزشک دستی');
$manual->setMedicalSystemCode($code);
$this->em->persist($manual);
$this->em->flush();
$data = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $this->importPayload($code));
$this->assertSame(200, $this->responseCode());
$this->assertFalse($data['data']['created']);
$this->assertSame('duplicate', $data['data']['skipped']);
$this->assertSame($manual->getUuid(), $data['data']['uuid']);
$this->em->clear();
$count = $this->em->getRepository(Doctor::class)->count(['medicalSystemCode' => $code]);
$this->assertSame(1, $count);
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['medicalSystemCode' => $code]);
$this->assertSame('پزشک دستی', $doctor->getName());
$this->assertSame('manual', $doctor->getSource());
}
public function testValidationErrors(): void
{
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$this->authJson('POST', '/api/v1/admin/doctors/import', $admin, ['medical_system_code' => $this->freshCode()]);
$this->assertSame(422, $this->responseCode());
$this->authJson('POST', '/api/v1/admin/doctors/import', $admin, ['name' => 'دکتر بی‌کد']);
$this->assertSame(422, $this->responseCode());
}
public function testNonAdminIsRejected(): void
{
$user = $this->createUser(['ROLE_USER']);
$this->authJson('POST', '/api/v1/admin/doctors/import', $user, $this->importPayload($this->freshCode()));
$this->assertSame(403, $this->responseCode());
}
public function testImporterRoleCanImportButNothingElse(): void
{
$importer = $this->createUser(['ROLE_USER', 'ROLE_IMPORTER']);
$this->authJson('POST', '/api/v1/admin/doctors/import', $importer, $this->importPayload($this->freshCode()));
$this->assertSame(201, $this->responseCode(), 'ROLE_IMPORTER must be able to import');
$this->authJson('GET', '/api/v1/admin/users', $importer);
$this->assertSame(403, $this->responseCode(), 'ROLE_IMPORTER must NOT reach other admin endpoints');
$this->authJson('GET', '/api/v1/admin/doctor-claims', $importer);
$this->assertSame(403, $this->responseCode());
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Tests\Shared;
use App\Shared\Util\PersianText;
use PHPUnit\Framework\TestCase;
class PersianTextTest extends TestCase
{
public function testArabicYehAndKafAreUnified(): void
{
$this->assertTrue(PersianText::sameName("علي اكبري", 'علی اکبری'));
}
public function testHalfSpaceAndExtraWhitespace(): void
{
$this->assertTrue(PersianText::sameName("محمد\u{200C}رضا کریمی ", 'محمد رضا کریمی'));
}
public function testPersianAndArabicDigits(): void
{
$this->assertSame('1371/1/1', PersianText::normalize('۱۳۷۱/۱/۱'));
$this->assertSame('0912', PersianText::normalize('٠٩١٢'));
}
public function testStripDoctorTitle(): void
{
$this->assertSame('فرخنده حسینی', PersianText::stripDoctorTitle('دکتر فرخنده حسینی'));
$this->assertSame('فرخنده حسینی', PersianText::stripDoctorTitle(' دکتر فرخنده حسینی '));
$this->assertSame('فرخنده حسینی', PersianText::stripDoctorTitle('فرخنده حسینی'));
$this->assertSame('صفورا حجازی نیا', PersianText::stripDoctorTitle('دکتر صفورا حجازی نیا'));
// پیشوند متوالی و کافِ عربی
$this->assertSame('صفورا حجازی نیا', PersianText::stripDoctorTitle('دکتر دکتر صفورا حجازی نیا'));
$this->assertSame('علی اکبری', PersianText::stripDoctorTitle('دكتر علي اكبري'));
}
public function testDifferentNamesStayDifferent(): void
{
$this->assertFalse(PersianText::sameName('علی اکبری', 'ولی اکبری'));
}
}