feat: enhance doctor import process with source profile ID for improved idempotency and deduplication

This commit is contained in:
hamed
2026-07-19 20:19:35 +03:30
parent 74577c2ff6
commit e670b38821
11 changed files with 593 additions and 13 deletions
+16
View File
@@ -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;
+15 -5
View File
@@ -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']);
@@ -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 خوشه — بازبینی دستی لازم، ادغام خودکار نشد"
: 'خوشهٔ مشکوکی یافت نشد',
);
}
}
+26
View File
@@ -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;
}
}