diff --git a/.claude/prompt/doctor-import-dedup-by-profile-id.md b/.claude/prompt/doctor-import-dedup-by-profile-id.md new file mode 100644 index 00000000..d8633482 --- /dev/null +++ b/.claude/prompt/doctor-import-dedup-by-profile-id.md @@ -0,0 +1,179 @@ +# سخت‌سازی ضدتکرار ایمپورت پزشک با شناسهٔ پروفایل نظام پزشکی + +## پروژه + +`clinicpro` (backend). این پرامپت **گارد اجرایی** ضدتکرار است؛ پرامپت همتای خزنده +`clinicpro-crawler/.claude/prompt/crawler-dedup-and-data-audit.md` منبع داده را اصلاح می‌کند. +این یکی اول اجرا شود — خزنده به این قرارداد تکیه می‌کند. + +## زمینه + +خزندهٔ نظام پزشکی (`clinicpro-crawler`) پزشکان را با `POST /api/v1/admin/doctors/import` +وارد می‌کند. idempotency فعلی فقط روی `(source, medical_system_code)` است +(`DoctorImportService::doImport` + یونیک‌ایندکس `uniq_doctors_source_code`). اما شناسهٔ +**authoritative** واقعی irimc، آی‌دیِ پروفایل است که در `source_ref` ذخیره می‌شود +(`https://membersearch.irimc.org/member/profile?id=`) — نه `medical_system_code`. + +بررسی دادهٔ فعلی (۲۳۳۷ رکورد `source='irimc'`): همه `source_ref` یکتا دارند و همه +`medical_system_code` یکتا. تکرار دقیق فعلاً وجود ندارد. ولی هم‌نام‌هایی مثل «محمد صالحی» +با کدهای متفاوت (`42507`, `آ-1469` — هر دو «آسیب‌شناسی») وجود دارند که profile id متمایز +دارند؛ ممکن است یک نفر باشند ولی از دید irimc دو پروفایل جدا هستند. + +## مشکل / هدف + +**هدف: هیچ‌وقت یک پروفایلِ irimc دو رکورد نسازد** — حتی اگر در جست‌وجوهای مختلف با +`medical_system_code` متفاوت برگردد. کلید idempotency باید علاوه بر +`(source, medical_system_code)`، روی `(source, source_ref)` (profile id) هم باشد. + +**ضدهدف: ادغام خودکار پروفایل‌های متمایز.** هم‌نام‌ها با profile id متفاوت **نباید** +خودکار ادغام شوند — فقط برای بازبینی دستی گزارش شوند. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Doctor/Service/DoctorImportService.php` | منطق idempotency ایمپورت | +| `src/Doctor/Entity/Doctor.php` | فیلد `sourceRef` + یونیک‌ایندکس‌ها | +| `src/Doctor/Repository/DoctorRepository.php` | کوئری‌های پیدا کردن پزشک | +| `migrations/` | یونیک‌ایندکس جدید روی `(source, source_ref)` | +| `src/Doctor/Command/RepairImportedDoctorsCommand.php` | جای مناسب برای گام گزارش تکراری | +| `src/Doctor/Service/Repair/` | گام‌های ترمیم (الگوی `DoctorRepairStep`) | +| `docs/api/doctor-import.md` | مستند idempotency | + +## وضعیت فعلی + +### idempotency فقط با medical_system_code (`DoctorImportService::doImport`) + +```php +$code = trim((string) ($data['medical_system_code'] ?? $data['medicalSystemCode'])); +$source = trim((string) ($data['source'] ?? 'irimc')) ?: 'irimc'; + +// idempotency: همان پزشکِ منبع → به‌روزرسانی، نه ساخت تکراری +$doctor = $doctorRepo->findOneBy(['source' => $source, 'medicalSystemCode' => $code]); + +if ($doctor === null) { + // جلوگیری از تکرار بین منابع بر پایهٔ کد + $existing = $doctorRepo->findOneBy(['medicalSystemCode' => $code]); + if ($existing !== null) { + return new DoctorImportResult($existing, false, 'duplicate'); + } +} +// ... +// source_ref فقط ذخیره می‌شود، کلید dedup نیست: +if (array_key_exists('source_ref', $data) || array_key_exists('profile_url', $data)) { + $doctor->setSourceRef($data['source_ref'] ?? $data['profile_url'] ?? null); +} +``` + +### یونیک‌ایندکس فعلی + +``` +uniq_doctors_source_code → (source, medical_system_code) +``` +`source_ref` هیچ ایندکس/قید یکتایی ندارد. + +## وظایف + +### ۱. dedup روی profile id نظام پزشکی + +**استخراج آی‌دی پایدار از `source_ref`.** خودِ URL کامل ممکن است تغییر قالب بدهد +(query param اضافه/کم)، پس آی‌دیِ UUID داخل آن استخراج و نرمالایز شود. یک متد در +`DoctorImportService` (یا یک VO کوچک): + +```php +/** آی‌دی پایدار پروفایل نظام پزشکی از داخل source_ref/profile_url. */ +private function extractProfileId(?string $ref): ?string +{ + if ($ref === null || trim($ref) === '') { + return null; + } + // .../member/profile?id= (بدون توجه به دامنه/پارامترهای دیگر) + if (preg_match('/[?&]id=([0-9a-f-]{8,})/i', $ref, $m)) { + return strtolower($m[1]); + } + return null; // قالب ناشناخته → روی همان medical_system_code fallback کن +} +``` + +**اولویت dedup:** اول با profile id، بعد با کد. + +```php +$profileId = $this->extractProfileId($data['source_ref'] ?? $data['profile_url'] ?? null); + +$doctor = null; +if ($profileId !== null) { + // همان پروفایل irimc → همیشه همان رکورد، حتی اگر کد فرق کرده باشد + $doctor = $doctorRepo->findOneByProfileId($source, $profileId); +} +if ($doctor === null) { + $doctor = $doctorRepo->findOneBy(['source' => $source, 'medicalSystemCode' => $code]); +} +``` + +> تصمیم لازم: برای مطابقت با profile id، یا ستون `source_ref` را عیناً کوئری بزن +> (اگر خزنده URL پایدار می‌فرستد) یا یک ستون نرمالِ `source_profile_id` اضافه کن +> (بهتر، چون ایندکس یکتا روی UUID خالص تمیزتر است). گزینهٔ دوم پیشنهاد می‌شود: +> ستون `source_profile_id VARCHAR(36) NULL` + پرکردنش در ایمپورت + یونیک‌ایندکس +> `(source, source_profile_id)`. برای دادهٔ موجود در گام backfill (وظیفهٔ ۳) پر شود. + +### ۲. یونیک‌ایندکس + migration + +- ستون `source_profile_id` به `Doctor` (اگر گزینهٔ دوم را گرفتی). +- یونیک‌ایندکس جزئی: `(source, source_profile_id)` که `NULL` را نادیده می‌گیرد + (پزشکان `manual`/`seed` بدون profile id نباید تداخل کنند — MariaDB روی چند `NULL` + در یونیک‌ایندکس تداخل نمی‌گیرد، پس امن است). +- `doctrine:migrations:diff` → `migrate`. + +```php +#[ORM\Column(name: 'source_profile_id', type: 'string', length: 36, nullable: true)] +private ?string $sourceProfileId = null; +``` +```php +#[ORM\UniqueConstraint(name: 'uniq_doctors_source_profile', columns: ['source', 'source_profile_id'])] +``` + +**نکتهٔ هم‌زمانی:** الگوی موجود `doImport` روی `UniqueConstraintViolationException` رفت +(`resetManager` + تلاش مجدد → مسیر update). این را برای ایندکس جدید هم حفظ کن. + +### ۳. گام گزارش تکراری مشکوک (بدون ادغام خودکار) + +یک `DoctorRepairStep` جدید `report-suspected-duplicates` در +`src/Doctor/Service/Repair/` اضافه کن (خودکار کشف می‌شود؛ الگوی گام‌های موجود). +این گام **هیچ‌چیز تغییر نمی‌دهد** — فقط خوشه‌های مشکوک را چاپ می‌کند: + +- معیار: نام نرمال‌شده یکسان (`PersianText::normalize`) **و** حداقل یک `specialty_id` + مشترک **و** همان شهر، ولی `source_profile_id` (یا `source_ref`) متفاوت. +- خروجی: جدول `[نام، تعداد، profile_idها، تخصص مشترک، شهر]` برای بازبینی دستی. +- در همین گام، `source_profile_id` خالیِ رکوردهای قدیمی را از `source_ref` backfill کن + (این‌جا idempotent و قابل `--dry-run`). + +> چرا ادغام خودکار نه: profile id در irimc authoritative است و متمایز بودن آن یعنی +> irimc آن‌ها را دو نفر می‌داند. «محمد صالحیِ آسیب‌شناس» می‌تواند دو نفر واقعی باشد. +> ادغام اشتباه، دادهٔ دو پزشک را یکی می‌کند و برگشت‌ناپذیر است. + +### ۴. مستندسازی + +`docs/api/doctor-import.md`: بخش idempotency را به‌روزرسانی کن — اولویت +`source_profile_id` بر `medical_system_code`، رفتار پزشکان بدون profile id، و گام +`report-suspected-duplicates`. + +## نکات مهم + +- **پزشکان `manual`/`seed` بدون profile id نباید بشکنند:** `source_profile_id = NULL` + و یونیک‌ایندکس چند `NULL` را می‌پذیرد. مسیر ایمپورت فقط وقتی profile id دارد از آن + استفاده می‌کند؛ وگرنه دقیقاً مثل قبل روی `medical_system_code` کار می‌کند. +- **پروفایل `claimed` دست‌نخورده:** منطق فعلی که ایمپورت مجددِ پروفایل تصاحب‌شده را + بازنویسی نمی‌کند (`return ... 'claimed'`) باید حفظ شود. +- **backfill قبل از افزودن یونیک‌ایندکس:** اگر دادهٔ موجود پس از پرکردن + `source_profile_id` تکراری داشته باشد، migration یونیک‌ایندکس می‌شکند. اول در گام + ترمیم backfill کن و تکراری‌های دقیق (همان profile id، دو رکورد) را — که الان صفرند + ولی باید چک شوند — گزارش/حل کن، بعد ایندکس را بزن. +- **الگوها:** Controller از `BaseController`؛ خطا با `$this->error()` / `AppException`؛ + تایم‌استمپ Unix؛ گام ترمیم با `DoctorRepairStep` و tag `app.doctor_repair_step`. +- **تست الزامی (موفق + خطا + مرزی):** + - همان profile id با `medical_system_code` متفاوت → **یک** رکورد (update، نه create). + - profile id متفاوت با همان نام/تخصص → **دو** رکورد (ادغام نشود). + - رکورد بدون profile id (manual) → رفتار قبلی، بدون تداخل یونیک. + - idempotency: ایمپورت دوبارهٔ همان payload → صفر رکورد جدید. + - گام گزارش: خوشهٔ مشکوک را می‌یابد ولی هیچ رکوردی را تغییر نمی‌دهد. +- بعد از هر تغییر: `ddev exec php bin/phpunit` سبز، `docs/api/doctor-import.md` به‌روز. diff --git a/docs/api/doctor-import.md b/docs/api/doctor-import.md index 7d8fa282..b27c7f72 100644 --- a/docs/api/doctor-import.md +++ b/docs/api/doctor-import.md @@ -22,6 +22,7 @@ | `owner_status` | `unclaimed` | `claimed` \| `unclaimed` \| `pending_transfer` | | `source` | `irimc` | `manual` (پیش‌فرض رکوردهای قدیمی) \| `irimc` | | `source_ref` | `profile_url` | شناسهٔ رکورد مبدأ برای ممیزی | +| `source_profile_id` | UUID داخل `source_ref` | شناسهٔ authoritative پروفایل نظام پزشکی؛ کلید اصلی idempotency | | `managed_by` | id کاربر ادمینِ فراخوان | کاربری که پروفایل را مدیریت می‌کند | | `claimed_at` | `null` | زمان انتقال مالکیت (هنگام claim پر می‌شود) | @@ -31,19 +32,31 @@ ## idempotency -کلید یکتای `(source, medical_system_code)` — از این نسخه **در سطح دیتابیس** هم unique است -(`uniq_doctors_source_code`، migration `Version20260711150000`)؛ درخواست هم‌زمانِ همان پزشک +**اولویت کلید:** اول `source_profile_id` (شناسهٔ authoritative پروفایل نظام پزشکی، استخراج‌شده +از UUID داخل `source_ref`)، سپس `(source, medical_system_code)`. هر دو در سطح دیتابیس unique +هستند (`uniq_doctors_source_profile` و `uniq_doctors_source_code`)؛ درخواست هم‌زمانِ همان پزشک با retry داخلی به مسیر update می‌رود و هرگز رکورد تکراری نمی‌سازد. -- اگر پزشکی با همان `source`+`medical_system_code` وجود نداشته باشد → **ساخته** می‌شود (`201`). -- اگر وجود داشته باشد و `owner_status != claimed` → **به‌روزرسانی** می‌شود (`200`). -- اگر وجود داشته باشد و `owner_status == claimed` → **رد** می‌شود (`200`, `skipped: "claimed"`) - تا دادهٔ مالک واقعی بازنویسی نشود. +- **همان پروفایل با کد متفاوت:** اگر پزشکی با همان `source`+`source_profile_id` وجود داشته + باشد → **به‌روزرسانی** می‌شود، حتی اگر `medical_system_code` عوض شده باشد. یک پروفایل irimc + هرگز دو رکورد نمی‌سازد (تست: `DoctorImportTest::testSameProfileIdWithDifferentCodeUpdatesInsteadOfDuplicating`). +- اگر پروفایل شناسه نداشت (قالب `source_ref` ناشناخته) → روی `(source, medical_system_code)` + fallback می‌شود؛ رفتار رکوردهای `manual`/`seed` بدون تغییر می‌ماند (چند `NULL` در + یونیک‌ایندکس MariaDB تداخل نمی‌گیرد). +- اگر پزشکی نبود → **ساخته** می‌شود (`201`). +- اگر بود و `owner_status != claimed` → **به‌روزرسانی** (`200`). +- اگر بود و `owner_status == claimed` → **رد** (`200`, `skipped: "claimed"`) تا دادهٔ مالک + واقعی بازنویسی نشود. - **تکرار بین منابع:** اگر پزشکی با همان `medical_system_code` ولی `source` متفاوت (مثلاً ثبت دستی در پنل) وجود داشته باشد → **رد** می‌شود (`200`, `skipped: "duplicate"`)؛ نه رکورد جدیدی ساخته می‌شود و نه رکورد موجود بازنویسی می‌شود. `uuid` همان رکورد موجود برگردانده می‌شود (تست: `DoctorImportTest::testManualDoctorWithSameCodeIsNeverDuplicated`). +> **پروفایل‌های هم‌نامِ متمایز ادغام نمی‌شوند:** دو `source_profile_id` متفاوت یعنی irimc +> آن‌ها را دو پزشک می‌داند (ممکن است دو نفر واقعی باشند). گام +> `app:doctors:repair --only=report-suspected-duplicates` خوشه‌های مشکوک (نام+تخصص+شهرِ +> یکسان، profile id متفاوت) را فقط **گزارش** می‌کند؛ تصمیم ادغام انسانی است. + --- ## Request @@ -66,9 +79,9 @@ | فیلد | الزامی | توضیح | |---|:---:|---| | `name` | ✅ | نام کامل پزشک — پیشوند «دکتر» **هنگام ذخیره حذف** می‌شود (کنوانسیون: نام بدون عنوان؛ UI خودش «دکتر» را جلو می‌گذارد). ورودی می‌تواند با یا بدون «دکتر» باشد. | -| `medical_system_code` | ✅ | کد نظام پزشکی (کلید idempotency) | +| `medical_system_code` | ✅ | کد نظام پزشکی (کلید idempotency ثانویه) | | `source` | — | پیش‌فرض `irimc` | -| `source_ref` | — | `profile_url` یا شناسهٔ مبدأ | +| `source_ref` | — | `profile_url` نظام پزشکی؛ UUID داخلش استخراج و در `source_profile_id` ذخیره می‌شود و **کلید اصلی idempotency** است | | `gender` | — | `man` \| `woman` | | `degree` | — | `general` \| `expert` \| `specialist` \| `subspecialistplus` | | `info` | — | متن تخصص/توضیح | @@ -226,6 +239,8 @@ php bin/console app:doctors:repair --list # فهرست گام‌ها | `degrees` | بازمحاسبهٔ درجه از روی عنوان خام `info` | | `specialty-parents` | افزودن تخصص‌های والد به پزشکانی که فقط تخصص فرزند دارند | | `surrogate-role` | افزودن `ROLE_UNCLAIMED_DOCTOR` به کاربران جانشین قدیمی | +| `source-profile-id` | پرکردن `source_profile_id` از `source_ref` (پیش‌نیاز dedup مبتنی بر profile id) | +| `report-suspected-duplicates` | گزارش خوشه‌های مشکوک به تکراری (نام+تخصص+شهرِ یکسان، profile id متفاوت) — **بدون** ادغام خودکار | سوییچ‌ها: diff --git a/migrations/Version20260719163834.php b/migrations/Version20260719163834.php new file mode 100644 index 00000000..2a7ff2cc --- /dev/null +++ b/migrations/Version20260719163834.php @@ -0,0 +1,29 @@ +addSql('ALTER TABLE doctors ADD source_profile_id VARCHAR(36) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE doctors DROP source_profile_id'); + } +} diff --git a/migrations/Version20260719164144.php b/migrations/Version20260719164144.php new file mode 100644 index 00000000..5802f165 --- /dev/null +++ b/migrations/Version20260719164144.php @@ -0,0 +1,31 @@ +addSql('CREATE UNIQUE INDEX uniq_doctors_source_profile ON doctors (source, source_profile_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX uniq_doctors_source_profile ON doctors'); + } +} diff --git a/src/Doctor/Entity/Doctor.php b/src/Doctor/Entity/Doctor.php index 7fcbfa1e..3a57300d 100644 --- a/src/Doctor/Entity/Doctor.php +++ b/src/Doctor/Entity/Doctor.php @@ -20,6 +20,7 @@ use Symfony\Component\Uid\Uuid; #[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\UniqueConstraint(name: 'uniq_doctors_source_profile', columns: ['source', 'source_profile_id'])] #[ORM\Index(columns: ['active_doctor_appointment'], name: 'idx_doctors_active')] #[ORM\Index(columns: ['owner_status'], name: 'idx_doctors_owner')] class Doctor @@ -94,6 +95,11 @@ class Doctor #[ORM\Column(name: 'source_ref', type: 'string', length: 100, nullable: true)] private ?string $sourceRef = null; + // شناسهٔ پایدارِ پروفایل مبدأ (UUID داخل source_ref) — شناسهٔ authoritative نظام + // پزشکی و کلید اصلی idempotency: یک پروفایل هرگز دو رکورد نمی‌سازد حتی اگر کدش عوض شود. + #[ORM\Column(name: 'source_profile_id', type: 'string', length: 36, nullable: true)] + private ?string $sourceProfileId = null; + // شناسه کاربری که این پروفایلِ بدون‌مالک را وارد/مدیریت کرده (مثلاً کاربر سیستمی) #[ORM\Column(name: 'managed_by', type: 'integer', nullable: true)] private ?int $managedBy = null; @@ -238,6 +244,10 @@ class Doctor { return $this->sourceRef; } + public function getSourceProfileId(): ?string + { + return $this->sourceProfileId; + } public function getManagedBy(): ?int { return $this->managedBy; @@ -377,6 +387,12 @@ class Doctor $this->touch(); return $this; } + public function setSourceProfileId(?string $v): self + { + $this->sourceProfileId = $v; + $this->touch(); + return $this; + } public function setManagedBy(?int $v): self { $this->managedBy = $v; diff --git a/src/Doctor/Service/DoctorImportService.php b/src/Doctor/Service/DoctorImportService.php index 815646ac..64d8b5a9 100644 --- a/src/Doctor/Service/DoctorImportService.php +++ b/src/Doctor/Service/DoctorImportService.php @@ -52,12 +52,19 @@ class DoctorImportService $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'; + $ref = $data['source_ref'] ?? $data['profile_url'] ?? null; + // شناسهٔ authoritative پروفایل مبدأ؛ کلید اصلی idempotency (بر کد مقدم است). + $profileId = SourceProfileId::fromRef($ref); $doctorRepo = $this->em->getRepository(Doctor::class); $userRepo = $this->em->getRepository(User::class); - // idempotency: همان پزشکِ منبع → به‌روزرسانی، نه ساخت تکراری - $doctor = $doctorRepo->findOneBy(['source' => $source, 'medicalSystemCode' => $code]); + // idempotency: اول با شناسهٔ پروفایل (پایدار حتی اگر کد عوض شده باشد)، + // سپس با (source, medical_system_code). یک پروفایل هرگز دو رکورد نمی‌سازد. + $doctor = $profileId !== null + ? $doctorRepo->findOneBy(['source' => $source, 'sourceProfileId' => $profileId]) + : null; + $doctor ??= $doctorRepo->findOneBy(['source' => $source, 'medicalSystemCode' => $code]); $created = false; // پروفایل تصاحب‌شده را با ایمپورت مجدد بازنویسی نکن (مالک واقعی اولویت دارد) @@ -75,8 +82,10 @@ class DoctorImportService } if ($doctor === null) { - // کاربر جانشینِ یکتا و غیرفعال؛ شناسهٔ مصنوعی قطعی از روی کد نظام پزشکی - $synthetic = 'imp_' . substr(md5($source . ':' . $code), 0, 14); // ≤ ۱۸ کاراکتر، ASCII، یکتا + // کاربر جانشینِ یکتا و غیرفعال؛ شناسهٔ مصنوعی قطعی از شناسهٔ پایدارِ پروفایل + // (یا کد، اگر پروفایل شناسه ندارد) تا با تغییر کد، جانشین تکراری ساخته نشود. + $identity = $profileId ?? $code; + $synthetic = 'imp_' . substr(md5($source . ':' . $identity), 0, 14); // ≤ ۱۸ کاراکتر، ASCII، یکتا $user = $userRepo->findOneBy(['mobileNumber' => $synthetic]); if ($user === null) { $user = new User($synthetic); @@ -105,7 +114,8 @@ class DoctorImportService $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); + $doctor->setSourceRef($ref); + $doctor->setSourceProfileId($profileId); } if (!empty($data['gender'])) $doctor->setGender($data['gender']); if (!empty($data['degree'])) $doctor->setDegree($data['degree']); diff --git a/src/Doctor/Service/Repair/BackfillSourceProfileIdStep.php b/src/Doctor/Service/Repair/BackfillSourceProfileIdStep.php new file mode 100644 index 00000000..40ede812 --- /dev/null +++ b/src/Doctor/Service/Repair/BackfillSourceProfileIdStep.php @@ -0,0 +1,65 @@ +em->getRepository(Doctor::class)->createQueryBuilder('d') + ->where('d.sourceRef IS NOT NULL') + ->andWhere('d.sourceProfileId IS NULL'); + if (!$options->allSources) { + $qb->andWhere('d.source = :src')->setParameter('src', 'irimc'); + } + /** @var Doctor[] $doctors */ + $doctors = $qb->getQuery()->getResult(); + + $changed = 0; + $skipped = 0; + foreach ($doctors as $doctor) { + $id = SourceProfileId::fromRef($doctor->getSourceRef()); + if ($id === null) { + // source_ref قالب شناسه‌دار ندارد — روی همان کد fallback می‌شود، تغییری نده. + $skipped++; + continue; + } + $io->text(sprintf(' #%d %s → %s', $doctor->getId(), $doctor->getName(), $id)); + if (!$options->dryRun) { + $doctor->setSourceProfileId($id); + } + $changed++; + } + + return new RepairResult( + scanned: count($doctors), + changed: $changed, + skipped: $skipped, + note: $skipped > 0 ? "$skipped رکورد بدون شناسهٔ قابل استخراج" : null, + ); + } +} diff --git a/src/Doctor/Service/Repair/ReportSuspectedDuplicatesStep.php b/src/Doctor/Service/Repair/ReportSuspectedDuplicatesStep.php new file mode 100644 index 00000000..eb6664df --- /dev/null +++ b/src/Doctor/Service/Repair/ReportSuspectedDuplicatesStep.php @@ -0,0 +1,108 @@ +em->getRepository(Doctor::class)->createQueryBuilder('d'); + if (!$options->allSources) { + $qb->andWhere('d.source = :src')->setParameter('src', 'irimc'); + } + /** @var Doctor[] $doctors */ + $doctors = $qb->getQuery()->getResult(); + + // خوشه‌بندی بر نام نرمال‌شده — normalize در DB ممکن نیست، پس در PHP. + $byName = []; + foreach ($doctors as $doctor) { + $byName[PersianText::normalize($doctor->getName())][] = $doctor; + } + + $ids = static fn (iterable $coll): array => array_map( + static fn ($e) => $e->getId(), + $coll instanceof \Traversable ? iterator_to_array($coll) : (array) $coll, + ); + + $clusters = 0; + $suspected = 0; + foreach ($byName as $group) { + if (count($group) < 2) { + continue; + } + // زوج‌های همان نام که تخصص و شهر مشترک دارند ولی شناسهٔ پروفایل متفاوت. + $flagged = []; + for ($i = 0; $i < count($group); $i++) { + for ($j = $i + 1; $j < count($group); $j++) { + $a = $group[$i]; + $b = $group[$j]; + if ($a->getSourceProfileId() !== null + && $a->getSourceProfileId() === $b->getSourceProfileId()) { + continue; // همان پروفایل — dedup باید مهارش کند، تکراری مشکوک نیست + } + $specOverlap = array_intersect($ids($a->getSpecialties()), $ids($b->getSpecialties())); + $cityOverlap = array_intersect($ids($a->getCities()), $ids($b->getCities())); + if ($specOverlap !== [] && $cityOverlap !== []) { + $flagged[$a->getId()] = $a; + $flagged[$b->getId()] = $b; + } + } + } + if ($flagged === []) { + continue; + } + $clusters++; + $suspected += count($flagged); + $io->section(sprintf('«%s» — %d رکورد مشکوک', $group[0]->getName(), count($flagged))); + $rows = []; + foreach ($flagged as $doctor) { + $rows[] = [ + $doctor->getId(), + $doctor->getMedicalSystemCode(), + $doctor->getSourceProfileId() ?? '—', + implode('، ', array_map(static fn (Specialty $s) => $s->getName(), $doctor->getSpecialties()->toArray())), + implode('، ', array_map(static fn (City $c) => $c->getName(), $doctor->getCities()->toArray())), + ]; + } + $io->table(['#', 'کد نظام', 'profile_id', 'تخصص', 'شهر'], $rows); + } + + return new RepairResult( + scanned: count($doctors), + changed: 0, + skipped: $suspected, + note: $suspected > 0 + ? "$suspected رکورد در $clusters خوشه — بازبینی دستی لازم، ادغام خودکار نشد" + : 'خوشهٔ مشکوکی یافت نشد', + ); + } +} diff --git a/src/Doctor/Service/SourceProfileId.php b/src/Doctor/Service/SourceProfileId.php new file mode 100644 index 00000000..5ff8bfa5 --- /dev/null +++ b/src/Doctor/Service/SourceProfileId.php @@ -0,0 +1,26 @@ +` است؛ خودِ URL ممکن است + * قالبش (دامنه/پارامتر اضافه) تغییر کند، ولی UUID پروفایل ثابت و authoritative است + * و کلید اصلی idempotency ایمپورت را می‌سازد. + */ +final class SourceProfileId +{ + /** UUID داخل query param `id=`؛ قالب ناشناخته → null (fallback روی medical_system_code). */ + public static function fromRef(?string $ref): ?string + { + if ($ref === null || trim($ref) === '') { + return null; + } + if (preg_match('/[?&]id=([0-9a-f-]{8,})/i', $ref, $m) === 1) { + return strtolower($m[1]); + } + + return null; + } +} diff --git a/tests/Doctor/DoctorImportTest.php b/tests/Doctor/DoctorImportTest.php index 3c2f4198..6fcf92ca 100644 --- a/tests/Doctor/DoctorImportTest.php +++ b/tests/Doctor/DoctorImportTest.php @@ -190,6 +190,69 @@ class DoctorImportTest extends ApiTestCase $this->assertSame('manual', $doctor->getSource()); } + /** UUID تازه برای هر اجرا — یونیک‌ایندکس (source, source_profile_id) تکرار را رد می‌کند. */ + private function freshProfileId(): string + { + return bin2hex(random_bytes(16)); // ۳۲ نویسهٔ hex، منطبق با استخراج‌کنندهٔ profile id + } + + private function payloadWithProfile(string $code, string $profileId): array + { + $p = $this->importPayload($code); + $p['source_ref'] = "https://membersearch.irimc.org/member/profile?id=$profileId"; + return $p; + } + + public function testSameProfileIdWithDifferentCodeUpdatesInsteadOfDuplicating(): void + { + $admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']); + $profileId = $this->freshProfileId(); + $code1 = $this->freshCode(); + $code2 = $this->freshCode(); + + $first = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $this->payloadWithProfile($code1, $profileId)); + $this->assertSame(201, $this->responseCode()); + + // همان پروفایل irimc، ولی کد نظام پزشکی متفاوت (نمایش دیگرِ همان پزشک) + $second = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $this->payloadWithProfile($code2, $profileId)); + $this->assertSame(200, $this->responseCode()); + $this->assertFalse($second['data']['created']); + $this->assertSame($first['data']['uuid'], $second['data']['uuid']); + + $this->em->clear(); + $count = $this->em->getRepository(Doctor::class)->count(['source' => 'irimc', 'sourceProfileId' => $profileId]); + $this->assertSame(1, $count, 'یک پروفایل نباید دو رکورد بسازد حتی با کد متفاوت'); + $doctor = $this->em->getRepository(Doctor::class)->findOneBy(['sourceProfileId' => $profileId]); + $this->assertSame($code2, $doctor->getMedicalSystemCode(), 'کد باید به آخرین مقدار به‌روزرسانی شود'); + } + + public function testDifferentProfileIdsAreKeptSeparate(): void + { + $admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']); + + // همان نام، ولی دو پروفایل متمایز irimc → دو رکورد، بدون ادغام خودکار + $a = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $this->payloadWithProfile($this->freshCode(), $this->freshProfileId())); + $this->assertSame(201, $this->responseCode()); + $b = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $this->payloadWithProfile($this->freshCode(), $this->freshProfileId())); + $this->assertSame(201, $this->responseCode()); + + $this->assertTrue($b['data']['created']); + $this->assertNotSame($a['data']['uuid'], $b['data']['uuid']); + } + + public function testProfileIdIsBackfilledFromSourceRefOnImport(): void + { + $admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']); + $profileId = $this->freshProfileId(); + + $data = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $this->payloadWithProfile($this->freshCode(), $profileId)); + $this->assertSame(201, $this->responseCode()); + + $this->em->clear(); + $doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $data['data']['uuid']]); + $this->assertSame($profileId, $doctor->getSourceProfileId()); + } + public function testValidationErrors(): void { $admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']); diff --git a/tests/Doctor/SourceProfileIdTest.php b/tests/Doctor/SourceProfileIdTest.php new file mode 100644 index 00000000..bc2f6cfc --- /dev/null +++ b/tests/Doctor/SourceProfileIdTest.php @@ -0,0 +1,38 @@ +assertSame($expected, SourceProfileId::fromRef($ref)); + } + + public static function refs(): array + { + $uuid = 'ad600622-b98a-48ac-a448-89ef68fd2c46'; + + return [ + 'irimc profile url' => ["https://membersearch.irimc.org/member/profile?id=$uuid", $uuid], + 'extra query params' => ["https://membersearch.irimc.org/member/profile?lang=fa&id=$uuid&x=1", $uuid], + 'different host, same id' => ["http://example.test/x?id=$uuid", $uuid], + 'uppercase normalized to lowercase' => ['?id=' . strtoupper($uuid), $uuid], + 'no id param' => ['https://membersearch.irimc.org/member/profile', null], + 'plain code, not a url' => ['42507', null], + 'empty' => ['', null], + 'whitespace' => [' ', null], + 'null' => [null, null], + ]; + } +}