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:
@@ -1,100 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctor\Command;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Specialty\Entity\Specialty;
|
||||
use App\Specialty\Repository\SpecialtyRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* backfill: پزشکانی که فقط تخصص فرزند دارند (مثلاً «گوارش و کبد» بدون «داخلی»)
|
||||
* تمام تخصصهای والد تا ریشهٔ درخت را میگیرند. idempotent؛ با --dry-run فقط گزارش میدهد.
|
||||
*
|
||||
* php bin/console app:doctors:backfill-specialty-parents --dry-run
|
||||
* php bin/console app:doctors:backfill-specialty-parents
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:doctors:backfill-specialty-parents',
|
||||
description: 'Attach every ancestor specialty to doctors that only have the child one (idempotent, supports --dry-run)',
|
||||
)]
|
||||
class BackfillDoctorSpecialtyParentsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly SpecialtyRepository $specialtyRepo,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only, change nothing');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
/** @var Doctor[] $doctors */
|
||||
$doctors = $this->em->getRepository(Doctor::class)->findAll();
|
||||
|
||||
$touched = 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(
|
||||
'%s doctor #%d — adding specialty %s',
|
||||
$dryRun ? '[dry-run]' : '[update]',
|
||||
$doctor->getId(),
|
||||
implode(', ', $missing)
|
||||
));
|
||||
|
||||
if (!$dryRun) {
|
||||
foreach ($missing as $id) {
|
||||
$s = $this->specialtyRepo->find($id);
|
||||
if ($s !== null) {
|
||||
$doctor->getSpecialties()->add($s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$touched++;
|
||||
$added += count($missing);
|
||||
}
|
||||
|
||||
if (!$dryRun && $touched > 0) {
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
$io->success(sprintf(
|
||||
'%d doctor(s) %s, %d specialty link(s) added (of %d scanned)',
|
||||
$touched,
|
||||
$dryRun ? 'would be updated' : 'updated',
|
||||
$added,
|
||||
count($doctors)
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctor\Command;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Service\DoctorImportService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* backfill یکبارمصرف: نقش ROLE_UNCLAIMED_DOCTOR برای کاربران جانشینِ ایمپورت
|
||||
* (mobile با پیشوند imp_، غیرفعال، متصل به پزشک unclaimed) که پیش از افزودن
|
||||
* این نقش ساخته شدهاند. غیرمخرب؛ با --dry-run فقط گزارش میدهد.
|
||||
*
|
||||
* php bin/console app:doctors:backfill-surrogate-role --dry-run
|
||||
* php bin/console app:doctors:backfill-surrogate-role
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:doctors:backfill-surrogate-role',
|
||||
description: 'Add ROLE_UNCLAIMED_DOCTOR to legacy IRIMC surrogate users (idempotent, supports --dry-run)',
|
||||
)]
|
||||
class BackfillSurrogateRoleCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only, change nothing');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
/** @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();
|
||||
|
||||
$updated = 0;
|
||||
foreach ($surrogates as $user) {
|
||||
if ($user->hasRole(DoctorImportService::ROLE_UNCLAIMED_DOCTOR)) {
|
||||
continue;
|
||||
}
|
||||
$updated++;
|
||||
$io->text(sprintf('%s %s', $dryRun ? '[dry-run]' : '[update]', $user->getMobileNumber()));
|
||||
if (!$dryRun) {
|
||||
$user->addRole(DoctorImportService::ROLE_UNCLAIMED_DOCTOR);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$dryRun && $updated > 0) {
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
$io->success(sprintf('%d surrogate(s) %s (of %d scanned)', $updated, $dryRun ? 'would be updated' : 'updated', count($surrogates)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctor\Command;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Shared\Util\PersianText;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* حذف پیشوند «دکتر» از نام پزشکانی که با عنوان ذخیره شدهاند. نام پزشک هرگز نباید
|
||||
* عنوان داشته باشد؛ لایهٔ نمایش خودش تصمیم میگیرد چطور نشانش دهد.
|
||||
*
|
||||
* پیشفرض فقط source='irimc' است. `--all` هر منبعی (seed/manual) را هم پاک میکند —
|
||||
* لازم است چون مسیرهای ثبتنام تا پیش از این عنوان را حذف نمیکردند.
|
||||
*
|
||||
* php bin/console app:doctors:fix-irimc-names --dry-run
|
||||
* php bin/console app:doctors:fix-irimc-names --all
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:doctors:fix-irimc-names',
|
||||
description: 'Strip the leading «دکتر» title from existing IRIMC doctor names (idempotent, supports --dry-run)',
|
||||
)]
|
||||
class FixIrimcNamesCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only, change nothing');
|
||||
$this->addOption('all', null, InputOption::VALUE_NONE, 'Every doctor, not just source=irimc');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
$all = (bool) $input->getOption('all');
|
||||
|
||||
$qb = $this->em->getRepository(Doctor::class)->createQueryBuilder('d');
|
||||
if (!$all) {
|
||||
$qb->where('d.source = :src')->setParameter('src', 'irimc');
|
||||
}
|
||||
/** @var Doctor[] $doctors */
|
||||
$doctors = $qb->getQuery()->getResult();
|
||||
|
||||
$fixed = 0;
|
||||
foreach ($doctors as $doctor) {
|
||||
$clean = PersianText::stripDoctorTitle((string) $doctor->getName());
|
||||
if ($clean !== '' && $clean !== $doctor->getName()) {
|
||||
$io->text(sprintf('%s «%s» → «%s»', $dryRun ? '[dry-run]' : '[fix]', $doctor->getName(), $clean));
|
||||
if (!$dryRun) {
|
||||
$doctor->setName($clean);
|
||||
}
|
||||
$fixed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$dryRun && $fixed > 0) {
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
$io->success(sprintf(
|
||||
'%d نام %s (از %d پزشک %s).',
|
||||
$fixed,
|
||||
$dryRun ? 'قابل اصلاح' : 'اصلاح شد',
|
||||
count($doctors),
|
||||
$all ? 'بررسیشده' : 'IRIMC',
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctor\Command;
|
||||
|
||||
use App\Doctor\Service\Repair\DoctorRepairStep;
|
||||
use App\Doctor\Service\Repair\RepairOptions;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
|
||||
|
||||
/**
|
||||
* تنها کامند ترمیم دادهٔ ایمپورتشده از خزندهٔ نظام پزشکی.
|
||||
*
|
||||
* جای چهار کامند جداگانه را گرفته (fix-irimc-names، fix-irimc-degrees،
|
||||
* backfill-specialty-parents، backfill-surrogate-role) تا بعد از هر خزش
|
||||
* یک دستور کافی باشد. همهٔ گامها idempotentاند؛ اجرای دوباره بیضرر است.
|
||||
*
|
||||
* php bin/console app:doctors:repair --dry-run # گزارش کامل، بدون تغییر
|
||||
* php bin/console app:doctors:repair # اعمال همهٔ گامها
|
||||
* php bin/console app:doctors:repair --only=degrees # فقط یک گام
|
||||
* php bin/console app:doctors:repair --skip=names # همه جز یک گام
|
||||
* php bin/console app:doctors:repair --list # فهرست گامها
|
||||
*
|
||||
* گام جدید = یک کلاس با DoctorRepairStep در src/Doctor/Service/Repair/؛
|
||||
* خودکار پیدا و اجرا میشود و نیازی به دستزدن به این کامند نیست.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:doctors:repair',
|
||||
description: 'Repair crawler-imported doctor data — names, degrees, specialty parents, surrogate roles (idempotent, supports --dry-run)',
|
||||
)]
|
||||
class RepairImportedDoctorsCommand extends Command
|
||||
{
|
||||
/** @var DoctorRepairStep[] */
|
||||
private readonly array $steps;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
#[AutowireIterator('app.doctor_repair_step')] iterable $steps,
|
||||
) {
|
||||
$this->steps = iterator_to_array($steps, false);
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only, change nothing')
|
||||
->addOption('only', null, InputOption::VALUE_REQUIRED, 'Run only these steps (comma-separated)')
|
||||
->addOption('skip', null, InputOption::VALUE_REQUIRED, 'Run everything except these steps (comma-separated)')
|
||||
->addOption('all-sources', null, InputOption::VALUE_NONE, "Every doctor, not just source='irimc'")
|
||||
->addOption('include-claimed', null, InputOption::VALUE_NONE, 'Also overwrite claimed profiles (owner input normally wins)')
|
||||
->addOption('list', null, InputOption::VALUE_NONE, 'List the available steps and exit');
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
private function parseList(?string $raw): array
|
||||
{
|
||||
if ($raw === null || trim($raw) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map('trim', explode(',', $raw))));
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
if ($input->getOption('list')) {
|
||||
$io->table(
|
||||
['گام', 'توضیح'],
|
||||
array_map(fn (DoctorRepairStep $s) => [$s->name(), $s->description()], $this->steps),
|
||||
);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$only = $this->parseList($input->getOption('only'));
|
||||
$skip = $this->parseList($input->getOption('skip'));
|
||||
|
||||
$known = array_map(fn (DoctorRepairStep $s) => $s->name(), $this->steps);
|
||||
foreach ([...$only, ...$skip] as $requested) {
|
||||
if (!in_array($requested, $known, true)) {
|
||||
$io->error(sprintf('گام ناشناخته «%s». گامهای موجود: %s', $requested, implode(', ', $known)));
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
}
|
||||
|
||||
$selected = array_filter($this->steps, function (DoctorRepairStep $s) use ($only, $skip) {
|
||||
if ($only !== [] && !in_array($s->name(), $only, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !in_array($s->name(), $skip, true);
|
||||
});
|
||||
|
||||
if ($selected === []) {
|
||||
$io->warning('هیچ گامی برای اجرا انتخاب نشد.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$options = new RepairOptions(
|
||||
dryRun: (bool) $input->getOption('dry-run'),
|
||||
allSources: (bool) $input->getOption('all-sources'),
|
||||
includeClaimed: (bool) $input->getOption('include-claimed'),
|
||||
);
|
||||
|
||||
if ($options->dryRun) {
|
||||
$io->note('حالت dry-run — هیچ تغییری نوشته نمیشود.');
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$totalChanged = 0;
|
||||
foreach ($selected as $step) {
|
||||
$io->section($step->description());
|
||||
$result = $step->run($options, $io);
|
||||
|
||||
$totalChanged += $result->changed;
|
||||
$rows[] = [
|
||||
$step->name(),
|
||||
$result->scanned,
|
||||
$result->changed,
|
||||
$result->note ?? ($result->changed === 0 ? 'بدون تغییر' : ''),
|
||||
];
|
||||
}
|
||||
|
||||
// یک flush در پایان: کل ترمیم یک تراکنش است و dry-run هرگز چیزی نمینویسد.
|
||||
if (!$options->dryRun && $totalChanged > 0) {
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
$io->newLine();
|
||||
$io->table(['گام', 'بررسیشده', $options->dryRun ? 'قابل اصلاح' : 'اصلاحشده', 'توضیح'], $rows);
|
||||
|
||||
$io->success(sprintf(
|
||||
'مجموعاً %d رکورد %s در %d گام.',
|
||||
$totalChanged,
|
||||
$options->dryRun ? 'قابل اصلاح است' : 'اصلاح شد',
|
||||
count($selected),
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user