Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
95.76% |
113 / 118 |
|
40.00% |
2 / 5 |
CRAP | |
0.00% |
0 / 1 |
| PdfGeneratorService | |
95.73% |
112 / 117 |
|
40.00% |
2 / 5 |
20 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| generateForRecord | |
97.78% |
44 / 45 |
|
0.00% |
0 / 1 |
6 | |||
| generateBulk | |
93.10% |
27 / 29 |
|
0.00% |
0 / 1 |
5.01 | |||
| renderHtmlPreview | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
4 | |||
| saveVerificationRecord | |
90.00% |
18 / 20 |
|
0.00% |
0 / 1 |
4.02 | |||
| 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\Application\Service; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Database\FallbackPdoResolver; |
| 12 | use App\Core\Template\Application\Service\TemplateCompilerService; |
| 13 | use App\Core\Template\Application\Service\TemplateVariableResolverInterface; |
| 14 | use App\Modules\Pdf\Domain\Repository\PdfTemplateRepositoryInterface; |
| 15 | use App\Modules\Pdf\Infrastructure\Driver\HtmlPrintRendererDriver; |
| 16 | use App\Modules\Pdf\Infrastructure\Driver\MpdfRendererDriver; |
| 17 | use App\Modules\Pdf\Infrastructure\Driver\PdfRendererDriverInterface; |
| 18 | use InvalidArgumentException; |
| 19 | use PDO; |
| 20 | |
| 21 | /** |
| 22 | * PDF Document Generator Orchestrator. |
| 23 | * |
| 24 | * Resolves record context, executes Twig template compilation, registers digital |
| 25 | * verification records, and delegates PDF rendering to the configured driver. |
| 26 | * |
| 27 | * @package App\Modules\Pdf\Application\Service |
| 28 | */ |
| 29 | final class PdfGeneratorService implements PdfGeneratorServiceInterface |
| 30 | { |
| 31 | private PdfRendererDriverInterface $driver; |
| 32 | private ?PDO $pdo; |
| 33 | |
| 34 | /** |
| 35 | * PdfGeneratorService constructor. |
| 36 | * |
| 37 | * @param PdfTemplateRepositoryInterface $repository Template repository. |
| 38 | * @param TemplateVariableResolverInterface $resolver Context variable resolver. |
| 39 | * @param TemplateCompilerService $compiler Block AST and Twig compiler. |
| 40 | * @param PdfRendererDriverInterface|null $driver Optional PDF renderer driver. |
| 41 | * @param PDO|null $pdo Optional PDO database connection. |
| 42 | */ |
| 43 | public function __construct( |
| 44 | private readonly PdfTemplateRepositoryInterface $repository, |
| 45 | private readonly TemplateVariableResolverInterface $resolver, |
| 46 | private readonly TemplateCompilerService $compiler, |
| 47 | ?PdfRendererDriverInterface $driver = null, |
| 48 | ?PDO $pdo = null |
| 49 | ) { |
| 50 | $this->driver = $driver ?? new MpdfRendererDriver(new HtmlPrintRendererDriver()); |
| 51 | $this->pdo = $pdo ?? FallbackPdoResolver::resolveDefaultConnection(); |
| 52 | } |
| 53 | |
| 54 | /** |
| 55 | * Generates a PDF document for a single record. |
| 56 | * |
| 57 | * @param int $templateId Target template ID. |
| 58 | * @param string $moduleName Business module name. |
| 59 | * @param int $recordId Primary record identifier. |
| 60 | * @param array<string, mixed>|null $user Current user identity. |
| 61 | * @param array<string, mixed> $options Rendering options. |
| 62 | * @return array<string, string> Dictionary with filename, binary content, and verification data. |
| 63 | */ |
| 64 | public function generateForRecord( |
| 65 | int $templateId, |
| 66 | string $moduleName, |
| 67 | int $recordId, |
| 68 | ?array $user = null, |
| 69 | array $options = [] |
| 70 | ): array { |
| 71 | $template = $this->repository->findById($templateId); |
| 72 | if ($template === null) { |
| 73 | throw new InvalidArgumentException("PDF Template ID {$templateId} not found."); |
| 74 | } |
| 75 | |
| 76 | $verificationHash = (string)($options['verification_hash'] ?? bin2hex(random_bytes(16))); |
| 77 | $overrides = ['verification_hash' => $verificationHash]; |
| 78 | $context = $template->templateScope === 'list' |
| 79 | ? $this->resolver->resolveListContext($moduleName, [$recordId], $user) |
| 80 | : $this->resolver->resolveContext($moduleName, $recordId, $overrides, $user); |
| 81 | |
| 82 | $bodyHtml = $this->compiler->render($template->bodyHtml, $context); |
| 83 | $headerHtml = $template->headerHtml !== null ? $this->compiler->render($template->headerHtml, $context) : ''; |
| 84 | $footerHtml = $template->footerHtml !== null ? $this->compiler->render($template->footerHtml, $context) : ''; |
| 85 | |
| 86 | $filename = $this->compiler->render($template->filenamePattern, $context); |
| 87 | if (!str_ends_with(strtolower($filename), '.pdf')) { |
| 88 | $filename .= '.pdf'; |
| 89 | } |
| 90 | |
| 91 | $renderOptions = array_merge([ |
| 92 | 'format' => $template->pageFormat, |
| 93 | 'orientation' => $template->pageOrientation, |
| 94 | 'margin_top' => $template->marginTop, |
| 95 | 'margin_bottom' => $template->marginBottom, |
| 96 | 'margin_left' => $template->marginLeft, |
| 97 | 'margin_right' => $template->marginRight, |
| 98 | 'watermark_text' => $template->watermarkText, |
| 99 | 'header_html' => $headerHtml, |
| 100 | 'footer_html' => $footerHtml, |
| 101 | 'css_styles' => $template->cssStyles ?? '', |
| 102 | 'title' => $template->name, |
| 103 | ], $options); |
| 104 | |
| 105 | $binary = $this->driver->renderPdf($bodyHtml, $renderOptions); |
| 106 | $checksum = hash('sha256', $binary); |
| 107 | |
| 108 | $this->saveVerificationRecord( |
| 109 | $verificationHash, |
| 110 | $templateId, |
| 111 | $moduleName, |
| 112 | $recordId, |
| 113 | $filename, |
| 114 | $checksum, |
| 115 | (int)($user['id'] ?? 1) |
| 116 | ); |
| 117 | |
| 118 | return [ |
| 119 | 'filename' => $filename, |
| 120 | 'content' => $binary, |
| 121 | 'mime_type' => 'application/pdf', |
| 122 | 'verification_hash' => $verificationHash, |
| 123 | 'checksum_sha256' => $checksum, |
| 124 | ]; |
| 125 | } |
| 126 | |
| 127 | /** |
| 128 | * Generates a consolidated multi-record PDF document for bulk actions. |
| 129 | * |
| 130 | * @param int $templateId Target template ID. |
| 131 | * @param string $moduleName Business module name. |
| 132 | * @param array<int> $recordIds List of selected record IDs. |
| 133 | * @param array<string, mixed>|null $user Current user identity. |
| 134 | * @param array<string, mixed> $options Rendering options. |
| 135 | * @return array<string, string> Dictionary with filename, binary content, and mime_type. |
| 136 | */ |
| 137 | public function generateBulk( |
| 138 | int $templateId, |
| 139 | string $moduleName, |
| 140 | array $recordIds, |
| 141 | ?array $user = null, |
| 142 | array $options = [] |
| 143 | ): array { |
| 144 | $template = $this->repository->findById($templateId); |
| 145 | if ($template === null) { |
| 146 | throw new InvalidArgumentException("PDF Template ID {$templateId} not found."); |
| 147 | } |
| 148 | |
| 149 | if ($template->templateScope === 'list' || $template->category === 'list') { |
| 150 | $context = $this->resolver->resolveListContext($moduleName, $recordIds, $user); |
| 151 | $fullBody = $this->compiler->render($template->bodyHtml, $context); |
| 152 | } else { |
| 153 | $aggregatedBodies = []; |
| 154 | foreach ($recordIds as $recId) { |
| 155 | $context = $this->resolver->resolveContext($moduleName, (int)$recId, [], $user); |
| 156 | $aggregatedBodies[] = $this->compiler->render($template->bodyHtml, $context); |
| 157 | } |
| 158 | |
| 159 | $pageBreak = '<div class="pdf-page-break" style="page-break-after: always; break-after: page;"></div>'; |
| 160 | $fullBody = implode($pageBreak, $aggregatedBodies); |
| 161 | } |
| 162 | |
| 163 | $renderOptions = array_merge([ |
| 164 | 'format' => $template->pageFormat, |
| 165 | 'orientation' => $template->pageOrientation, |
| 166 | 'margin_top' => $template->marginTop, |
| 167 | 'margin_bottom' => $template->marginBottom, |
| 168 | 'margin_left' => $template->marginLeft, |
| 169 | 'margin_right' => $template->marginRight, |
| 170 | 'watermark_text' => $template->watermarkText, |
| 171 | 'css_styles' => $template->cssStyles ?? '', |
| 172 | 'title' => 'Zbiorczy wydruk - ' . $template->name, |
| 173 | ], $options); |
| 174 | |
| 175 | $binary = $this->driver->renderPdf($fullBody, $renderOptions); |
| 176 | |
| 177 | return [ |
| 178 | 'filename' => 'bulk_' . $moduleName . '_' . date('Ymd_His') . '.pdf', |
| 179 | 'content' => $binary, |
| 180 | 'mime_type' => 'application/pdf', |
| 181 | ]; |
| 182 | } |
| 183 | |
| 184 | /** |
| 185 | * Renders self-contained HTML preview for in-browser modal dialog. |
| 186 | * |
| 187 | * @param int $templateId Target template ID. |
| 188 | * @param string $moduleName Business module name. |
| 189 | * @param int $recordId Primary record identifier. |
| 190 | * @param array<string, mixed>|null $user Current user identity. |
| 191 | * @return string Complete preview HTML string. |
| 192 | */ |
| 193 | public function renderHtmlPreview( |
| 194 | int $templateId, |
| 195 | string $moduleName, |
| 196 | int $recordId, |
| 197 | ?array $user = null |
| 198 | ): string { |
| 199 | $template = $this->repository->findById($templateId); |
| 200 | if ($template === null) { |
| 201 | return '<div class="alert alert-danger">PDF template not found.</div>'; |
| 202 | } |
| 203 | |
| 204 | $context = $this->resolver->resolveContext($moduleName, $recordId, [], $user); |
| 205 | $bodyHtml = $this->compiler->render($template->bodyHtml, $context); |
| 206 | $headerHtml = $template->headerHtml !== null ? $this->compiler->render($template->headerHtml, $context) : ''; |
| 207 | $footerHtml = $template->footerHtml !== null ? $this->compiler->render($template->footerHtml, $context) : ''; |
| 208 | |
| 209 | $htmlDriver = new HtmlPrintRendererDriver(); |
| 210 | $options = [ |
| 211 | 'format' => $template->pageFormat, |
| 212 | 'orientation' => $template->pageOrientation, |
| 213 | 'margin_top' => $template->marginTop, |
| 214 | 'margin_bottom' => $template->marginBottom, |
| 215 | 'margin_left' => $template->marginLeft, |
| 216 | 'margin_right' => $template->marginRight, |
| 217 | 'header_html' => $headerHtml, |
| 218 | 'footer_html' => $footerHtml, |
| 219 | 'css_styles' => $template->cssStyles ?? '', |
| 220 | 'title' => $template->name, |
| 221 | ]; |
| 222 | |
| 223 | return $htmlDriver->renderPdf($bodyHtml, $options); |
| 224 | } |
| 225 | |
| 226 | /** |
| 227 | * Saves digital document verification record into the database. |
| 228 | * |
| 229 | * @param string $hash Unique verification hash. |
| 230 | * @param int $tplId Template ID. |
| 231 | * @param string $module Module name. |
| 232 | * @param int $recordId Record ID. |
| 233 | * @param string $filename File name. |
| 234 | * @param string $checksum SHA-256 binary checksum. |
| 235 | * @param int $userId Creator user ID. |
| 236 | */ |
| 237 | private function saveVerificationRecord( |
| 238 | string $hash, |
| 239 | int $tplId, |
| 240 | string $module, |
| 241 | int $recordId, |
| 242 | string $filename, |
| 243 | string $checksum, |
| 244 | int $userId |
| 245 | ): void { |
| 246 | if ($this->pdo === null || $recordId <= 0) { |
| 247 | return; |
| 248 | } |
| 249 | |
| 250 | try { |
| 251 | $stmt = $this->pdo->prepare( |
| 252 | 'INSERT INTO `a_mod_pdf_verification_records` (' |
| 253 | . '`document_hash`, `template_id`, `module_name`, `record_id`, `filename`, ' |
| 254 | . '`checksum_sha256`, `created_by`' |
| 255 | . ') VALUES (:hash, :tpl, :mod, :rec, :file, :chk, :uid) ' |
| 256 | . 'ON DUPLICATE KEY UPDATE `filename` = VALUES(`filename`), ' |
| 257 | . '`checksum_sha256` = VALUES(`checksum_sha256`)' |
| 258 | ); |
| 259 | $stmt->execute([ |
| 260 | ':hash' => $hash, |
| 261 | ':tpl' => $tplId, |
| 262 | ':mod' => $module, |
| 263 | ':rec' => $recordId, |
| 264 | ':file' => $filename, |
| 265 | ':chk' => $checksum, |
| 266 | ':uid' => $userId, |
| 267 | ]); |
| 268 | } catch (\Throwable) { |
| 269 | // Non-blocking for unmigrated or testing environments |
| 270 | } |
| 271 | } |
| 272 | } |