feat(validation): enforce naming rules for doctors and clinics to prevent placeholders
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Command;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Shared\Util\DisplayName;
|
||||
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;
|
||||
|
||||
/**
|
||||
* پزشکان/کلینیکهایی که نام واقعی ندارند (شمارهتلفن، «test») در نتایج عمومی و در
|
||||
* <title> صفحات سایت منتشر میشوند. این فرمان آنها را پیدا و دستهبندی میکند.
|
||||
*
|
||||
* گزارشمحور است: بدون --force هیچ چیزی نوشته نمیشود.
|
||||
*
|
||||
* php bin/console app:audit-polluted-records # فقط گزارش
|
||||
* php bin/console app:audit-polluted-records --force # اعمال تغییرات
|
||||
*
|
||||
* حذف انجام نمیدهد. رکورد دارای رابطهٔ واقعی (نوبت/پرداخت) هرگز خودکار دست نمیخورد؛
|
||||
* برای بقیه فقط انتشار عمومی را میبندد (active=false / is_active=false) تا تصمیمِ
|
||||
* حذف با تیم بماند و برگشتپذیر باشد.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:audit-polluted-records',
|
||||
description: 'Find doctors/clinics whose name is a phone number or a placeholder; optionally unpublish them',
|
||||
)]
|
||||
class AuditPollutedRecordsCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('force', null, InputOption::VALUE_NONE, 'Apply changes. Without it nothing is written.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$force = (bool) $input->getOption('force');
|
||||
|
||||
$doctors = $this->auditDoctors($io);
|
||||
$clinics = $this->auditClinics($io);
|
||||
|
||||
$unpublishable = array_merge(
|
||||
array_filter($doctors, fn(array $r) => !$r['hasRelations'] && $r['published']),
|
||||
array_filter($clinics, fn(array $r) => !$r['hasRelations'] && $r['published']),
|
||||
);
|
||||
|
||||
if ($doctors === [] && $clinics === []) {
|
||||
$io->success('هیچ رکورد آلودهای یافت نشد.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if (!$force) {
|
||||
$io->warning(sprintf(
|
||||
'حالت گزارش. %d رکورد قابل خارجکردن از انتشار است. برای اعمال: --force',
|
||||
count($unpublishable)
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($unpublishable as $row) {
|
||||
$row['entity'] instanceof Doctor
|
||||
? $row['entity']->setActiveDoctorAppointment(false)
|
||||
: $row['entity']->setIsActive(false);
|
||||
}
|
||||
$this->em->flush();
|
||||
|
||||
$io->success(sprintf('%d رکورد از انتشار عمومی خارج شد (حذف نشد).', count($unpublishable)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** @return array<int, array{entity: Doctor, hasRelations: bool, published: bool}> */
|
||||
private function auditDoctors(SymfonyStyle $io): array
|
||||
{
|
||||
$rows = [];
|
||||
$table = [];
|
||||
|
||||
/** @var Doctor $doctor */
|
||||
foreach ($this->em->getRepository(Doctor::class)->findAll() as $doctor) {
|
||||
if (!DisplayName::isPlaceholder($doctor->getName())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$appointments = (int) $this->em->createQuery(
|
||||
'SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a WHERE a.doctor = :d'
|
||||
)->setParameter('d', $doctor)->getSingleScalarResult();
|
||||
|
||||
$rows[] = [
|
||||
'entity' => $doctor,
|
||||
'hasRelations' => $appointments > 0,
|
||||
'published' => $doctor->isActiveDoctorAppointment(),
|
||||
];
|
||||
$table[] = [
|
||||
$doctor->getId(),
|
||||
$doctor->getName(),
|
||||
$doctor->getOwnerStatus(),
|
||||
$appointments,
|
||||
$doctor->isActiveDoctorAppointment() ? 'بله' : 'خیر',
|
||||
];
|
||||
}
|
||||
|
||||
if ($table !== []) {
|
||||
$io->section(sprintf('پزشکان با نام نامعتبر (%d)', count($table)));
|
||||
$io->table(['id', 'name', 'owner_status', 'نوبتها', 'منتشر شده'], $table);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return array<int, array{entity: Clinic, hasRelations: bool, published: bool}> */
|
||||
private function auditClinics(SymfonyStyle $io): array
|
||||
{
|
||||
$rows = [];
|
||||
$table = [];
|
||||
|
||||
/** @var Clinic $clinic */
|
||||
foreach ($this->em->getRepository(Clinic::class)->findAll() as $clinic) {
|
||||
if (!DisplayName::isPlaceholder($clinic->getName())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$doctorCount = $clinic->getDoctors()->count();
|
||||
|
||||
$rows[] = [
|
||||
'entity' => $clinic,
|
||||
'hasRelations' => $doctorCount > 0,
|
||||
'published' => $clinic->isActive(),
|
||||
];
|
||||
$table[] = [
|
||||
$clinic->getId(),
|
||||
$clinic->getName(),
|
||||
$doctorCount,
|
||||
$clinic->isActive() ? 'بله' : 'خیر',
|
||||
];
|
||||
}
|
||||
|
||||
if ($table !== []) {
|
||||
$io->section(sprintf('کلینیکها با نام نامعتبر (%d)', count($table)));
|
||||
$io->table(['id', 'name', 'پزشکان', 'فعال'], $table);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Util;
|
||||
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
* نام نمایشی پزشک/کلینیک در نتایج عمومی و در <title> صفحات سایت رندر میشود.
|
||||
* شمارهتلفن یا «test» نام واقعی نیست و صفحه را بیارزش میکند.
|
||||
*
|
||||
* این قواعد باید با nobat724_front/lib/entityQuality.js یکی بماند؛ سایت عمومی
|
||||
* همین رکوردها را noindex میکند. آنجا ماسک است، اینجا جلوگیری از تولید.
|
||||
*/
|
||||
final class DisplayName
|
||||
{
|
||||
private const PHONE_LIKE = '/^0?9\d{9}$/';
|
||||
|
||||
private const PLACEHOLDERS = ['test', 'تست', '-', '—', 'null', 'undefined'];
|
||||
|
||||
private const MIN_LENGTH = 2;
|
||||
|
||||
/** نامی که نباید در نتایج عمومی منتشر شود. */
|
||||
public static function isPlaceholder(?string $name): bool
|
||||
{
|
||||
$trimmed = trim((string) $name);
|
||||
if ($trimmed === '' || mb_strlen($trimmed) < self::MIN_LENGTH) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match(self::PHONE_LIKE, str_replace([' ', '-', ''], '', $trimmed)) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(mb_strtolower($trimmed), self::PLACEHOLDERS, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AppException وقتی نام واقعی نباشد — ExceptionSubscriber آن را به 422 تبدیل میکند
|
||||
*/
|
||||
public static function assertReal(?string $name): void
|
||||
{
|
||||
if (self::isPlaceholder($name)) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
'نام معتبر نیست؛ شماره تلفن یا مقدار آزمایشی بهعنوان نام پذیرفته نمیشود',
|
||||
422
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user