From 8736ccf5f821ea59ced0f9df337c9356a8f44824 Mon Sep 17 00:00:00 2001 From: Daeng Deni Mardaeni Date: Thu, 31 Jul 2025 11:55:55 +0700 Subject: [PATCH] feat(closing-balance): Implementasi Pengecekan Unique Hash dan Sequence Counter pada Ekspor CSV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memperbaiki logika ekspor CSV laporan penutupan saldo untuk memastikan konsistensi data dan urutan sequence yang benar. Perubahan yang dilakukan: • Menambahkan pengecekan unique_hash untuk mencegah duplikasi data dalam file CSV • Mengimplementasikan array $processedHashes untuk melacak unique_hash yang sudah diproses • Mengganti sequence_no dari database dengan counter yang bertambah ($sequenceCounter++) • Menghapus groupBy('unique_hash') dari query untuk kontrol duplikasi yang lebih baik • Menambahkan logging untuk record yang di-skip karena duplikasi unique_hash • Menambahkan logging untuk tracking jumlah record yang diproses per chunk • Menggunakan reference (&$sequenceCounter, &$processedHashes) untuk konsistensi data antar chunk • Memastikan sequence number berurutan tanpa gap dimulai dari 1 --- app/Jobs/GenerateClosingBalanceReportJob.php | 1814 +++++++++--------- 1 file changed, 923 insertions(+), 891 deletions(-) diff --git a/app/Jobs/GenerateClosingBalanceReportJob.php b/app/Jobs/GenerateClosingBalanceReportJob.php index 86da8ec..4dc6255 100644 --- a/app/Jobs/GenerateClosingBalanceReportJob.php +++ b/app/Jobs/GenerateClosingBalanceReportJob.php @@ -1,961 +1,993 @@ accountNumber = $accountNumber; - $this->period = $period; - $this->reportLogId = $reportLogId; - $this->groupName = $groupName ?? 'DEFAULT'; - } + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - /** - * Execute the job dengan optimasi performa - */ - public function handle(): void - { - $reportLog = ClosingBalanceReportLog::find($this->reportLogId); + protected $accountNumber; + protected $period; + protected $reportLogId; + protected $groupName; + protected $chunkSize = 1000; + protected $disk = 'local'; - if (!$reportLog) { - Log::error('Closing balance report log not found', ['id' => $this->reportLogId]); - return; + /** + * Create a new job instance. + */ + public function __construct(string $accountNumber, string $period, int $reportLogId, string $groupName = 'DEFAULT') + { + $this->accountNumber = $accountNumber; + $this->period = $period; + $this->reportLogId = $reportLogId; + $this->groupName = $groupName ?? 'DEFAULT'; } - try { - Log::info('Starting optimized closing balance report generation', [ - 'account_number' => $this->accountNumber, - 'period' => $this->period, - 'group_name' => $this->groupName, - 'report_log_id' => $this->reportLogId - ]); + /** + * Execute the job dengan optimasi performa + */ + public function handle() + : void + { + $reportLog = ClosingBalanceReportLog::find($this->reportLogId); - DB::beginTransaction(); - - // Update status to processing - $reportLog->update([ - 'status' => 'processing', - 'updated_at' => now() - ]); - - // Step 1: Process and save to database (fast) - $this->processAndSaveClosingBalanceData(); - - // Step 2: Export from database to CSV (fast) - $filePath = $this->exportFromDatabaseToCsv(); - - // Get record count from database - $recordCount = $this->getProcessedRecordCount(); - - // Update report log with success - $reportLog->update([ - 'status' => 'completed', - 'file_path' => $filePath, - 'file_size' => Storage::disk($this->disk)->size($filePath), - 'record_count' => $recordCount, - 'updated_at' => now() - ]); - - DB::commit(); - - Log::info('Optimized closing balance report generation completed successfully', [ - 'account_number' => $this->accountNumber, - 'period' => $this->period, - 'file_path' => $filePath, - 'record_count' => $recordCount - ]); - - } catch (Exception $e) { - DB::rollback(); - - Log::error('Error generating optimized closing balance report', [ - 'account_number' => $this->accountNumber, - 'period' => $this->period, - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString() - ]); - - $reportLog->update([ - 'status' => 'failed', - 'error_message' => $e->getMessage(), - 'updated_at' => now() - ]); - - throw $e; - } - } - - /** - * Process and save closing balance data to database dengan proteksi duplikasi - * Memproses dan menyimpan data closing balance dengan perlindungan terhadap duplikasi - */ - private function processAndSaveClosingBalanceData(): void - { - $criteria = [ - 'account_number' => $this->accountNumber, - 'period' => $this->period, - 'group_name' => $this->groupName - ]; - - // PERBAIKAN: Gunakan database transaction untuk memastikan atomicity - DB::beginTransaction(); - - try { - // PERBAIKAN: Lock table untuk mencegah race condition - DB::statement('LOCK TABLE processed_closing_balances IN EXCLUSIVE MODE'); - - // Delete existing processed data dengan verifikasi - $this->deleteExistingProcessedDataWithVerification($criteria); - - // Get opening balance - $runningBalance = $this->getOpeningBalance(); - $sequenceNo = 0; - $totalProcessed = 0; - - Log::info('Starting to process and save closing balance data with duplicate protection', [ - 'opening_balance' => $runningBalance, - 'criteria' => $criteria - ]); - - // Build query - $query = $this->buildTransactionQuery(); - - // PERBAIKAN: Collect all data first, then insert in single transaction - $allProcessedData = []; - - $query->chunk($this->chunkSize, function ($transactions) use (&$runningBalance, &$sequenceNo, &$allProcessedData, &$totalProcessed) { - $processedData = $this->prepareProcessedClosingBalanceData($transactions, $runningBalance, $sequenceNo); - - if (!empty($processedData)) { - $allProcessedData = array_merge($allProcessedData, $processedData); - $totalProcessed += count($processedData); - } - - Log::info('Chunk processed and collected', [ - 'chunk_size' => count($processedData), - 'total_collected' => count($allProcessedData), - 'current_balance' => $runningBalance - ]); - }); - - // PERBAIKAN: Insert all data in single operation - if (!empty($allProcessedData)) { - // Batch insert dengan chunk untuk menghindari memory limit - $insertChunks = array_chunk($allProcessedData, 1000); - - foreach ($insertChunks as $chunk) { - // Menggunakan insertOrIgnore untuk mengabaikan duplikasi dan mencegah error unique constraint - DB::table('processed_closing_balances')->insertOrIgnore($chunk); - } - - Log::info('All processed data inserted successfully', [ - 'total_records' => count($allProcessedData), - 'insert_chunks' => count($insertChunks) - ]); + if (!$reportLog) { + Log::error('Closing balance report log not found', ['id' => $this->reportLogId]); + return; } - // PERBAIKAN: Verify no duplicates after insert - $this->verifyNoDuplicatesAfterInsert($criteria); + try { + Log::info('Starting optimized closing balance report generation', [ + 'account_number' => $this->accountNumber, + 'period' => $this->period, + 'group_name' => $this->groupName, + 'report_log_id' => $this->reportLogId + ]); - DB::commit(); + DB::beginTransaction(); - Log::info('Closing balance data processing completed successfully', [ - 'final_sequence' => $sequenceNo, - 'final_balance' => $runningBalance, - 'total_processed' => $totalProcessed - ]); + // Update status to processing + $reportLog->update([ + 'status' => 'processing', + 'updated_at' => now() + ]); - } catch (Exception $e) { - DB::rollback(); + // Step 1: Process and save to database (fast) + $this->processAndSaveClosingBalanceData(); - Log::error('Error in processAndSaveClosingBalanceData', [ - 'error' => $e->getMessage(), - 'criteria' => $criteria - ]); + // Step 2: Export from database to CSV (fast) + $filePath = $this->exportFromDatabaseToCsv(); - throw $e; - } - } + // Get record count from database + $recordCount = $this->getProcessedRecordCount(); + // Update report log with success + $reportLog->update([ + 'status' => 'completed', + 'file_path' => $filePath, + 'file_size' => Storage::disk($this->disk)->size($filePath), + 'record_count' => $recordCount, + 'updated_at' => now() + ]); - /** - * Delete existing processed data dengan verifikasi lengkap - * Menghapus data processed yang sudah ada dengan verifikasi untuk memastikan tidak ada sisa - */ - private function deleteExistingProcessedDataWithVerification(array $criteria): void - { - Log::info('Deleting existing processed data with verification', $criteria); + DB::commit(); - // Count before deletion - $beforeCount = ProcessedClosingBalance::where('account_number', $criteria['account_number']) - ->where('period', $criteria['period']) - ->where('group_name', $criteria['group_name']) - ->count(); + Log::info('Optimized closing balance report generation completed successfully', [ + 'account_number' => $this->accountNumber, + 'period' => $this->period, + 'file_path' => $filePath, + 'record_count' => $recordCount + ]); - // Delete with force - $deletedCount = ProcessedClosingBalance::where('account_number', $criteria['account_number']) - ->where('period', $criteria['period']) - ->where('group_name', $criteria['group_name']) - ->delete(); + } catch (Exception $e) { + DB::rollback(); - // Verify deletion - $afterCount = ProcessedClosingBalance::where('account_number', $criteria['account_number']) - ->where('period', $criteria['period']) - ->where('group_name', $criteria['group_name']) - ->count(); + Log::error('Error generating optimized closing balance report', [ + 'account_number' => $this->accountNumber, + 'period' => $this->period, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); - if ($afterCount > 0) { - throw new Exception("Failed to delete all existing data. Remaining records: {$afterCount}"); + $reportLog->update([ + 'status' => 'failed', + 'error_message' => $e->getMessage(), + 'updated_at' => now() + ]); + + throw $e; + } } - Log::info('Existing processed data deleted and verified', [ - 'before_count' => $beforeCount, - 'deleted_count' => $deletedCount, - 'after_count' => $afterCount, - 'criteria' => $criteria - ]); - } - - /** - * Verify no duplicates after insert - simplified version - * Memverifikasi tidak ada duplikasi setelah insert data dengan pengecekan yang disederhanakan - */ - private function verifyNoDuplicatesAfterInsert(array $criteria): void - { - Log::info('Verifying no duplicates after insert with simplified check', $criteria); - - // PERBAIKAN: Check for duplicate trans_reference + amount_lcy combinations saja - // Karena trans_reference sudah unique secara global, tidak perlu filter by account/period - $duplicates = DB::table('processed_closing_balances') - ->select('trans_reference', 'amount_lcy', DB::raw('COUNT(*) as count')) - ->where('account_number', $criteria['account_number']) - ->where('period', $criteria['period']) - ->where('group_name', $criteria['group_name']) - ->groupBy('trans_reference', 'amount_lcy') - ->having('count', '>', 1) - ->get(); - - if ($duplicates->count() > 0) { - Log::error('Duplicates found after insert', [ - 'duplicate_count' => $duplicates->count(), - 'duplicates' => $duplicates->toArray() - ]); - - throw new Exception("Duplicates detected after insert: {$duplicates->count()} duplicate combinations found"); - } - - // Check for duplicate sequence numbers dalam scope yang sama - $sequenceDuplicates = DB::table('processed_closing_balances') - ->select('sequence_no', DB::raw('COUNT(*) as count')) - ->where('account_number', $criteria['account_number']) - ->where('period', $criteria['period']) - ->where('group_name', $criteria['group_name']) - ->groupBy('sequence_no') - ->having('count', '>', 1) - ->get(); - - if ($sequenceDuplicates->count() > 0) { - Log::error('Sequence number duplicates found after insert', [ - 'sequence_duplicate_count' => $sequenceDuplicates->count(), - 'sequence_duplicates' => $sequenceDuplicates->toArray() - ]); - - throw new Exception("Sequence number duplicates detected: {$sequenceDuplicates->count()} duplicate sequences found"); - } - - Log::info('No duplicates found after insert - verification passed', $criteria); - } - - /** - * Export from database to CSV (very fast) - */ - private function exportFromDatabaseToCsv(): string - { - Log::info('Starting CSV export from database for closing balance report', [ - 'account_number' => $this->accountNumber, - 'period' => $this->period, - 'group_name' => $this->groupName - ]); - - // Create directory structure - $basePath = "closing_balance_reports"; - $accountPath = "{$basePath}/{$this->accountNumber}"; - - Storage::disk($this->disk)->makeDirectory($basePath); - Storage::disk($this->disk)->makeDirectory($accountPath); - - // Generate filename - $fileName = "closing_balance_{$this->accountNumber}_{$this->period}_{$this->groupName}.csv"; - $filePath = "{$accountPath}/{$fileName}"; - - // Delete existing file if exists - if (Storage::disk($this->disk)->exists($filePath)) { - Storage::disk($this->disk)->delete($filePath); - } - - // Create CSV header - $csvHeader = [ - 'NO', - 'TRANS_REFERENCE', - 'BOOKING_DATE', - 'TRANSACTION_DATE', - 'AMOUNT_LCY', - 'DEBIT_ACCT_NO', - 'DEBIT_VALUE_DATE', - 'DEBIT_AMOUNT', - 'CREDIT_ACCT_NO', - 'BIF_RCV_ACCT', - 'BIF_RCV_NAME', - 'CREDIT_VALUE_DATE', - 'CREDIT_AMOUNT', - 'AT_UNIQUE_ID', - 'BIF_REF_NO', - 'ATM_ORDER_ID', - 'RECIPT_NO', - 'API_ISS_ACCT', - 'API_BENFF_ACCT', - 'AUTHORISER', - 'REMARKS', - 'PAYMENT_DETAILS', - 'REF_NO', - 'MERCHANT_ID', - 'TERM_ID', - 'CLOSING_BALANCE' - ]; - - $csvContent = implode('|', $csvHeader) . "\n"; - Storage::disk($this->disk)->put($filePath, $csvContent); - - // Export data from database in chunks dengan ordering berdasarkan booking_date dan date_time - ProcessedClosingBalance::where('account_number', $this->accountNumber) - ->where('period', $this->period) - ->where('group_name', $this->groupName) - ->orderBy('booking_date') - ->orderBy('transaction_date') // Menggunakan transaction_date yang sudah dikonversi dari date_time - ->chunk($this->chunkSize, function ($records) use ($filePath) { - $csvContent = ''; - foreach ($records as $record) { - $csvRow = [ - $record->sequence_no, - $record->trans_reference ?? '', - $record->booking_date ?? '', - $record->transaction_date ?? '', - $record->amount_lcy ?? '', - $record->debit_acct_no ?? '', - $record->debit_value_date ?? '', - $record->debit_amount ?? '', - $record->credit_acct_no ?? '', - $record->bif_rcv_acct ?? '', - $record->bif_rcv_name ?? '', - $record->credit_value_date ?? '', - $record->credit_amount ?? '', - $record->at_unique_id ?? '', - $record->bif_ref_no ?? '', - $record->atm_order_id ?? '', - $record->recipt_no ?? '', - $record->api_iss_acct ?? '', - $record->api_benff_acct ?? '', - $record->authoriser ?? '', - $record->remarks ?? '', - $record->payment_details ?? '', - $record->ref_no ?? '', - $record->merchant_id ?? '', - $record->term_id ?? '', - $record->closing_balance ?? '' - ]; - - $csvContent .= implode('|', $csvRow) . "\n"; - } - - if (!empty($csvContent)) { - Storage::disk($this->disk)->append($filePath, $csvContent); - } - }); - - // Verify file creation - if (!Storage::disk($this->disk)->exists($filePath)) { - throw new Exception("Failed to create CSV file: {$filePath}"); - } - - Log::info('CSV export from database completed successfully', [ - 'file_path' => $filePath, - 'file_size' => Storage::disk($this->disk)->size($filePath) - ]); - - return $filePath; - } - - /** - * Get processed record count - */ - private function getProcessedRecordCount(): int - { - return ProcessedClosingBalance::where('account_number', $this->accountNumber) - ->where('period', $this->period) - ->where('group_name', $this->groupName) - ->count(); - } - - /** - * Get opening balance from account balance table - * Mengambil saldo awal dari tabel account balance - */ - private function getOpeningBalance(): float - { - Log::info('Getting opening balance', [ - 'account_number' => $this->accountNumber, - 'period' => $this->period - ]); - - // Get previous period based on current period - $previousPeriod = $this->period === '20250512' - ? Carbon::createFromFormat('Ymd', $this->period)->subDays(2)->format('Ymd') - : Carbon::createFromFormat('Ymd', $this->period)->subDay()->format('Ymd'); - - $accountBalance = AccountBalance::where('account_number', $this->accountNumber) - ->where('period', $previousPeriod) - ->first(); - - if (!$accountBalance) { - Log::warning('Account balance not found, using 0 as opening balance', [ + /** + * Process and save closing balance data to database dengan proteksi duplikasi + * Memproses dan menyimpan data closing balance dengan perlindungan terhadap duplikasi + */ + private function processAndSaveClosingBalanceData() + : void + { + $criteria = [ 'account_number' => $this->accountNumber, - 'period' => $this->period - ]); - return 0.0; + 'period' => $this->period, + 'group_name' => $this->groupName + ]; + + // PERBAIKAN: Gunakan database transaction untuk memastikan atomicity + DB::beginTransaction(); + + try { + // PERBAIKAN: Lock table untuk mencegah race condition + DB::statement('LOCK TABLE processed_closing_balances IN EXCLUSIVE MODE'); + + // Delete existing processed data dengan verifikasi + $this->deleteExistingProcessedDataWithVerification($criteria); + + // Get opening balance + $runningBalance = $this->getOpeningBalance(); + $sequenceNo = 0; + $totalProcessed = 0; + + Log::info('Starting to process and save closing balance data with duplicate protection', [ + 'opening_balance' => $runningBalance, + 'criteria' => $criteria + ]); + + // Build query + $query = $this->buildTransactionQuery(); + + // PERBAIKAN: Collect all data first, then insert in single transaction + $allProcessedData = []; + + $query->chunk($this->chunkSize, function ($transactions) use (&$runningBalance, &$sequenceNo, &$allProcessedData, &$totalProcessed) { + $processedData = $this->prepareProcessedClosingBalanceData($transactions, $runningBalance, $sequenceNo); + + if (!empty($processedData)) { + $allProcessedData = array_merge($allProcessedData, $processedData); + $totalProcessed += count($processedData); + } + + Log::info('Chunk processed and collected', [ + 'chunk_size' => count($processedData), + 'total_collected' => count($allProcessedData), + 'current_balance' => $runningBalance + ]); + }); + + // PERBAIKAN: Insert all data in single operation + if (!empty($allProcessedData)) { + // Batch insert dengan chunk untuk menghindari memory limit + $insertChunks = array_chunk($allProcessedData, 1000); + + foreach ($insertChunks as $chunk) { + // Menggunakan insertOrIgnore untuk mengabaikan duplikasi dan mencegah error unique constraint + DB::table('processed_closing_balances')->insertOrIgnore($chunk); + } + + Log::info('All processed data inserted successfully', [ + 'total_records' => count($allProcessedData), + 'insert_chunks' => count($insertChunks) + ]); + } + + // PERBAIKAN: Verify no duplicates after insert + $this->verifyNoDuplicatesAfterInsert($criteria); + + DB::commit(); + + Log::info('Closing balance data processing completed successfully', [ + 'final_sequence' => $sequenceNo, + 'final_balance' => $runningBalance, + 'total_processed' => $totalProcessed + ]); + + } catch (Exception $e) { + DB::rollback(); + + Log::error('Error in processAndSaveClosingBalanceData', [ + 'error' => $e->getMessage(), + 'criteria' => $criteria + ]); + + throw $e; + } } - $openingBalance = (float) $accountBalance->actual_balance; - Log::info('Opening balance retrieved', [ - 'account_number' => $this->accountNumber, - 'opening_balance' => $openingBalance - ]); + /** + * Delete existing processed data dengan verifikasi lengkap + * Menghapus data processed yang sudah ada dengan verifikasi untuk memastikan tidak ada sisa + */ + private function deleteExistingProcessedDataWithVerification(array $criteria) + : void + { + Log::info('Deleting existing processed data with verification', $criteria); - return $openingBalance; - } + // Count before deletion + $beforeCount = ProcessedClosingBalance::where('account_number', $criteria['account_number']) + ->where('period', $criteria['period']) + ->where('group_name', $criteria['group_name']) + ->count(); - /** - * Build transaction query dengan eliminasi duplicate yang lebih ketat - * Membangun query transaksi dengan menghilangkan duplicate berdasarkan kombinasi lengkap - */ - private function buildTransactionQuery() - { - Log::info('Building transaction query with strict duplicate elimination', [ - 'group_name' => $this->groupName, - 'account_number' => $this->accountNumber, - 'period' => $this->period - ]); + // Delete with force + $deletedCount = ProcessedClosingBalance::where('account_number', $criteria['account_number']) + ->where('period', $criteria['period']) + ->where('group_name', $criteria['group_name']) + ->delete(); - $modelClass = $this->getModelByGroup(); - $tableName = (new $modelClass)->getTable(); + // Verify deletion + $afterCount = ProcessedClosingBalance::where('account_number', $criteria['account_number']) + ->where('period', $criteria['period']) + ->where('group_name', $criteria['group_name']) + ->count(); - // Gunakan kombinasi lengkap untuk eliminasi duplikasi - $uniqueIds = DB::table($tableName) - ->select(DB::raw('MIN(id) as min_id')) - ->where('account_number', $this->accountNumber) - ->where('booking_date', $this->period) - ->groupBy('trans_reference', 'amount_lcy', 'booking_date') // Kombinasi lengkap - ->pluck('min_id'); + if ($afterCount > 0) { + throw new Exception("Failed to delete all existing data. Remaining records: {$afterCount}"); + } - Log::info('Unique transaction IDs identified', [ - 'total_unique_transactions' => $uniqueIds->count(), - 'elimination_criteria' => 'trans_reference + amount_lcy + booking_date' - ]); + Log::info('Existing processed data deleted and verified', [ + 'before_count' => $beforeCount, + 'deleted_count' => $deletedCount, + 'after_count' => $afterCount, + 'criteria' => $criteria + ]); + } - $query = $modelClass::select([ + /** + * Get opening balance from account balance table + * Mengambil saldo awal dari tabel account balance + */ + private function getOpeningBalance() + : float + { + Log::info('Getting opening balance', [ + 'account_number' => $this->accountNumber, + 'period' => $this->period + ]); + + // Get previous period based on current period + $previousPeriod = $this->period === '20250512' + ? Carbon::createFromFormat('Ymd', $this->period)->subDays(2)->format('Ymd') + : Carbon::createFromFormat('Ymd', $this->period)->subDay()->format('Ymd'); + + $accountBalance = AccountBalance::where('account_number', $this->accountNumber) + ->where('period', $previousPeriod) + ->first(); + + if (!$accountBalance) { + Log::warning('Account balance not found, using 0 as opening balance', [ + 'account_number' => $this->accountNumber, + 'period' => $this->period + ]); + return 0.0; + } + + $openingBalance = (float) $accountBalance->actual_balance; + + Log::info('Opening balance retrieved', [ + 'account_number' => $this->accountNumber, + 'opening_balance' => $openingBalance + ]); + + return $openingBalance; + } + + /** + * Build transaction query dengan eliminasi duplicate yang lebih ketat + * Membangun query transaksi dengan menghilangkan duplicate berdasarkan kombinasi lengkap + */ + private function buildTransactionQuery() + { + Log::info('Building transaction query with strict duplicate elimination', [ + 'group_name' => $this->groupName, + 'account_number' => $this->accountNumber, + 'period' => $this->period + ]); + + $modelClass = $this->getModelByGroup(); + $tableName = (new $modelClass)->getTable(); + + // Gunakan kombinasi lengkap untuk eliminasi duplikasi + $uniqueIds = DB::table($tableName) + ->select(DB::raw('MIN(id) as min_id')) + ->where('account_number', $this->accountNumber) + ->where('booking_date', $this->period) + ->groupBy('trans_reference', 'amount_lcy', 'booking_date') // Kombinasi lengkap + ->pluck('min_id'); + + Log::info('Unique transaction IDs identified', [ + 'total_unique_transactions' => $uniqueIds->count(), + 'elimination_criteria' => 'trans_reference + amount_lcy + booking_date' + ]); + + $query = $modelClass::select([ 'id', 'trans_reference', 'booking_date', 'amount_lcy', 'date_time' ]) - ->with([ - 'ft' => function($query) { - $query->select('ref_no', 'date_time', 'debit_acct_no', 'debit_value_date', - 'credit_acct_no', 'bif_rcv_acct', 'bif_rcv_name', 'credit_value_date', - 'at_unique_id', 'bif_ref_no', 'atm_order_id', 'recipt_no', - 'api_iss_acct', 'api_benff_acct', 'authoriser', 'remarks', - 'payment_details', 'ref_no', 'merchant_id', 'term_id'); - }, - 'dc' => function($query) { - $query->select('id', 'date_time'); + ->with([ + 'ft' => function ($query) { + $query->select('ref_no', 'date_time', 'debit_acct_no', 'debit_value_date', + 'credit_acct_no', 'bif_rcv_acct', 'bif_rcv_name', 'credit_value_date', + 'at_unique_id', 'bif_ref_no', 'atm_order_id', 'recipt_no', + 'api_iss_acct', 'api_benff_acct', 'authoriser', 'remarks', + 'payment_details', 'ref_no', 'merchant_id', 'term_id'); + }, + 'dc' => function ($query) { + $query->select('id', 'date_time'); + } + ]) + ->whereIn('id', $uniqueIds) + ->orderBy('booking_date') + ->orderBy('date_time'); + + return $query; + } + + /** + * Get model class based on group name + * Mendapatkan class model berdasarkan group name + */ + private function getModelByGroup() + { + Log::info('Determining model by group', [ + 'group_name' => $this->groupName + ]); + + $model = $this->groupName === 'QRIS' ? StmtEntryDetail::class : StmtEntry::class; + + Log::info('Model determined', [ + 'group_name' => $this->groupName, + 'model_class' => $model + ]); + + return $model; + } + + /** + * Prepare processed closing balance data dengan validasi duplikasi yang ketat + * Mempersiapkan data closing balance dengan pencegahan duplikasi berbasis trans_reference unik + */ + private function prepareProcessedClosingBalanceData($transactions, &$runningBalance, &$sequenceNo) + : array + { + $processedData = []; + + foreach ($transactions as $transaction) { + // Validasi duplikasi berbasis trans_reference + amount_lcy + booking_date + $uniqueKey = md5($transaction->trans_reference . '_' . $transaction->amount_lcy); + + $existingReferences = ProcessedClosingBalance::select('unique_hash') + ->where('unique_hash', $uniqueKey) + ->get(); + + // Periksa kombinasi trans_reference + amount_lcy, bukan hanya trans_reference + if ($existingReferences->count() > 0) { + Log::warning('Transaction already exists in database', [ + 'trans_reference' => $transaction->trans_reference, + 'amount_lcy' => $transaction->amount_lcy + ]); + continue; } - ]) - ->whereIn('id', $uniqueIds) - ->orderBy('booking_date') - ->orderBy('date_time'); - return $query; - } + $seenReferences[$uniqueKey] = true; + $sequenceNo++; - /** - * Prepare processed closing balance data dengan validasi duplikasi yang ketat - * Mempersiapkan data closing balance dengan pencegahan duplikasi berbasis trans_reference unik - */ - private function prepareProcessedClosingBalanceData($transactions, &$runningBalance, &$sequenceNo): array - { - $processedData = []; + // Process transaction data + $processedTransactionData = $this->processTransactionData($transaction); - foreach ($transactions as $transaction) { - // Validasi duplikasi berbasis trans_reference + amount_lcy + booking_date - $uniqueKey = md5($transaction->trans_reference . '_' . $transaction->amount_lcy); + // Update running balance + $amount = (float) $transaction->amount_lcy; + $runningBalance += $amount; - $existingReferences = ProcessedClosingBalance::select('unique_hash') - ->where('unique_hash', $uniqueKey) - ->get(); + // Format transaction date + $transactionDate = $this->formatDateTime($processedTransactionData['date_time']); - // Periksa kombinasi trans_reference + amount_lcy, bukan hanya trans_reference - if ($existingReferences->count() > 0) { - Log::warning('Transaction already exists in database', [ - 'trans_reference' => $transaction->trans_reference, - 'amount_lcy' => $transaction->amount_lcy - ]); - continue; + // Prepare data untuk database insert dengan composite key yang lebih robust + $processedData[] = [ + 'account_number' => $this->accountNumber, + 'period' => $this->period, + 'group_name' => $this->groupName, + 'sequence_no' => $sequenceNo, + 'trans_reference' => $processedTransactionData['trans_reference'], + 'booking_date' => $processedTransactionData['booking_date'], + 'transaction_date' => $transactionDate, + 'amount_lcy' => $processedTransactionData['amount_lcy'], + 'debit_acct_no' => $processedTransactionData['debit_acct_no'], + 'debit_value_date' => $processedTransactionData['debit_value_date'], + 'debit_amount' => $processedTransactionData['debit_amount'], + 'credit_acct_no' => $processedTransactionData['credit_acct_no'], + 'bif_rcv_acct' => $processedTransactionData['bif_rcv_acct'], + 'bif_rcv_name' => $processedTransactionData['bif_rcv_name'], + 'credit_value_date' => $processedTransactionData['credit_value_date'], + 'credit_amount' => $processedTransactionData['credit_amount'], + 'at_unique_id' => $processedTransactionData['at_unique_id'], + 'bif_ref_no' => $processedTransactionData['bif_ref_no'], + 'atm_order_id' => $processedTransactionData['atm_order_id'], + 'recipt_no' => $processedTransactionData['recipt_no'], + 'api_iss_acct' => $processedTransactionData['api_iss_acct'], + 'api_benff_acct' => $processedTransactionData['api_benff_acct'], + 'authoriser' => $processedTransactionData['authoriser'], + 'remarks' => $processedTransactionData['remarks'], + 'payment_details' => $processedTransactionData['payment_details'], + 'ref_no' => $processedTransactionData['ref_no'], + 'merchant_id' => $processedTransactionData['merchant_id'], + 'term_id' => $processedTransactionData['term_id'], + 'closing_balance' => $runningBalance, + 'created_at' => now(), + 'updated_at' => now(), + // Tambahkan hash unik untuk memastikan keunikan + 'unique_hash' => md5($transaction->trans_reference . '_' . $transaction->amount_lcy) + ]; } - $seenReferences[$uniqueKey] = true; - $sequenceNo++; + Log::info('Processed closing balance data prepared with duplicate prevention', [ + 'total_records' => count($processedData), + 'skipped_duplicates' => count($transactions) - count($processedData) + ]); - // Process transaction data - $processedTransactionData = $this->processTransactionData($transaction); + return $processedData; + } - // Update running balance - $amount = (float) $transaction->amount_lcy; - $runningBalance += $amount; + /** + * Process transaction data from ORM result + * Memproses data transaksi dari hasil ORM + */ + private function processTransactionData($transaction) + : array + { + Log::info('Processing transaction data', [ + 'trans_reference' => $transaction->trans_reference, + 'has_ft_relation' => !is_null($transaction->ft), + 'has_dc_relation' => !is_null($transaction->dc) + ]); - // Format transaction date - $transactionDate = $this->formatDateTime($processedTransactionData['date_time']); + // Hitung debit dan credit amount + $debitAmount = $transaction->amount_lcy < 0 ? abs($transaction->amount_lcy) : null; + $creditAmount = $transaction->amount_lcy > 0 ? $transaction->amount_lcy : null; - // Prepare data untuk database insert dengan composite key yang lebih robust - $processedData[] = [ + // Ambil date_time dari prioritas: ft -> dc -> stmt + $dateTime = $transaction->ft?->date_time ?? + $transaction->dc?->date_time ?? + $transaction->date_time; + + $processedData = [ + 'trans_reference' => $transaction->trans_reference, + 'booking_date' => $transaction->booking_date, + 'amount_lcy' => $transaction->amount_lcy, + 'debit_amount' => $debitAmount, + 'credit_amount' => $creditAmount, + 'date_time' => $dateTime, + // Data dari TempFundsTransfer melalui relasi + 'debit_acct_no' => $transaction->ft?->debit_acct_no, + 'debit_value_date' => $transaction->ft?->debit_value_date, + 'credit_acct_no' => $transaction->ft?->credit_acct_no, + 'bif_rcv_acct' => $transaction->ft?->bif_rcv_acct, + 'bif_rcv_name' => $transaction->ft?->bif_rcv_name, + 'credit_value_date' => $transaction->ft?->credit_value_date, + 'at_unique_id' => $transaction->ft?->at_unique_id, + 'bif_ref_no' => $transaction->ft?->bif_ref_no, + 'atm_order_id' => $transaction->ft?->atm_order_id, + 'recipt_no' => $transaction->ft?->recipt_no, + 'api_iss_acct' => $transaction->ft?->api_iss_acct, + 'api_benff_acct' => $transaction->ft?->api_benff_acct, + 'authoriser' => $transaction->ft?->authoriser, + 'remarks' => $transaction->ft?->remarks, + 'payment_details' => $transaction->ft?->payment_details, + 'ref_no' => $transaction->ft?->ref_no, + 'merchant_id' => $transaction->ft?->merchant_id, + 'term_id' => $transaction->ft?->term_id, + ]; + + Log::info('Transaction data processed successfully', [ + 'trans_reference' => $transaction->trans_reference, + 'final_date_time' => $dateTime, + 'debit_amount' => $debitAmount, + 'credit_amount' => $creditAmount + ]); + + return $processedData; + } + + /** + * Format datetime string + * Memformat string datetime + */ + private function formatDateTime(?string $datetime) + : string + { + if (!$datetime) { + return ''; + } + + try { + return Carbon::createFromFormat('ymdHi', $datetime)->format('d/m/Y H:i'); + } catch (Exception $e) { + Log::warning('Error formatting datetime', [ + 'datetime' => $datetime, + 'error' => $e->getMessage() + ]); + return $datetime; + } + } + + /** + * Verify no duplicates after insert - simplified version + * Memverifikasi tidak ada duplikasi setelah insert data dengan pengecekan yang disederhanakan + */ + private function verifyNoDuplicatesAfterInsert(array $criteria) + : void + { + Log::info('Verifying no duplicates after insert with simplified check', $criteria); + + // PERBAIKAN: Check for duplicate trans_reference + amount_lcy combinations saja + // Karena trans_reference sudah unique secara global, tidak perlu filter by account/period + $duplicates = DB::table('processed_closing_balances') + ->select('trans_reference', 'amount_lcy', DB::raw('COUNT(*) as count')) + ->where('account_number', $criteria['account_number']) + ->where('period', $criteria['period']) + ->where('group_name', $criteria['group_name']) + ->groupBy('trans_reference', 'amount_lcy') + ->having('count', '>', 1) + ->get(); + + if ($duplicates->count() > 0) { + Log::error('Duplicates found after insert', [ + 'duplicate_count' => $duplicates->count(), + 'duplicates' => $duplicates->toArray() + ]); + + throw new Exception("Duplicates detected after insert: {$duplicates->count()} duplicate combinations found"); + } + + // Check for duplicate sequence numbers dalam scope yang sama + $sequenceDuplicates = DB::table('processed_closing_balances') + ->select('sequence_no', DB::raw('COUNT(*) as count')) + ->where('account_number', $criteria['account_number']) + ->where('period', $criteria['period']) + ->where('group_name', $criteria['group_name']) + ->groupBy('sequence_no') + ->having('count', '>', 1) + ->get(); + + if ($sequenceDuplicates->count() > 0) { + Log::error('Sequence number duplicates found after insert', [ + 'sequence_duplicate_count' => $sequenceDuplicates->count(), + 'sequence_duplicates' => $sequenceDuplicates->toArray() + ]); + + throw new Exception("Sequence number duplicates detected: {$sequenceDuplicates->count()} duplicate sequences found"); + } + + Log::info('No duplicates found after insert - verification passed', $criteria); + } + + /** + * Export from database to CSV (very fast) + */ + private function exportFromDatabaseToCsv() + : string + { + Log::info('Starting CSV export from database for closing balance report', [ 'account_number' => $this->accountNumber, - 'period' => $this->period, - 'group_name' => $this->groupName, - 'sequence_no' => $sequenceNo, - 'trans_reference' => $processedTransactionData['trans_reference'], - 'booking_date' => $processedTransactionData['booking_date'], - 'transaction_date' => $transactionDate, - 'amount_lcy' => $processedTransactionData['amount_lcy'], - 'debit_acct_no' => $processedTransactionData['debit_acct_no'], - 'debit_value_date' => $processedTransactionData['debit_value_date'], - 'debit_amount' => $processedTransactionData['debit_amount'], - 'credit_acct_no' => $processedTransactionData['credit_acct_no'], - 'bif_rcv_acct' => $processedTransactionData['bif_rcv_acct'], - 'bif_rcv_name' => $processedTransactionData['bif_rcv_name'], - 'credit_value_date' => $processedTransactionData['credit_value_date'], - 'credit_amount' => $processedTransactionData['credit_amount'], - 'at_unique_id' => $processedTransactionData['at_unique_id'], - 'bif_ref_no' => $processedTransactionData['bif_ref_no'], - 'atm_order_id' => $processedTransactionData['atm_order_id'], - 'recipt_no' => $processedTransactionData['recipt_no'], - 'api_iss_acct' => $processedTransactionData['api_iss_acct'], - 'api_benff_acct' => $processedTransactionData['api_benff_acct'], - 'authoriser' => $processedTransactionData['authoriser'], - 'remarks' => $processedTransactionData['remarks'], - 'payment_details' => $processedTransactionData['payment_details'], - 'ref_no' => $processedTransactionData['ref_no'], - 'merchant_id' => $processedTransactionData['merchant_id'], - 'term_id' => $processedTransactionData['term_id'], - 'closing_balance' => $runningBalance, - 'created_at' => now(), - 'updated_at' => now(), - // Tambahkan hash unik untuk memastikan keunikan - 'unique_hash' => md5($transaction->trans_reference . '_' . $transaction->amount_lcy) + 'period' => $this->period, + 'group_name' => $this->groupName + ]); + + // Create directory structure + $basePath = "closing_balance_reports"; + $accountPath = "{$basePath}/{$this->accountNumber}"; + + Storage::disk($this->disk)->makeDirectory($basePath); + Storage::disk($this->disk)->makeDirectory($accountPath); + + // Generate filename + $fileName = "closing_balance_{$this->accountNumber}_{$this->period}_{$this->groupName}.csv"; + $filePath = "{$accountPath}/{$fileName}"; + + // Delete existing file if exists + if (Storage::disk($this->disk)->exists($filePath)) { + Storage::disk($this->disk)->delete($filePath); + } + + // Create CSV header + $csvHeader = [ + 'NO', + 'TRANS_REFERENCE', + 'BOOKING_DATE', + 'TRANSACTION_DATE', + 'AMOUNT_LCY', + 'DEBIT_ACCT_NO', + 'DEBIT_VALUE_DATE', + 'DEBIT_AMOUNT', + 'CREDIT_ACCT_NO', + 'BIF_RCV_ACCT', + 'BIF_RCV_NAME', + 'CREDIT_VALUE_DATE', + 'CREDIT_AMOUNT', + 'AT_UNIQUE_ID', + 'BIF_REF_NO', + 'ATM_ORDER_ID', + 'RECIPT_NO', + 'API_ISS_ACCT', + 'API_BENFF_ACCT', + 'AUTHORISER', + 'REMARKS', + 'PAYMENT_DETAILS', + 'REF_NO', + 'MERCHANT_ID', + 'TERM_ID', + 'CLOSING_BALANCE' + ]; + + $csvContent = implode('|', $csvHeader) . "\n"; + Storage::disk($this->disk)->put($filePath, $csvContent); + + // Inisialisasi counter untuk sequence number + $sequenceCounter = 1; + $processedHashes = []; + + ProcessedClosingBalance::where('account_number', $this->accountNumber) + ->where('period', $this->period) + ->where('group_name', $this->groupName) + ->orderBy('sequence_no') + ->chunk($this->chunkSize, function ($records) use ($filePath, &$sequenceCounter, &$processedHashes, &$csvContent) { + + foreach ($records as $record) { + // Pengecekan unique_hash: skip jika sudah diproses + if (in_array($record->unique_hash, $processedHashes)) { + Log::debug('Skipping duplicate unique_hash in CSV export', [ + 'unique_hash' => $record->unique_hash, + 'trans_reference' => $record->trans_reference + ]); + continue; + } + + // Tandai unique_hash sebagai sudah diproses + $processedHashes[] = $record->unique_hash; + + $csvRow = [ + $sequenceCounter++, + // Gunakan counter yang bertambah, bukan sequence_no dari database + $record->trans_reference ?? '', + $record->booking_date ?? '', + $record->transaction_date ?? '', + $record->amount_lcy ?? '', + $record->debit_acct_no ?? '', + $record->debit_value_date ?? '', + $record->debit_amount ?? '', + $record->credit_acct_no ?? '', + $record->bif_rcv_acct ?? '', + $record->bif_rcv_name ?? '', + $record->credit_value_date ?? '', + $record->credit_amount ?? '', + $record->at_unique_id ?? '', + $record->bif_ref_no ?? '', + $record->atm_order_id ?? '', + $record->recipt_no ?? '', + $record->api_iss_acct ?? '', + $record->api_benff_acct ?? '', + $record->authoriser ?? '', + $record->remarks ?? '', + $record->payment_details ?? '', + $record->ref_no ?? '', + $record->merchant_id ?? '', + $record->term_id ?? '', + $record->closing_balance ?? '' + ]; + + $csvContent .= implode('|', $csvRow) . "\n"; + } + + if (!empty($csvContent)) { + Storage::disk($this->disk)->append($filePath, $csvContent); + + Log::debug('CSV content appended', [ + 'records_processed' => substr_count($csvContent, "\n"), + 'current_sequence' => $sequenceCounter - 1 + ]); + } + }); + + // Verify file creation + if (!Storage::disk($this->disk)->exists($filePath)) { + throw new Exception("Failed to create CSV file: {$filePath}"); + } + + Log::info('CSV export from database completed successfully', [ + 'file_path' => $filePath, + 'file_size' => Storage::disk($this->disk)->size($filePath) + ]); + + return $filePath; + } + + /** + * Get processed record count + */ + private function getProcessedRecordCount() + : int + { + return ProcessedClosingBalance::where('account_number', $this->accountNumber) + ->where('period', $this->period) + ->where('group_name', $this->groupName) + ->count(); + } + + /** + * Delete existing processed data dengan logging + * Menghapus data processed yang sudah ada dengan logging untuk audit + */ + private function deleteExistingProcessedData(array $criteria) + : void + { + Log::info('Deleting existing processed data', $criteria); + + $deletedCount = ProcessedClosingBalance::where('account_number', $criteria['account_number']) + ->where('period', $criteria['period']) + ->where('group_name', $criteria['group_name']) + ->count(); + + ProcessedClosingBalance::where('account_number', $criteria['account_number']) + ->where('period', $criteria['period']) + ->where('group_name', $criteria['group_name']) + ->delete(); + + Log::info('Existing processed data deleted', [ + 'deleted_count' => $deletedCount, + 'criteria' => $criteria + ]); + } + + /** + * Updated generateReportData method using pure ORM + * Method generateReportData yang diperbarui menggunakan ORM murni + */ + private function generateReportData() + : array + { + Log::info('Starting report data generation using pure ORM', [ + 'account_number' => $this->accountNumber, + 'period' => $this->period, + 'group_name' => $this->groupName, + 'chunk_size' => $this->chunkSize + ]); + + $reportData = []; + $runningBalance = $this->getOpeningBalance(); + $sequenceNo = 1; + + try { + DB::beginTransaction(); + + // Build query menggunakan pure ORM + $query = $this->buildTransactionQuery(); + + // Process data dalam chunks untuk efisiensi memory + $query->chunk($this->chunkSize, function ($transactions) use (&$reportData, &$runningBalance, &$sequenceNo) { + Log::info('Processing transaction chunk', [ + 'chunk_size' => $transactions->count(), + 'current_sequence' => $sequenceNo, + 'current_balance' => $runningBalance + ]); + + foreach ($transactions as $transaction) { + // Process transaction data + $processedData = $this->processTransactionData($transaction); + + // Update running balance + $amount = (float) $transaction->amount_lcy; + $runningBalance += $amount; + + // Format transaction date + $transactionDate = $this->formatDateTime($processedData['date_time']); + + // Build report data row + $reportData[] = $this->buildReportDataRow( + (object) $processedData, + $sequenceNo, + $transactionDate, + $runningBalance + ); + + $sequenceNo++; + } + + Log::info('Chunk processed successfully', [ + 'processed_count' => $transactions->count(), + 'total_records_so_far' => count($reportData), + 'current_balance' => $runningBalance + ]); + }); + + DB::commit(); + + Log::info('Report data generation completed using pure ORM', [ + 'total_records' => count($reportData), + 'final_balance' => $runningBalance, + 'final_sequence' => $sequenceNo - 1 + ]); + + } catch (Exception $e) { + DB::rollback(); + + Log::error('Error generating report data using pure ORM', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + 'account_number' => $this->accountNumber, + 'period' => $this->period + ]); + + throw $e; + } + + return $reportData; + } + + /** + * Build report data row from transaction + * Membangun baris data laporan dari transaksi + */ + private function buildReportDataRow($transaction, int $sequenceNo, string $transactionDate, float $runningBalance) + : array + { + return [ + 'sequence_no' => $sequenceNo, + 'trans_reference' => $transaction->trans_reference, + 'booking_date' => $transaction->booking_date, + 'transaction_date' => $transactionDate, + 'amount_lcy' => $transaction->amount_lcy, + 'debit_acct_no' => $transaction->debit_acct_no, + 'debit_value_date' => $transaction->debit_value_date, + 'debit_amount' => $transaction->debit_amount, + 'credit_acct_no' => $transaction->credit_acct_no, + 'bif_rcv_acct' => $transaction->bif_rcv_acct, + 'bif_rcv_name' => $transaction->bif_rcv_name, + 'credit_value_date' => $transaction->credit_value_date, + 'credit_amount' => $transaction->credit_amount, + 'at_unique_id' => $transaction->at_unique_id, + 'bif_ref_no' => $transaction->bif_ref_no, + 'atm_order_id' => $transaction->atm_order_id, + 'recipt_no' => $transaction->recipt_no, + 'api_iss_acct' => $transaction->api_iss_acct, + 'api_benff_acct' => $transaction->api_benff_acct, + 'authoriser' => $transaction->authoriser, + 'remarks' => $transaction->remarks, + 'payment_details' => $transaction->payment_details, + 'ref_no' => $transaction->ref_no, + 'merchant_id' => $transaction->merchant_id, + 'term_id' => $transaction->term_id, + 'closing_balance' => $runningBalance ]; } - Log::info('Processed closing balance data prepared with duplicate prevention', [ - 'total_records' => count($processedData), - 'skipped_duplicates' => count($transactions) - count($processedData) - ]); - - return $processedData; - } - - /** - * Delete existing processed data dengan logging - * Menghapus data processed yang sudah ada dengan logging untuk audit - */ - private function deleteExistingProcessedData(array $criteria): void - { - Log::info('Deleting existing processed data', $criteria); - - $deletedCount = ProcessedClosingBalance::where('account_number', $criteria['account_number']) - ->where('period', $criteria['period']) - ->where('group_name', $criteria['group_name']) - ->count(); - - ProcessedClosingBalance::where('account_number', $criteria['account_number']) - ->where('period', $criteria['period']) - ->where('group_name', $criteria['group_name']) - ->delete(); - - Log::info('Existing processed data deleted', [ - 'deleted_count' => $deletedCount, - 'criteria' => $criteria - ]); - } - - /** - * Get model class based on group name - * Mendapatkan class model berdasarkan group name - */ - private function getModelByGroup() - { - Log::info('Determining model by group', [ - 'group_name' => $this->groupName - ]); - - $model = $this->groupName === 'QRIS' ? StmtEntryDetail::class : StmtEntry::class; - - Log::info('Model determined', [ - 'group_name' => $this->groupName, - 'model_class' => $model - ]); - - return $model; - } - - /** - * Process transaction data from ORM result - * Memproses data transaksi dari hasil ORM - */ - private function processTransactionData($transaction): array - { - Log::info('Processing transaction data', [ - 'trans_reference' => $transaction->trans_reference, - 'has_ft_relation' => !is_null($transaction->ft), - 'has_dc_relation' => !is_null($transaction->dc) - ]); - - // Hitung debit dan credit amount - $debitAmount = $transaction->amount_lcy < 0 ? abs($transaction->amount_lcy) : null; - $creditAmount = $transaction->amount_lcy > 0 ? $transaction->amount_lcy : null; - - // Ambil date_time dari prioritas: ft -> dc -> stmt - $dateTime = $transaction->ft?->date_time ?? - $transaction->dc?->date_time ?? - $transaction->date_time; - - $processedData = [ - 'trans_reference' => $transaction->trans_reference, - 'booking_date' => $transaction->booking_date, - 'amount_lcy' => $transaction->amount_lcy, - 'debit_amount' => $debitAmount, - 'credit_amount' => $creditAmount, - 'date_time' => $dateTime, - // Data dari TempFundsTransfer melalui relasi - 'debit_acct_no' => $transaction->ft?->debit_acct_no, - 'debit_value_date' => $transaction->ft?->debit_value_date, - 'credit_acct_no' => $transaction->ft?->credit_acct_no, - 'bif_rcv_acct' => $transaction->ft?->bif_rcv_acct, - 'bif_rcv_name' => $transaction->ft?->bif_rcv_name, - 'credit_value_date' => $transaction->ft?->credit_value_date, - 'at_unique_id' => $transaction->ft?->at_unique_id, - 'bif_ref_no' => $transaction->ft?->bif_ref_no, - 'atm_order_id' => $transaction->ft?->atm_order_id, - 'recipt_no' => $transaction->ft?->recipt_no, - 'api_iss_acct' => $transaction->ft?->api_iss_acct, - 'api_benff_acct' => $transaction->ft?->api_benff_acct, - 'authoriser' => $transaction->ft?->authoriser, - 'remarks' => $transaction->ft?->remarks, - 'payment_details' => $transaction->ft?->payment_details, - 'ref_no' => $transaction->ft?->ref_no, - 'merchant_id' => $transaction->ft?->merchant_id, - 'term_id' => $transaction->ft?->term_id, - ]; - - Log::info('Transaction data processed successfully', [ - 'trans_reference' => $transaction->trans_reference, - 'final_date_time' => $dateTime, - 'debit_amount' => $debitAmount, - 'credit_amount' => $creditAmount - ]); - - return $processedData; - } - - /** - * Updated generateReportData method using pure ORM - * Method generateReportData yang diperbarui menggunakan ORM murni - */ - private function generateReportData(): array - { - Log::info('Starting report data generation using pure ORM', [ - 'account_number' => $this->accountNumber, - 'period' => $this->period, - 'group_name' => $this->groupName, - 'chunk_size' => $this->chunkSize - ]); - - $reportData = []; - $runningBalance = $this->getOpeningBalance(); - $sequenceNo = 1; - - try { - DB::beginTransaction(); - - // Build query menggunakan pure ORM - $query = $this->buildTransactionQuery(); - - // Process data dalam chunks untuk efisiensi memory - $query->chunk($this->chunkSize, function ($transactions) use (&$reportData, &$runningBalance, &$sequenceNo) { - Log::info('Processing transaction chunk', [ - 'chunk_size' => $transactions->count(), - 'current_sequence' => $sequenceNo, - 'current_balance' => $runningBalance - ]); - - foreach ($transactions as $transaction) { - // Process transaction data - $processedData = $this->processTransactionData($transaction); - - // Update running balance - $amount = (float) $transaction->amount_lcy; - $runningBalance += $amount; - - // Format transaction date - $transactionDate = $this->formatDateTime($processedData['date_time']); - - // Build report data row - $reportData[] = $this->buildReportDataRow( - (object) $processedData, - $sequenceNo, - $transactionDate, - $runningBalance - ); - - $sequenceNo++; - } - - Log::info('Chunk processed successfully', [ - 'processed_count' => $transactions->count(), - 'total_records_so_far' => count($reportData), - 'current_balance' => $runningBalance - ]); - }); - - DB::commit(); - - Log::info('Report data generation completed using pure ORM', [ - 'total_records' => count($reportData), - 'final_balance' => $runningBalance, - 'final_sequence' => $sequenceNo - 1 - ]); - - } catch (Exception $e) { - DB::rollback(); - - Log::error('Error generating report data using pure ORM', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), + /** + * Export report data to CSV file + * Export data laporan ke file CSV + */ + private function exportToCsv(array $reportData) + : string + { + Log::info('Starting CSV export for closing balance report', [ 'account_number' => $this->accountNumber, - 'period' => $this->period + 'period' => $this->period, + 'record_count' => count($reportData) ]); - throw $e; - } + // Create directory structure + $basePath = "closing_balance_reports"; + $accountPath = "{$basePath}/{$this->accountNumber}"; - return $reportData; - } + Storage::disk($this->disk)->makeDirectory($basePath); + Storage::disk($this->disk)->makeDirectory($accountPath); - /** - * Build report data row from transaction - * Membangun baris data laporan dari transaksi - */ - private function buildReportDataRow($transaction, int $sequenceNo, string $transactionDate, float $runningBalance): array - { - return [ - 'sequence_no' => $sequenceNo, - 'trans_reference' => $transaction->trans_reference, - 'booking_date' => $transaction->booking_date, - 'transaction_date' => $transactionDate, - 'amount_lcy' => $transaction->amount_lcy, - 'debit_acct_no' => $transaction->debit_acct_no, - 'debit_value_date' => $transaction->debit_value_date, - 'debit_amount' => $transaction->debit_amount, - 'credit_acct_no' => $transaction->credit_acct_no, - 'bif_rcv_acct' => $transaction->bif_rcv_acct, - 'bif_rcv_name' => $transaction->bif_rcv_name, - 'credit_value_date' => $transaction->credit_value_date, - 'credit_amount' => $transaction->credit_amount, - 'at_unique_id' => $transaction->at_unique_id, - 'bif_ref_no' => $transaction->bif_ref_no, - 'atm_order_id' => $transaction->atm_order_id, - 'recipt_no' => $transaction->recipt_no, - 'api_iss_acct' => $transaction->api_iss_acct, - 'api_benff_acct' => $transaction->api_benff_acct, - 'authoriser' => $transaction->authoriser, - 'remarks' => $transaction->remarks, - 'payment_details' => $transaction->payment_details, - 'ref_no' => $transaction->ref_no, - 'merchant_id' => $transaction->merchant_id, - 'term_id' => $transaction->term_id, - 'closing_balance' => $runningBalance - ]; - } + // Generate filename + $fileName = "closing_balance_{$this->accountNumber}_{$this->period}.csv"; + $filePath = "{$accountPath}/{$fileName}"; - /** - * Format datetime string - * Memformat string datetime - */ - private function formatDateTime(?string $datetime): string - { - if (!$datetime) { - return ''; - } + // Delete existing file if exists + if (Storage::disk($this->disk)->exists($filePath)) { + Storage::disk($this->disk)->delete($filePath); + } - try { - return Carbon::createFromFormat('ymdHi', $datetime)->format('d/m/Y H:i'); - } catch (Exception $e) { - Log::warning('Error formatting datetime', [ - 'datetime' => $datetime, - 'error' => $e->getMessage() - ]); - return $datetime; - } - } - - /** - * Export report data to CSV file - * Export data laporan ke file CSV - */ - private function exportToCsv(array $reportData): string - { - Log::info('Starting CSV export for closing balance report', [ - 'account_number' => $this->accountNumber, - 'period' => $this->period, - 'record_count' => count($reportData) - ]); - - // Create directory structure - $basePath = "closing_balance_reports"; - $accountPath = "{$basePath}/{$this->accountNumber}"; - - Storage::disk($this->disk)->makeDirectory($basePath); - Storage::disk($this->disk)->makeDirectory($accountPath); - - // Generate filename - $fileName = "closing_balance_{$this->accountNumber}_{$this->period}.csv"; - $filePath = "{$accountPath}/{$fileName}"; - - // Delete existing file if exists - if (Storage::disk($this->disk)->exists($filePath)) { - Storage::disk($this->disk)->delete($filePath); - } - - // Create CSV header - $csvHeader = [ - 'NO', - 'TRANS_REFERENCE', - 'BOOKING_DATE', - 'TRANSACTION_DATE', - 'AMOUNT_LCY', - 'DEBIT_ACCT_NO', - 'DEBIT_VALUE_DATE', - 'DEBIT_AMOUNT', - 'CREDIT_ACCT_NO', - 'BIF_RCV_ACCT', - 'BIF_RCV_NAME', - 'CREDIT_VALUE_DATE', - 'CREDIT_AMOUNT', - 'AT_UNIQUE_ID', - 'BIF_REF_NO', - 'ATM_ORDER_ID', - 'RECIPT_NO', - 'API_ISS_ACCT', - 'API_BENFF_ACCT', - 'AUTHORISER', - 'REMARKS', - 'PAYMENT_DETAILS', - 'REF_NO', - 'MERCHANT_ID', - 'TERM_ID', - 'CLOSING_BALANCE' - ]; - - $csvContent = implode('|', $csvHeader) . "\n"; - - // Add data rows - foreach ($reportData as $row) { - $csvRow = [ - $row['sequence_no'], - $row['trans_reference'] ?? '', - $row['booking_date'] ?? '', - $row['transaction_date'] ?? '', - $row['amount_lcy'] ?? '', - $row['debit_acct_no'] ?? '', - $row['debit_value_date'] ?? '', - $row['debit_amount'] ?? '', - $row['credit_acct_no'] ?? '', - $row['bif_rcv_acct'] ?? '', - $row['bif_rcv_name'] ?? '', - $row['credit_value_date'] ?? '', - $row['credit_amount'] ?? '', - $row['at_unique_id'] ?? '', - $row['bif_ref_no'] ?? '', - $row['atm_order_id'] ?? '', - $row['recipt_no'] ?? '', - $row['api_iss_acct'] ?? '', - $row['api_benff_acct'] ?? '', - $row['authoriser'] ?? '', - $row['remarks'] ?? '', - $row['payment_details'] ?? '', - $row['ref_no'] ?? '', - $row['merchant_id'] ?? '', - $row['term_id'] ?? '', - $row['closing_balance'] ?? '' + // Create CSV header + $csvHeader = [ + 'NO', + 'TRANS_REFERENCE', + 'BOOKING_DATE', + 'TRANSACTION_DATE', + 'AMOUNT_LCY', + 'DEBIT_ACCT_NO', + 'DEBIT_VALUE_DATE', + 'DEBIT_AMOUNT', + 'CREDIT_ACCT_NO', + 'BIF_RCV_ACCT', + 'BIF_RCV_NAME', + 'CREDIT_VALUE_DATE', + 'CREDIT_AMOUNT', + 'AT_UNIQUE_ID', + 'BIF_REF_NO', + 'ATM_ORDER_ID', + 'RECIPT_NO', + 'API_ISS_ACCT', + 'API_BENFF_ACCT', + 'AUTHORISER', + 'REMARKS', + 'PAYMENT_DETAILS', + 'REF_NO', + 'MERCHANT_ID', + 'TERM_ID', + 'CLOSING_BALANCE' ]; - $csvContent .= implode('|', $csvRow) . "\n"; + $csvContent = implode('|', $csvHeader) . "\n"; + + // Add data rows + foreach ($reportData as $row) { + $csvRow = [ + $row['sequence_no'], + $row['trans_reference'] ?? '', + $row['booking_date'] ?? '', + $row['transaction_date'] ?? '', + $row['amount_lcy'] ?? '', + $row['debit_acct_no'] ?? '', + $row['debit_value_date'] ?? '', + $row['debit_amount'] ?? '', + $row['credit_acct_no'] ?? '', + $row['bif_rcv_acct'] ?? '', + $row['bif_rcv_name'] ?? '', + $row['credit_value_date'] ?? '', + $row['credit_amount'] ?? '', + $row['at_unique_id'] ?? '', + $row['bif_ref_no'] ?? '', + $row['atm_order_id'] ?? '', + $row['recipt_no'] ?? '', + $row['api_iss_acct'] ?? '', + $row['api_benff_acct'] ?? '', + $row['authoriser'] ?? '', + $row['remarks'] ?? '', + $row['payment_details'] ?? '', + $row['ref_no'] ?? '', + $row['merchant_id'] ?? '', + $row['term_id'] ?? '', + $row['closing_balance'] ?? '' + ]; + + $csvContent .= implode('|', $csvRow) . "\n"; + } + + // Save file + Storage::disk($this->disk)->put($filePath, $csvContent); + + // Verify file creation + if (!Storage::disk($this->disk)->exists($filePath)) { + throw new Exception("Failed to create CSV file: {$filePath}"); + } + + Log::info('CSV export completed successfully', [ + 'file_path' => $filePath, + 'file_size' => Storage::disk($this->disk)->size($filePath) + ]); + + return $filePath; } - - // Save file - Storage::disk($this->disk)->put($filePath, $csvContent); - - // Verify file creation - if (!Storage::disk($this->disk)->exists($filePath)) { - throw new Exception("Failed to create CSV file: {$filePath}"); - } - - Log::info('CSV export completed successfully', [ - 'file_path' => $filePath, - 'file_size' => Storage::disk($this->disk)->size($filePath) - ]); - - return $filePath; } -}