Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
83.70% |
77 / 92 |
|
14.29% |
1 / 7 |
CRAP | |
0.00% |
0 / 1 |
| ProcessPdfQueueTask | |
83.52% |
76 / 91 |
|
14.29% |
1 / 7 |
30.26 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| run | |
85.00% |
17 / 20 |
|
0.00% |
0 / 1 |
7.17 | |||
| processTaskJob | |
80.95% |
34 / 42 |
|
0.00% |
0 / 1 |
11.84 | |||
| markJobStarted | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
2.02 | |||
| updateProgress | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
2.01 | |||
| markJobCompleted | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
2.01 | |||
| markJobFailed | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
2.01 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Modules\Pdf\Task; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Cron\CronTaskInterface; |
| 12 | use App\Core\Database\FallbackPdoResolver; |
| 13 | use App\Modules\Pdf\Application\Service\PdfGeneratorServiceInterface; |
| 14 | use PDO; |
| 15 | use Throwable; |
| 16 | use ZipArchive; |
| 17 | |
| 18 | /** |
| 19 | * Background Task Worker for Asynchronous PDF Generation and ZIP Packaging. |
| 20 | * |
| 21 | * Polls pending batch rendering tasks, updates progress percentage in real-time, |
| 22 | * generates individual PDF documents, and bundles results into downloadable ZIP archives. |
| 23 | * |
| 24 | * @package App\Modules\Pdf\Task |
| 25 | */ |
| 26 | final class ProcessPdfQueueTask implements CronTaskInterface |
| 27 | { |
| 28 | private const string SQL_UPDATE_PREFIX = 'UPDATE `a_mod_pdf_queue_records` SET '; |
| 29 | |
| 30 | private ?PDO $pdo; |
| 31 | |
| 32 | /** |
| 33 | * ProcessPdfQueueTask constructor. |
| 34 | * |
| 35 | * @param PdfGeneratorServiceInterface|null $generator Optional PDF generator instance. |
| 36 | * @param PDO|null $pdo Optional database handle. |
| 37 | */ |
| 38 | public function __construct( |
| 39 | private ?PdfGeneratorServiceInterface $generator = null, |
| 40 | ?PDO $pdo = null |
| 41 | ) { |
| 42 | $this->pdo = $pdo ?? FallbackPdoResolver::resolveDefaultConnection(); |
| 43 | } |
| 44 | |
| 45 | /** |
| 46 | * Executes batch PDF rendering worker. |
| 47 | * |
| 48 | * @return string Execution summary log. |
| 49 | */ |
| 50 | public function run(): string |
| 51 | { |
| 52 | if ($this->pdo === null) { |
| 53 | return '[ProcessPdfQueueTask] Skipped: Database connection unavailable.'; |
| 54 | } |
| 55 | |
| 56 | $result = ''; |
| 57 | try { |
| 58 | $stmt = $this->pdo->prepare( |
| 59 | 'SELECT `id`, `template_id`, `module_name`, `record_ids_json` FROM `a_mod_pdf_queue_records` ' |
| 60 | . 'WHERE `status` = :status ORDER BY `scheduled_at` ASC, `id` ASC LIMIT 3' |
| 61 | ); |
| 62 | $stmt->execute([':status' => 'pending']); |
| 63 | $tasks = $stmt->fetchAll(PDO::FETCH_ASSOC); |
| 64 | |
| 65 | if ($tasks === [] || !is_array($tasks)) { |
| 66 | $result = '[ProcessPdfQueueTask] No pending batch PDF jobs to process.'; |
| 67 | } else { |
| 68 | $processedJobs = 0; |
| 69 | foreach ($tasks as $task) { |
| 70 | if (is_array($task)) { |
| 71 | $this->processTaskJob($task); |
| 72 | $processedJobs++; |
| 73 | } |
| 74 | } |
| 75 | $result = sprintf('[ProcessPdfQueueTask] Finished processing %d batch PDF job(s).', $processedJobs); |
| 76 | } |
| 77 | } catch (Throwable $e) { |
| 78 | $result = sprintf('[ProcessPdfQueueTask] Worker failed with error: %s', $e->getMessage()); |
| 79 | } |
| 80 | |
| 81 | return $result; |
| 82 | } |
| 83 | |
| 84 | /** |
| 85 | * Executes individual batch PDF job. |
| 86 | * |
| 87 | * @param array<string, mixed> $task Database task record. |
| 88 | */ |
| 89 | private function processTaskJob(array $task): void |
| 90 | { |
| 91 | $jobId = (int)$task['id']; |
| 92 | $templateId = (int)$task['template_id']; |
| 93 | $module = (string)$task['module_name']; |
| 94 | $rawRecords = (string)($task['record_ids_json'] ?? '[]'); |
| 95 | |
| 96 | $recordIds = json_decode($rawRecords, true); |
| 97 | if (!is_array($recordIds) || $recordIds === []) { |
| 98 | $this->markJobFailed($jobId, 'Empty or invalid record_ids_json payload.'); |
| 99 | return; |
| 100 | } |
| 101 | |
| 102 | $this->markJobStarted($jobId); |
| 103 | |
| 104 | $tempDir = sys_get_temp_dir() . '/pdf_batch_' . $jobId; |
| 105 | if (!is_dir($tempDir)) { |
| 106 | @mkdir($tempDir, 0775, true); |
| 107 | } |
| 108 | |
| 109 | $zipPath = $tempDir . '/documents_bundle_' . $jobId . '.zip'; |
| 110 | $zip = new ZipArchive(); |
| 111 | $zipOpened = $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true; |
| 112 | |
| 113 | $total = count($recordIds); |
| 114 | $processed = 0; |
| 115 | |
| 116 | try { |
| 117 | foreach ($recordIds as $recId) { |
| 118 | $recIdInt = (int)$recId; |
| 119 | if ($this->generator !== null) { |
| 120 | $res = $this->generator->generateForRecord($templateId, $module, $recIdInt); |
| 121 | $filename = (string)($res['filename'] ?? sprintf('doc_%d.pdf', $recIdInt)); |
| 122 | $content = (string)($res['content'] ?? ''); |
| 123 | } else { |
| 124 | $filename = sprintf('%s_%d.pdf', $module, $recIdInt); |
| 125 | $content = '%PDF-1.4 Mock Simulated PDF File Content for testing'; |
| 126 | } |
| 127 | |
| 128 | if ($zipOpened) { |
| 129 | $zip->addFromString($filename, $content); |
| 130 | } |
| 131 | |
| 132 | $processed++; |
| 133 | $progressPercent = (int)round(($processed / $total) * 100); |
| 134 | $this->updateProgress($jobId, $processed, $progressPercent); |
| 135 | } |
| 136 | |
| 137 | if ($zipOpened) { |
| 138 | $zip->close(); |
| 139 | } |
| 140 | |
| 141 | $storageDir = 'var/pdf_storage'; |
| 142 | if (!is_dir($storageDir)) { |
| 143 | @mkdir($storageDir, 0775, true); |
| 144 | } |
| 145 | $finalZipPath = $storageDir . '/batch_' . $jobId . '_' . date('Ymd_His') . '.zip'; |
| 146 | @rename($zipPath, $finalZipPath); |
| 147 | |
| 148 | $this->markJobCompleted($jobId, $finalZipPath); |
| 149 | } catch (Throwable $e) { |
| 150 | if ($zipOpened) { |
| 151 | @$zip->close(); |
| 152 | } |
| 153 | $this->markJobFailed($jobId, $e->getMessage()); |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | /** |
| 158 | * Marks task as processing. |
| 159 | * |
| 160 | * @param int $jobId Job ID. |
| 161 | */ |
| 162 | private function markJobStarted(int $jobId): void |
| 163 | { |
| 164 | if ($this->pdo === null) { |
| 165 | return; |
| 166 | } |
| 167 | |
| 168 | $stmt = $this->pdo->prepare( |
| 169 | self::SQL_UPDATE_PREFIX . '`status` = :status, `started_at` = NOW(6) WHERE `id` = :id' |
| 170 | ); |
| 171 | $stmt->execute([':status' => 'processing', ':id' => $jobId]); |
| 172 | } |
| 173 | |
| 174 | /** |
| 175 | * Updates batch job progress counters. |
| 176 | * |
| 177 | * @param int $jobId Job ID. |
| 178 | * @param int $processed Processed count. |
| 179 | * @param int $percent Progress percentage. |
| 180 | */ |
| 181 | private function updateProgress(int $jobId, int $processed, int $percent): void |
| 182 | { |
| 183 | if ($this->pdo === null) { |
| 184 | return; |
| 185 | } |
| 186 | |
| 187 | $stmt = $this->pdo->prepare( |
| 188 | self::SQL_UPDATE_PREFIX |
| 189 | . '`processed_count` = :proc, `progress_percent` = :pct WHERE `id` = :id' |
| 190 | ); |
| 191 | $stmt->execute([':proc' => $processed, ':pct' => $percent, ':id' => $jobId]); |
| 192 | } |
| 193 | |
| 194 | /** |
| 195 | * Marks task as completed. |
| 196 | * |
| 197 | * @param int $jobId Job ID. |
| 198 | * @param string $zipPath Resulting archive path. |
| 199 | */ |
| 200 | private function markJobCompleted(int $jobId, string $zipPath): void |
| 201 | { |
| 202 | if ($this->pdo === null) { |
| 203 | return; |
| 204 | } |
| 205 | |
| 206 | $stmt = $this->pdo->prepare( |
| 207 | self::SQL_UPDATE_PREFIX |
| 208 | . '`status` = :status, `progress_percent` = 100, `result_zip_path` = :path, ' |
| 209 | . '`completed_at` = NOW(6) WHERE `id` = :id' |
| 210 | ); |
| 211 | $stmt->execute([':status' => 'completed', ':path' => $zipPath, ':id' => $jobId]); |
| 212 | } |
| 213 | |
| 214 | /** |
| 215 | * Marks task as failed with error description. |
| 216 | * |
| 217 | * @param int $jobId Job ID. |
| 218 | * @param string $error Error message. |
| 219 | */ |
| 220 | private function markJobFailed(int $jobId, string $error): void |
| 221 | { |
| 222 | if ($this->pdo === null) { |
| 223 | return; |
| 224 | } |
| 225 | |
| 226 | $stmt = $this->pdo->prepare( |
| 227 | self::SQL_UPDATE_PREFIX |
| 228 | . '`status` = :status, `error_message` = :err, `completed_at` = NOW(6) WHERE `id` = :id' |
| 229 | ); |
| 230 | $stmt->execute([':status' => 'failed', ':err' => mb_substr($error, 0, 1000), ':id' => $jobId]); |
| 231 | } |
| 232 | } |