diff --git a/docs/api/doctor-import.md b/docs/api/doctor-import.md index b27c7f72..1331c68f 100644 --- a/docs/api/doctor-import.md +++ b/docs/api/doctor-import.md @@ -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`) بدون هیچ راه +> فراری با خطا متوقف می‌شود. diff --git a/src/Doctor/Command/PurgeDoctorsCommand.php b/src/Doctor/Command/PurgeDoctorsCommand.php index c4f4c401..2c79f7b4 100644 --- a/src/Doctor/Command/PurgeDoctorsCommand.php +++ b/src/Doctor/Command/PurgeDoctorsCommand.php @@ -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; } diff --git a/src/Doctor/Command/PurgeUnclaimedDoctorsCommand.php b/src/Doctor/Command/PurgeUnclaimedDoctorsCommand.php new file mode 100644 index 00000000..267d7f0a --- /dev/null +++ b/src/Doctor/Command/PurgeUnclaimedDoctorsCommand.php @@ -0,0 +1,157 @@ +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} */ + 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 $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; + } +} diff --git a/tests/Doctor/PurgeDoctorsCommandTest.php b/tests/Doctor/PurgeDoctorsCommandTest.php new file mode 100644 index 00000000..fc37d018 --- /dev/null +++ b/tests/Doctor/PurgeDoctorsCommandTest.php @@ -0,0 +1,55 @@ +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; + } + } + } +} diff --git a/tests/Doctor/PurgeUnclaimedDoctorsCommandTest.php b/tests/Doctor/PurgeUnclaimedDoctorsCommandTest.php new file mode 100644 index 00000000..cbd6a663 --- /dev/null +++ b/tests/Doctor/PurgeUnclaimedDoctorsCommandTest.php @@ -0,0 +1,91 @@ +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 نباید با پیش‌فرض حذف شود'); + } +}