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