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` بهروز.
|
||||
Reference in New Issue
Block a user