Files
clinicpro/.claude/prompt/doctor-import-dedup-by-profile-id.md
T

180 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# سخت‌سازی ضدتکرار ایمپورت پزشک با شناسهٔ پروفایل نظام پزشکی
## پروژه
`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` به‌روز.