Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.70% covered (warning)
83.70%
77 / 92
14.29% covered (danger)
14.29%
1 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProcessPdfQueueTask
83.52% covered (warning)
83.52%
76 / 91
14.29% covered (danger)
14.29%
1 / 7
30.26
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 run
85.00% covered (warning)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
7.17
 processTaskJob
80.95% covered (warning)
80.95%
34 / 42
0.00% covered (danger)
0.00%
0 / 1
11.84
 markJobStarted
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 updateProgress
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 markJobCompleted
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 markJobFailed
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Pdf\Task;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Cron\CronTaskInterface;
12use App\Core\Database\FallbackPdoResolver;
13use App\Modules\Pdf\Application\Service\PdfGeneratorServiceInterface;
14use PDO;
15use Throwable;
16use 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 */
26final 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}