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