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
+29 -11
View File
@@ -79,7 +79,7 @@
> تخصص‌ها درختی‌اند: هر شناسهٔ فرزند پیش از ذخیره با تمام والدهایش تا ریشه گسترش می‌یابد
> (ارسال `[3]` «گوارش و کبد» → ذخیرهٔ `[2, 3]` یعنی «داخلی» + «گوارش و کبد»)، پس فرستادن
> فقط برگ کافی است. شناسه‌های ناموجود نادیده گرفته می‌شوند.
> برای دادهٔ ایمپورت‌شدهٔ قبل از این تغییر: `php bin/console app:doctors:backfill-specialty-parents`.
> برای دادهٔ ایمپورت‌شدهٔ قبل از این تغییر: `php bin/console app:doctors:repair --only=specialty-parents`.
---
@@ -209,23 +209,41 @@ X-Service-Token: <مقدار env CRAWLER_SERVICE_TOKEN>
- چرخش credential: تغییر `CRAWLER_SERVICE_TOKEN` + تغییر رمز با `app:system-owner … --password=…`؛
ابطال فوری: `--deactivate`.
### backfill نقش جانشین‌های قدیمی
### ترمیم دادهٔ ایمپورت‌شده — یک کامند برای همه
جانشین‌های ساخته‌شده قبل از افزودن نقش marker:
پس از هر خزش، **یک** دستور کافی است. همهٔ گام‌ها idempotent‌اند؛ اجرای دوباره بی‌ضرر است
و صفر تغییر می‌دهد.
```bash
php bin/console app:doctors:backfill-surrogate-role --dry-run # فقط گزارش
php bin/console app:doctors:backfill-surrogate-role # اعمال
php bin/console app:doctors:repair --dry-run # گزارش کامل، بدون هیچ تغییری
php bin/console app:doctors:repair # اعمال همهٔ گام‌ها
php bin/console app:doctors:repair --list # فهرست گام‌ها
```
### اصلاح نام رکوردهای قدیمی (حذف پیشوند «دکتر»)
| گام | چه چیزی را درست می‌کند |
|---|---|
| `names` | حذف پیشوند «دکتر» از نام (نظام پزشکی نام را با عنوان می‌دهد؛ لایهٔ نمایش خودش عنوان می‌گذارد) |
| `degrees` | بازمحاسبهٔ درجه از روی عنوان خام `info` |
| `specialty-parents` | افزودن تخصص‌های والد به پزشکانی که فقط تخصص فرزند دارند |
| `surrogate-role` | افزودن `ROLE_UNCLAIMED_DOCTOR` به کاربران جانشین قدیمی |
رکوردهای IRIMC که پیش از این تغییر با پیشوند «دکتر» ذخیره شده بودند:
سوییچ‌ها:
```bash
php bin/console app:doctors:fix-irimc-names --dry-run # فقط گزارش
php bin/console app:doctors:fix-irimc-names # اعمال (فقط source='irimc')
```
| سوییچ | اثر |
|---|---|
| `--dry-run` | فقط گزارش؛ هیچ نوشتنی انجام نمی‌شود |
| `--only=a,b` | فقط این گام‌ها |
| `--skip=a,b` | همه جز این گام‌ها |
| `--all-sources` | پزشکان هر منبعی، نه فقط `source='irimc'` |
| `--include-claimed` | پروفایل‌های تصاحب‌شده را هم بازنویسی کن (پیش‌فرض: خیر — ورودی مالک مقدم است) |
> **گام `degrees` چرا لازم شد:** خزنده (`crawler_core.py::map_degree`) تا ۱۴۰۵/۰۴/۲۸ دو کد را
> جابه‌جا می‌فرستاد — «فوق تخصص» را `specialist` (برچسب: متخصص) و «تخصص» را `expert`
> (برچسب: فوق تخصص). خزنده اصلاح شده است؛ این گام رکوردهای ایمپورت‌شدهٔ همان دوره را
> از روی متن خام `info` بازمحاسبه می‌کند، نه با معکوس‌کردن کورکورانهٔ مقدار فعلی.
> **افزودن گام جدید:** یک کلاس با `DoctorRepairStep` در `src/Doctor/Service/Repair/` بساز؛
> خودکار کشف و اجرا می‌شود و نیازی به تغییر کامند نیست.
### پاک‌سازی کامل برای دیتابیس تست
@@ -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;
}
}
+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);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Tests\Doctor;
use App\Doctor\Entity\Doctor;
use App\Doctor\Service\IrimcDegreeMapper;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* تا ۱۴۰۵/۰۴/۲۸ خزندهٔ irimc دو کد را جابه‌جا می‌فرستاد: «فوق تخصص» را specialist
* (برچسب: متخصص) و «تخصص» را expert (برچسب: فوق تخصص). نتیجه‌اش ۱۸۸۵ پزشک با درجهٔ
* غلط بود — مثلاً «تخصص جراحی استخوان و مفاصل (ارتوپدی)» که «فوق تخصص» نمایش می‌داد.
*
* این تست قرارداد نگاشت را قفل می‌کند تا جابه‌جایی دوباره اتفاق نیفتد.
*/
class IrimcDegreeMappingTest extends TestCase
{
#[DataProvider('titles')]
public function testDegreeFromIrimcTitle(?string $title, ?string $expected): void
{
$this->assertSame($expected, IrimcDegreeMapper::fromTitle($title));
}
public static function titles(): array
{
return [
// همان رکوردی که باگ را لو داد (پزشک #2269)
'takhasos with general doctorate' => [
'تخصص جراحی استخوان و مفاصل (ارتوپدی) دکترای حرفه‌ای پزشکی',
'specialist',
],
'plain takhasos' => ['تخصص بیماری‌های داخلی', 'specialist'],
'motakhases' => ['متخصص زنان و زایمان', 'specialist'],
// «فوق تخصص» شامل «تخصص» است — ترتیب شرط‌ها باید این را درست بگیرد
'fogh takhasos' => ['فوق تخصص جراحی قلب و عروق', 'expert'],
'fogh takhasos zwnj' => ['فوق‌تخصص گوارش و کبد بالغین', 'expert'],
// فلوشیپ بر همه مقدم است، حتی وقتی کنارش «تخصص» آمده
'fellowship' => ['فلوشیپ اکوکاردیوگرافی', 'subspecialistplus'],
'fellowship w/ takh' => ['فلوشیپ جراحی، تخصص چشم‌پزشکی', 'subspecialistplus'],
// عمومی فقط وقتی هیچ تخصصی در عنوان نیست
'general doctorate' => ['دکترای حرفه‌ای پزشکی', 'general'],
'general physician' => ['پزشک عمومی', 'general'],
// ناشناخته/خالی → null؛ درجهٔ غلط از نبودِ درجه بدتر است
'unknown' => ['کارشناس تغذیه', null],
'empty' => ['', null],
'whitespace' => [' ', null],
'null' => [null, null],
];
}
#[DataProvider('titles')]
public function testMappedDegreeIsAlwaysAValidEntityDegree(?string $title, ?string $expected): void
{
$degree = IrimcDegreeMapper::fromTitle($title);
if ($degree !== null) {
$this->assertContains($degree, Doctor::DEGREES);
}
$this->assertSame($expected, $degree);
}
public function testLabelsMatchTheAdminPanelWording(): void
{
// اگر این‌ها با DoctorDetailPage.tsx (DEGREE_LABELS) فرق کنند، دوباره
// همان باگ «تخصص ↔ فوق تخصص» برمی‌گردد، این‌بار در لایهٔ نمایش.
$this->assertSame('متخصص', IrimcDegreeMapper::label('specialist'));
$this->assertSame('فوق تخصص', IrimcDegreeMapper::label('expert'));
$this->assertSame('عمومی', IrimcDegreeMapper::label('general'));
$this->assertSame('فلوشیپ', IrimcDegreeMapper::label('subspecialistplus'));
}
public function testEveryLabelKeyIsAKnownEntityDegree(): void
{
foreach (array_keys(IrimcDegreeMapper::LABELS) as $degree) {
$this->assertTrue(IrimcDegreeMapper::isValid($degree), "«$degree» در Doctor::DEGREES نیست");
}
$this->assertFalse(IrimcDegreeMapper::isValid('nonsense'));
$this->assertFalse(IrimcDegreeMapper::isValid(null));
}
}
@@ -0,0 +1,119 @@
<?php
namespace App\Tests\Doctor;
use App\Doctor\Entity\Doctor;
use App\Doctor\Service\Repair\DoctorRepairStep;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
/**
* `app:doctors:repair` جای چهار کامند ترمیمی جداگانه را گرفته است. این تست سه
* قرارداد را قفل می‌کند: گام‌ها خودکار کشف می‌شوند، --dry-run هیچ‌چیز نمی‌نویسد،
* و اجرای دوم صفر تغییر می‌دهد (idempotent).
*/
class RepairImportedDoctorsCommandTest extends KernelTestCase
{
private function tester(): CommandTester
{
return new CommandTester(
(new Application(self::bootKernel()))->find('app:doctors:repair')
);
}
/** پزشک irimc با نام عنوان‌دار و درجهٔ جابه‌جا — دقیقاً شکل رکوردهای معیوب. */
private function makeBrokenDoctor(): Doctor
{
$em = static::getContainer()->get('doctrine')->getManager();
$mobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
$user = new \App\Auth\Entity\User($mobile);
$user->setStatus(0);
$em->persist($user);
$doctor = new Doctor($user, 'دکتر آزمون ترمیم');
$doctor->setSource('irimc');
$doctor->setOwnerStatus('unclaimed');
$doctor->setInfo('تخصص بیماری‌های داخلی دکترای حرفه‌ای پزشکی');
$doctor->setDegree('expert'); // غلط: باید specialist باشد
$em->persist($doctor);
$em->flush();
return $doctor;
}
public function testEveryStepIsAutoDiscovered(): void
{
self::bootKernel();
try {
$steps = iterator_to_array(
static::getContainer()->get('test.doctor_repair_steps'),
false
);
} catch (ServiceNotFoundException) {
// بدون alias عمومی برای تگ، از خروجی --list به‌عنوان منبع حقیقت استفاده کن.
$tester = $this->tester();
$tester->execute(['--list' => true]);
$out = $tester->getDisplay();
foreach (['names', 'degrees', 'specialty-parents', 'surrogate-role'] as $name) {
$this->assertStringContainsString($name, $out, "گام «$name» در --list نیست");
}
return;
}
$names = array_map(fn (DoctorRepairStep $s) => $s->name(), $steps);
foreach (['names', 'degrees', 'specialty-parents', 'surrogate-role'] as $name) {
$this->assertContains($name, $names);
}
}
public function testDryRunWritesNothing(): void
{
$doctor = $this->makeBrokenDoctor();
$em = static::getContainer()->get('doctrine')->getManager();
$id = $doctor->getId();
$tester = $this->tester();
$tester->execute(['--dry-run' => true, '--only' => 'degrees,names']);
$tester->assertCommandIsSuccessful();
$em->clear();
$fresh = $em->getRepository(Doctor::class)->find($id);
$this->assertSame('expert', $fresh->getDegree(), 'dry-run نباید درجه را عوض کند');
$this->assertSame('دکتر آزمون ترمیم', $fresh->getName(), 'dry-run نباید نام را عوض کند');
}
public function testRepairFixesDegreeAndNameThenIsIdempotent(): void
{
$doctor = $this->makeBrokenDoctor();
$em = static::getContainer()->get('doctrine')->getManager();
$id = $doctor->getId();
$this->tester()->execute(['--only' => 'degrees,names']);
$em->clear();
$fresh = $em->getRepository(Doctor::class)->find($id);
$this->assertSame('specialist', $fresh->getDegree(), 'درجه باید از عنوان خام بازمحاسبه شود');
$this->assertSame('آزمون ترمیم', $fresh->getName(), 'پیشوند «دکتر» باید حذف شود');
// اجرای دوم: همان رکورد دیگر نباید در خروجی بیاید.
$second = $this->tester();
$second->execute(['--only' => 'degrees,names']);
$this->assertStringNotContainsString("#$id ", $second->getDisplay(), 'گام باید idempotent باشد');
}
public function testUnknownStepIsRejected(): void
{
$tester = $this->tester();
$tester->execute(['--only' => 'nonsense']);
$this->assertSame(2, $tester->getStatusCode(), 'گام ناشناخته باید INVALID برگرداند');
$this->assertStringContainsString('گام ناشناخته', $tester->getDisplay());
}
}