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:
+29
-11
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user