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

10 KiB
Raw Permalink Blame History

سخت‌سازی ضدتکرار ایمپورت پزشک با شناسهٔ پروفایل نظام پزشکی

پروژه

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)

$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 کوچک):

/** آی‌دی پایدار پروفایل نظام پزشکی از داخل 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، بعد با کد.

$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:diffmigrate.
#[ORM\Column(name: 'source_profile_id', type: 'string', length: 36, nullable: true)]
private ?string $sourceProfileId = null;
#[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 به‌روز.