diff --git a/docs/api/doctor-import.md b/docs/api/doctor-import.md index 0d76472f..7d8fa282 100644 --- a/docs/api/doctor-import.md +++ b/docs/api/doctor-import.md @@ -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/` بساز؛ +> خودکار کشف و اجرا می‌شود و نیازی به تغییر کامند نیست. ### پاک‌سازی کامل برای دیتابیس تست diff --git a/src/Doctor/Command/BackfillDoctorSpecialtyParentsCommand.php b/src/Doctor/Command/BackfillDoctorSpecialtyParentsCommand.php deleted file mode 100644 index 80a744ec..00000000 --- a/src/Doctor/Command/BackfillDoctorSpecialtyParentsCommand.php +++ /dev/null @@ -1,100 +0,0 @@ -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; - } -} diff --git a/src/Doctor/Command/BackfillSurrogateRoleCommand.php b/src/Doctor/Command/BackfillSurrogateRoleCommand.php deleted file mode 100644 index 66f35ced..00000000 --- a/src/Doctor/Command/BackfillSurrogateRoleCommand.php +++ /dev/null @@ -1,76 +0,0 @@ -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; - } -} diff --git a/src/Doctor/Command/FixIrimcNamesCommand.php b/src/Doctor/Command/FixIrimcNamesCommand.php deleted file mode 100644 index 5f6774fc..00000000 --- a/src/Doctor/Command/FixIrimcNamesCommand.php +++ /dev/null @@ -1,81 +0,0 @@ -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; - } -} diff --git a/src/Doctor/Command/RepairImportedDoctorsCommand.php b/src/Doctor/Command/RepairImportedDoctorsCommand.php new file mode 100644 index 00000000..3e572a81 --- /dev/null +++ b/src/Doctor/Command/RepairImportedDoctorsCommand.php @@ -0,0 +1,151 @@ +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; + } +} diff --git a/src/Doctor/Service/IrimcDegreeMapper.php b/src/Doctor/Service/IrimcDegreeMapper.php new file mode 100644 index 00000000..2f1c4128 --- /dev/null +++ b/src/Doctor/Service/IrimcDegreeMapper.php @@ -0,0 +1,63 @@ + 'عمومی', + '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); + } +} diff --git a/src/Doctor/Service/Repair/BackfillSpecialtyParentsStep.php b/src/Doctor/Service/Repair/BackfillSpecialtyParentsStep.php new file mode 100644 index 00000000..6a2f38e5 --- /dev/null +++ b/src/Doctor/Service/Repair/BackfillSpecialtyParentsStep.php @@ -0,0 +1,77 @@ +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, + ); + } +} diff --git a/src/Doctor/Service/Repair/BackfillSurrogateRoleStep.php b/src/Doctor/Service/Repair/BackfillSurrogateRoleStep.php new file mode 100644 index 00000000..fbd52f7b --- /dev/null +++ b/src/Doctor/Service/Repair/BackfillSurrogateRoleStep.php @@ -0,0 +1,60 @@ +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); + } +} diff --git a/src/Doctor/Service/Repair/DoctorRepairStep.php b/src/Doctor/Service/Repair/DoctorRepairStep.php new file mode 100644 index 00000000..7af1513f --- /dev/null +++ b/src/Doctor/Service/Repair/DoctorRepairStep.php @@ -0,0 +1,28 @@ +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, + ); + } +} diff --git a/src/Doctor/Service/Repair/RepairOptions.php b/src/Doctor/Service/Repair/RepairOptions.php new file mode 100644 index 00000000..d0745640 --- /dev/null +++ b/src/Doctor/Service/Repair/RepairOptions.php @@ -0,0 +1,26 @@ +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); + } +} diff --git a/tests/Doctor/IrimcDegreeMappingTest.php b/tests/Doctor/IrimcDegreeMappingTest.php new file mode 100644 index 00000000..f0aab807 --- /dev/null +++ b/tests/Doctor/IrimcDegreeMappingTest.php @@ -0,0 +1,85 @@ +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)); + } +} diff --git a/tests/Doctor/RepairImportedDoctorsCommandTest.php b/tests/Doctor/RepairImportedDoctorsCommandTest.php new file mode 100644 index 00000000..f1365328 --- /dev/null +++ b/tests/Doctor/RepairImportedDoctorsCommandTest.php @@ -0,0 +1,119 @@ +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()); + } +}