fix(doctor): strip «دکتر» prefix on IRIMC import + name-fix & purge commands

Root cause of "دکتر دکتر …" (and ellipsis-truncated "…نی") in admin: IRIMC
names already contain the «دکتر» title, while the panel renders «دکتر {name}».
Convention is to store the bare name.

- DoctorImportService: normalize name via PersianText::stripDoctorTitle
  (also fixes ي/ی, ك/ک, half-space)
- PersianText::stripDoctorTitle now strips consecutive «دکتر دکتر …» prefixes
- app:doctors:fix-irimc-names: one-off backfill for existing source='irimc'
  rows (dry-run supported) — fixed 340 rows
- app:doctors:purge: FK-safe full wipe of doctors + all dependent tables +
  orphan surrogate users, for a clean test DB (dry-run default, --force to
  apply, prod-guarded)
- tests: PersianTextTest cases for the title stripping; DoctorImportTest
  asserts stored name has no «دکتر» prefix
- docs/api/doctor-import.md: name convention + the two new commands

Verified: import "دکتر صفورا حجازی نیا" → stored "صفورا حجازی نیا" → panel
shows single «دکتر صفورا حجازی نیا».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-11 14:07:18 +03:30
co-authored by Claude Opus 4.8
parent 0b49b03a1e
commit 51432c7bb9
21 changed files with 2460 additions and 1333 deletions
@@ -0,0 +1,70 @@
<?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;
/**
* اصلاح یک‌بارمصرف نامِ پزشکانِ ایمپورت‌شدهٔ IRIMC که با پیشوند «دکتر» ذخیره شده‌اند.
* فقط source='irimc' را دست می‌زند؛ پزشکان manual/seed را تغییر نمی‌دهد.
*
* php bin/console app:doctors:fix-irimc-names --dry-run
* php bin/console app:doctors:fix-irimc-names
*/
#[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');
}
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)->createQueryBuilder('d')
->where('d.source = :src')
->setParameter('src', 'irimc')
->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 پزشک IRIMC).', $fixed, $dryRun ? 'قابل اصلاح' : 'اصلاح شد', count($doctors)));
return Command::SUCCESS;
}
}
@@ -0,0 +1,89 @@
<?php
namespace App\Doctor\Command;
use Doctrine\DBAL\Connection;
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;
/**
* پاک‌سازی کامل همهٔ پزشکان و داده‌های وابسته — برای ساختِ یک دیتابیس تمیزِ تست.
*
* مخرب است: بدون --force فقط تعداد رکوردهای هر جدول را گزارش می‌دهد (dry-run).
* روی prod نیازمند --i-know-this-is-prod است.
*
* php bin/console app:doctors:purge # فقط گزارش
* php bin/console app:doctors:purge --force # پاک‌سازی
*/
#[AsCommand(
name: 'app:doctors:purge',
description: 'Delete ALL doctors and doctor-related data for a clean test DB (dry-run by default; --force to apply)',
)]
class PurgeDoctorsCommand extends Command
{
/** ترتیب: فرزندان اول، سپس doctors. FK_CHECKS خاموش می‌شود پس ترتیب فقط برای خوانایی است. */
private const TABLES = [
'doctor_claim_requests', 'doctor_secretaries', 'doctor_addresses', 'doctor_insurances',
'doctor_provinces', 'doctor_cities', 'doctor_specialties', 'doctor_expertise',
'clinic_doctors', 'clinic_doctor_invitations', 'weekly_schedules', 'date_overrides',
'holidays', 'comments', 'rates', 'appointments', 'doctors',
];
public function __construct(private readonly Connection $conn)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('force', null, InputOption::VALUE_NONE, 'Actually delete (otherwise dry-run report)');
$this->addOption('i-know-this-is-prod', null, InputOption::VALUE_NONE, 'Required to run against APP_ENV=prod');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$force = (bool) $input->getOption('force');
if (($_ENV['APP_ENV'] ?? 'dev') === 'prod' && !$input->getOption('i-know-this-is-prod')) {
$io->error('روی prod بدون --i-know-this-is-prod اجرا نمی‌شود. این عمل داده‌های واقعی (نوبت/نظر/پرداخت) را حذف می‌کند.');
return Command::FAILURE;
}
$io->section($force ? 'پاک‌سازی پزشکان و داده‌های وابسته' : 'گزارش (dry-run — چیزی حذف نمی‌شود)');
$rows = [];
foreach (self::TABLES as $t) {
$rows[] = [$t, (int) $this->conn->fetchOne("SELECT COUNT(*) FROM {$t}")];
}
$io->table(['جدول', 'رکورد'], $rows);
if (!$force) {
$io->warning('برای حذف واقعی، دوباره با --force اجرا کن.');
return Command::SUCCESS;
}
$this->conn->executeStatement('SET FOREIGN_KEY_CHECKS=0');
try {
foreach (self::TABLES as $t) {
$n = $this->conn->executeStatement("DELETE FROM {$t}");
$io->text(sprintf('%s: %d حذف شد', $t, $n));
}
// کاربران جانشینِ ایمپورت (imp_..., غیرفعال) که اکنون یتیم‌اند
$surrogates = $this->conn->executeStatement(
"DELETE FROM users WHERE mobile_number LIKE 'imp\\_%' AND status = 0"
);
$io->text(sprintf('کاربران جانشین: %d حذف شد', $surrogates));
} finally {
$this->conn->executeStatement('SET FOREIGN_KEY_CHECKS=1');
}
$io->success('دیتابیس پزشکان پاک شد.');
return Command::SUCCESS;
}
}
+3 -1
View File
@@ -46,7 +46,9 @@ class DoctorImportService
private function doImport(array $data, User $importedBy): DoctorImportResult
{
return $this->em->wrapInTransaction(function () use ($data, $importedBy): DoctorImportResult {
$name = trim((string) $data['name']);
// نامِ نظام پزشکی پیشوند «دکتر» دارد؛ کنوانسیون پنل نام بدون پیشوند است
// (UI خودش «دکتر» را جلو می‌گذارد). حذف پیشوند + normalize فارسی.
$name = \App\Shared\Util\PersianText::stripDoctorTitle((string) $data['name']);
$code = trim((string) ($data['medical_system_code'] ?? $data['medicalSystemCode']));
$source = trim((string) ($data['source'] ?? 'irimc')) ?: 'irimc';
+3 -1
View File
@@ -45,6 +45,8 @@ final class PersianText
/** حذف عنوان «دکتر» از ابتدای نام (برای مقایسهٔ نام پروفایل با نام ثبت احوال). */
public static function stripDoctorTitle(string $name): string
{
return trim(preg_replace('/^\s*دکتر\s+/u', '', self::normalize($name)) ?? $name);
$normalized = self::normalize($name);
// پیشوندهای متوالی «دکتر دکتر …» را هم کامل حذف می‌کند.
return trim(preg_replace('/^(?:دکتر\s+)+/u', '', $normalized) ?? $normalized);
}
}