nhance/app/Helpers/merge_pdf_helper.php
2026-07-15 15:49:13 +05:30

572 lines
22 KiB
PHP

<?php
use App\Models\ClaimFilesModel;
use Mpdf\Mpdf;
use Mpdf\Output\Destination;
if (! defined('MERGED_CLAIM_FILE_TYPE')) {
// claim_files.file_type values currently in use:
// 1 = legacy letter, 2 = uploaded claim doc, 3 = approved/settle letter
// 4 = merged combined PDF (registered by this helper)
define('MERGED_CLAIM_FILE_TYPE', 4);
}
if (! function_exists('merge_ticket_pdfs')) {
/**
* Merge all active PDF/image rows in claim_files for a given ticket_master id
* into one combined PDF and register that PDF as a new claim_files row
* with file_type = MERGED_CLAIM_FILE_TYPE.
*
* Source rows are picked from claim_files where:
* - ticket_id = $ticket_master_id
* - is_active = 1
* - mime_type IN $opts['include_mime_types'] (default PDF/JPG/PNG)
* - file_type IN $opts['include_file_types'] (default [1, 2, 3])
*
* @param int $ticket_master_id
* @param array $opts {
* @var bool $replace Default true. Soft-delete previous merged row before re-creating.
* @var int $created_by Override created_by user id on the inserted row.
* @var int $ticket_type Default 1. Stored on the inserted claim_files row.
* @var array $include_file_types Default [1, 2, 3].
* @var array $include_mime_types Default ['application/pdf', 'image/jpeg', 'image/png'].
* }
* @return array {status, merged_file_id, file_name, pages, source_count, message}
*/
function merge_ticket_pdfs(int $ticket_master_id, array $opts = []): array
{
$opts += [
'replace' => 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;
}
}