feat: enhance doctor import process with source profile ID for improved idempotency and deduplication
This commit is contained in:
@@ -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=<uuid>`) — نه `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=<uuid> → <uuid> (بدون توجه به دامنه/پارامترهای دیگر)
|
||||||
|
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` بهروز.
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
| `owner_status` | `unclaimed` | `claimed` \| `unclaimed` \| `pending_transfer` |
|
| `owner_status` | `unclaimed` | `claimed` \| `unclaimed` \| `pending_transfer` |
|
||||||
| `source` | `irimc` | `manual` (پیشفرض رکوردهای قدیمی) \| `irimc` |
|
| `source` | `irimc` | `manual` (پیشفرض رکوردهای قدیمی) \| `irimc` |
|
||||||
| `source_ref` | `profile_url` | شناسهٔ رکورد مبدأ برای ممیزی |
|
| `source_ref` | `profile_url` | شناسهٔ رکورد مبدأ برای ممیزی |
|
||||||
|
| `source_profile_id` | UUID داخل `source_ref` | شناسهٔ authoritative پروفایل نظام پزشکی؛ کلید اصلی idempotency |
|
||||||
| `managed_by` | id کاربر ادمینِ فراخوان | کاربری که پروفایل را مدیریت میکند |
|
| `managed_by` | id کاربر ادمینِ فراخوان | کاربری که پروفایل را مدیریت میکند |
|
||||||
| `claimed_at` | `null` | زمان انتقال مالکیت (هنگام claim پر میشود) |
|
| `claimed_at` | `null` | زمان انتقال مالکیت (هنگام claim پر میشود) |
|
||||||
|
|
||||||
@@ -31,19 +32,31 @@
|
|||||||
|
|
||||||
## idempotency
|
## idempotency
|
||||||
|
|
||||||
کلید یکتای `(source, medical_system_code)` — از این نسخه **در سطح دیتابیس** هم unique است
|
**اولویت کلید:** اول `source_profile_id` (شناسهٔ authoritative پروفایل نظام پزشکی، استخراجشده
|
||||||
(`uniq_doctors_source_code`، migration `Version20260711150000`)؛ درخواست همزمانِ همان پزشک
|
از UUID داخل `source_ref`)، سپس `(source, medical_system_code)`. هر دو در سطح دیتابیس unique
|
||||||
|
هستند (`uniq_doctors_source_profile` و `uniq_doctors_source_code`)؛ درخواست همزمانِ همان پزشک
|
||||||
با retry داخلی به مسیر update میرود و هرگز رکورد تکراری نمیسازد.
|
با retry داخلی به مسیر update میرود و هرگز رکورد تکراری نمیسازد.
|
||||||
|
|
||||||
- اگر پزشکی با همان `source`+`medical_system_code` وجود نداشته باشد → **ساخته** میشود (`201`).
|
- **همان پروفایل با کد متفاوت:** اگر پزشکی با همان `source`+`source_profile_id` وجود داشته
|
||||||
- اگر وجود داشته باشد و `owner_status != claimed` → **بهروزرسانی** میشود (`200`).
|
باشد → **بهروزرسانی** میشود، حتی اگر `medical_system_code` عوض شده باشد. یک پروفایل irimc
|
||||||
- اگر وجود داشته باشد و `owner_status == claimed` → **رد** میشود (`200`, `skipped: "claimed"`)
|
هرگز دو رکورد نمیسازد (تست: `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` متفاوت
|
- **تکرار بین منابع:** اگر پزشکی با همان `medical_system_code` ولی `source` متفاوت
|
||||||
(مثلاً ثبت دستی در پنل) وجود داشته باشد → **رد** میشود (`200`, `skipped: "duplicate"`)؛
|
(مثلاً ثبت دستی در پنل) وجود داشته باشد → **رد** میشود (`200`, `skipped: "duplicate"`)؛
|
||||||
نه رکورد جدیدی ساخته میشود و نه رکورد موجود بازنویسی میشود. `uuid` همان رکورد موجود
|
نه رکورد جدیدی ساخته میشود و نه رکورد موجود بازنویسی میشود. `uuid` همان رکورد موجود
|
||||||
برگردانده میشود (تست: `DoctorImportTest::testManualDoctorWithSameCodeIsNeverDuplicated`).
|
برگردانده میشود (تست: `DoctorImportTest::testManualDoctorWithSameCodeIsNeverDuplicated`).
|
||||||
|
|
||||||
|
> **پروفایلهای همنامِ متمایز ادغام نمیشوند:** دو `source_profile_id` متفاوت یعنی irimc
|
||||||
|
> آنها را دو پزشک میداند (ممکن است دو نفر واقعی باشند). گام
|
||||||
|
> `app:doctors:repair --only=report-suspected-duplicates` خوشههای مشکوک (نام+تخصص+شهرِ
|
||||||
|
> یکسان، profile id متفاوت) را فقط **گزارش** میکند؛ تصمیم ادغام انسانی است.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Request
|
## Request
|
||||||
@@ -66,9 +79,9 @@
|
|||||||
| فیلد | الزامی | توضیح |
|
| فیلد | الزامی | توضیح |
|
||||||
|---|:---:|---|
|
|---|:---:|---|
|
||||||
| `name` | ✅ | نام کامل پزشک — پیشوند «دکتر» **هنگام ذخیره حذف** میشود (کنوانسیون: نام بدون عنوان؛ UI خودش «دکتر» را جلو میگذارد). ورودی میتواند با یا بدون «دکتر» باشد. |
|
| `name` | ✅ | نام کامل پزشک — پیشوند «دکتر» **هنگام ذخیره حذف** میشود (کنوانسیون: نام بدون عنوان؛ UI خودش «دکتر» را جلو میگذارد). ورودی میتواند با یا بدون «دکتر» باشد. |
|
||||||
| `medical_system_code` | ✅ | کد نظام پزشکی (کلید idempotency) |
|
| `medical_system_code` | ✅ | کد نظام پزشکی (کلید idempotency ثانویه) |
|
||||||
| `source` | — | پیشفرض `irimc` |
|
| `source` | — | پیشفرض `irimc` |
|
||||||
| `source_ref` | — | `profile_url` یا شناسهٔ مبدأ |
|
| `source_ref` | — | `profile_url` نظام پزشکی؛ UUID داخلش استخراج و در `source_profile_id` ذخیره میشود و **کلید اصلی idempotency** است |
|
||||||
| `gender` | — | `man` \| `woman` |
|
| `gender` | — | `man` \| `woman` |
|
||||||
| `degree` | — | `general` \| `expert` \| `specialist` \| `subspecialistplus` |
|
| `degree` | — | `general` \| `expert` \| `specialist` \| `subspecialistplus` |
|
||||||
| `info` | — | متن تخصص/توضیح |
|
| `info` | — | متن تخصص/توضیح |
|
||||||
@@ -226,6 +239,8 @@ php bin/console app:doctors:repair --list # فهرست گامها
|
|||||||
| `degrees` | بازمحاسبهٔ درجه از روی عنوان خام `info` |
|
| `degrees` | بازمحاسبهٔ درجه از روی عنوان خام `info` |
|
||||||
| `specialty-parents` | افزودن تخصصهای والد به پزشکانی که فقط تخصص فرزند دارند |
|
| `specialty-parents` | افزودن تخصصهای والد به پزشکانی که فقط تخصص فرزند دارند |
|
||||||
| `surrogate-role` | افزودن `ROLE_UNCLAIMED_DOCTOR` به کاربران جانشین قدیمی |
|
| `surrogate-role` | افزودن `ROLE_UNCLAIMED_DOCTOR` به کاربران جانشین قدیمی |
|
||||||
|
| `source-profile-id` | پرکردن `source_profile_id` از `source_ref` (پیشنیاز dedup مبتنی بر profile id) |
|
||||||
|
| `report-suspected-duplicates` | گزارش خوشههای مشکوک به تکراری (نام+تخصص+شهرِ یکسان، profile id متفاوت) — **بدون** ادغام خودکار |
|
||||||
|
|
||||||
سوییچها:
|
سوییچها:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260719163834 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add doctors.source_profile_id (IRIMC profile UUID) for import idempotency';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260719164144 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Unique index (source, source_profile_id) — one IRIMC profile never yields two doctors';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// NULL چندگانه در یونیکایندکس MariaDB تداخل نمیگیرد، پس پزشکان manual/seed
|
||||||
|
// بدون source_profile_id سالم میمانند.
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ use Symfony\Component\Uid\Uuid;
|
|||||||
#[ORM\Table(name: 'doctors')]
|
#[ORM\Table(name: 'doctors')]
|
||||||
#[ORM\UniqueConstraint(name: 'idx_doctors_user', columns: ['user_id'])]
|
#[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_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: ['active_doctor_appointment'], name: 'idx_doctors_active')]
|
||||||
#[ORM\Index(columns: ['owner_status'], name: 'idx_doctors_owner')]
|
#[ORM\Index(columns: ['owner_status'], name: 'idx_doctors_owner')]
|
||||||
class Doctor
|
class Doctor
|
||||||
@@ -94,6 +95,11 @@ class Doctor
|
|||||||
#[ORM\Column(name: 'source_ref', type: 'string', length: 100, nullable: true)]
|
#[ORM\Column(name: 'source_ref', type: 'string', length: 100, nullable: true)]
|
||||||
private ?string $sourceRef = null;
|
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)]
|
#[ORM\Column(name: 'managed_by', type: 'integer', nullable: true)]
|
||||||
private ?int $managedBy = null;
|
private ?int $managedBy = null;
|
||||||
@@ -238,6 +244,10 @@ class Doctor
|
|||||||
{
|
{
|
||||||
return $this->sourceRef;
|
return $this->sourceRef;
|
||||||
}
|
}
|
||||||
|
public function getSourceProfileId(): ?string
|
||||||
|
{
|
||||||
|
return $this->sourceProfileId;
|
||||||
|
}
|
||||||
public function getManagedBy(): ?int
|
public function getManagedBy(): ?int
|
||||||
{
|
{
|
||||||
return $this->managedBy;
|
return $this->managedBy;
|
||||||
@@ -377,6 +387,12 @@ class Doctor
|
|||||||
$this->touch();
|
$this->touch();
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
public function setSourceProfileId(?string $v): self
|
||||||
|
{
|
||||||
|
$this->sourceProfileId = $v;
|
||||||
|
$this->touch();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
public function setManagedBy(?int $v): self
|
public function setManagedBy(?int $v): self
|
||||||
{
|
{
|
||||||
$this->managedBy = $v;
|
$this->managedBy = $v;
|
||||||
|
|||||||
@@ -52,12 +52,19 @@ class DoctorImportService
|
|||||||
$name = \App\Shared\Util\PersianText::stripDoctorTitle((string) $data['name']);
|
$name = \App\Shared\Util\PersianText::stripDoctorTitle((string) $data['name']);
|
||||||
$code = trim((string) ($data['medical_system_code'] ?? $data['medicalSystemCode']));
|
$code = trim((string) ($data['medical_system_code'] ?? $data['medicalSystemCode']));
|
||||||
$source = trim((string) ($data['source'] ?? 'irimc')) ?: 'irimc';
|
$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);
|
$doctorRepo = $this->em->getRepository(Doctor::class);
|
||||||
$userRepo = $this->em->getRepository(User::class);
|
$userRepo = $this->em->getRepository(User::class);
|
||||||
|
|
||||||
// idempotency: همان پزشکِ منبع → بهروزرسانی، نه ساخت تکراری
|
// idempotency: اول با شناسهٔ پروفایل (پایدار حتی اگر کد عوض شده باشد)،
|
||||||
$doctor = $doctorRepo->findOneBy(['source' => $source, 'medicalSystemCode' => $code]);
|
// سپس با (source, medical_system_code). یک پروفایل هرگز دو رکورد نمیسازد.
|
||||||
|
$doctor = $profileId !== null
|
||||||
|
? $doctorRepo->findOneBy(['source' => $source, 'sourceProfileId' => $profileId])
|
||||||
|
: null;
|
||||||
|
$doctor ??= $doctorRepo->findOneBy(['source' => $source, 'medicalSystemCode' => $code]);
|
||||||
$created = false;
|
$created = false;
|
||||||
|
|
||||||
// پروفایل تصاحبشده را با ایمپورت مجدد بازنویسی نکن (مالک واقعی اولویت دارد)
|
// پروفایل تصاحبشده را با ایمپورت مجدد بازنویسی نکن (مالک واقعی اولویت دارد)
|
||||||
@@ -75,8 +82,10 @@ class DoctorImportService
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($doctor === null) {
|
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]);
|
$user = $userRepo->findOneBy(['mobileNumber' => $synthetic]);
|
||||||
if ($user === null) {
|
if ($user === null) {
|
||||||
$user = new User($synthetic);
|
$user = new User($synthetic);
|
||||||
@@ -105,7 +114,8 @@ class DoctorImportService
|
|||||||
$doctor->setMedicalSystemCode($code);
|
$doctor->setMedicalSystemCode($code);
|
||||||
$doctor->setManagedBy($importedBy->getId());
|
$doctor->setManagedBy($importedBy->getId());
|
||||||
if (array_key_exists('source_ref', $data) || array_key_exists('profile_url', $data)) {
|
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['gender'])) $doctor->setGender($data['gender']);
|
||||||
if (!empty($data['degree'])) $doctor->setDegree($data['degree']);
|
if (!empty($data['degree'])) $doctor->setDegree($data['degree']);
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Doctor\Service\Repair;
|
||||||
|
|
||||||
|
use App\Doctor\Entity\Doctor;
|
||||||
|
use App\Doctor\Service\SourceProfileId;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* پرکردن `source_profile_id` رکوردهای موجود از روی `source_ref` — پیشنیازِ یونیکایندکس
|
||||||
|
* `(source, source_profile_id)`. بدون این، رکوردهای قدیمی `source_profile_id=NULL` میمانند
|
||||||
|
* و dedup مبتنی بر profile id فقط برای ایمپورتهای جدید کار میکند.
|
||||||
|
*/
|
||||||
|
final class BackfillSourceProfileIdStep implements DoctorRepairStep
|
||||||
|
{
|
||||||
|
public function __construct(private readonly EntityManagerInterface $em)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'source-profile-id';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'پرکردن source_profile_id از روی source_ref برای رکوردهای موجود';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(RepairOptions $options, SymfonyStyle $io): RepairResult
|
||||||
|
{
|
||||||
|
$qb = $this->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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Doctor\Service\Repair;
|
||||||
|
|
||||||
|
use App\Doctor\Entity\Doctor;
|
||||||
|
use App\Location\Entity\City;
|
||||||
|
use App\Shared\Util\PersianText;
|
||||||
|
use App\Specialty\Entity\Specialty;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* گزارش خوشههای پزشکِ **مشکوک به تکراری** برای بازبینی دستی — هرگز ادغام خودکار نمیکند.
|
||||||
|
*
|
||||||
|
* معیار: نام نرمالشدهٔ یکسان + دستکم یک تخصص مشترک + دستکم یک شهر مشترک، ولی
|
||||||
|
* source_profile_id (شناسهٔ authoritative نظام پزشکی) متفاوت. متفاوت بودن این شناسه یعنی
|
||||||
|
* irimc آنها را دو پروفایل جدا میداند؛ ممکن است دو نفر واقعی باشند، پس تصمیمِ ادغام
|
||||||
|
* انسانی است. این گام فقط داده تغییر نمیدهد (changed=0).
|
||||||
|
*/
|
||||||
|
final class ReportSuspectedDuplicatesStep implements DoctorRepairStep
|
||||||
|
{
|
||||||
|
public function __construct(private readonly EntityManagerInterface $em)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'report-suspected-duplicates';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'گزارش خوشههای مشکوک به تکراری (بدون ادغام — فقط بازبینی دستی)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(RepairOptions $options, SymfonyStyle $io): RepairResult
|
||||||
|
{
|
||||||
|
$qb = $this->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 خوشه — بازبینی دستی لازم، ادغام خودکار نشد"
|
||||||
|
: 'خوشهٔ مشکوکی یافت نشد',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Doctor\Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* استخراج شناسهٔ پایدارِ پروفایل مبدأ از `source_ref`/`profile_url`.
|
||||||
|
*
|
||||||
|
* برای irimc، URL به شکل `.../member/profile?id=<uuid>` است؛ خودِ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -190,6 +190,69 @@ class DoctorImportTest extends ApiTestCase
|
|||||||
$this->assertSame('manual', $doctor->getSource());
|
$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
|
public function testValidationErrors(): void
|
||||||
{
|
{
|
||||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Doctor;
|
||||||
|
|
||||||
|
use App\Doctor\Service\SourceProfileId;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* شناسهٔ پروفایل، کلید اصلی idempotency ایمپورت است؛ استخراجش باید در برابر تغییر
|
||||||
|
* قالب URL (دامنه/پارامتر اضافه) پایدار بماند و برای قالب ناشناخته null بدهد
|
||||||
|
* تا مسیر روی medical_system_code fallback کند.
|
||||||
|
*/
|
||||||
|
class SourceProfileIdTest extends TestCase
|
||||||
|
{
|
||||||
|
#[DataProvider('refs')]
|
||||||
|
public function testFromRef(?string $ref, ?string $expected): void
|
||||||
|
{
|
||||||
|
$this->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],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user