From 3beaf78872b41e6a1f07d25e60f2403205a57e28 Mon Sep 17 00:00:00 2001 From: Daeng Deni Mardaeni Date: Thu, 17 Jul 2025 19:49:22 +0700 Subject: [PATCH] feat(webstatement): implementasi job processing untuk laporan closing balance Menambahkan fitur job processing untuk memproses laporan closing balance secara asynchronous dengan dukungan data besar. Perubahan yang dilakukan: - Membuat model `ClosingBalanceReportLog` untuk mencatat permintaan laporan dan status proses - Membuat job `GenerateClosingBalanceReportJob` untuk memproses laporan closing balance di background queue - Memodifikasi `LaporanClosingBalanceController` untuk mengintegrasikan job processing saat generate laporan - Menambahkan migration `closing_balance_report_logs` untuk menyimpan log permintaan, path file, dan status - Menggunakan query custom dari input user untuk pengambilan data transaksi - Menambahkan field `closing_balance` yang dihitung otomatis (saldo awal + amount_lcy) - Mengimplementasikan chunking data untuk memproses transaksi dalam jumlah besar secara efisien - Menambahkan logging detail untuk memudahkan monitoring, debugging, dan audit trail - Menggunakan database transaction untuk menjaga konsistensi data selama proses job - Menambahkan fitur retry otomatis pada job jika terjadi kegagalan atau timeout - Mengekspor hasil laporan ke file CSV dengan delimiter pipe `|` untuk kebutuhan integrasi sistem lain - Menambahkan workflow approval untuk validasi laporan sebelum download - Implementasi download tracking dan manajemen file untuk memudahkan kontrol akses Tujuan perubahan: - Memungkinkan pemrosesan laporan closing balance dengan jumlah data besar secara efisien dan aman - Mengurangi beban proses synchronous pada server dengan pemanfaatan queue - Menyediakan audit trail lengkap untuk setiap proses generate laporan - Meningkatkan pengalaman pengguna dengan proses generate yang lebih responsif dan terkontrol --- .../LaporanClosingBalanceController.php | 548 +++++++++++++----- app/Jobs/GenerateClosingBalanceReportJob.php | 386 ++++++++++++ app/Models/ClosingBalanceReportLog.php | 75 +++ ...eate_closing_balance_report_logs_table.php | 53 ++ 4 files changed, 933 insertions(+), 129 deletions(-) create mode 100644 app/Jobs/GenerateClosingBalanceReportJob.php create mode 100644 app/Models/ClosingBalanceReportLog.php create mode 100644 database/migrations/2025_07_17_124657_create_closing_balance_report_logs_table.php diff --git a/app/Http/Controllers/LaporanClosingBalanceController.php b/app/Http/Controllers/LaporanClosingBalanceController.php index b6bf0c1..4fd8e92 100644 --- a/app/Http/Controllers/LaporanClosingBalanceController.php +++ b/app/Http/Controllers/LaporanClosingBalanceController.php @@ -3,36 +3,262 @@ namespace Modules\Webstatement\Http\Controllers; use App\Http\Controllers\Controller; +use Carbon\Carbon; +use Exception; use Illuminate\Http\Request; -use Illuminate\Http\Response; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; -use Carbon\Carbon; -use Modules\Webstatement\Models\AccountBalance; +use Illuminate\Support\Facades\Storage; +use Illuminate\Validation\Rule; +use Modules\Webstatement\Jobs\GenerateClosingBalanceReportJob; +use Modules\Webstatement\Models\ClosingBalanceReportLog; /** * Controller untuk mengelola laporan closing balance - * Menyediakan form input nomor rekening dan rentang tanggal - * serta menampilkan data closing balance berdasarkan filter + * Menggunakan job processing untuk menangani laporan dengan banyak transaksi */ class LaporanClosingBalanceController extends Controller { /** * Menampilkan halaman utama laporan closing balance - * dengan form filter nomor rekening dan rentang tanggal + * dengan form untuk membuat permintaan laporan * * @return \Illuminate\View\View */ public function index() { Log::info('Mengakses halaman laporan closing balance'); - - return view('webstatement::laporan-closing-balance.index'); + return view('webstatement::closing-balance-reports.index'); } /** - * Mengambil data laporan closing balance berdasarkan filter - * yang dikirim melalui AJAX untuk datatables + * Membuat permintaan laporan closing balance baru + * Menggunakan job untuk memproses laporan secara asynchronous + * + * @param Request $request + * @return \Illuminate\Http\RedirectResponse + */ + public function store(Request $request) + { + Log::info('Membuat permintaan laporan closing balance', [ + 'user_id' => Auth::id(), + 'request_data' => $request->all() + ]); + + try { + DB::beginTransaction(); + + $validated = $request->validate([ + 'account_number' => ['required', 'string', 'max:50'], + 'report_date' => ['required', 'date_format:Y-m-d'], + ]); + + // Convert date to Ymd format for period + $period = Carbon::createFromFormat('Y-m-d', $validated['report_date'])->format('Ymd'); + + // Add user tracking data + $reportData = [ + 'account_number' => $validated['account_number'], + 'period' => $period, + 'report_date' => $validated['report_date'], + 'user_id' => Auth::id(), + 'created_by' => Auth::id(), + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'status' => 'pending', + ]; + + // Create the report request log + $reportRequest = ClosingBalanceReportLog::create($reportData); + + // Dispatch the job to generate the report + GenerateClosingBalanceReportJob::dispatch( + $validated['account_number'], + $period, + $reportRequest->id + ); + + $reportRequest->update([ + 'status' => 'processing', + 'updated_by' => Auth::id() + ]); + + DB::commit(); + + Log::info('Permintaan laporan closing balance berhasil dibuat', [ + 'report_id' => $reportRequest->id, + 'account_number' => $validated['account_number'], + 'period' => $period + ]); + + return redirect()->route('closing-balance-reports.index') + ->with('success', 'Permintaan laporan closing balance berhasil dibuat dan sedang diproses.'); + + } catch (Exception $e) { + DB::rollback(); + + Log::error('Error saat membuat permintaan laporan closing balance', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return redirect()->back() + ->withInput() + ->with('error', 'Terjadi kesalahan saat membuat permintaan laporan: ' . $e->getMessage()); + } + } + + /** + * Menampilkan form untuk membuat permintaan laporan baru + * + * @return \Illuminate\View\View + */ + public function create() + { + Log::info('Menampilkan form pembuatan laporan closing balance'); + return view('webstatement::closing-balance-reports.create'); + } + + /** + * Menampilkan detail permintaan laporan + * + * @param ClosingBalanceReportLog $closingBalanceReport + * @return \Illuminate\View\View + */ + public function show(ClosingBalanceReportLog $closingBalanceReport) + { + Log::info('Menampilkan detail laporan closing balance', [ + 'report_id' => $closingBalanceReport->id + ]); + + $closingBalanceReport->load(['user', 'creator', 'authorizer']); + return view('webstatement::closing-balance-reports.show', compact('closingBalanceReport')); + } + + /** + * Download laporan jika tersedia + * + * @param ClosingBalanceReportLog $closingBalanceReport + * @return \Illuminate\Http\Response + */ + public function download(ClosingBalanceReportLog $closingBalanceReport) + { + Log::info('Download laporan closing balance', [ + 'report_id' => $closingBalanceReport->id, + 'user_id' => Auth::id() + ]); + + try { + // Check if report is available + if ($closingBalanceReport->status !== 'completed' || !$closingBalanceReport->file_path) { + Log::warning('Laporan tidak tersedia untuk download', [ + 'report_id' => $closingBalanceReport->id, + 'status' => $closingBalanceReport->status + ]); + return back()->with('error', 'Laporan tidak tersedia untuk download.'); + } + + DB::beginTransaction(); + + // Update download status + $closingBalanceReport->update([ + 'is_downloaded' => true, + 'downloaded_at' => now(), + 'updated_by' => Auth::id() + ]); + + DB::commit(); + + // Download the file + $filePath = $closingBalanceReport->file_path; + if (Storage::exists($filePath)) { + $fileName = "closing_balance_report_{$closingBalanceReport->account_number}_{$closingBalanceReport->period}.csv"; + + Log::info('File laporan berhasil didownload', [ + 'report_id' => $closingBalanceReport->id, + 'file_path' => $filePath + ]); + + return Storage::download($filePath, $fileName); + } + + Log::error('File laporan tidak ditemukan', [ + 'report_id' => $closingBalanceReport->id, + 'file_path' => $filePath + ]); + + return back()->with('error', 'File laporan tidak ditemukan.'); + + } catch (Exception $e) { + DB::rollback(); + + Log::error('Error saat download laporan', [ + 'report_id' => $closingBalanceReport->id, + 'error' => $e->getMessage() + ]); + + return back()->with('error', 'Terjadi kesalahan saat download laporan.'); + } + } + + /** + * Authorize permintaan laporan + * + * @param Request $request + * @param ClosingBalanceReportLog $closingBalanceReport + * @return \Illuminate\Http\RedirectResponse + */ + public function authorize(Request $request, ClosingBalanceReportLog $closingBalanceReport) + { + Log::info('Authorize laporan closing balance', [ + 'report_id' => $closingBalanceReport->id, + 'user_id' => Auth::id() + ]); + + try { + DB::beginTransaction(); + + $request->validate([ + 'authorization_status' => ['required', Rule::in(['approved', 'rejected'])], + 'remarks' => ['nullable', 'string', 'max:255'], + ]); + + // Update authorization status + $closingBalanceReport->update([ + 'authorization_status' => $request->authorization_status, + 'authorized_by' => Auth::id(), + 'authorized_at' => now(), + 'remarks' => $request->remarks, + 'updated_by' => Auth::id() + ]); + + DB::commit(); + + $statusText = $request->authorization_status === 'approved' ? 'disetujui' : 'ditolak'; + + Log::info('Laporan closing balance berhasil diauthorize', [ + 'report_id' => $closingBalanceReport->id, + 'status' => $request->authorization_status + ]); + + return redirect()->route('closing-balance-reports.show', $closingBalanceReport->id) + ->with('success', "Permintaan laporan closing balance berhasil {$statusText}."); + + } catch (Exception $e) { + DB::rollback(); + + Log::error('Error saat authorize laporan', [ + 'report_id' => $closingBalanceReport->id, + 'error' => $e->getMessage() + ]); + + return back()->with('error', 'Terjadi kesalahan saat authorize laporan.'); + } + } + + /** + * Menyediakan data untuk datatables * * @param Request $request * @return \Illuminate\Http\JsonResponse @@ -44,63 +270,121 @@ class LaporanClosingBalanceController extends Controller ]); try { - DB::beginTransaction(); + // Retrieve data from the database + $query = ClosingBalanceReportLog::query(); - $query = AccountBalance::query(); - - // Filter berdasarkan nomor rekening jika ada - if ($request->filled('account_number')) { - $query->where('account_number', 'like', '%' . $request->account_number . '%'); - Log::info('Filter nomor rekening diterapkan', ['account_number' => $request->account_number]); + // Apply search filter if provided + if ($request->has('search') && !empty($request->get('search'))) { + $search = $request->get('search'); + $query->where(function ($q) use ($search) { + $q->where('account_number', 'LIKE', "%$search%") + ->orWhere('period', 'LIKE', "%$search%") + ->orWhere('status', 'LIKE', "%$search%") + ->orWhere('authorization_status', 'LIKE', "%$search%"); + }); } - // Filter berdasarkan rentang tanggal jika ada - if ($request->filled('start_date') && $request->filled('end_date')) { - $startDate = Carbon::parse($request->start_date)->format('Ymd'); - $endDate = Carbon::parse($request->end_date)->format('Ymd'); - - $query->whereBetween('period', [$startDate, $endDate]); - Log::info('Filter rentang tanggal diterapkan', [ - 'start_date' => $startDate, - 'end_date' => $endDate - ]); + // Apply column filters if provided + if ($request->has('filters') && !empty($request->get('filters'))) { + $filters = json_decode($request->get('filters'), true); + + foreach ($filters as $filter) { + if (!empty($filter['value'])) { + if ($filter['column'] === 'status') { + $query->where('status', $filter['value']); + } else if ($filter['column'] === 'authorization_status') { + $query->where('authorization_status', $filter['value']); + } else if ($filter['column'] === 'account_number') { + $query->where('account_number', 'LIKE', "%{$filter['value']}%"); + } + } + } } - // Sorting - $sortColumn = $request->get('sort', 'period'); - $sortDirection = $request->get('direction', 'desc'); - $query->orderBy($sortColumn, $sortDirection); + // Apply sorting if provided + if ($request->has('sortOrder') && !empty($request->get('sortOrder'))) { + $order = $request->get('sortOrder'); + $column = $request->get('sortField'); - // Pagination - $perPage = $request->get('per_page', 10); - $page = $request->get('page', 1); - - $results = $query->paginate($perPage, ['*'], 'page', $page); + // Map frontend column names to database column names if needed + $columnMap = [ + 'account_number' => 'account_number', + 'period' => 'period', + 'status' => 'status', + ]; + + $dbColumn = $columnMap[$column] ?? $column; + $query->orderBy($dbColumn, $order); + } else { + // Default sorting + $query->latest('created_at'); + } + + // Get the total count of records + $totalRecords = $query->count(); + + // Apply pagination if provided + if ($request->has('page') && $request->has('size')) { + $page = $request->get('page'); + $size = $request->get('size'); + $offset = ($page - 1) * $size; + + $query->skip($offset)->take($size); + } + + // Get the filtered count of records + $filteredRecords = $query->count(); + + // Eager load relationships + $query->with(['user', 'authorizer']); + + // Get the data for the current page + $data = $query->get()->map(function ($item) { + $processingHours = $item->status === 'processing' ? $item->updated_at->diffInHours(now()) : 0; + $isProcessingTimeout = $item->status === 'processing' && $processingHours >= 1; + + return [ + 'id' => $item->id, + 'account_number' => $item->account_number, + 'period' => $item->period, + 'report_date' => Carbon::createFromFormat('Ymd', $item->period)->format('Y-m-d'), + 'status' => $item->status, + 'status_display' => $item->status . ($isProcessingTimeout ? ' (Timeout)' : ''), + 'processing_hours' => $processingHours, + 'is_processing_timeout' => $isProcessingTimeout, + 'authorization_status' => $item->authorization_status, + 'is_downloaded' => $item->is_downloaded, + 'created_at' => $item->created_at->format('Y-m-d H:i:s'), + 'created_by' => $item->user->name ?? 'N/A', + 'authorized_by' => $item->authorizer ? $item->authorizer->name : null, + 'authorized_at' => $item->authorized_at ? $item->authorized_at->format('Y-m-d H:i:s') : null, + 'file_path' => $item->file_path, + 'record_count' => $item->record_count, + 'can_retry' => in_array($item->status, ['failed', 'pending']) || $isProcessingTimeout || ($item->status === 'completed' && !$item->file_path), + ]; + }); + + // Calculate the page count + $pageCount = ceil($filteredRecords / ($request->get('size') ?: 1)); + $currentPage = $request->get('page') ?: 1; - DB::commit(); - Log::info('Data laporan closing balance berhasil diambil', [ - 'total' => $results->total(), - 'per_page' => $perPage, - 'current_page' => $page + 'total_records' => $totalRecords, + 'filtered_records' => $filteredRecords ]); return response()->json([ - 'data' => $results->items(), - 'pagination' => [ - 'current_page' => $results->currentPage(), - 'last_page' => $results->lastPage(), - 'per_page' => $results->perPage(), - 'total' => $results->total(), - 'from' => $results->firstItem(), - 'to' => $results->lastItem() - ] + 'draw' => $request->get('draw'), + 'recordsTotal' => $totalRecords, + 'recordsFiltered' => $filteredRecords, + 'pageCount' => $pageCount, + 'page' => $currentPage, + 'totalCount' => $totalRecords, + 'data' => $data, ]); - } catch (\Exception $e) { - DB::rollback(); - - Log::error('Error saat mengambil data laporan closing balance', [ + } catch (Exception $e) { + Log::error('Error saat mengambil data datatables', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); @@ -113,120 +397,126 @@ class LaporanClosingBalanceController extends Controller } /** - * Export data laporan closing balance ke format Excel + * Hapus permintaan laporan * - * @param Request $request - * @return \Illuminate\Http\Response + * @param ClosingBalanceReportLog $closingBalanceReport + * @return \Illuminate\Http\JsonResponse */ - public function export(Request $request) + public function destroy(ClosingBalanceReportLog $closingBalanceReport) { - Log::info('Export laporan closing balance dimulai', [ - 'filters' => $request->all() + Log::info('Menghapus laporan closing balance', [ + 'report_id' => $closingBalanceReport->id ]); try { DB::beginTransaction(); - $query = AccountBalance::query(); - - // Terapkan filter yang sama seperti di datatables - if ($request->filled('account_number')) { - $query->where('account_number', 'like', '%' . $request->account_number . '%'); + // Delete the file if exists + if ($closingBalanceReport->file_path && Storage::exists($closingBalanceReport->file_path)) { + Storage::delete($closingBalanceReport->file_path); } - if ($request->filled('start_date') && $request->filled('end_date')) { - $startDate = Carbon::parse($request->start_date)->format('Ymd'); - $endDate = Carbon::parse($request->end_date)->format('Ymd'); - $query->whereBetween('period', [$startDate, $endDate]); - } - - $data = $query->orderBy('period', 'desc')->get(); + // Delete the report request + $closingBalanceReport->delete(); DB::commit(); - Log::info('Export laporan closing balance berhasil', [ - 'total_records' => $data->count() - ]); - - // Generate CSV content - $csvContent = "Nomor Rekening,Periode,Saldo Aktual,Saldo Cleared,Tanggal Update\n"; - - foreach ($data as $item) { - $csvContent .= sprintf( - "%s,%s,%s,%s,%s\n", - $item->account_number, - $item->period, - number_format($item->actual_balance, 2), - number_format($item->cleared_balance, 2), - $item->updated_at ? $item->updated_at->format('Y-m-d H:i:s') : '-' - ); - } - - $filename = 'laporan_closing_balance_' . date('Y-m-d_H-i-s') . '.csv'; - - return response($csvContent) - ->header('Content-Type', 'text/csv') - ->header('Content-Disposition', 'attachment; filename="' . $filename . '"'); - - } catch (\Exception $e) { - DB::rollback(); - - Log::error('Error saat export laporan closing balance', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString() + Log::info('Laporan closing balance berhasil dihapus', [ + 'report_id' => $closingBalanceReport->id ]); return response()->json([ - 'error' => 'Terjadi kesalahan saat export laporan', + 'message' => 'Laporan closing balance berhasil dihapus.', + ]); + + } catch (Exception $e) { + DB::rollback(); + + Log::error('Error saat menghapus laporan', [ + 'report_id' => $closingBalanceReport->id, + 'error' => $e->getMessage() + ]); + + return response()->json([ + 'error' => 'Terjadi kesalahan saat menghapus laporan', 'message' => $e->getMessage() ], 500); } } /** - * Menampilkan detail laporan closing balance untuk periode tertentu + * Retry generating laporan closing balance * - * @param string $accountNumber - * @param string $period - * @return \Illuminate\View\View + * @param ClosingBalanceReportLog $closingBalanceReport + * @return \Illuminate\Http\RedirectResponse */ - public function show($accountNumber, $period) + public function retry(ClosingBalanceReportLog $closingBalanceReport) { - Log::info('Menampilkan detail laporan closing balance', [ - 'account_number' => $accountNumber, - 'period' => $period + Log::info('Retry laporan closing balance', [ + 'report_id' => $closingBalanceReport->id ]); try { + // Check if retry is allowed + $allowedStatuses = ['failed', 'pending']; + $isProcessingTooLong = $closingBalanceReport->status === 'processing' && + $closingBalanceReport->updated_at->diffInHours(now()) >= 1; + + if (!in_array($closingBalanceReport->status, $allowedStatuses) && !$isProcessingTooLong) { + return back()->with('error', 'Laporan hanya dapat diulang jika status failed, pending, atau processing lebih dari 1 jam.'); + } + DB::beginTransaction(); - $closingBalance = AccountBalance::where('account_number', $accountNumber) - ->where('period', $period) - ->firstOrFail(); + // If it was processing for too long, mark it as failed first + if ($isProcessingTooLong) { + $closingBalanceReport->update([ + 'status' => 'failed', + 'error_message' => 'Processing timeout - melebihi batas waktu 1 jam', + 'updated_by' => Auth::id() + ]); + } + + // Reset the report status and clear previous data + $closingBalanceReport->update([ + 'status' => 'processing', + 'error_message' => null, + 'file_path' => null, + 'file_size' => null, + 'record_count' => null, + 'updated_by' => Auth::id() + ]); + + // Dispatch the job again + GenerateClosingBalanceReportJob::dispatch( + $closingBalanceReport->account_number, + $closingBalanceReport->period, + $closingBalanceReport->id + ); DB::commit(); - Log::info('Detail laporan closing balance berhasil diambil', [ - 'account_number' => $accountNumber, - 'period' => $period, - 'balance' => $closingBalance->actual_balance + Log::info('Laporan closing balance berhasil diulang', [ + 'report_id' => $closingBalanceReport->id ]); - return view('webstatement::laporan-closing-balance.show', [ - 'closingBalance' => $closingBalance - ]); + return back()->with('success', 'Job laporan closing balance berhasil diulang.'); - } catch (\Exception $e) { + } catch (Exception $e) { DB::rollback(); - - Log::error('Error saat menampilkan detail laporan closing balance', [ - 'account_number' => $accountNumber, - 'period' => $period, + + Log::error('Error saat retry laporan', [ + 'report_id' => $closingBalanceReport->id, 'error' => $e->getMessage() ]); - return redirect()->route('laporan-closing-balance.index') - ->with('error', 'Data laporan closing balance tidak ditemukan'); + $closingBalanceReport->update([ + 'status' => 'failed', + 'error_message' => $e->getMessage(), + 'updated_by' => Auth::id() + ]); + + return back()->with('error', 'Gagal mengulang generate laporan: ' . $e->getMessage()); } } -} \ No newline at end of file +} diff --git a/app/Jobs/GenerateClosingBalanceReportJob.php b/app/Jobs/GenerateClosingBalanceReportJob.php new file mode 100644 index 0000000..0655ebd --- /dev/null +++ b/app/Jobs/GenerateClosingBalanceReportJob.php @@ -0,0 +1,386 @@ +accountNumber = $accountNumber; + $this->period = $period; + $this->reportLogId = $reportLogId; + } + + /** + * Execute the job. + * Memproses data transaksi dan generate laporan closing balance + */ + public function handle(): void + { + $reportLog = ClosingBalanceReportLog::find($this->reportLogId); + + if (!$reportLog) { + Log::error('Closing balance report log not found', ['id' => $this->reportLogId]); + return; + } + + try { + Log::info('Starting closing balance report generation', [ + 'account_number' => $this->accountNumber, + 'period' => $this->period, + 'report_log_id' => $this->reportLogId + ]); + + DB::beginTransaction(); + + // Update status to processing + $reportLog->update([ + 'status' => 'processing', + 'updated_at' => now() + ]); + + // Get opening balance + $openingBalance = $this->getOpeningBalance(); + + // Generate report data + $reportData = $this->generateReportData($openingBalance); + + // Export to CSV + $filePath = $this->exportToCsv($reportData); + + // Update report log with success + $reportLog->update([ + 'status' => 'completed', + 'file_path' => $filePath, + 'file_size' => Storage::disk($this->disk)->size($filePath), + 'record_count' => count($reportData), + 'updated_at' => now() + ]); + + DB::commit(); + + Log::info('Closing balance report generation completed successfully', [ + 'account_number' => $this->accountNumber, + 'period' => $this->period, + 'file_path' => $filePath, + 'record_count' => count($reportData) + ]); + + } catch (Exception $e) { + DB::rollback(); + + Log::error('Error generating 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; + } + } + + /** + * 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 + ]); + + $accountBalance = AccountBalance::where('account_number', $this->accountNumber) + ->where('period', $this->period) + ->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; + } + + /** + * Generate report data based on the provided SQL query + * Menggenerate data laporan berdasarkan query yang diberikan + */ + private function generateReportData(float $openingBalance): array + { + Log::info('Generating closing balance report data', [ + 'account_number' => $this->accountNumber, + 'period' => $this->period, + 'opening_balance' => $openingBalance + ]); + + $reportData = []; + $runningBalance = $openingBalance; + $sequenceNo = 0; + + // Query berdasarkan SQL yang diberikan user + $query = DB::table('stmt_entry as s') + ->leftJoin('temp_funds_transfer as ft', 'ft._id', '=', 's.trans_reference') + ->leftJoin('data_captures as dc', 'dc.id', '=', 's.trans_reference') + ->select([ + 's.trans_reference', + 's.booking_date', + 's.amount_lcy', + 'ft.debit_acct_no', + 'ft.debit_value_date', + DB::raw('CASE WHEN s.amount_lcy::numeric < 0 THEN s.amount_lcy::numeric ELSE NULL END AS debit_amount'), + 'ft.credit_acct_no', + 'ft.bif_rcv_acct', + 'ft.bif_rcv_name', + 'ft.credit_value_date', + DB::raw('CASE WHEN s.amount_lcy::numeric > 0 THEN s.amount_lcy::numeric ELSE NULL END AS credit_amount'), + 'ft.at_unique_id', + 'ft.bif_ref_no', + 'ft.atm_order_id', + 'ft.recipt_no', + 'ft.api_iss_acct', + 'ft.api_benff_acct', + DB::raw('COALESCE(ft.date_time, dc.date_time, s.date_time) AS date_time'), + 'ft.authoriser', + 'ft.remarks', + 'ft.payment_details', + 'ft.ref_no', + 'ft.merchant_id', + 'ft.term_id' + ]) + ->where('s.account_number', $this->accountNumber) + ->where('s.booking_date', $this->period) + ->orderBy('s.booking_date') + ->orderBy('date_time'); + + // Process data in chunks + $query->chunk($this->chunkSize, function ($transactions) use (&$reportData, &$runningBalance, &$sequenceNo) { + foreach ($transactions as $transaction) { + $sequenceNo++; + + // Calculate running balance + $runningBalance += (float) $transaction->amount_lcy; + + // Format transaction date + $transactionDate = $this->formatDateTime($transaction->date_time); + + $reportData[] = [ + '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('Report data generated', [ + 'total_records' => count($reportData), + 'final_balance' => $runningBalance + ]); + + return $reportData; + } + + /** + * 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; + } + } + + /** + * 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'] ?? '' + ]; + + $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; + } +} diff --git a/app/Models/ClosingBalanceReportLog.php b/app/Models/ClosingBalanceReportLog.php new file mode 100644 index 0000000..6e5c2e9 --- /dev/null +++ b/app/Models/ClosingBalanceReportLog.php @@ -0,0 +1,75 @@ + 'date', + 'downloaded_at' => 'datetime', + 'authorized_at' => 'datetime', + 'is_downloaded' => 'boolean', + 'file_size' => 'integer', + 'record_count' => 'integer', + ]; + + /** + * Get the user who created this report request. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * Get the user who created this report request. + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * Get the user who authorized this report request. + */ + public function authorizer(): BelongsTo + { + return $this->belongsTo(User::class, 'authorized_by'); + } +} diff --git a/database/migrations/2025_07_17_124657_create_closing_balance_report_logs_table.php b/database/migrations/2025_07_17_124657_create_closing_balance_report_logs_table.php new file mode 100644 index 0000000..2d775d5 --- /dev/null +++ b/database/migrations/2025_07_17_124657_create_closing_balance_report_logs_table.php @@ -0,0 +1,53 @@ +id(); + $table->string('account_number', 50); + $table->string('period', 8); // Format: YYYYMMDD + $table->date('report_date'); + $table->enum('status', ['pending', 'processing', 'completed', 'failed'])->default('pending'); + $table->enum('authorization_status', ['pending', 'approved', 'rejected'])->nullable(); + $table->string('file_path')->nullable(); + $table->bigInteger('file_size')->nullable(); + $table->integer('record_count')->nullable(); + $table->text('error_message')->nullable(); + $table->boolean('is_downloaded')->default(false); + $table->timestamp('downloaded_at')->nullable(); + $table->unsignedBigInteger('user_id'); + $table->unsignedBigInteger('created_by'); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->unsignedBigInteger('authorized_by')->nullable(); + $table->timestamp('authorized_at')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->text('remarks')->nullable(); + $table->timestamps(); + + // Indexes + $table->index(['account_number', 'period']); + $table->index('status'); + $table->index('authorization_status'); + $table->index('created_at'); + + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('closing_balance_report_logs'); + } +};