feat: enhance doctor import process with source profile ID for improved idempotency and deduplication
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 خوشه — بازبینی دستی لازم، ادغام خودکار نشد"
|
||||
: 'خوشهٔ مشکوکی یافت نشد',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user