feat: add command to purge unclaimed imported doctors and their surrogates
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user