Refactor doctor data repair commands into a single command

- Removed individual commands for backfilling specialty parents, surrogate roles, and fixing IRIMC names.
- Introduced RepairImportedDoctorsCommand to consolidate functionality.
- Implemented a step-based approach for repairs, allowing for idempotent execution.
- Added new service classes for handling specific repair steps, including BackfillSpecialtyParentsStep, BackfillSurrogateRoleStep, FixDegreeStep, and StripNameTitleStep.
- Created RepairOptions and RepairResult classes to manage step execution options and results.
- Updated tests to ensure new command structure and functionality are covered, including idempotency and dry-run behavior.
- Added IrimcDegreeMapper for mapping IRIMC titles to degrees.
This commit is contained in:
hamed
2026-07-19 19:20:04 +03:30
parent ef83ce4452
commit 801c6f96db
15 changed files with 795 additions and 268 deletions
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Doctor\Service;
use App\Doctor\Entity\Doctor;
/**
* نگاشت عنوان خام نظام پزشکی (ستون تخصص در membersearch.irimc.org) به کد درجهٔ ClinicPro.
*
* آینهٔ عمدیِ clinicpro-crawler/crawler_core.py::map_degree — خزنده در ریپوی جداست و
* درجه را از پیش محاسبه‌شده می‌فرستد، ولی این سمت هم باید بتواند همان محاسبه را
* بازتولید کند تا رکوردهای ایمپورت‌شدهٔ قدیمی قابل ترمیم باشند
* (app:doctors:fix-irimc-degrees). هر تغییری در یکی باید در دیگری هم اعمال شود.
*/
final class IrimcDegreeMapper
{
/** برچسب فارسیِ هر کد — همان چیزی که پنل ادمین نشان می‌دهد. */
public const LABELS = [
'general' => 'عمومی',
'specialist' => 'متخصص',
'expert' => 'فوق تخصص',
'subspecialistplus' => 'فلوشیپ',
];
/**
* ترتیب شرط‌ها معنادار است:
* - «فوق تخصص» خودش شامل «تخصص» است، پس باید پیش از آن بررسی شود؛
* - بسیاری از عنوان‌ها هم «تخصص …» دارند و هم «دکترای حرفه‌ای پزشکی»،
* و داشتنِ تخصص بر مدرک عمومی مقدم است، پس general آخر می‌آید.
*
* عنوان ناشناخته → null؛ هرگز حدس نزن، چون درجهٔ غلط از نبودِ درجه بدتر است.
*/
public static function fromTitle(?string $title): ?string
{
if ($title === null || trim($title) === '') {
return null;
}
if (str_contains($title, 'فلوشیپ')) {
return 'subspecialistplus';
}
if (str_contains($title, 'فوق تخصص') || str_contains($title, 'فوق‌تخصص')) {
return 'expert';
}
if (str_contains($title, 'تخصص') || str_contains($title, 'متخصص')) {
return 'specialist';
}
if (str_contains($title, 'دکترای حرفه‌ای') || str_contains($title, 'عموم')) {
return 'general';
}
return null;
}
public static function label(?string $degree): string
{
return self::LABELS[$degree] ?? var_export($degree, true);
}
public static function isValid(?string $degree): bool
{
return $degree !== null && in_array($degree, Doctor::DEGREES, true);
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Doctor\Service\Repair;
use App\Doctor\Entity\Doctor;
use App\Specialty\Entity\Specialty;
use App\Specialty\Repository\SpecialtyRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* پزشکانی که فقط تخصص فرزند دارند (مثلاً «گوارش و کبد» بدون «داخلی») تمام
* تخصص‌های والد تا ریشهٔ درخت را می‌گیرند — وگرنه در فیلتر تخصصِ والد پیدا نمی‌شوند.
*
* برخلاف گام‌های دیگر روی هر منبعی اجرا می‌شود: درخت تخصص‌ها ربطی به irimc ندارد
* و رکورد دستی هم می‌تواند همین نقص را داشته باشد.
*/
final class BackfillSpecialtyParentsStep implements DoctorRepairStep
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly SpecialtyRepository $specialtyRepo,
) {
}
public function name(): string
{
return 'specialty-parents';
}
public function description(): string
{
return 'افزودن تخصص‌های والد به پزشکانی که فقط تخصص فرزند دارند';
}
public function run(RepairOptions $options, SymfonyStyle $io): RepairResult
{
/** @var Doctor[] $doctors */
$doctors = $this->em->getRepository(Doctor::class)->findAll();
$changed = 0;
$added = 0;
foreach ($doctors as $doctor) {
$current = array_map(
static fn (Specialty $s) => $s->getId(),
$doctor->getSpecialties()->toArray()
);
if ($current === []) {
continue;
}
$missing = array_diff($this->specialtyRepo->expandWithAncestors($current), $current);
if ($missing === []) {
continue;
}
$io->text(sprintf(' #%d + تخصص %s', $doctor->getId(), implode(', ', $missing)));
if (!$options->dryRun) {
foreach ($missing as $id) {
$s = $this->specialtyRepo->find($id);
if ($s !== null) {
$doctor->getSpecialties()->add($s);
}
}
}
$changed++;
$added += count($missing);
}
return new RepairResult(
scanned: count($doctors),
changed: $changed,
note: $added > 0 ? "$added پیوند تخصص افزوده شد" : null,
);
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Doctor\Service\Repair;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Doctor\Service\DoctorImportService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* کاربران جانشینِ ایمپورت (موبایل با پیشوند imp_، غیرفعال، متصل به پزشک unclaimed)
* که پیش از افزودن نقش marker ساخته شده‌اند، ROLE_UNCLAIMED_DOCTOR می‌گیرند.
* بدون این نقش، جریان «تصاحب پروفایل» آن‌ها را نمی‌شناسد.
*/
final class BackfillSurrogateRoleStep implements DoctorRepairStep
{
public function __construct(private readonly EntityManagerInterface $em)
{
}
public function name(): string
{
return 'surrogate-role';
}
public function description(): string
{
return 'افزودن ROLE_UNCLAIMED_DOCTOR به کاربران جانشین ایمپورت';
}
public function run(RepairOptions $options, SymfonyStyle $io): RepairResult
{
/** @var User[] $surrogates */
$surrogates = $this->em->createQueryBuilder()
->select('u')
->from(User::class, 'u')
->join(Doctor::class, 'd', 'WITH', 'd.user = u')
->where("u.mobileNumber LIKE 'imp\\_%'")
->andWhere('u.status = 0')
->andWhere("d.ownerStatus = 'unclaimed'")
->getQuery()
->getResult();
$changed = 0;
foreach ($surrogates as $user) {
if ($user->hasRole(DoctorImportService::ROLE_UNCLAIMED_DOCTOR)) {
continue;
}
$io->text(sprintf(' %s', $user->getMobileNumber()));
if (!$options->dryRun) {
$user->addRole(DoctorImportService::ROLE_UNCLAIMED_DOCTOR);
}
$changed++;
}
return new RepairResult(scanned: count($surrogates), changed: $changed);
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Doctor\Service\Repair;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
/**
* یک گام ترمیمِ دادهٔ ایمپورت‌شده از خزندهٔ نظام پزشکی.
*
* هر گام باید idempotent باشد: اجرای دوم روی دادهٔ ترمیم‌شده باید صفر تغییر بدهد.
* گام‌ها مستقل‌اند و ترتیبشان نباید نتیجه را عوض کند؛ اگر روزی وابستگی پیدا شد،
* باید صریح مستند شود نه اینکه به ترتیب ثبت در کانتینر تکیه کند.
*
* پیاده‌سازی‌ها خودشان flush نمی‌کنند — کامند یک‌بار در پایان flush می‌کند تا کل
* ترمیم یک تراکنش باشد و --dry-run هیچ‌وقت چیزی ننویسد.
*/
#[AutoconfigureTag('app.doctor_repair_step')]
interface DoctorRepairStep
{
/** شناسهٔ کوتاه برای --only / --skip (kebab-case). */
public function name(): string;
/** یک خط فارسی: این گام چه چیزی را درست می‌کند. */
public function description(): string;
public function run(RepairOptions $options, SymfonyStyle $io): RepairResult;
}
@@ -0,0 +1,81 @@
<?php
namespace App\Doctor\Service\Repair;
use App\Doctor\Entity\Doctor;
use App\Doctor\Service\IrimcDegreeMapper;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* خزندهٔ irimc (clinicpro-crawler/crawler_core.py::map_degree) تا ۱۴۰۵/۰۴/۲۸ دو کد را
* جابه‌جا می‌فرستاد: «فوق تخصص» را specialist (برچسب: متخصص) و «تخصص» را expert
* (برچسب: فوق تخصص). خزنده اصلاح شده، ولی رکوردهای ایمپورت‌شده هنوز غلط‌اند.
*
* درجه دوباره از روی متن خام `info` — همان رشته‌ای که خزنده از ستون تخصص سایت گرفته —
* محاسبه می‌شود، نه با معکوس‌کردن کورکورانهٔ مقدار فعلی؛ چون فقط رکوردهای همان دورهٔ
* معیوب باید عوض شوند و اجرای دوباره نباید چیزی را خراب کند.
*/
final class FixDegreeStep implements DoctorRepairStep
{
public function __construct(private readonly EntityManagerInterface $em)
{
}
public function name(): string
{
return 'degrees';
}
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');
}
if (!$options->includeClaimed) {
$qb->andWhere('d.ownerStatus != :claimed')->setParameter('claimed', 'claimed');
}
/** @var Doctor[] $doctors */
$doctors = $qb->getQuery()->getResult();
$changed = 0;
$skipped = 0;
foreach ($doctors as $doctor) {
$want = IrimcDegreeMapper::fromTitle($doctor->getInfo());
// متن خام قابل نگاشت نیست — درجهٔ فعلی را حدس‌زده تغییر نده.
if ($want === null) {
$skipped++;
continue;
}
if ($want === $doctor->getDegree()) {
continue;
}
$io->text(sprintf(
' #%d %s: %s → %s',
$doctor->getId(),
$doctor->getName(),
IrimcDegreeMapper::label($doctor->getDegree()),
IrimcDegreeMapper::label($want),
));
if (!$options->dryRun) {
$doctor->setDegree($want);
}
$changed++;
}
return new RepairResult(
scanned: count($doctors),
changed: $changed,
skipped: $skipped,
note: $skipped > 0 ? "$skipped رکورد بدون عنوان قابل نگاشت" : null,
);
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Doctor\Service\Repair;
/**
* سوییچ‌های مشترک بین گام‌های ترمیم. هر گام فقط آن‌هایی را که برایش معنا دارد
* می‌خواند؛ مثلاً backfill نقشِ کاربر جانشین اصلاً به allSources کاری ندارد.
*/
final class RepairOptions
{
public function __construct(
/** فقط گزارش بده، چیزی ننویس. */
public readonly bool $dryRun = false,
/** پزشکان هر منبعی (seed/manual)، نه فقط source='irimc'. */
public readonly bool $allSources = false,
/**
* پروفایل‌های تصاحب‌شده را هم بازنویسی کن. پیش‌فرض خاموش است چون
* مالک واقعی ممکن است داده را دستی اصلاح کرده باشد و ورودی او
* بر دادهٔ خزنده مقدم است.
*/
public readonly bool $includeClaimed = false,
) {
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Doctor\Service\Repair;
/**
* نتیجهٔ یک گام ترمیم. `skipped` رکوردهایی است که عمداً دست‌نخورده مانده‌اند
* (مثلاً عنوان غیرقابل‌نگاشت) — جدا از رکوردهایی که اصلاً نیاز به تغییر نداشتند،
* چون آن‌ها را باید در گزارش دید نه اینکه در «همه‌چیز درست بود» گم شوند.
*/
final class RepairResult
{
public function __construct(
public readonly int $scanned = 0,
public readonly int $changed = 0,
public readonly int $skipped = 0,
/** توضیح یک‌خطی اختیاری برای جدول خلاصه. */
public readonly ?string $note = null,
) {
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Doctor\Service\Repair;
use App\Doctor\Entity\Doctor;
use App\Shared\Util\PersianText;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* نام پزشک هرگز نباید پیشوند «دکتر» داشته باشد؛ لایهٔ نمایش خودش تصمیم می‌گیرد
* چطور نشانش دهد. نظام پزشکی نام‌ها را با عنوان می‌دهد و مسیرهای ثبت‌نام قدیمی
* هم آن را پاک نمی‌کردند، پس «دکتر دکتر حامد حسینی» رندر می‌شد.
*/
final class StripNameTitleStep implements DoctorRepairStep
{
public function __construct(private readonly EntityManagerInterface $em)
{
}
public function name(): string
{
return 'names';
}
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();
$changed = 0;
foreach ($doctors as $doctor) {
$clean = PersianText::stripDoctorTitle((string) $doctor->getName());
if ($clean === '' || $clean === $doctor->getName()) {
continue;
}
$io->text(sprintf(' #%d «%s» → «%s»', $doctor->getId(), $doctor->getName(), $clean));
if (!$options->dryRun) {
$doctor->setName($clean);
}
$changed++;
}
return new RepairResult(scanned: count($doctors), changed: $changed);
}
}