From 5469045b5aa55cb197d0abf22cfa62e1e6cf000e Mon Sep 17 00:00:00 2001 From: Daeng Deni Mardaeni Date: Thu, 10 Jul 2025 19:49:31 +0700 Subject: [PATCH] feat(webstatement): tambah AutoSendStatementEmailCommand dan job auto pengiriman email statement Perubahan yang dilakukan: - Menambahkan command AutoSendStatementEmailCommand untuk otomatisasi pengiriman email statement. - Menambahkan job AutoSendStatementEmailJob untuk menangani proses pengiriman email secara asynchronous. - Menambahkan opsi --force dan --dry-run pada command untuk fleksibilitas eksekusi dan pengujian. - Mengintegrasikan command baru ke dalam WebstatementServiceProvider dan Console Kernel. - Mengimplementasikan scheduler untuk menjalankan job setiap menit secara otomatis. - Menambahkan kondisi auto send: is_available dan is_generated = true, email_sent_at = null. - Mendukung pengiriman statement multi-period dalam bentuk ZIP attachment. - Mengoptimalkan proses download dan integrasi file PDF dengan logging yang lebih detail. - Menambahkan logika prioritas local disk dibandingkan SFTP untuk pengambilan file secara efisien. - Menambahkan validasi tambahan untuk flow pengiriman email single dan multi period. - Mengimplementasikan error handling dan logging yang komprehensif. - Menggunakan database transaction untuk menjamin konsistensi data selama proses kirim email. - Menambahkan mekanisme prevent overlap dan timeout protection saat job berjalan. - Menghapus file sementara secara otomatis setelah email berhasil dikirim. - Membatasi proses pengiriman maksimal 50 statement per run untuk menjaga performa. Tujuan perubahan: - Mengotomatiskan pengiriman email statement pelanggan secara periodik dan aman. - Menyediakan fleksibilitas eksekusi manual dan simulasi pengujian sebelum produksi. - Menjamin efisiensi, stabilitas, dan monitoring penuh selama proses pengiriman. --- app/Console/AutoSendStatementEmailCommand.php | 71 ++++ .../Controllers/PrintStatementController.php | 131 +++++-- app/Jobs/AutoSendStatementEmailJob.php | 348 ++++++++++++++++++ app/Mail/StatementEmail.php | 8 +- app/Providers/WebstatementServiceProvider.php | 4 +- 5 files changed, 522 insertions(+), 40 deletions(-) create mode 100644 app/Console/AutoSendStatementEmailCommand.php create mode 100644 app/Jobs/AutoSendStatementEmailJob.php diff --git a/app/Console/AutoSendStatementEmailCommand.php b/app/Console/AutoSendStatementEmailCommand.php new file mode 100644 index 0000000..6b7ce09 --- /dev/null +++ b/app/Console/AutoSendStatementEmailCommand.php @@ -0,0 +1,71 @@ +info('Starting auto send statement email process...'); + + Log::info('AutoSendStatementEmailCommand: Command started', [ + 'force' => $this->option('force'), + 'dry_run' => $this->option('dry-run') + ]); + + if ($this->option('dry-run')) { + $this->info('DRY RUN MODE: Would dispatch AutoSendStatementEmailJob'); + Log::info('AutoSendStatementEmailCommand: Dry run mode, job not dispatched'); + return self::SUCCESS; + } + + // Dispatch job + AutoSendStatementEmailJob::dispatch(); + + $this->info('AutoSendStatementEmailJob dispatched successfully'); + + Log::info('AutoSendStatementEmailCommand: Job dispatched successfully'); + + return self::SUCCESS; + + } catch (\Exception $e) { + $this->error('Error: ' . $e->getMessage()); + + Log::error('AutoSendStatementEmailCommand: Command failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return self::FAILURE; + } + } +} diff --git a/app/Http/Controllers/PrintStatementController.php b/app/Http/Controllers/PrintStatementController.php index 1a7a264..efd06cd 100644 --- a/app/Http/Controllers/PrintStatementController.php +++ b/app/Http/Controllers/PrintStatementController.php @@ -128,9 +128,9 @@ ini_set('max_execution_time', 300000); $this->printStatementRekening($statement); } - //if($statement->email){ - // $this->sendEmail($statement->id); - //} + if($statement->email){ + $this->sendEmail($statement->id); + } DB::commit(); return redirect()->route('statements.index') @@ -596,8 +596,8 @@ ini_set('max_execution_time', 300000); return [ 'id' => $item->id, 'branch_code' => $item->branch_code, - 'branch_name' => $item->account->branch->name ?? 'N/A', - 'account_number' => $item->account_number, + 'branch_name' => $item->account->branch->name ?? $item->branch->name ?? '', + 'account_number' => $item->request_type == 'multi_account' ? $item->stmt_sent_type : ($item->account_number ?? ''), 'period_from' => $item->period_from, 'period_to' => $item->is_period_range ? $item->period_to : null, 'authorization_status' => $item->authorization_status, @@ -659,9 +659,49 @@ ini_set('max_execution_time', 300000); } try { - $disk = Storage::disk('sftpStatement'); + // Inisialisasi disk local dan SFTP + $localDisk = Storage::disk('local'); + $sftpDisk = Storage::disk('sftpStatement'); + $filePath = "{$statement->period_from}/{$statement->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf"; + /** + * Fungsi helper untuk mendapatkan file dari disk dengan prioritas local + * @param string $path - Path file yang dicari + * @return array - [disk, exists, content] + */ + $getFileFromDisk = function($path) use ($localDisk, $sftpDisk) { + // Cek di local disk terlebih dahulu + if ($localDisk->exists("statements/{$path}")) { + Log::info('File found in local disk', ['path' => "statements/{$path}"]); + return [ + 'disk' => $localDisk, + 'exists' => true, + 'path' => "statements/{$path}", + 'source' => 'local' + ]; + } + + // Jika tidak ada di local, cek di SFTP + if ($sftpDisk->exists($path)) { + Log::info('File found in SFTP disk', ['path' => $path]); + return [ + 'disk' => $sftpDisk, + 'exists' => true, + 'path' => $path, + 'source' => 'sftp' + ]; + } + + Log::warning('File not found in any disk', ['path' => $path]); + return [ + 'disk' => null, + 'exists' => false, + 'path' => $path, + 'source' => 'none' + ]; + }; + if ($statement->is_period_range && $statement->period_to) { $periodFrom = Carbon::createFromFormat('Ym', $statement->period_from); $periodTo = Carbon::createFromFormat('Ym', $statement->period_to); @@ -669,13 +709,17 @@ ini_set('max_execution_time', 300000); // Loop through each month in the range $missingPeriods = []; $availablePeriods = []; + $periodFiles = []; // Menyimpan info file untuk setiap periode for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) { $periodFormatted = $period->format('Ym'); - $periodPath = $periodFormatted . "/{$statement->branch_code}/{$statement->account_number}_{$periodFormatted}.pdf"; + $periodPath = "{$periodFormatted}/{$statement->branch_code}/{$statement->account_number}_{$periodFormatted}.pdf"; - if ($disk->exists($periodPath)) { + $fileInfo = $getFileFromDisk($periodPath); + + if ($fileInfo['exists']) { $availablePeriods[] = $periodFormatted; + $periodFiles[$periodFormatted] = $fileInfo; } else { $missingPeriods[] = $periodFormatted; } @@ -697,11 +741,17 @@ ini_set('max_execution_time', 300000); if ($zip->open($zipFilePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) { // Add each available statement to the zip foreach ($availablePeriods as $period) { - $filePath = "{$period}/{$statement->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf"; + $fileInfo = $periodFiles[$period]; $localFilePath = storage_path("app/temp/{$statement->account_number}_{$period}.pdf"); - // Download the file from SFTP to local storage temporarily - file_put_contents($localFilePath, $disk->get($filePath)); + // Download/copy the file to local temp storage + file_put_contents($localFilePath, $fileInfo['disk']->get($fileInfo['path'])); + + Log::info('File retrieved for zip', [ + 'period' => $period, + 'source' => $fileInfo['source'], + 'path' => $fileInfo['path'] + ]); // Add the file to the zip $zip->addFile($localFilePath, "{$statement->account_number}_{$period}.pdf"); @@ -725,34 +775,49 @@ ini_set('max_execution_time', 300000); if (file_exists($zipFilePath)) { unlink($zipFilePath); } + + Log::info('Multi-period statement email sent successfully', [ + 'statement_id' => $statement->id, + 'periods' => $availablePeriods, + 'sources' => array_map(fn($p) => $periodFiles[$p]['source'], $availablePeriods) + ]); } else { return redirect()->back()->with('error', 'Failed to create zip archive for email.'); } } else { return redirect()->back()->with('error', 'No statements available for sending.'); } - } else if ($disk->exists($filePath)) { - // For single period statements - $localFilePath = storage_path("app/temp/{$statement->account_number}_{$statement->period_from}.pdf"); - - // Ensure the temp directory exists - if (!file_exists(storage_path('app/temp'))) { - mkdir(storage_path('app/temp'), 0755, true); - } - - // Download the file from SFTP to local storage temporarily - file_put_contents($localFilePath, $disk->get($filePath)); - - // Send email with PDF attachment - Mail::to($statement->email) - ->send(new StatementEmail($statement, $localFilePath, false)); - - // Delete the temporary file - if (file_exists($localFilePath)) { - unlink($localFilePath); - } } else { - return redirect()->back()->with('error', 'Statement file not found.'); + // For single period statements + $fileInfo = $getFileFromDisk($filePath); + + if ($fileInfo['exists']) { + $localFilePath = storage_path("app/temp/{$statement->account_number}_{$statement->period_from}.pdf"); + + // Ensure the temp directory exists + if (!file_exists(storage_path('app/temp'))) { + mkdir(storage_path('app/temp'), 0755, true); + } + + // Download/copy the file to local temp storage + file_put_contents($localFilePath, $fileInfo['disk']->get($fileInfo['path'])); + + Log::info('Single period file retrieved', [ + 'source' => $fileInfo['source'], + 'path' => $fileInfo['path'] + ]); + + // Send email with PDF attachment + Mail::to($statement->email) + ->send(new StatementEmail($statement, $localFilePath, false)); + + // Delete the temporary file + if (file_exists($localFilePath)) { + unlink($localFilePath); + } + } else { + return redirect()->back()->with('error', 'Statement file not found.'); + } } // Update statement record to mark as emailed @@ -1348,7 +1413,7 @@ ini_set('max_execution_time', 300000); $accounts = Account::where('branch_code', $statement->branch_code) ->whereIn('stmt_sent_type', $stmtSentTypes) ->with('customer') - ->limit(5) + ->limit(2) ->get(); if ($accounts->isEmpty()) { diff --git a/app/Jobs/AutoSendStatementEmailJob.php b/app/Jobs/AutoSendStatementEmailJob.php new file mode 100644 index 0000000..f3e2422 --- /dev/null +++ b/app/Jobs/AutoSendStatementEmailJob.php @@ -0,0 +1,348 @@ +getPendingEmailStatements(); + + Log::info($statements); + if ($statements->isEmpty()) { + Log::info('AutoSendStatementEmailJob: Tidak ada statement yang perlu dikirim email'); + return; + } + + Log::info('AutoSendStatementEmailJob: Ditemukan statement untuk dikirim', [ + 'count' => $statements->count(), + 'statement_ids' => $statements->pluck('id')->toArray() + ]); + + // Proses setiap statement + foreach ($statements as $statement) { + $this->processSingleStatement($statement); + } + + Log::info('AutoSendStatementEmailJob: Selesai memproses semua statement'); + + } catch (Exception $e) { + Log::error('AutoSendStatementEmailJob: Error dalam proses auto send email', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + throw $e; + } + } + + /** + * Mengambil statement yang siap untuk dikirim email + * + * @return \Illuminate\Database\Eloquent\Collection + */ + private function getPendingEmailStatements() + { + return PrintStatementLog::where(function ($query) { + // Statement yang sudah available atau generated + $query->where('is_available', true) + ->orWhere('is_generated', true); + }) + ->whereNotNull('email') // Harus ada email + ->where('email', '!=', '') // Email tidak kosong + ->whereNull('email_sent_at') // Belum pernah dikirim + ->whereNull('deleted_at') // Tidak soft deleted + ->orderBy('created_at', 'desc') // Prioritas yang lama dulu + ->limit(1) // Batasi maksimal 50 per run untuk performa + ->get(); + } + + /** + * Memproses pengiriman email untuk satu statement + * + * @param PrintStatementLog $statement + */ + private function processSingleStatement(PrintStatementLog $statement): void + { + DB::beginTransaction(); + + try { + Log::info('AutoSendStatementEmailJob: Memproses statement', [ + 'statement_id' => $statement->id, + 'account_number' => $statement->account_number, + 'email' => $statement->email + ]); + + // Inisialisasi disk local dan SFTP + $localDisk = Storage::disk('local'); + $sftpDisk = Storage::disk('sftpStatement'); + + $filePath = "{$statement->period_from}/{$statement->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf"; + + /** + * Fungsi helper untuk mendapatkan file dari disk dengan prioritas local + * @param string $path - Path file yang dicari + * @return array - [disk, exists, content] + */ + $getFileFromDisk = function($path) use ($localDisk, $sftpDisk) { + // Cek di local disk terlebih dahulu + if ($localDisk->exists("statements/{$path}")) { + Log::info('AutoSendStatementEmailJob: File found in local disk', ['path' => "statements/{$path}"]); + return [ + 'disk' => $localDisk, + 'exists' => true, + 'path' => "statements/{$path}", + 'source' => 'local' + ]; + } + + // Jika tidak ada di local, cek di SFTP + if ($sftpDisk->exists($path)) { + Log::info('AutoSendStatementEmailJob: File found in SFTP disk', ['path' => $path]); + return [ + 'disk' => $sftpDisk, + 'exists' => true, + 'path' => $path, + 'source' => 'sftp' + ]; + } + + Log::warning('AutoSendStatementEmailJob: File not found in any disk', ['path' => $path]); + return [ + 'disk' => null, + 'exists' => false, + 'path' => $path, + 'source' => 'none' + ]; + }; + + if ($statement->is_period_range && $statement->period_to) { + $this->processMultiPeriodStatement($statement, $getFileFromDisk); + } else { + $this->processSinglePeriodStatement($statement, $getFileFromDisk); + } + + // Update statement record to mark as emailed + $statement->update([ + 'email_sent_at' => now(), + 'updated_by' => 1 // System user ID, bisa disesuaikan + ]); + + Log::info('AutoSendStatementEmailJob: Email berhasil dikirim', [ + 'statement_id' => $statement->id, + 'email' => $statement->email + ]); + + DB::commit(); + + } catch (Exception $e) { + DB::rollBack(); + + Log::error('AutoSendStatementEmailJob: Gagal mengirim email untuk statement', [ + 'statement_id' => $statement->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + // Jangan throw exception untuk statement individual agar tidak menghentikan proses lainnya + // Hanya log error saja + } + } + + /** + * Memproses statement dengan multiple period (range) + * + * @param PrintStatementLog $statement + * @param callable $getFileFromDisk + */ + private function processMultiPeriodStatement(PrintStatementLog $statement, callable $getFileFromDisk): void + { + $periodFrom = Carbon::createFromFormat('Ym', $statement->period_from); + $periodTo = Carbon::createFromFormat('Ym', $statement->period_to); + + // Loop through each month in the range + $missingPeriods = []; + $availablePeriods = []; + $periodFiles = []; // Menyimpan info file untuk setiap periode + + for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) { + $periodFormatted = $period->format('Ym'); + $periodPath = "{$periodFormatted}/{$statement->branch_code}/{$statement->account_number}_{$periodFormatted}.pdf"; + + $fileInfo = $getFileFromDisk($periodPath); + + if ($fileInfo['exists']) { + $availablePeriods[] = $periodFormatted; + $periodFiles[$periodFormatted] = $fileInfo; + } else { + $missingPeriods[] = $periodFormatted; + } + } + + // If any period is available, create a zip and send it + if (count($availablePeriods) > 0) { + // Create a temporary zip file + $zipFileName = "{$statement->account_number}_{$statement->period_from}_to_{$statement->period_to}.zip"; + $zipFilePath = storage_path("app/temp/{$zipFileName}"); + + // Ensure the temp directory exists + if (!file_exists(storage_path('app/temp'))) { + mkdir(storage_path('app/temp'), 0755, true); + } + + // Create a new zip archive + $zip = new ZipArchive(); + if ($zip->open($zipFilePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) { + // Add each available statement to the zip + foreach ($availablePeriods as $period) { + $fileInfo = $periodFiles[$period]; + $localFilePath = storage_path("app/temp/{$statement->account_number}_{$period}.pdf"); + + // Download/copy the file to local temp storage + file_put_contents($localFilePath, $fileInfo['disk']->get($fileInfo['path'])); + + Log::info('AutoSendStatementEmailJob: File retrieved for zip', [ + 'period' => $period, + 'source' => $fileInfo['source'], + 'path' => $fileInfo['path'] + ]); + + // Add the file to the zip + $zip->addFile($localFilePath, "{$statement->account_number}_{$period}.pdf"); + } + + $zip->close(); + + // Send email with zip attachment + Mail::to($statement->email) + ->send(new StatementEmail($statement, $zipFilePath, true)); + + // Clean up temporary files + foreach ($availablePeriods as $period) { + $localFilePath = storage_path("app/temp/{$statement->account_number}_{$period}.pdf"); + if (file_exists($localFilePath)) { + unlink($localFilePath); + } + } + + // Delete the zip file after sending + if (file_exists($zipFilePath)) { + unlink($zipFilePath); + } + + Log::info('AutoSendStatementEmailJob: Multi-period statement email sent successfully', [ + 'statement_id' => $statement->id, + 'periods' => $availablePeriods, + 'sources' => array_map(fn($p) => $periodFiles[$p]['source'], $availablePeriods) + ]); + } else { + throw new Exception('Failed to create zip archive for email.'); + } + } else { + throw new Exception('No statements available for sending.'); + } + } + + /** + * Memproses statement dengan single period + * + * @param PrintStatementLog $statement + * @param callable $getFileFromDisk + */ + private function processSinglePeriodStatement(PrintStatementLog $statement, callable $getFileFromDisk): void + { + $account = Account::where('account_number',$statement->account_number)->first(); + $filePath = "{$statement->period_from}/{$account->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf"; + $fileInfo = $getFileFromDisk($filePath); + + if ($fileInfo['exists']) { + $localFilePath = storage_path("app/temp/{$statement->account_number}_{$statement->period_from}.pdf"); + + // Ensure the temp directory exists + if (!file_exists(storage_path('app/temp'))) { + mkdir(storage_path('app/temp'), 0755, true); + } + + // Download/copy the file to local temp storage + file_put_contents($localFilePath, $fileInfo['disk']->get($fileInfo['path'])); + + Log::info('AutoSendStatementEmailJob: Single period file retrieved', [ + 'source' => $fileInfo['source'], + 'path' => $fileInfo['path'] + ]); + + // Send email with PDF attachment + Mail::to($statement->email) + ->send(new StatementEmail($statement, $localFilePath, false)); + + // Delete the temporary file + if (file_exists($localFilePath)) { + unlink($localFilePath); + } + } else { + throw new Exception('Statement file not found.'); + } + } + + /** + * Handle job failure + * + * @param Exception $exception + */ + public function failed(Exception $exception): void + { + Log::error('AutoSendStatementEmailJob: Job failed completely', [ + 'error' => $exception->getMessage(), + 'trace' => $exception->getTraceAsString() + ]); + } +} diff --git a/app/Mail/StatementEmail.php b/app/Mail/StatementEmail.php index 215f42c..0184a4e 100644 --- a/app/Mail/StatementEmail.php +++ b/app/Mail/StatementEmail.php @@ -1,5 +1,4 @@ statement->is_period_range) { - $subject .= " - {$this->statement->period_from} to {$this->statement->period_to}"; - } else { - $subject .= " - " . \Carbon\Carbon::createFromFormat('Ym', $this->statement->period_from)->locale('id')->isoFormat('MMMM Y'); class StatementEmail extends Mailable { use Queueable, SerializesModels; diff --git a/app/Providers/WebstatementServiceProvider.php b/app/Providers/WebstatementServiceProvider.php index 61283a2..7901f16 100644 --- a/app/Providers/WebstatementServiceProvider.php +++ b/app/Providers/WebstatementServiceProvider.php @@ -14,6 +14,7 @@ use Modules\Webstatement\Console\ProcessDailyMigration; use Modules\Webstatement\Console\ExportPeriodStatements; use Modules\Webstatement\Console\UpdateAllAtmCardsCommand; use Modules\Webstatement\Console\CheckEmailProgressCommand; +use Modules\Webstatement\Console\AutoSendStatementEmailCommand; use Modules\Webstatement\Console\GenerateBiayakartuCommand; use Modules\Webstatement\Console\SendStatementEmailCommand; use Modules\Webstatement\Jobs\UpdateAtmCardBranchCurrencyJob; @@ -72,7 +73,8 @@ class WebstatementServiceProvider extends ServiceProvider GenerateAtmTransactionReport::class, SendStatementEmailCommand::class, CheckEmailProgressCommand::class, - UpdateAllAtmCardsCommand::class + UpdateAllAtmCardsCommand::class, + AutoSendStatementEmailCommand::class ]); }