Files
webstatement/app/Jobs/AutoSendStatementEmailJob.php
Daeng Deni Mardaeni 5469045b5a 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.
2025-07-10 19:49:31 +07:00

349 lines
13 KiB
PHP

<?php
namespace Modules\Webstatement\Jobs;
use Exception;
use ZipArchive;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use Illuminate\Queue\InteractsWithQueue;
use Modules\Webstatement\Models\Account;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Modules\Webstatement\Mail\StatementEmail;
use Modules\Webstatement\Models\PrintStatementLog;
class AutoSendStatementEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Timeout untuk job dalam detik (10 menit)
*/
public $timeout = 600;
/**
* Jumlah maksimal retry jika job gagal
*/
public $tries = 3;
/**
* Create a new job instance.
*/
public function __construct()
{
// Constructor kosong karena job ini tidak memerlukan parameter
}
/**
* Execute the job untuk mengirim email statement secara otomatis
*
* Job ini akan:
* 1. Mencari statement yang siap dikirim (is_available/is_generated = true, email_sent_at = null)
* 2. Memvalidasi keberadaan email
* 3. Mengirim email dengan attachment PDF
* 4. Update status email_sent_at
*/
public function handle(): void
{
try {
Log::info('AutoSendStatementEmailJob: Memulai proses auto send email');
// Ambil statement yang siap dikirim email
$statements = $this->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()
]);
}
}