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
@@ -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 خوشه — بازبینی دستی لازم، ادغام خودکار نشد"
: 'خوشهٔ مشکوکی یافت نشد',
);
}
}