true, 'created_by' => null, 'ticket_type' => 1, 'include_file_types' => [1, 2], 'include_mime_types' => ['application/pdf', 'image/jpeg', 'image/png'], ]; $result = [ 'status' => false, 'merged_file_id' => null, 'file_name' => null, 'pages' => 0, 'source_count' => 0, 'message' => '', ]; if ($ticket_master_id <= 0) { $result['message'] = 'Invalid ticket_master_id'; return $result; } $claimFiles = new ClaimFilesModel(); $rows = $claimFiles ->where('ticket_id', $ticket_master_id) ->where('is_active', 1) ->whereIn('file_type', $opts['include_file_types']) ->orderBy('id', 'ASC') ->findAll(); if (empty($rows)) { $result['status'] = true; $result['message'] = 'No PDF/image files to merge'; return $result; } if (count($rows) ==1){ //set file_type for for that one file. $claimFiles->where('id', $rows[0]['id'])->set(['file_type' => 4])->update(); $result['status'] = true; $result['message'] = 'Only one PDF/image file to merge'; return $result; } $uploadDir = rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'claim_files' . DIRECTORY_SEPARATOR; $sourceFiles = []; foreach ($rows as $row) { $full = merge_ticket_pdf_resolve_disk_path($row, $uploadDir, true); if ($full !== null) { $mime = merge_ticket_pdf_resolve_mime($full, (string) ($row['mime_type'] ?? '')); if (! in_array($mime, $opts['include_mime_types'], true)) { log_message('error', "merge_ticket_pdfs | unsupported source mime {$mime} | claim_file_id={$row['id']} | path={$full}"); if (strpos($full, 'claim_files_runtime') !== false) { @unlink($full); } continue; } $sourceFiles[] = [ 'path' => $full, 'mime' => $mime, 'id' => $row['id'] ?? null, 'is_temp' => strpos($full, 'claim_files_runtime') !== false, ]; } else { $name = ! empty($row['url']) ? $row['url'] : ($row['file_name'] ?? ''); log_message('error', "merge_ticket_pdfs | missing file in local/S3 | claim_file_id={$row['id']} | path=" . $uploadDir . basename((string) $name)); } } if (empty($sourceFiles)) { $result['message'] = 'No readable PDF/image files on disk'; return $result; } $cleanupMergeTemps = static function (array $files): void { foreach ($files as $sourceFile) { if (! empty($sourceFile['is_temp']) && ! empty($sourceFile['path']) && is_file($sourceFile['path'])) { @unlink($sourceFile['path']); } } }; $result['source_count'] = count($sourceFiles); log_message( 'error', 'merge_ticket_pdfs | source files resolved | ticket_id=' . $ticket_master_id . ' | sources=' . json_encode(array_map(static function ($sourceFile) { return [ 'id' => $sourceFile['id'], 'mime' => $sourceFile['mime'], 'file' => basename($sourceFile['path']), ]; }, $sourceFiles)) ); $tempDir = rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . 'mpdf'; if (! is_dir($tempDir)) { @mkdir($tempDir, 0775, true); } $mpdfCacheDir = $tempDir . DIRECTORY_SEPARATOR . 'mpdf'; if (! is_dir($mpdfCacheDir)) { @mkdir($mpdfCacheDir, 0775, true); } $mergedName = 'merged_' . $ticket_master_id . '_' . time() . '_' . bin2hex(random_bytes(5)) . '.pdf'; $mergedPath = $uploadDir . $mergedName; try { $mpdf = new Mpdf([ 'tempDir' => $tempDir, 'mode' => 'utf-8', ]); $totalPages = 0; foreach ($sourceFiles as $sourceFile) { $src = $sourceFile['path']; try { if ($sourceFile['mime'] === 'application/pdf') { $pageCount = merge_ticket_pdf_add_pdf_pages($mpdf, $src, $tempDir); $totalPages += $pageCount; log_message('error', "merge_ticket_pdfs | added PDF | file={$src} | pages={$pageCount}"); } elseif (in_array($sourceFile['mime'], ['image/jpeg', 'image/png'], true)) { if (merge_ticket_pdf_add_image_page($mpdf, $src)) { $totalPages++; log_message('error', "merge_ticket_pdfs | added image | file={$src} | mime={$sourceFile['mime']}"); } } else { log_message('error', "merge_ticket_pdfs | unsupported source mime {$sourceFile['mime']} | {$src}"); } } catch (\Throwable $e) { log_message('error', "merge_ticket_pdfs | failed to import {$src} | " . $e->getMessage()); } } if ($totalPages === 0) { $cleanupMergeTemps($sourceFiles); $result['message'] = 'All source PDF/image files failed to import'; return $result; } $mpdf->Output($mergedPath, Destination::FILE); } catch (\Throwable $e) { $cleanupMergeTemps($sourceFiles); log_message('error', 'merge_ticket_pdfs | mpdf failure | ticket_id=' . $ticket_master_id . ' | ' . $e->getMessage()); $result['message'] = 'Merge failed: ' . $e->getMessage(); return $result; } if (! is_file($mergedPath)) { $cleanupMergeTemps($sourceFiles); $result['message'] = 'Merged file was not created'; return $result; } $storage = \Config\Services::getFileStorageService(); if ($storage->usesS3()) { $uploadResult = $storage->upload($mergedPath, rtrim($uploadDir, '/\\'), $mergedName); if (! ($uploadResult['success'] ?? false)) { log_message('error', 'merge_ticket_pdfs | failed to mirror merged file to S3 | ticket_id=' . $ticket_master_id . ' | ' . json_encode($uploadResult)); @unlink($mergedPath); $cleanupMergeTemps($sourceFiles); $result['message'] = 'Merged file upload to storage failed'; return $result; } // S3-only: drop permanent local merged copy after successful upload. @unlink($mergedPath); } if ($opts['replace']) { $claimFiles ->where('ticket_id', $ticket_master_id) ->where('file_type', MERGED_CLAIM_FILE_TYPE) ->where('is_active', 1) ->set(['is_active' => 0]) ->update(); } $insertData = [ 'ticket_id' => $ticket_master_id, // 'ticket_type' => $opts['ticket_type'], 'file_type' => MERGED_CLAIM_FILE_TYPE, 'doc_name' => 'MERGED_CLAIM_DOCS_PDF', 'file_name' => $mergedName, 'url' => $mergedName, 'mime_type' => 'application/pdf', 'is_active' => 1, ]; if (! empty($opts['created_by'])) { $insertData['created_by'] = $opts['created_by']; } $insertedId = $claimFiles->insert($insertData); $cleanupMergeTemps($sourceFiles); if (! $insertedId) { log_message('error', 'merge_ticket_pdfs | DB insert failed for merged file | ticket_id=' . $ticket_master_id); @unlink($mergedPath); $result['message'] = 'Failed to register merged file in claim_files'; return $result; } log_message( 'info', "merge_ticket_pdfs | OK | ticket_id={$ticket_master_id} | sources={$result['source_count']} | pages={$totalPages} | claim_file_id={$insertedId}" ); $result['status'] = true; $result['merged_file_id'] = (int) $insertedId; $result['file_name'] = $mergedName; $result['pages'] = $totalPages; $result['message'] = 'Merged successfully'; return $result; } } if (! function_exists('merge_ticket_pdf_add_pdf_pages')) { /** * Import PDF pages. If FPDI cannot import the source (commonly encrypted * PDFs), normalize through Ghostscript and retry. */ function merge_ticket_pdf_add_pdf_pages(Mpdf $mpdf, string $pdfPath, string $tempDir): int { try { return merge_ticket_pdf_import_pdf_pages($mpdf, $pdfPath); } catch (\Throwable $e) { log_message('error', "merge_ticket_pdfs | direct PDF import failed | file={$pdfPath} | " . $e->getMessage()); $normalizedPath = merge_ticket_pdf_normalize_with_ghostscript($pdfPath, $tempDir); if ($normalizedPath === null) { throw $e; } try { $pageCount = merge_ticket_pdf_import_pdf_pages($mpdf, $normalizedPath); log_message('error', "merge_ticket_pdfs | Ghostscript normalized PDF imported | original={$pdfPath} | normalized={$normalizedPath} | pages={$pageCount}"); @unlink($normalizedPath); return $pageCount; } catch (\Throwable $normalizedError) { @unlink($normalizedPath); throw $normalizedError; } } } } if (! function_exists('merge_ticket_pdf_import_pdf_pages')) { function merge_ticket_pdf_import_pdf_pages(Mpdf $mpdf, string $pdfPath): int { $pageCount = $mpdf->setSourceFile($pdfPath); for ($p = 1; $p <= $pageCount; $p++) { $tplId = $mpdf->importPage($p); $mpdf->AddPage(); // adjustPageSize=true makes the output page match the imported page. $mpdf->useTemplate($tplId, 0, 0, null, null, true); } return $pageCount; } } if (! function_exists('merge_ticket_pdf_normalize_with_ghostscript')) { function merge_ticket_pdf_normalize_with_ghostscript(string $pdfPath, string $tempDir): ?string { $ghostscript = is_executable('/usr/bin/gs') ? '/usr/bin/gs' : trim((string) @shell_exec('command -v gs 2>/dev/null')); if ($ghostscript === '' || ! is_executable($ghostscript)) { log_message('error', "merge_ticket_pdfs | Ghostscript not available for PDF normalization | file={$pdfPath}"); return null; } $normalizedPath = rtrim($tempDir, '/\\') . DIRECTORY_SEPARATOR . 'normalized_' . uniqid('', true) . '.pdf'; $cmd = escapeshellarg($ghostscript) . ' -q -dNOPAUSE -dBATCH -dSAFER -sDEVICE=pdfwrite -dCompatibilityLevel=1.4' . ' -sOutputFile=' . escapeshellarg($normalizedPath) . ' ' . escapeshellarg($pdfPath) . ' 2>&1'; $output = []; $exitCode = 1; @exec($cmd, $output, $exitCode); if ($exitCode !== 0 || ! is_file($normalizedPath) || filesize($normalizedPath) <= 0) { log_message('error', 'merge_ticket_pdfs | Ghostscript normalization failed | file=' . $pdfPath . ' | exit=' . $exitCode . ' | output=' . implode(' ', $output)); @unlink($normalizedPath); return null; } return $normalizedPath; } } if (! function_exists('merge_ticket_pdf_resolve_mime')) { /** * Resolve file type from stored MIME, filesystem MIME, and extension. * Some uploads can be saved as image/jpg, empty MIME, or octet-stream in DB. */ function merge_ticket_pdf_resolve_mime(string $path, string $storedMime = ''): string { $storedMime = strtolower(trim($storedMime)); if ($storedMime === 'image/jpg') { return 'image/jpeg'; } if (in_array($storedMime, ['application/pdf', 'image/jpeg', 'image/png'], true)) { return $storedMime; } $detectedMime = ''; if (function_exists('mime_content_type')) { $detectedMime = strtolower((string) @mime_content_type($path)); if ($detectedMime === 'image/jpg') { return 'image/jpeg'; } if (in_array($detectedMime, ['application/pdf', 'image/jpeg', 'image/png'], true)) { return $detectedMime; } } $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); if ($ext === 'pdf') { return 'application/pdf'; } if (in_array($ext, ['jpg', 'jpeg'], true)) { return 'image/jpeg'; } if ($ext === 'png') { return 'image/png'; } if (in_array($detectedMime, ['application/octet-stream', 'binary/octet-stream'], true)) { if ($ext === 'pdf') { return 'application/pdf'; } if (in_array($ext, ['jpg', 'jpeg'], true)) { return 'image/jpeg'; } if ($ext === 'png') { return 'image/png'; } } return $detectedMime ?: ($storedMime ?: 'application/octet-stream'); } } if (! function_exists('merge_ticket_pdf_resolve_disk_path')) { /** * Resolve on-disk path for a claim_files row (url and/or file_name). */ function merge_ticket_pdf_resolve_disk_path(array $row, string $uploadDir, bool $allowTempDownload = true): ?string { $candidates = []; if (! empty($row['url'])) { $candidates[] = basename((string) $row['url']); } if (! empty($row['file_name'])) { $candidates[] = basename((string) $row['file_name']); } foreach (array_unique($candidates) as $name) { if ($name === '' || preg_match('#^https?://#i', $name)) { continue; } $full = $uploadDir . $name; if (is_file($full) && is_readable($full)) { return $full; } if ($allowTempDownload) { helper('utility'); $resolved = storage_resolve_claim_file_path(rtrim($uploadDir, '/\\'), $name); if (($resolved['success'] ?? false) && ! empty($resolved['path']) && is_file($resolved['path'])) { return $resolved['path']; } } } return null; } } if (! function_exists('merge_ticket_pdf_add_image_page')) { /** * Add an uploaded image as a single PDF page, preserving portrait/landscape * orientation and fitting the image proportionally inside the page. */ function merge_ticket_pdf_add_image_page(Mpdf $mpdf, string $imagePath): bool { $imageInfo = @getimagesize($imagePath); $ext = strtolower(pathinfo($imagePath, PATHINFO_EXTENSION)); $imageType = $ext === 'png' || ((int) ($imageInfo[2] ?? 0) === IMAGETYPE_PNG) ? 'png' : 'jpg'; $imageWidthPx = (int) ($imageInfo[0] ?? 0); $imageHeightPx = (int) ($imageInfo[1] ?? 0); $hasSize = $imageWidthPx > 0 && $imageHeightPx > 0; $isLandscape = $hasSize && $imageWidthPx > $imageHeightPx; $pageWidth = $isLandscape ? 297 : 210; $pageHeight = $isLandscape ? 210 : 297; $margin = 0; $availableWidth = $pageWidth - ($margin * 2); $availableHeight = $pageHeight - ($margin * 2); if ($hasSize) { $scale = min($availableWidth / $imageWidthPx, $availableHeight / $imageHeightPx); $drawWidth = $imageWidthPx * $scale; $drawHeight = $imageHeightPx * $scale; $x = ($pageWidth - $drawWidth) / 2; $y = ($pageHeight - $drawHeight) / 2; } else { // Some PNGs fail getimagesize(), but mPDF can still render them. // Use a portrait A4 fallback and let mPDF calculate image height. log_message('error', "merge_ticket_pdfs | image size unavailable, using fallback page | {$imagePath}"); $drawWidth = $availableWidth; $drawHeight = 0; $x = $margin; $y = $margin; } $mpdf->AddPageByArray([ 'orientation' => $isLandscape ? 'L' : 'P', 'sheet-size' => 'A4', 'margin-left' => 0, 'margin-right' => 0, 'margin-top' => 0, 'margin-bottom' => 0, 'margin-header' => 0, 'margin-footer' => 0, ]); $mpdf->Image($imagePath, $x, $y, $drawWidth, $drawHeight, $imageType); return true; } } if (! function_exists('merge_ticket_manual_merge_status')) { /** * UI/status helper: whether manual merge should be offered on Claim Files tab. * * @return array{ * has_merged_file: bool, * mergeable_count: int, * show_manual_merge: bool, * button_label: string * } */ function merge_ticket_manual_merge_status(int $ticket_master_id, array $opts = []): array { $opts += [ 'include_file_types' => [1, 2], 'include_mime_types' => ['application/pdf', 'image/jpeg', 'image/png'], ]; $status = [ 'has_merged_file' => false, 'mergeable_count' => 0, 'show_manual_merge' => false, 'button_label' => 'Merge documents', ]; if ($ticket_master_id <= 0) { return $status; } $claimFiles = new ClaimFilesModel(); $rows = $claimFiles ->where('ticket_id', $ticket_master_id) ->where('is_active', 1) ->whereIn('file_type', array_merge($opts['include_file_types'], [MERGED_CLAIM_FILE_TYPE])) ->orderBy('id', 'ASC') ->findAll(); $uploadDir = rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'claim_files' . DIRECTORY_SEPARATOR; $mergeableCount = 0; $sourceRowCount = 0; foreach ($rows as $row) { if ((int) ($row['file_type'] ?? 0) === MERGED_CLAIM_FILE_TYPE) { $status['has_merged_file'] = true; continue; } if (! in_array((int) ($row['file_type'] ?? 0), $opts['include_file_types'], true)) { continue; } $sourceRowCount++; $full = merge_ticket_pdf_resolve_disk_path($row, $uploadDir, false); if ($full === null) { helper('utility'); $name = basename((string) (! empty($row['url']) ? $row['url'] : ($row['file_name'] ?? ''))); if ($name === '') { continue; } $storage = \Config\Services::getFileStorageService(); $available = $storage->exists(rtrim($uploadDir, '/\\'), $name); if (! $available) { continue; } $mime = merge_ticket_pdf_resolve_mime($name, (string) ($row['mime_type'] ?? '')); if (in_array($mime, $opts['include_mime_types'], true)) { $mergeableCount++; } continue; } $mime = merge_ticket_pdf_resolve_mime($full, (string) ($row['mime_type'] ?? '')); if (in_array($mime, $opts['include_mime_types'], true)) { $mergeableCount++; } } $status['mergeable_count'] = $mergeableCount; // Show manual merge when there is no merged file but user has uploaded docs in the list. if (! $status['has_merged_file']) { if ($mergeableCount >= 1 || $sourceRowCount >= 2) { $status['show_manual_merge'] = true; $status['button_label'] = 'Merge documents'; } } elseif ($mergeableCount >= 2) { $status['show_manual_merge'] = true; $status['button_label'] = 'Re-merge documents'; } return $status; } }