feat: add command to purge unclaimed imported doctors and their surrogates

This commit is contained in:
hamed
2026-07-20 09:44:09 +03:30
parent 97e5acdf18
commit 46d7253ad9
5 changed files with 337 additions and 6 deletions
+29 -3
View File
@@ -260,14 +260,40 @@ php bin/console app:doctors:repair --list # فهرست گام‌ها
> **افزودن گام جدید:** یک کلاس با `DoctorRepairStep` در `src/Doctor/Service/Repair/` بساز؛
> خودکار کشف و اجرا می‌شود و نیازی به تغییر کامند نیست.
### حذف پزشکانِ ایمپورت‌شدهٔ claim‌نشده
فقط رکوردهای خزنده که هنوز مالکیتشان گرفته نشده (`owner_status='unclaimed'`) و کاربران
جانشینِ یتیمشان را حذف می‌کند. پزشکان دستی (`source='manual'`) و پروفایل‌های تصاحب‌شده
(`claimed`/`pending_transfer`) دست‌نخورده می‌مانند.
```bash
php bin/console app:doctors:purge-unclaimed # dry-run: تعداد هدف + هر جدول وابسته
php bin/console app:doctors:purge-unclaimed --force # حذف
php bin/console app:doctors:purge-unclaimed --source=irimc # فقط این منبع (پیش‌فرض irimc)
php bin/console app:doctors:purge-unclaimed --all-sources # هر منبعِ غیر manual
```
| سوییچ | اثر |
|---|---|
| `--force` | حذف واقعی (بدون آن فقط گزارش) |
| `--source=X` | فقط منبع X (پیش‌فرض `irimc`) |
| `--all-sources` | هر پزشک claim‌نشدهٔ غیرِ `manual` |
| `--i-know-this-is-prod` | لازم برای اجرا روی `APP_ENV=prod` |
> حذف FK-safe است (همان ۱۶ جدول وابستهٔ `app:doctors:purge`). اگر نوبتی به پزشک
> claim‌نشده وصل باشد در dry-run هشدار می‌دهد چون آن نوبت هم حذف می‌شود. کاربر جانشین
> فقط وقتی حذف می‌شود که غیرفعال (`status=0`) و با موبایلِ `imp_` باشد — تا کاربر واقعی
> به‌اشتباه پاک نشود.
### پاک‌سازی کامل برای دیتابیس تست
حذف همهٔ پزشکان + داده‌های وابسته (FK-safe) برای شروع تمیز:
حذف **همهٔ** پزشکان + داده‌های وابسته (FK-safe) برای شروع تمیز:
```bash
php bin/console app:doctors:purge # dry-run: فقط گزارش تعداد هر جدول
php bin/console app:doctors:purge --force # حذف واقعی + کاربران جانشین یتیم
```
> ⚠️ مخرب — `appointments`/`comments`/`rates` را هم پاک می‌کند. روی prod نیازمند
> `--i-know-this-is-prod` است و پیش‌فرض متوقف می‌شود.
> ⚠️ مخرب — `appointments`/`comments`/`rates` را هم پاک می‌کند. **فقط در محیط
> `dev`/`test` اجرا می‌شود**؛ روی هر `APP_ENV` دیگری (از جمله `prod`) بدون هیچ راه
> فراری با خطا متوقف می‌شود.
+5 -3
View File
@@ -41,7 +41,6 @@ class PurgeDoctorsCommand extends Command
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
@@ -49,8 +48,11 @@ class PurgeDoctorsCommand extends Command
$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 اجرا نمی‌شود. این عمل داده‌های واقعی (نوبت/نظر/پرداخت) را حذف می‌کند.');
// فقط و فقط dev/test. این کامند همهٔ پزشکان + نوبت/نظر/پرداخت را پاک می‌کند و
// هیچ راه فراری برای prod ندارد.
$env = $_ENV['APP_ENV'] ?? 'dev';
if (!in_array($env, ['dev', 'test'], true)) {
$io->error(sprintf('این کامند فقط در محیط dev اجرا می‌شود (APP_ENV فعلی: %s).', $env));
return Command::FAILURE;
}
@@ -0,0 +1,157 @@
<?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;
/**
* حذف پزشکانِ ایمپورت‌شدهٔ خزنده که هنوز مالکیتشان گرفته نشده (`owner_status='unclaimed'`).
*
* برخلاف {@see PurgeDoctorsCommand} که همهٔ پزشکان را پاک می‌کند، این کامند فقط
* رکوردهای claim‌نشدهٔ یک منبع (پیش‌فرض `irimc`) و کاربران جانشینِ یتیم‌شان را حذف
* می‌کند؛ پزشکان دستی و پروفایل‌های تصاحب‌شده (`claimed`/`pending_transfer`) دست‌نخورده
* می‌مانند.
*
* php bin/console app:doctors:purge-unclaimed # فقط گزارش
* php bin/console app:doctors:purge-unclaimed --force # حذف
*/
#[AsCommand(
name: 'app:doctors:purge-unclaimed',
description: 'Delete crawler-imported doctors that were never claimed (dry-run by default; --force to apply)',
)]
class PurgeUnclaimedDoctorsCommand extends Command
{
/** جدول‌های فرزندِ کلیددارِ doctor_id. ترتیب برای خوانایی است؛ FK_CHECKS خاموش می‌شود. */
private const CHILD_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',
];
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)')
->addOption('source', null, InputOption::VALUE_REQUIRED, "Only this source (default 'irimc')", 'irimc')
->addOption('all-sources', null, InputOption::VALUE_NONE, 'Any non-manual source, not just --source')
->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;
}
// معیار مشترک همهٔ کوئری‌ها: claim‌نشده + فیلتر منبع.
[$where, $params] = $this->buildCriteria((bool) $input->getOption('all-sources'), (string) $input->getOption('source'));
$targetCount = (int) $this->conn->fetchOne("SELECT COUNT(*) FROM doctors d WHERE {$where}", $params);
if ($targetCount === 0) {
$io->success('هیچ پزشک claim‌نشده‌ای با این معیار نیست.');
return Command::SUCCESS;
}
$io->section($force ? 'حذف پزشکان claim‌نشده' : 'گزارش (dry-run — چیزی حذف نمی‌شود)');
$io->text(sprintf('پزشکان هدف: %d', $targetCount));
$rows = [];
$appointmentRows = 0;
foreach (self::CHILD_TABLES as $t) {
$n = (int) $this->conn->fetchOne(
"SELECT COUNT(*) FROM {$t} c JOIN doctors d ON d.id = c.doctor_id WHERE {$where}",
$params,
);
if ($t === 'appointments') {
$appointmentRows = $n;
}
$rows[] = [$t, $n];
}
$io->table(['جدول وابسته', 'رکورد'], $rows);
// نوبت روی پزشک claim‌نشده یعنی بیمار واقعی رزرو کرده — حذفش دادهٔ واقعی می‌برد.
if ($appointmentRows > 0) {
$io->warning(sprintf('%d نوبت به این پزشکان وصل است و حذف خواهد شد.', $appointmentRows));
}
if (!$force) {
$io->warning('برای حذف واقعی، دوباره با --force اجرا کن.');
return Command::SUCCESS;
}
// کاربران جانشین را پیش از حذف پزشکان بگیر؛ بعدش دیگر قابل یافتن نیستند.
$surrogateIds = $this->conn->fetchFirstColumn(
"SELECT d.user_id FROM doctors d WHERE {$where}",
$params,
);
$this->conn->executeStatement('SET FOREIGN_KEY_CHECKS=0');
try {
foreach (self::CHILD_TABLES as $t) {
$n = $this->conn->executeStatement(
"DELETE c FROM {$t} c JOIN doctors d ON d.id = c.doctor_id WHERE {$where}",
$params,
);
$io->text(sprintf('%s: %d حذف شد', $t, $n));
}
$deleted = $this->conn->executeStatement("DELETE d FROM doctors d WHERE {$where}", $params);
$io->text(sprintf('doctors: %d حذف شد', $deleted));
$surrogates = $this->deleteSurrogates($surrogateIds);
$io->text(sprintf('کاربران جانشین: %d حذف شد', $surrogates));
} finally {
$this->conn->executeStatement('SET FOREIGN_KEY_CHECKS=1');
}
$io->success('پزشکان claim‌نشده پاک شدند.');
return Command::SUCCESS;
}
/** @return array{0:string,1:array<string,string>} */
private function buildCriteria(bool $allSources, string $source): array
{
if ($allSources) {
return ["d.owner_status = 'unclaimed' AND d.source <> 'manual'", []];
}
return ["d.owner_status = 'unclaimed' AND d.source = :source", ['source' => $source]];
}
/**
* فقط کاربران جانشینِ ایمپورت را حذف کن — غیرفعال و با موبایلِ synthetic — تا اگر
* پزشکی به‌اشتباه به کاربر واقعی وصل بود، آن کاربر پاک نشود.
*
* @param list<int|string> $ids
*/
private function deleteSurrogates(array $ids): int
{
$deleted = 0;
foreach (array_chunk($ids, 500) as $chunk) {
$deleted += $this->conn->executeStatement(
"DELETE FROM users WHERE id IN (:ids) AND status = 0 AND mobile_number LIKE 'imp\\_%'",
['ids' => $chunk],
['ids' => \Doctrine\DBAL\ArrayParameterType::INTEGER],
);
}
return $deleted;
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Tests\Doctor;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
/**
* `app:doctors:purge` کل جدول پزشکان را پاک می‌کند و باید **فقط** در dev/test اجرا
* شود. این تست قفل می‌کند که هیچ راه فراری برای prod نماند و اجرای بی‌سوییچ فقط گزارش
* بدهد.
*/
class PurgeDoctorsCommandTest extends KernelTestCase
{
private function tester(): CommandTester
{
return new CommandTester(
(new Application(self::bootKernel()))->find('app:doctors:purge')
);
}
public function testDryRunRunsInTestEnv(): void
{
$tester = $this->tester();
$tester->execute([]);
$tester->assertCommandIsSuccessful();
$this->assertStringContainsString('dry-run', $tester->getDisplay());
}
public function testRefusesOnProdWithNoEscapeHatch(): void
{
self::bootKernel();
$original = $_ENV['APP_ENV'] ?? null;
$_ENV['APP_ENV'] = 'prod';
try {
$tester = new CommandTester(
(new Application(self::$kernel))->find('app:doctors:purge')
);
$tester->execute(['--force' => true]);
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
$this->assertStringContainsString('فقط در محیط dev', $tester->getDisplay());
} finally {
if ($original === null) {
unset($_ENV['APP_ENV']);
} else {
$_ENV['APP_ENV'] = $original;
}
}
}
}
@@ -0,0 +1,91 @@
<?php
namespace App\Tests\Doctor;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
/**
* `app:doctors:purge-unclaimed` فقط باید پزشکانِ ایمپورت‌شدهٔ claim‌نشده و کاربران
* جانشینشان را حذف کند. این تست دو قرارداد بحرانی را قفل می‌کند: --dry-run هیچ‌چیز
* نمی‌نویسد، و پزشکان claimed / با منبع دیگر هرگز پاک نمی‌شوند.
*/
class PurgeUnclaimedDoctorsCommandTest extends KernelTestCase
{
private function tester(): CommandTester
{
return new CommandTester(
(new Application(self::bootKernel()))->find('app:doctors:purge-unclaimed')
);
}
private function makeDoctor(string $source, string $ownerStatus, int $userStatus = 0): Doctor
{
$em = static::getContainer()->get('doctrine')->getManager();
$mobile = ($userStatus === 0 ? 'imp_' : '09') . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
$user = new User($mobile);
$user->setStatus($userStatus);
$em->persist($user);
$doctor = new Doctor($user, 'دکتر آزمون');
$doctor->setSource($source);
$doctor->setOwnerStatus($ownerStatus);
$em->persist($doctor);
$em->flush();
return $doctor;
}
private function doctorExists(int $id): bool
{
$conn = static::getContainer()->get(Connection::class);
return (bool) $conn->fetchOne('SELECT COUNT(*) FROM doctors WHERE id = ?', [$id]);
}
public function testDryRunDeletesNothing(): void
{
$doctor = $this->makeDoctor('irimc', 'unclaimed');
$id = $doctor->getId();
$tester = $this->tester();
$tester->execute([]);
$tester->assertCommandIsSuccessful();
$this->assertTrue($this->doctorExists($id), 'dry-run نباید پزشک را حذف کند');
}
public function testForceDeletesUnclaimedImportedDoctorAndSurrogate(): void
{
$doctor = $this->makeDoctor('irimc', 'unclaimed');
$id = $doctor->getId();
$surrogateId = $doctor->getUser()->getId();
$tester = $this->tester();
$tester->execute(['--force' => true]);
$tester->assertCommandIsSuccessful();
$conn = static::getContainer()->get(Connection::class);
$this->assertFalse($this->doctorExists($id), 'پزشک claim‌نشده باید حذف شود');
$this->assertFalse(
(bool) $conn->fetchOne('SELECT COUNT(*) FROM users WHERE id = ?', [$surrogateId]),
'کاربر جانشین یتیم باید حذف شود',
);
}
public function testClaimedAndOtherSourceDoctorsSurvive(): void
{
$claimed = $this->makeDoctor('irimc', 'claimed', userStatus: 1);
$manual = $this->makeDoctor('manual', 'unclaimed');
$this->tester()->execute(['--force' => true]);
$this->assertTrue($this->doctorExists($claimed->getId()), 'پزشک claimed نباید حذف شود');
$this->assertTrue($this->doctorExists($manual->getId()), 'منبع غیر irimc نباید با پیش‌فرض حذف شود');
}
}