feat: add command to purge unclaimed imported doctors and their surrogates
This commit is contained in:
@@ -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`) بدون هیچ راه
|
||||
> فراری با خطا متوقف میشود.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 نباید با پیشفرض حذف شود');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user