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:
@@ -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