statement = $statement; $this->accounts = $accounts; $this->period = $period; $this->clientName = $clientName; // Calculate period dates using same logic as ExportStatementPeriodJob $this->calculatePeriodDates(); } /** * Calculate start and end dates for the given period * Menggunakan logika yang sama dengan ExportStatementPeriodJob */ private function calculatePeriodDates(): void { $year = substr($this->period, 0, 4); $month = substr($this->period, 4, 2); // Special case for May 2025 - start from 12th if ($this->period === '202505') { $this->startDate = Carbon::createFromDate($year, $month, 12)->startOfDay(); } else { // For all other periods, start from 1st of the month $this->startDate = Carbon::createFromDate($year, $month, 1)->startOfDay(); } // End date is always the last day of the month $this->endDate = Carbon::createFromDate($year, $month, 1)->endOfMonth()->endOfDay(); Log::info('Period dates calculated for PDF generation', [ 'period' => $this->period, 'start_date' => $this->startDate->format('Y-m-d'), 'end_date' => $this->endDate->format('Y-m-d') ]); } /** * Execute the job. */ public function handle(): void { try { Log::info('Starting multi account PDF generation', [ 'statement_id' => $this->statement->id, 'total_accounts' => $this->accounts->count(), 'period' => $this->period, 'date_range' => $this->startDate->format('Y-m-d') . ' to ' . $this->endDate->format('Y-m-d') ]); $pdfFiles = []; $successCount = 0; $failedCount = 0; $errors = []; // Process each account foreach ($this->accounts as $account) { try { $pdfPath = $this->generateAccountPdf($account); if ($pdfPath) { $pdfFiles[] = $pdfPath; $successCount++; Log::info('PDF generated successfully for account', [ 'account_number' => $account->account_number, 'pdf_path' => $pdfPath ]); } // Memory cleanup after each account gc_collect_cycles(); } catch (Exception $e) { $failedCount++; $errors[] = [ 'account_number' => $account->account_number, 'error' => $e->getMessage() ]; Log::error('Failed to generate PDF for account', [ 'account_number' => $account->account_number, 'error' => $e->getMessage() ]); } } // Create ZIP file if there are PDFs $zipPath = null; if (!empty($pdfFiles)) { $zipPath = $this->createZipFile($pdfFiles); } // Update statement log $this->statement->update([ 'processed_accounts' => $this->accounts->count(), 'success_count' => $successCount, 'failed_count' => $failedCount, 'status' => $failedCount > 0 ? 'completed_with_errors' : 'completed', 'completed_at' => now(), 'is_available' => $zipPath ? true : false, 'is_generated' => $zipPath ? true : false, 'error_message' => !empty($errors) ? json_encode($errors) : null ]); Log::info('Multi account PDF generation completed', [ 'statement_id' => $this->statement->id, 'success_count' => $successCount, 'failed_count' => $failedCount, 'zip_path' => $zipPath ]); } catch (Exception $e) { Log::error('Multi account PDF generation failed', [ 'statement_id' => $this->statement->id, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); // Update statement with error status $this->statement->update([ 'status' => 'failed', 'completed_at' => now(), 'error_message' => $e->getMessage() ]); throw $e; } } /** * Generate PDF untuk satu account * Menggunakan data dari ProcessedStatement yang sudah diproses oleh ExportStatementPeriodJob * * @param Account $account * @return string|null Path to generated PDF */ protected function generateAccountPdf($account) { try { // Prepare account query untuk processing $accountQuery = [ 'account_number' => $account->account_number, 'period' => $this->period ]; // Get total entry count $totalCount = $this->getTotalEntryCount($account->account_number); // Delete existing processed data dan process ulang $this->deleteExistingProcessedData($accountQuery); $this->processAndSaveStatementEntries($account, $totalCount); // Get statement entries from ProcessedStatement (data yang sudah diproses) $stmtEntries = $this->getProcessedStatementEntries($account->account_number); // Get saldo awal bulan menggunakan logika yang sama dengan ExportStatementPeriodJob $saldoAwalBulan = $this->getSaldoAwalBulan($account->account_number); // Get branch info $branch = Branch::where('code', $account->branch_code)->first(); // Prepare images for PDF $images = $this->prepareImagesForPdf(); $headerImagePath = public_path('assets/media/images/bg-header-table.png'); $headerTableBg = file_exists($headerImagePath) ? base64_encode(file_get_contents($headerImagePath)) : null; // Render HTML $html = view('webstatement::statements.stmt', [ 'stmtEntries' => $stmtEntries, 'account' => $account, 'customer' => $account->customer, 'images' => $images, 'branch' => $branch, 'period' => $this->period, 'saldoAwalBulan' => $saldoAwalBulan, 'headerTableBg' => $headerTableBg, ])->render(); // Generate PDF filename $filename = "statement_{$account->account_number}_{$this->period}_" . now()->format('YmdHis') . '.pdf'; $storagePath = "statements/{$this->period}/multi_account/{$this->statement->id}"; $fullStoragePath = "{$storagePath}/{$filename}"; // Ensure directory exists Storage::disk('local')->makeDirectory($storagePath); // Generate PDF path $pdfPath = storage_path("app/{$fullStoragePath}"); // Generate PDF using Browsershot Browsershot::html($html) ->showBackground() ->setOption('addStyleTag', json_encode(['content' => '@page { margin: 0; }'])) ->setOption('protocolTimeout', 2147483) // 2 menit timeout ->format('A4') ->margins(0, 0, 0, 0) ->waitUntil('load') ->timeout(2147483) ->save($pdfPath); // Verify file was created if (!file_exists($pdfPath)) { throw new Exception('PDF file was not created'); } // Clear variables to free memory unset($html, $stmtEntries, $images); return $pdfPath; } catch (Exception $e) { Log::error('Failed to generate PDF for account', [ 'account_number' => $account->account_number, 'error' => $e->getMessage() ]); throw $e; } } /** * Get total entry count untuk account * Menggunakan logika yang sama dengan ExportStatementPeriodJob * * @param string $accountNumber * @return int */ protected function getTotalEntryCount($accountNumber): int { $query = StmtEntry::where('account_number', $accountNumber) ->whereBetween('booking_date', [ $this->startDate->format('Ymd'), $this->endDate->format('Ymd') ]); Log::info("Getting total entry count for PDF generation", [ 'account' => $accountNumber, 'start_date' => $this->startDate->format('Ymd'), 'end_date' => $this->endDate->format('Ymd') ]); return $query->count(); } /** * Delete existing processed data untuk account * Menggunakan logika yang sama dengan ExportStatementPeriodJob * * @param array $criteria * @return void */ protected function deleteExistingProcessedData(array $criteria): void { Log::info('Deleting existing processed data for PDF generation', [ 'account_number' => $criteria['account_number'], 'period' => $criteria['period'] ]); ProcessedStatement::where('account_number', $criteria['account_number']) ->where('period', $criteria['period']) ->delete(); } /** * Process dan save statement entries untuk account * Menggunakan logika yang sama dengan ExportStatementPeriodJob * * @param Account $account * @param int $totalCount * @return void */ protected function processAndSaveStatementEntries($account, int $totalCount): void { // Get saldo awal dari AccountBalance $saldoAwalBulan = $this->getSaldoAwalBulan($account->account_number); $runningBalance = (float) $saldoAwalBulan->actual_balance; $globalSequence = 0; Log::info("Processing {$totalCount} statement entries for PDF generation", [ 'account_number' => $account->account_number, 'starting_balance' => $runningBalance ]); StmtEntry::with(['ft', 'transaction']) ->where('account_number', $account->account_number) ->whereBetween('booking_date', [ $this->startDate->format('Ymd'), $this->endDate->format('Ymd') ]) ->orderBy('date_time', 'ASC') ->orderBy('trans_reference', 'ASC') ->chunk(1000, function ($entries) use (&$runningBalance, &$globalSequence, $account) { $processedData = $this->prepareProcessedData($entries, $runningBalance, $globalSequence, $account->account_number); if (!empty($processedData)) { DB::table('processed_statements')->insert($processedData); } }); } /** * Prepare processed data untuk batch insert * Menggunakan logika yang sama dengan ExportStatementPeriodJob * * @param $entries * @param float $runningBalance * @param int $globalSequence * @param string $accountNumber * @return array */ protected function prepareProcessedData($entries, &$runningBalance, &$globalSequence, $accountNumber): array { $processedData = []; foreach ($entries as $item) { $globalSequence++; $runningBalance += (float) $item->amount_lcy; $actualDate = $this->formatActualDate($item); $processedData[] = [ 'account_number' => $accountNumber, 'period' => $this->period, 'sequence_no' => $globalSequence, 'transaction_date' => $item->booking_date, 'reference_number' => $item->trans_reference, 'transaction_amount' => $item->amount_lcy, 'transaction_type' => $item->amount_lcy < 0 ? 'D' : 'C', 'description' => $this->generateNarrative($item), 'end_balance' => $runningBalance, 'actual_date' => $actualDate, 'created_at' => now(), 'updated_at' => now(), ]; } return $processedData; } /** * Format actual date dari item * Menggunakan logika yang sama dengan ExportStatementPeriodJob * * @param $item * @return string */ protected function formatActualDate($item): string { try { $prefix = substr($item->trans_reference ?? '', 0, 2); $relationMap = [ 'FT' => 'ft', 'TT' => 'tt', 'DC' => 'dc', 'AA' => 'aa' ]; $datetime = $item->date_time; if (isset($relationMap[$prefix])) { $relation = $relationMap[$prefix]; $datetime = $item->$relation?->date_time ?? $datetime; } return Carbon::createFromFormat( 'ymdHi', $datetime )->format('d/m/Y H:i'); } catch (Exception $e) { Log::warning("Error formatting actual date: " . $e->getMessage()); return Carbon::now()->format('d/m/Y H:i'); } } /** * Generate narrative untuk statement entry * Menggunakan logika yang sama dengan ExportStatementPeriodJob * * @param $item * @return string */ protected function generateNarrative($item) { $narr = []; if ($item->transaction) { if ($item->transaction->stmt_narr) { $narr[] = $item->transaction->stmt_narr; } if ($item->narrative) { $narr[] = $item->narrative; } if ($item->transaction->narr_type) { $narr[] = $this->getFormatNarrative($item->transaction->narr_type, $item); } } else if ($item->narrative) { $narr[] = $item->narrative; } if ($item->ft?->recipt_no) { $narr[] = 'Receipt No: ' . $item->ft->recipt_no; } return implode(' ', array_filter($narr)); } /** * Get formatted narrative berdasarkan narrative type * Menggunakan logika yang sama dengan ExportStatementPeriodJob * * @param $narr * @param $item * @return string */ protected function getFormatNarrative($narr, $item) { $narrParam = TempStmtNarrParam::where('_id', $narr)->first(); if (!$narrParam) { return ''; } $fmt = ''; if ($narrParam->_id == 'FTIN') { $fmt = 'FT.IN'; } else if ($narrParam->_id == 'FTOUT') { $fmt = 'FT.OUT'; } else if ($narrParam->_id == 'TTTRFOUT') { $fmt = 'TT.O.TRF'; } else if ($narrParam->_id == 'TTTRFIN') { $fmt = 'TT.I.TRF'; } else if ($narrParam->_id == 'APITRX'){ $fmt = 'API.TSEL'; } else if ($narrParam->_id == 'ONUSCR'){ $fmt = 'ONUS.CR'; } else if ($narrParam->_id == 'ONUSDR'){ $fmt = 'ONUS.DR'; }else { $fmt = $narrParam->_id; } $narrFormat = TempStmtNarrFormat::where('_id', $fmt)->first(); if (!$narrFormat) { return ''; } // Get the format string from the database $formatString = $narrFormat->text_data ?? ''; // Parse the format string // Split by the separator ']' $parts = explode(']', $formatString); $result = ''; foreach ($parts as $index => $part) { if (empty($part)) { continue; } if ($index === 0) { // For the first part, take only what's before the '!' $splitPart = explode('!', $part); if (count($splitPart) > 0) { // Remove quotes, backslashes, and other escape characters $cleanPart = trim($splitPart[0]).' '; // Remove quotes at the beginning and end $cleanPart = preg_replace('/^["\'\\\\]+|["\'\\\\]+$/', '', $cleanPart); // Remove any remaining backslashes $cleanPart = str_replace('\\', '', $cleanPart); // Remove any remaining quotes $cleanPart = str_replace('"', '', $cleanPart); $result .= $cleanPart; } } else { // For other parts, these are field placeholders $fieldName = strtolower(str_replace('.', '_', $part)); // Get the corresponding parameter value from narrParam $paramValue = null; // Check if the field exists as a property in narrParam if (property_exists($narrParam, $fieldName)) { $paramValue = $narrParam->$fieldName; } else if (isset($narrParam->$fieldName)) { $paramValue = $narrParam->$fieldName; } // If we found a value, add it to the result if ($paramValue !== null) { $result .= $paramValue; } else { // If no value found, try to use the original field name as a fallback if ($fieldName !== 'recipt_no') { $prefix = substr($item->trans_reference ?? '', 0, 2); $relationMap = [ 'FT' => 'ft', 'TT' => 'tt', 'DC' => 'dc', 'AA' => 'aa' ]; if (isset($relationMap[$prefix])) { $relation = $relationMap[$prefix]; $result .= ($item->$relation?->$fieldName ?? '') . ' '; } } } } } return str_replace('', ' ', $result); } /** * Get processed statement entries untuk account * Menggunakan data dari tabel ProcessedStatement yang sudah diproses * * @param string $accountNumber * @return \Illuminate\Database\Eloquent\Collection */ protected function getProcessedStatementEntries($accountNumber) { Log::info('Getting processed statement entries', [ 'account_number' => $accountNumber, 'period' => $this->period ]); return ProcessedStatement::where('account_number', $accountNumber) ->where('period', $this->period) ->orderBy('sequence_no', 'ASC') ->get(); } /** * Get saldo awal bulan untuk account * Menggunakan logika yang sama dengan ExportStatementPeriodJob * * @param string $accountNumber * @return object */ protected function getSaldoAwalBulan($accountNumber) { // Menggunakan logika yang sama dengan ExportStatementPeriodJob // Ambil saldo dari ProcessedStatement entry pertama dikurangi transaction_amount $firstEntry = ProcessedStatement::where('account_number', $accountNumber) ->where('period', $this->period) ->orderBy('sequence_no', 'ASC') ->first(); if ($firstEntry) { $saldoAwal = $firstEntry->end_balance - $firstEntry->transaction_amount; return (object) ['actual_balance' => $saldoAwal]; } // Fallback ke AccountBalance jika tidak ada ProcessedStatement $saldoPeriod = $this->calculateSaldoPeriod($this->period); $saldo = AccountBalance::where('account_number', $accountNumber) ->where('period', $saldoPeriod) ->first(); return $saldo ?: (object) ['actual_balance' => 0]; } /** * Calculate saldo period berdasarkan aturan bisnis * Menggunakan logika yang sama dengan ExportStatementPeriodJob * * @param string $period * @return string */ protected function calculateSaldoPeriod($period) { if ($period === '202505') { return '20250510'; } // For periods after 202505, get last day of previous month if ($period > '202505') { $year = substr($period, 0, 4); $month = substr($period, 4, 2); $firstDay = Carbon::createFromFormat('Ym', $period)->startOfMonth(); return $firstDay->copy()->subDay()->format('Ymd'); } return $period . '01'; } /** * Prepare images as base64 for PDF * * @return array */ protected function prepareImagesForPdf() { $images = []; $imagePaths = [ 'headerTableBg' => 'assets/media/images/bg-header-table.png', 'watermark' => 'assets/media/images/watermark.png', 'logoArthagraha' => 'assets/media/images/logo-arthagraha.png', 'logoAgi' => 'assets/media/images/logo-agi.png', 'bannerFooter' => 'assets/media/images/banner-footer.png' ]; foreach ($imagePaths as $key => $path) { $fullPath = public_path($path); if (file_exists($fullPath)) { $images[$key] = base64_encode(file_get_contents($fullPath)); } else { $images[$key] = null; Log::warning('Image file not found', ['path' => $fullPath]); } } return $images; } /** * Create ZIP file dari multiple PDF files * * @param array $pdfFiles * @return string|null Path to ZIP file */ protected function createZipFile($pdfFiles) { try { $zipFilename = "statements_{$this->period}_multi_account_{$this->statement->id}_" . now()->format('YmdHis') . '.zip'; $zipStoragePath = "statements/{$this->period}/multi_account/{$this->statement->id}"; $fullZipPath = "{$zipStoragePath}/{$zipFilename}"; // Ensure directory exists Storage::disk('local')->makeDirectory($zipStoragePath); $zipPath = storage_path("app/{$fullZipPath}"); $zip = new ZipArchive(); if ($zip->open($zipPath, ZipArchive::CREATE) !== TRUE) { throw new Exception('Cannot create ZIP file'); } foreach ($pdfFiles as $pdfFile) { if (file_exists($pdfFile)) { $filename = basename($pdfFile); $zip->addFile($pdfFile, $filename); } } $zip->close(); // Verify ZIP file was created if (!file_exists($zipPath)) { throw new Exception('ZIP file was not created'); } Log::info('ZIP file created successfully', [ 'zip_path' => $zipPath, 'pdf_count' => count($pdfFiles), 'statement_id' => $this->statement->id ]); // Clean up individual PDF files after creating ZIP foreach ($pdfFiles as $pdfFile) { if (file_exists($pdfFile)) { unlink($pdfFile); } } return $zipPath; } catch (Exception $e) { Log::error('Failed to create ZIP file', [ 'error' => $e->getMessage(), 'statement_id' => $this->statement->id ]); return null; } } }