diff --git a/app/Console/Commands/CertificateGenerateWindow.php b/app/Console/Commands/CertificateGenerateWindow.php index e3b248b50..4d4c516bc 100644 --- a/app/Console/Commands/CertificateGenerateWindow.php +++ b/app/Console/Commands/CertificateGenerateWindow.php @@ -10,11 +10,11 @@ class CertificateGenerateWindow extends Command { protected $signature = 'certificate:generate-window {--edition=2025 : Target edition year} - {--type=excellence : excellence|super-organiser} - {--limit=500 : Max recipients to process in this run} + {--type=all : excellence|super-organiser|all} + {--limit=500 : Max recipients to process per type in this run} {--include-failed : Include rows with previous generation errors}'; - protected $description = 'Generate certificates in controlled windows (e.g. 500 at a time)'; + protected $description = 'Generate certificates in controlled windows (e.g. 500 at a time); use --type=all for both certs'; public function handle(): int { @@ -22,17 +22,36 @@ public function handle(): int $limit = max(1, (int) $this->option('limit')); $includeFailed = (bool) $this->option('include-failed'); $typeInput = strtolower(trim((string) $this->option('type'))); - $type = match ($typeInput) { - 'excellence' => 'Excellence', - 'super-organiser', 'superorganiser' => 'SuperOrganiser', - default => null, - }; - - if ($type === null) { - $this->error("Invalid --type value: {$typeInput}. Use 'excellence' or 'super-organiser'."); + $types = $this->resolveTypes($typeInput); + if ($types === null) { + $this->error("Invalid --type value: {$typeInput}. Use 'excellence', 'super-organiser', or 'all'."); return self::FAILURE; } + $totalOk = 0; + $totalFailed = 0; + $any = false; + + foreach ($types as $type) { + $result = $this->generateWindowForType($edition, $type, $limit, $includeFailed); + $totalOk += $result['ok']; + $totalFailed += $result['failed']; + if ($result['processed'] > 0) { + $any = true; + } + } + + $this->newLine(); + $this->info("Generate window(s) complete. Total success: {$totalOk}, Total failed: {$totalFailed}."); + if ($any) { + $this->line('Run the same command again to process the next batch.'); + } + return self::SUCCESS; + } + + /** @return array{processed: int, ok: int, failed: int} */ + private function generateWindowForType(int $edition, string $type, int $limit, bool $includeFailed): array + { $query = Excellence::query() ->where('edition', $edition) ->where('type', $type) @@ -48,12 +67,13 @@ public function handle(): int $rows = $query->get(); if ($rows->isEmpty()) { - $this->info('No pending recipients found for this window.'); - return self::SUCCESS; + $this->line(" [{$type}] No pending recipients for this window."); + return ['processed' => 0, 'ok' => 0, 'failed' => 0]; } - $this->info("Generating {$rows->count()} {$type} certificates for edition {$edition}..."); + $this->info("[{$type}] Generating {$rows->count()} certificates (edition {$edition})..."); $bar = $this->output->createProgressBar($rows->count()); + $bar->setFormat(" %current%/%max% [%bar%] %percent:3s%%"); $bar->start(); $ok = 0; @@ -97,10 +117,19 @@ public function handle(): int } $bar->finish(); - $this->newLine(2); - $this->info("Window complete. Success: {$ok}, Failed: {$failed}."); - $this->line('Run the same command again to process the next window.'); + $this->newLine(); + $this->line(" [{$type}] Done. Success: {$ok}, Failed: {$failed}."); + return ['processed' => $rows->count(), 'ok' => $ok, 'failed' => $failed]; + } - return self::SUCCESS; + /** @return array|null */ + private function resolveTypes(string $typeInput): ?array + { + return match ($typeInput) { + 'all' => ['Excellence', 'SuperOrganiser'], + 'excellence' => ['Excellence'], + 'super-organiser', 'superorganiser' => ['SuperOrganiser'], + default => null, + }; } } diff --git a/app/Console/Commands/CertificateSendWindow.php b/app/Console/Commands/CertificateSendWindow.php index 67d240f48..341084229 100644 --- a/app/Console/Commands/CertificateSendWindow.php +++ b/app/Console/Commands/CertificateSendWindow.php @@ -13,11 +13,11 @@ class CertificateSendWindow extends Command { protected $signature = 'certificate:send-window {--edition=2025 : Target edition year} - {--type=excellence : excellence|super-organiser} - {--limit=500 : Max recipients to send in this run} + {--type=all : excellence|super-organiser|all} + {--limit=500 : Max recipients to send per type in this run} {--include-send-failed : Include rows that previously failed sending}'; - protected $description = 'Send certificate emails in controlled windows (e.g. 500 at a time)'; + protected $description = 'Send certificate emails in controlled windows (e.g. 500 at a time); use --type=all for both certs'; public function handle(): int { @@ -25,17 +25,36 @@ public function handle(): int $limit = max(1, (int) $this->option('limit')); $includeSendFailed = (bool) $this->option('include-send-failed'); $typeInput = strtolower(trim((string) $this->option('type'))); - $type = match ($typeInput) { - 'excellence' => 'Excellence', - 'super-organiser', 'superorganiser' => 'SuperOrganiser', - default => null, - }; - - if ($type === null) { - $this->error("Invalid --type value: {$typeInput}. Use 'excellence' or 'super-organiser'."); + $types = $this->resolveTypes($typeInput); + if ($types === null) { + $this->error("Invalid --type value: {$typeInput}. Use 'excellence', 'super-organiser', or 'all'."); return self::FAILURE; } + $totalQueued = 0; + $totalFailed = 0; + $any = false; + + foreach ($types as $type) { + $result = $this->sendWindowForType($edition, $type, $limit, $includeSendFailed); + $totalQueued += $result['queued']; + $totalFailed += $result['failed']; + if ($result['processed'] > 0) { + $any = true; + } + } + + $this->newLine(); + $this->info("Send window(s) complete. Total queued: {$totalQueued}, Total failed: {$totalFailed}."); + if ($any) { + $this->line('Run the same command again to process the next batch (or ensure queue worker is running: php artisan queue:work).'); + } + return self::SUCCESS; + } + + /** @return array{processed: int, queued: int, failed: int} */ + private function sendWindowForType(int $edition, string $type, int $limit, bool $includeSendFailed): array + { $query = Excellence::query() ->where('edition', $edition) ->where('type', $type) @@ -51,12 +70,13 @@ public function handle(): int $rows = $query->get(); if ($rows->isEmpty()) { - $this->info('No recipients found for this send window.'); - return self::SUCCESS; + $this->line(" [{$type}] No recipients left for this window."); + return ['processed' => 0, 'queued' => 0, 'failed' => 0]; } - $this->info("Queueing {$rows->count()} {$type} emails for edition {$edition}..."); + $this->info("[{$type}] Queueing {$rows->count()} emails (edition {$edition})..."); $bar = $this->output->createProgressBar($rows->count()); + $bar->setFormat(" %current%/%max% [%bar%] %percent:3s%%"); $bar->start(); $queued = 0; @@ -89,10 +109,19 @@ public function handle(): int } $bar->finish(); - $this->newLine(2); - $this->info("Send window complete. Queued: {$queued}, Failed: {$failed}."); - $this->line('Run again to process the next send window.'); + $this->newLine(); + $this->line(" [{$type}] Done. Queued: {$queued}, Failed: {$failed}."); + return ['processed' => $rows->count(), 'queued' => $queued, 'failed' => $failed]; + } - return self::SUCCESS; + /** @return array|null */ + private function resolveTypes(string $typeInput): ?array + { + return match ($typeInput) { + 'all' => ['Excellence', 'SuperOrganiser'], + 'excellence' => ['Excellence'], + 'super-organiser', 'superorganiser' => ['SuperOrganiser'], + default => null, + }; } } diff --git a/app/Console/Commands/CertificateStats.php b/app/Console/Commands/CertificateStats.php index aab90bd0f..9aa785064 100644 --- a/app/Console/Commands/CertificateStats.php +++ b/app/Console/Commands/CertificateStats.php @@ -12,7 +12,7 @@ class CertificateStats extends Command { protected $signature = 'certificate:stats {--edition=2025 : Target edition year} - {--type=excellence : excellence|super-organiser} + {--type=all : excellence|super-organiser|all} {--sample=10 : Number of sample user IDs to show for missing/extra}'; protected $description = 'Show diagnostic counts for certificate backend recipients and processing status'; @@ -23,12 +23,20 @@ public function handle(): int $typeOption = (string) $this->option('type'); $sampleSize = max(0, (int) $this->option('sample')); - $normalizedType = $this->normalizeType($typeOption); - if ($normalizedType === null) { - $this->error("Invalid --type value: {$typeOption}. Use 'excellence' or 'super-organiser'."); + $types = $this->resolveTypes($typeOption); + if ($types === null) { + $this->error("Invalid --type value: {$typeOption}. Use 'excellence', 'super-organiser', or 'all'."); return self::FAILURE; } + foreach ($types as $normalizedType) { + $this->outputStatsForType($edition, $normalizedType, $sampleSize); + } + return self::SUCCESS; + } + + private function outputStatsForType(int $edition, string $normalizedType, int $sampleSize): void + { $eligibleUserIds = $this->eligibleUserIds($edition, $normalizedType); $eligibleUserIds = array_values(array_unique(array_filter(array_map('intval', $eligibleUserIds)))); @@ -91,16 +99,16 @@ public function handle(): int $this->line('Sample missing user IDs: ' . $this->sampleCsv($missingUserIds, $sampleSize)); $this->line('Sample extra user IDs: ' . $this->sampleCsv($extraUserIds, $sampleSize)); } - - return self::SUCCESS; } - private function normalizeType(string $typeOption): ?string + /** @return array|null */ + private function resolveTypes(string $typeOption): ?array { $slug = strtolower(trim($typeOption)); return match ($slug) { - 'excellence' => 'Excellence', - 'super-organiser', 'superorganiser' => 'SuperOrganiser', + 'all' => ['Excellence', 'SuperOrganiser'], + 'excellence' => ['Excellence'], + 'super-organiser', 'superorganiser' => ['SuperOrganiser'], default => null, }; }