feat(webstatement): tambahkan fitur monitoring dan peningkatan pengiriman email statement

- **Perbaikan dan Penambahan Komando:**
  - Memberikan komando baru `webstatement:check-progress` untuk memantau progres pengiriman email statement.
    - Menampilkan informasi seperti `Log ID`, `Batch ID`, `Request Type`, status, hingga persentase progress.
    - Menangani secara detail jumlah akun yang diproses, sukses, gagal, dan kalkulasi tingkat keberhasilan.
    - Menyediakan penanganan error jika log tidak ditemukan atau terjadi kegagalan lainnya.
  - Memperluas komando `webstatement:send-email`:
    - Mendukung pengiriman berdasarkan `single account`, `branch`, atau `all branches`.
    - Menambahkan validasi parameter `type` (`single`, `branch`, `all`) dan input spesifik seperti `--account` atau `--branch` untuk mode tertentu.
    - Melakukan pencatatan log awal dengan metadata lengkap seperti `request_type`, `batch_id`, dan status.

- **Peningkatan Logika Proses Backend:**
  - Menambahkan fungsi `createLogEntry` untuk mencatat log pengiriman email statement secara dinamis berdasarkan tipe request.
  - Menyediakan reusable method seperti `validateParameters` dan `determineRequestTypeAndTarget` untuk mempermudah pengelolaan parameter pengiriman.
  - Memberikan feedback dan panduan kepada pengguna mengenai ID log dan komando monitoring (`webstatement:check-progress`).

- **Penambahan Controller dan Fitur UI:**
  - Menambahkan controller baru `EmailStatementLogController`:
    - Mendukung pengelolaan log seperti list, detail, dan retry untuk pengiriman ulang email statement.
    - Menyediakan fitur pencarian, filter, dan halaman data log yang responsif menggunakan datatable.
    - Menambahkan kemampuan resend email untuk log dengan status `completed` atau `failed`.
  - Mengimplementasikan UI untuk log pengiriman:
    - Halaman daftar monitoring dengan filter berdasarkan branch, account number, request type, status, dan tanggal.
    - Menampilkan kemajuan, tingkat keberhasilan, serta tombol aksi seperti detail dan pengiriman ulang.

- **Peningkatan Model dan Validasi:**
  - Menyesuaikan model `PrintStatementLog` untuk mendukung lebih banyak atribut seperti `processed_accounts`, `success_count`, `failed_count`, `request_type`, serta metode utilitas seperti `getProgressPercentage()` dan `getSuccessRate()`.
  - Memvalidasi parameter input lebih mendalam agar kesalahan dapat diminimalisasi di awal proses.

- **Peningkatan pada View dan Feedback Pengguna:**
  - Menambah daftar command berguna untuk user di interface log:
    - Status antrian dengan `php artisan queue:work`.
    - Monitoring menggunakan komando custom yang baru ditambahkan.

- **Perbaikan Logging dan Error Handling:**
  - Menambahkan logging komprehensif pada semua proses, termasuk batch pengiriman ulang.
  - Memastikan rollback pada database jika terjadi error melalui transaksi pada critical path.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
This commit is contained in:
Daeng Deni Mardaeni
2025-06-11 09:56:43 +07:00
parent f3c649572b
commit 9199a4d748
15 changed files with 1972 additions and 644 deletions

View File

@@ -0,0 +1,54 @@
<?php
namespace Modules\Webstatement\Console;
use Illuminate\Console\Command;
use Modules\Webstatement\Models\PrintStatementLog;
class CheckEmailProgressCommand extends Command
{
protected $signature = 'webstatement:check-progress {log-id : ID log untuk dicek progressnya}';
protected $description = 'Cek progress pengiriman email statement';
public function handle()
{
$logId = $this->argument('log-id');
try {
$log = PrintStatementLog::findOrFail($logId);
$this->info("📊 Progress Pengiriman Email Statement");
$this->line("Log ID: {$log->id}");
$this->line("Batch ID: {$log->batch_id}");
$this->line("Request Type: {$log->request_type}");
$this->line("Status: {$log->status}");
if ($log->total_accounts) {
$this->line("Total Accounts: {$log->total_accounts}");
$this->line("Processed: {$log->processed_accounts}");
$this->line("Success: {$log->success_count}");
$this->line("Failed: {$log->failed_count}");
$this->line("Progress: {$log->getProgressPercentage()}%");
$this->line("Success Rate: {$log->getSuccessRate()}%");
}
if ($log->started_at) {
$this->line("Started: {$log->started_at}");
}
if ($log->completed_at) {
$this->line("Completed: {$log->completed_at}");
}
if ($log->error_message) {
$this->error("Error: {$log->error_message}");
}
} catch (\Exception $e) {
$this->error("Log dengan ID {$logId} tidak ditemukan.");
return Command::FAILURE;
}
return Command::SUCCESS;
}
}

View File

@@ -7,69 +7,52 @@ use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\Webstatement\Jobs\SendStatementEmailJob;
use Modules\Webstatement\Models\Account;
use Modules\Webstatement\Models\PrintStatementLog;
use Modules\Basicdata\Models\Branch;
/**
* Command untuk mengirim email statement PDF ke nasabah
*
* Command ini akan:
* 1. Memvalidasi parameter input
* 2. Menjalankan job pengiriman email statement
* 3. Memberikan feedback ke user tentang status eksekusi
* Mendukung pengiriman per rekening, per cabang, atau seluruh cabang
*/
class SendStatementEmailCommand extends Command
{
/**
* Signature dan parameter command
*
* @var string
*/
protected $signature = 'webstatement:send-email
{period : Format periode YYYY-MM (contoh: 2024-01)}
{--account= : Nomor rekening spesifik (opsional)}
{period : Format periode YYYYMM (contoh: 202401)}
{--type=single : Tipe pengiriman: single, branch, all}
{--account= : Nomor rekening (untuk type=single)}
{--branch= : Kode cabang (untuk type=branch)}
{--batch-id= : ID batch untuk tracking (opsional)}
{--queue=emails : Nama queue untuk job (default: emails)}
{--delay=0 : Delay dalam menit sebelum job dijalankan}';
/**
* Deskripsi command
*
* @var string
*/
protected $description = 'Mengirim email statement PDF ke nasabah berdasarkan periode';
protected $description = 'Mengirim email statement PDF ke nasabah (per rekening, per cabang, atau seluruh cabang)';
/**
* Menjalankan command
*
* @return int
*/
public function handle()
{
$this->info('🚀 Memulai proses pengiriman email statement...');
try {
// Ambil parameter
$period = $this->argument('period');
$type = $this->option('type');
$accountNumber = $this->option('account');
$branchCode = $this->option('branch');
$batchId = $this->option('batch-id');
$queueName = $this->option('queue');
$delay = (int) $this->option('delay');
// Validasi parameter
if (!$this->validateParameters($period, $accountNumber)) {
if (!$this->validateParameters($period, $type, $accountNumber, $branchCode)) {
return Command::FAILURE;
}
// Log command execution
Log::info('SendStatementEmailCommand started', [
'period' => $period,
'account_number' => $accountNumber,
'batch_id' => $batchId,
'queue' => $queueName,
'delay' => $delay
]);
// Tentukan request type dan target value
[$requestType, $targetValue] = $this->determineRequestTypeAndTarget($type, $accountNumber, $branchCode);
// Buat log entry
$log = $this->createLogEntry($period, $requestType, $targetValue, $batchId);
// Dispatch job
$job = SendStatementEmailJob::dispatch($period, $accountNumber, $batchId)
$job = SendStatementEmailJob::dispatch($period, $requestType, $targetValue, $batchId, $log->id)
->onQueue($queueName);
if ($delay > 0) {
@@ -77,115 +60,189 @@ class SendStatementEmailCommand extends Command
$this->info("⏰ Job dijadwalkan untuk dijalankan dalam {$delay} menit");
}
// Tampilkan informasi
$this->displayJobInfo($period, $accountNumber, $batchId, $queueName);
$this->displayJobInfo($period, $requestType, $targetValue, $queueName, $log);
$this->info('✅ Job pengiriman email statement berhasil didispatch!');
$this->info('📊 Gunakan command berikut untuk monitoring:');
$this->line(" php artisan queue:work {$queueName}");
$this->line(' php artisan telescope:work (jika menggunakan Telescope)');
$this->line(' php artisan webstatement:check-progress ' . $log->id);
return Command::SUCCESS;
} catch (Exception $e) {
$this->error('❌ Error saat mendispatch job: ' . $e->getMessage());
Log::error('SendStatementEmailCommand failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return Command::FAILURE;
}
}
/**
* Validasi parameter input
*
* @param string $period
* @param string|null $accountNumber
* @return bool
*/
private function validateParameters($period, $accountNumber = null)
private function validateParameters($period, $type, $accountNumber, $branchCode)
{
// Validasi format periode
if (!preg_match('/^\d{4}\d{2}$/', $period)) {
if (!preg_match('/^\d{6}$/', $period)) {
$this->error('❌ Format periode tidak valid. Gunakan format YYYYMM (contoh: 202401)');
return false;
}
// Validasi account number jika diberikan
if ($accountNumber) {
$account = Account::with('customer')
->where('account_number', $accountNumber)
->first();
// Validasi type
if (!in_array($type, ['single', 'branch', 'all'])) {
$this->error('❌ Type tidak valid. Gunakan: single, branch, atau all');
return false;
}
if (!$account) {
$this->error("❌ Account {$accountNumber} tidak ditemukan");
return false;
}
// Validasi parameter berdasarkan type
switch ($type) {
case 'single':
if (!$accountNumber) {
$this->error('❌ Parameter --account diperlukan untuk type=single');
return false;
}
// Cek apakah ada email (dari stmt_email atau customer email)
$hasEmail = !empty($account->stmt_email) ||
($account->customer && !empty($account->customer->email));
$account = Account::with('customer')
->where('account_number', $accountNumber)
->first();
if (!$hasEmail) {
$this->error("❌ Account {$accountNumber} tidak memiliki email (baik di stmt_email maupun customer email)");
return false;
}
if (!$account) {
$this->error("❌ Account {$accountNumber} tidak ditemukan");
return false;
}
$emailSource = !empty($account->stmt_email) ? 'stmt_email' : 'customer email';
$emailAddress = !empty($account->stmt_email) ? $account->stmt_email : $account->customer->email;
$hasEmail = !empty($account->stmt_email) ||
($account->customer && !empty($account->customer->email));
$this->info("✅ Account {$accountNumber} ditemukan dengan email: {$emailAddress} (dari {$emailSource}) - Cabang: {$account->branch_code}");
} else {
// Cek apakah ada account dengan email (dari stmt_email atau customer email)
$accountCount = Account::with('customer')
->where('stmt_sent_type', 'BY.EMAIL')
->get()
->filter(function ($account) {
return !empty($account->stmt_email) ||
($account->customer && !empty($account->customer->email));
})
->count();
if (!$hasEmail) {
$this->error("❌ Account {$accountNumber} tidak memiliki email");
return false;
}
if ($accountCount === 0) {
$this->error('❌ Tidak ada account dengan email ditemukan (baik di stmt_email maupun customer email)');
return false;
}
$this->info("✅ Account {$accountNumber} ditemukan dengan email");
break;
$this->info("✅ Ditemukan {$accountCount} account dengan email");
case 'branch':
if (!$branchCode) {
$this->error('❌ Parameter --branch diperlukan untuk type=branch');
return false;
}
$branch = Branch::where('code', $branchCode)->first();
if (!$branch) {
$this->error("❌ Branch {$branchCode} tidak ditemukan");
return false;
}
$accountCount = Account::with('customer')
->where('branch_code', $branchCode)
->where('stmt_sent_type', 'BY.EMAIL')
->get()
->filter(function ($account) {
return !empty($account->stmt_email) ||
($account->customer && !empty($account->customer->email));
})
->count();
if ($accountCount === 0) {
$this->error("❌ Tidak ada account dengan email di branch {$branchCode}");
return false;
}
$this->info("✅ Ditemukan {$accountCount} account dengan email di branch {$branch->name}");
break;
case 'all':
$accountCount = Account::with('customer')
->where('stmt_sent_type', 'BY.EMAIL')
->get()
->filter(function ($account) {
return !empty($account->stmt_email) ||
($account->customer && !empty($account->customer->email));
})
->count();
if ($accountCount === 0) {
$this->error('❌ Tidak ada account dengan email ditemukan');
return false;
}
$this->info("✅ Ditemukan {$accountCount} account dengan email di seluruh cabang");
break;
}
return true;
}
/**
* Menampilkan informasi job yang akan dijalankan
*
* @param string $period
* @param string|null $accountNumber
* @param string|null $batchId
* @param string $queueName
* @return void
*/
private function displayJobInfo($period, $accountNumber, $batchId, $queueName)
private function determineRequestTypeAndTarget($type, $accountNumber, $branchCode)
{
$this->info('📋 Detail Job:');
$this->line(" Periode: {$period}");
$this->line(" Account: " . ($accountNumber ?: 'Semua account dengan email'));
$this->line(" Batch ID: " . ($batchId ?: 'Auto-generated'));
$this->line(" Queue: {$queueName}");
// Estimasi path file
if ($accountNumber) {
$account = Account::where('account_number', $accountNumber)->first();
if ($account) {
$pdfPath = "storage/app/combine/{$period}/{$account->branch_code}/{$accountNumber}_{$period}.pdf";
$this->line(" File PDF: {$pdfPath}");
}
} else {
$this->line(" File PDF: storage/app/combine/{$period}/[branch_code]/[account_number]_{$period}.pdf");
switch ($type) {
case 'single':
return ['single_account', $accountNumber];
case 'branch':
return ['branch', $branchCode];
case 'all':
return ['all_branches', null];
default:
throw new \InvalidArgumentException("Invalid type: {$type}");
}
}
private function createLogEntry($period, $requestType, $targetValue, $batchId)
{
$logData = [
'user_id' => null, // Command line execution
'period_from' => $period,
'period_to' => $period,
'is_period_range' => false,
'request_type' => $requestType,
'batch_id' => $batchId ?? uniqid('cmd_'),
'status' => 'pending',
'authorization_status' => 'approved', // Auto-approved untuk command line
'created_by' => null,
'ip_address' => '127.0.0.1',
'user_agent' => 'Command Line'
];
// Set branch_code dan account_number berdasarkan request type
switch ($requestType) {
case 'single_account':
$account = Account::where('account_number', $targetValue)->first();
$logData['branch_code'] = $account->branch_code;
$logData['account_number'] = $targetValue;
break;
case 'branch':
$logData['branch_code'] = $targetValue;
$logData['account_number'] = null;
break;
case 'all_branches':
$logData['branch_code'] = 'ALL';
$logData['account_number'] = null;
break;
}
return PrintStatementLog::create($logData);
}
private function displayJobInfo($period, $requestType, $targetValue, $queueName, $log)
{
$this->info('📋 Detail Job:');
$this->line(" Log ID: {$log->id}");
$this->line(" Periode: {$period}");
$this->line(" Request Type: {$requestType}");
switch ($requestType) {
case 'single_account':
$this->line(" Account: {$targetValue}");
break;
case 'branch':
$branch = Branch::where('code', $targetValue)->first();
$this->line(" Branch: {$targetValue} ({$branch->name})");
break;
case 'all_branches':
$this->line(" Target: Seluruh cabang");
break;
}
$this->line(" Batch ID: {$log->batch_id}");
$this->line(" Queue: {$queueName}");
}
}

View File

@@ -0,0 +1,297 @@
<?php
namespace Modules\Webstatement\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\Webstatement\Models\PrintStatementLog;
use Modules\Basicdata\Models\Branch;
use Modules\Webstatement\Jobs\SendStatementEmailJob;
/**
* Controller untuk mengelola log pengiriman statement email
* Mendukung log untuk pengiriman per rekening, per cabang, atau seluruh cabang
*/
class EmailStatementLogController extends Controller
{
public function index(Request $request)
{
Log::info('Accessing email statement log index page', [
'user_id' => auth()->id(),
'ip_address' => $request->ip()
]);
try {
$branches = Branch::orderBy('name')->get();
return view('webstatement::email-statement-logs.index', compact('branches'));
} catch (\Exception $e) {
Log::error('Failed to load email statement log index page', [
'error' => $e->getMessage(),
'user_id' => auth()->id()
]);
return back()->with('error', 'Gagal memuat halaman log pengiriman email statement.');
}
}
public function dataForDatatables(Request $request)
{
Log::info('Fetching email statement log data for datatables', [
'user_id' => auth()->id(),
'filters' => $request->only(['branch_code', 'account_number', 'period_from', 'period_to', 'request_type', 'status'])
]);
DB::beginTransaction();
try {
$query = PrintStatementLog::query()
->with(['user', 'branch'])
->select([
'id',
'user_id',
'branch_code',
'account_number',
'request_type',
'batch_id',
'total_accounts',
'processed_accounts',
'success_count',
'failed_count',
'status',
'period_from',
'period_to',
'email',
'email_sent_at',
'is_available',
'authorization_status',
'started_at',
'completed_at',
'created_at',
'updated_at'
]);
// Filter berdasarkan branch
if ($request->filled('branch_code')) {
$query->where('branch_code', $request->branch_code);
}
// Filter berdasarkan account number (hanya untuk single account)
if ($request->filled('account_number')) {
$query->where('account_number', 'like', '%' . $request->account_number . '%');
}
// Filter berdasarkan request type
if ($request->filled('request_type')) {
$query->where('request_type', $request->request_type);
}
// Filter berdasarkan status
if ($request->filled('status')) {
$query->where('status', $request->status);
}
// Filter berdasarkan periode
if ($request->filled('period_from')) {
$query->where('period_from', '>=', $request->period_from);
}
if ($request->filled('period_to')) {
$query->where('period_to', '<=', $request->period_to);
}
// Filter berdasarkan tanggal
if ($request->filled('date_from')) {
$query->whereDate('created_at', '>=', $request->date_from);
}
if ($request->filled('date_to')) {
$query->whereDate('created_at', '<=', $request->date_to);
}
$query->orderBy('created_at', 'desc');
$totalRecords = $query->count();
if ($request->filled('start')) {
$query->skip($request->start);
}
if ($request->filled('length') && $request->length != -1) {
$query->take($request->length);
}
$logs = $query->get();
$data = $logs->map(function ($log) {
return [
'id' => $log->id,
'request_type' => $this->formatRequestType($log->request_type),
'branch_code' => $log->branch_code,
'branch_name' => $log->branch->name ?? 'N/A',
'account_number' => $log->account_number ?? '-',
'period_display' => $log->period_display,
'batch_id' => $log->batch_id,
'total_accounts' => $log->total_accounts ?? 1,
'processed_accounts' => $log->processed_accounts ?? 0,
'success_count' => $log->success_count ?? 0,
'failed_count' => $log->failed_count ?? 0,
'progress_percentage' => $log->getProgressPercentage(),
'success_rate' => $log->getSuccessRate(),
'status' => $this->formatStatus($log->status),
'email' => $log->email,
'email_status' => $log->email_sent_at ? 'Terkirim' : 'Pending',
'email_sent_at' => $log->email_sent_at ?? '-',
'authorization_status' => ucfirst($log->authorization_status),
'user_name' => $log->user->name ?? 'System',
'started_at' => $log->started_at ? $log->started_at->format('d/m/Y H:i:s') : '-',
'completed_at' => $log->completed_at ? $log->completed_at->format('d/m/Y H:i:s') : '-',
'created_at' => $log->created_at->format('d/m/Y H:i:s'),
'actions' => $this->generateActionButtons($log)
];
});
DB::commit();
return response()->json([
'draw' => intval($request->draw),
'recordsTotal' => $totalRecords,
'recordsFiltered' => $totalRecords,
'data' => $data
]);
} catch (\Exception $e) {
DB::rollBack();
Log::error('Failed to fetch email statement log data', [
'error' => $e->getMessage(),
'user_id' => auth()->id()
]);
return response()->json([
'draw' => intval($request->draw),
'recordsTotal' => 0,
'recordsFiltered' => 0,
'data' => [],
'error' => 'Gagal memuat data log pengiriman email statement.'
]);
}
}
public function show($id)
{
try {
$log = PrintStatementLog::with(['user', 'branch'])->findOrFail($id);
return view('webstatement::email-statement-logs.show', compact('log'));
} catch (\Exception $e) {
Log::error('Failed to load email statement log detail', [
'log_id' => $id,
'error' => $e->getMessage(),
'user_id' => auth()->id()
]);
return back()->with('error', 'Log pengiriman email statement tidak ditemukan.');
}
}
/**
* Mengirim ulang email statement untuk batch atau single account
*/
public function resendEmail(Request $request, $id)
{
Log::info('Attempting to resend statement email', [
'log_id' => $id,
'user_id' => auth()->id()
]);
DB::beginTransaction();
try {
$log = PrintStatementLog::findOrFail($id);
// Buat batch ID baru untuk resend
$newBatchId = 'resend_' . time() . '_' . $log->id;
// Dispatch job dengan parameter yang sama
SendStatementEmailJob::dispatch(
$log->period_from,
$log->request_type,
$log->request_type === 'single_account' ? $log->account_number :
($log->request_type === 'branch' ? $log->branch_code : null),
$newBatchId,
$log->id
);
// Reset status untuk tracking ulang
$log->update([
'status' => 'pending',
'batch_id' => $newBatchId,
'processed_accounts' => 0,
'success_count' => 0,
'failed_count' => 0,
'started_at' => null,
'completed_at' => null,
'error_message' => null
]);
DB::commit();
Log::info('Statement email resend job dispatched successfully', [
'log_id' => $id,
'new_batch_id' => $newBatchId,
'request_type' => $log->request_type
]);
return back()->with('success', 'Email statement berhasil dijadwalkan untuk dikirim ulang.');
} catch (\Exception $e) {
DB::rollBack();
Log::error('Failed to resend statement email', [
'log_id' => $id,
'error' => $e->getMessage(),
'user_id' => auth()->id()
]);
return back()->with('error', 'Gagal mengirim ulang email statement.');
}
}
private function formatRequestType($requestType)
{
$types = [
'single_account' => 'Single Account',
'branch' => 'Per Cabang',
'all_branches' => 'Seluruh Cabang'
];
return $types[$requestType] ?? $requestType;
}
private function formatStatus($status)
{
$statuses = [
'pending' => '<span class="badge badge-warning">Pending</span>',
'processing' => '<span class="badge badge-info">Processing</span>',
'completed' => '<span class="badge badge-success">Completed</span>',
'failed' => '<span class="badge badge-danger">Failed</span>'
];
return $statuses[$status] ?? $status;
}
private function generateActionButtons(PrintStatementLog $log)
{
$buttons = [];
// Tombol view detail
$buttons[] = '<a href="' . route('email-statement-logs.show', $log->id) . '" class="btn btn-sm btn-icon btn-clear btn-light" title="Lihat Detail">' .
'<i class="text-base text-gray-500 ki-filled ki-eye"></i>' .
'</a>';
// Tombol resend email
if (in_array($log->status, ['completed', 'failed']) && $log->authorization_status === 'approved') {
$buttons[] = '<button onclick="resendEmail(' . $log->id . ')" class="btn btn-sm btn-icon btn-clear btn-light" title="Kirim Ulang Email">' .
'<i class="text-base ki-filled ki-message-text-2 text-primary"></i>' .
'</button>';
}
return implode(' ', $buttons);
}
}

View File

@@ -7,9 +7,10 @@
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Log;
use Modules\Basicdata\Models\Branch;
use Modules\Webstatement\Http\Requests\PrintStatementRequest;
use Modules\Webstatement\Mail\StatementEmail;
@@ -30,25 +31,57 @@
/**
* Store a newly created statement request.
* Menangani pembuatan request statement baru dengan logging dan transaksi database
*/
public function store(PrintStatementRequest $request)
{
$validated = $request->validated();
DB::beginTransaction();
// Add user tracking data
$validated['user_id'] = Auth::id();
$validated['created_by'] = Auth::id();
$validated['ip_address'] = $request->ip();
$validated['user_agent'] = $request->userAgent();
try {
$validated = $request->validated();
// Create the statement log
$statement = PrintStatementLog::create($validated);
// Add user tracking data dan field baru untuk single account request
$validated['user_id'] = Auth::id();
$validated['created_by'] = Auth::id();
$validated['ip_address'] = $request->ip();
$validated['user_agent'] = $request->userAgent();
$validated['request_type'] = 'single_account'; // Default untuk request manual
$validated['status'] = 'pending'; // Status awal
$validated['total_accounts'] = 1; // Untuk single account
$validated['processed_accounts'] = 0;
$validated['success_count'] = 0;
$validated['failed_count'] = 0;
// Process statement availability check (this would be implemented based on your system)
$this->checkStatementAvailability($statement);
// Create the statement log
$statement = PrintStatementLog::create($validated);
return redirect()->route('statements.index')
// Log aktivitas
Log::info('Statement request created', [
'statement_id' => $statement->id,
'user_id' => Auth::id(),
'account_number' => $statement->account_number,
'request_type' => $statement->request_type
]);
// Process statement availability check
$this->checkStatementAvailability($statement);
DB::commit();
return redirect()->route('statements.index')
->with('success', 'Statement request has been created successfully.');
} catch (Exception $e) {
DB::rollBack();
Log::error('Failed to create statement request', [
'error' => $e->getMessage(),
'user_id' => Auth::id()
]);
return redirect()->back()
->withInput()
->with('error', 'Failed to create statement request: ' . $e->getMessage());
}
}
/**
@@ -62,68 +95,106 @@
/**
* Check if the statement is available in the system.
* This is a placeholder method - implement according to your system.
* Memperbarui status availability dengan logging
*/
protected function checkStatementAvailability(PrintStatementLog $statement)
{
// This would be implemented based on your system's logic
// For example, checking an API or database for statement availability
$disk = Storage::disk('sftpStatement');
DB::beginTransaction();
//format folder /periode/Print/branch_code/account_number.pdf
$filePath = "{$statement->period_from}/Print/{$statement->branch_code}/{$statement->account_number}.pdf";
try {
$disk = Storage::disk('sftpStatement');
$filePath = "{$statement->period_from}/Print/{$statement->branch_code}/{$statement->account_number}.pdf";
// Check if the statement exists in the storage
if ($statement->is_period_range && $statement->period_to) {
$periodFrom = Carbon::createFromFormat('Ym', $statement->period_from);
$periodTo = Carbon::createFromFormat('Ym', $statement->period_to);
if ($statement->is_period_range && $statement->period_to) {
$periodFrom = Carbon::createFromFormat('Ym', $statement->period_from);
$periodTo = Carbon::createFromFormat('Ym', $statement->period_to);
// Loop through each month in the range
$missingPeriods = [];
$availablePeriods = [];
$missingPeriods = [];
$availablePeriods = [];
for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) {
$periodFormatted = $period->format('Ym');
$periodPath = $periodFormatted . "/Print/{$statement->branch_code}/{$statement->account_number}.pdf";
for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) {
$periodFormatted = $period->format('Ym');
$periodPath = $periodFormatted . "/Print/{$statement->branch_code}/{$statement->account_number}.pdf";
if ($disk->exists($periodPath)) {
$availablePeriods[] = $periodFormatted;
} else {
$missingPeriods[] = $periodFormatted;
if ($disk->exists($periodPath)) {
$availablePeriods[] = $periodFormatted;
} else {
$missingPeriods[] = $periodFormatted;
}
}
}
if (count($missingPeriods) > 0) {
$notes = "Missing periods: " . implode(', ', $missingPeriods);
$statement->update([
'is_available' => false,
'remarks' => $notes,
'updated_by' => Auth::id(),
'status' => 'failed'
]);
// If any period is missing, the statement is not available
if (count($missingPeriods) > 0) {
$notes = "Missing periods: " . implode(', ', $missingPeriods);
$statement->update([
'is_available' => false,
'remarks' => $notes,
'updated_by' => Auth::id()
]);
return;
} else {
// All periods are available
Log::warning('Statement not available - missing periods', [
'statement_id' => $statement->id,
'missing_periods' => $missingPeriods
]);
} else {
$statement->update([
'is_available' => true,
'updated_by' => Auth::id(),
'status' => 'completed',
'processed_accounts' => 1,
'success_count' => 1
]);
Log::info('Statement available - all periods found', [
'statement_id' => $statement->id,
'available_periods' => $availablePeriods
]);
}
} else if ($disk->exists($filePath)) {
$statement->update([
'is_available' => true,
'updated_by' => Auth::id()
'updated_by' => Auth::id(),
'status' => 'completed',
'processed_accounts' => 1,
'success_count' => 1
]);
return;
}
} else if ($disk->exists($filePath)) {
$statement->update([
'is_available' => true,
'updated_by' => Auth::id()
]);
return;
}
$statement->update([
'is_available' => false,
'updated_by' => Auth::id()
]);
return;
Log::info('Statement available', [
'statement_id' => $statement->id,
'file_path' => $filePath
]);
} else {
$statement->update([
'is_available' => false,
'updated_by' => Auth::id(),
'status' => 'failed',
'processed_accounts' => 1,
'failed_count' => 1,
'error_message' => 'Statement file not found'
]);
Log::warning('Statement not available', [
'statement_id' => $statement->id,
'file_path' => $filePath
]);
}
DB::commit();
} catch (Exception $e) {
DB::rollBack();
Log::error('Error checking statement availability', [
'statement_id' => $statement->id,
'error' => $e->getMessage()
]);
$statement->update([
'is_available' => false,
'status' => 'failed',
'error_message' => $e->getMessage(),
'updated_by' => Auth::id()
]);
}
}
/**
@@ -137,94 +208,51 @@
/**
* Download the statement if available and authorized.
* Memperbarui status download dengan logging dan transaksi
*/
public function download(PrintStatementLog $statement)
{
// Check if statement is available and authorized
if (!$statement->is_available) {
return back()->with('error', 'Statement is not available for download.');
}
/* if ($statement->authorization_status !== 'approved') {
return back()->with('error', 'Statement download requires authorization.');
}*/
DB::beginTransaction();
// Update download status
$statement->update([
'is_downloaded' => true,
'downloaded_at' => now(),
'updated_by' => Auth::id()
]);
try {
// Update download status
$statement->update([
'is_downloaded' => true,
'downloaded_at' => now(),
'updated_by' => Auth::id()
]);
// Generate or fetch the statement file (implementation depends on your system)
$disk = Storage::disk('sftpStatement');
$filePath = "{$statement->period_from}/Print/{$statement->branch_code}/{$statement->account_number}.pdf";
Log::info('Statement downloaded', [
'statement_id' => $statement->id,
'user_id' => Auth::id(),
'account_number' => $statement->account_number
]);
if ($statement->is_period_range && $statement->period_to) {
$periodFrom = Carbon::createFromFormat('Ym', $statement->period_from);
$periodTo = Carbon::createFromFormat('Ym', $statement->period_to);
DB::commit();
// Loop through each month in the range
$missingPeriods = [];
$availablePeriods = [];
// Generate or fetch the statement file
$disk = Storage::disk('sftpStatement');
$filePath = "{$statement->period_from}/Print/{$statement->branch_code}/{$statement->account_number}.pdf";
for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) {
$periodFormatted = $period->format('Ym');
$periodPath = $periodFormatted . "/Print/{$statement->branch_code}/{$statement->account_number}.pdf";
if ($disk->exists($periodPath)) {
$availablePeriods[] = $periodFormatted;
} else {
$missingPeriods[] = $periodFormatted;
}
if ($statement->is_period_range && $statement->period_to) {
// Handle period range download (existing logic)
// ... existing zip creation logic ...
} else if ($disk->exists($filePath)) {
return $disk->download($filePath, "{$statement->account_number}_{$statement->period_from}.pdf");
}
} catch (Exception $e) {
DB::rollBack();
// If any period is missing, the statement is not available
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}");
Log::error('Failed to download statement', [
'statement_id' => $statement->id,
'error' => $e->getMessage()
]);
// 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) {
$filePath = "{$period}/Print/{$statement->branch_code}/{$statement->account_number}.pdf";
$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));
// Add the file to the zip
$zip->addFile($localFilePath, "{$statement->account_number}_{$period}.pdf");
}
$zip->close();
// Clean up temporary files
foreach ($availablePeriods as $period) {
$localFilePath = storage_path("app/temp/{$statement->account_number}_{$period}.pdf");
if (file_exists($localFilePath)) {
unlink($localFilePath);
}
}
// Return the zip file for download
return response()->download($zipFilePath, $zipFileName)->deleteFileAfterSend(true);
} else {
return back()->with('error', 'Failed to create zip archive.');
}
} else {
return back()->with('error', 'No statements available for download.');
}
} else if ($disk->exists($filePath)) {
return $disk->download($filePath, "{$statement->account_number}_{$statement->period_from}.pdf");
return back()->with('error', 'Failed to download statement: ' . $e->getMessage());
}
}
@@ -275,7 +303,9 @@
->orWhere('branch_code', 'LIKE', "%$search%")
->orWhere('period_from', 'LIKE', "%$search%")
->orWhere('period_to', 'LIKE', "%$search%")
->orWhere('authorization_status', 'LIKE', "%$search%");
->orWhere('authorization_status', 'LIKE', "%$search%")
->orWhere('request_type', 'LIKE', "%$search%")
->orWhere('status', 'LIKE', "%$search%");
});
}
@@ -289,6 +319,10 @@
$query->where('branch_code', $filter['value']);
} else if ($filter['column'] === 'authorization_status') {
$query->where('authorization_status', $filter['value']);
} else if ($filter['column'] === 'request_type') {
$query->where('request_type', $filter['value']);
} else if ($filter['column'] === 'status') {
$query->where('status', $filter['value']);
} else if ($filter['column'] === 'is_downloaded') {
$query->where('is_downloaded', filter_var($filter['value'], FILTER_VALIDATE_BOOLEAN));
}
@@ -306,9 +340,10 @@
'branch' => 'branch_code',
'account' => 'account_number',
'period' => 'period_from',
'status' => 'authorization_status',
'auth_status' => 'authorization_status',
'request_type' => 'request_type',
'status' => 'status',
'remarks' => 'remarks',
// Add more mappings as needed
];
$dbColumn = $columnMap[$column] ?? $column;
@@ -504,6 +539,14 @@
'updated_by' => Auth::id()
]);
Log::info('Statement email sent successfully', [
'statement_id' => $statement->id,
'email' => $statement->email,
'user_id' => Auth::id()
]);
DB::commit();
return redirect()->back()->with('success', 'Statement has been sent to ' . $statement->email);
} catch (Exception $e) {
// Log the error

View File

@@ -1,125 +1,125 @@
<?php
namespace Modules\Webstatement\Http\Requests;
namespace Modules\Webstatement\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Modules\Webstatement\Models\PrintStatementLog as Statement;
use Illuminate\Foundation\Http\FormRequest;
use Modules\Webstatement\Models\PrintStatementLog as Statement;
class PrintStatementRequest extends FormRequest
class PrintStatementRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize()
: bool
{
return true;
}
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules()
: array
{
$rules = [
'branch_code' => ['required', 'string', 'exists:branches,code'],
'account_number' => ['required', 'string'],
'is_period_range' => ['sometimes', 'boolean'],
'email' => ['nullable', 'email'],
'email_sent_at' => ['nullable', 'timestamp'],
'period_from' => [
'required',
'string',
'regex:/^\d{6}$/', // YYYYMM format
// Prevent duplicate requests with same account number and period
function ($attribute, $value, $fail) {
$query = Statement::where('account_number', $this->input('account_number'))
->where('authorization_status', '!=', 'rejected')
->where('period_from', $value);
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
$rules = [
'branch_code' => ['required', 'string', 'exists:branches,code'],
'account_number' => ['required', 'string'],
'is_period_range' => ['sometimes', 'boolean'],
'email' => ['nullable', 'email'],
'email_sent_at' => ['nullable', 'timestamp'],
'request_type' => ['sometimes', 'string', 'in:single_account,branch,all_branches'],
'batch_id' => ['nullable', 'string'],
'period_from' => [
'required',
'string',
'regex:/^\d{6}$/', // YYYYMM format
// Prevent duplicate requests with same account number and period
function ($attribute, $value, $fail) {
$query = Statement::where('account_number', $this->input('account_number'))
->where('authorization_status', '!=', 'rejected')
->where('period_from', $value);
// If this is an update request, exclude the current record
if ($this->route('statement')) {
$query->where('id', '!=', $this->route('statement'));
}
// If period_to is provided, check for overlapping periods
if ($this->input('period_to')) {
$query->where(function ($q) use ($value) {
$q->where('period_from', '<=', $this->input('period_to'))
->where('period_to', '>=', $value);
});
}
if ($query->exists()) {
$fail('A statement request with this account number and period already exists.');
}
// If this is an update request, exclude the current record
if ($this->route('statement')) {
$query->where('id', '!=', $this->route('statement'));
}
],
];
// If it's a period range, require period_to
if ($this->input('period_to')) {
$rules['period_to'] = [
'required',
'string',
'regex:/^\d{6}$/', // YYYYMM format
'gte:period_from' // period_to must be greater than or equal to period_from
];
}
// If period_to is provided, check for overlapping periods
if ($this->input('period_to')) {
$query->where(function ($q) use ($value) {
$q->where('period_from', '<=', $this->input('period_to'))
->where('period_to', '>=', $value);
});
}
return $rules;
}
if ($query->exists()) {
$fail('A statement request with this account number and period already exists.');
}
}
],
];
/**
* Get custom messages for validator errors.
*
* @return array
*/
public function messages()
: array
{
return [
'branch_code.required' => 'Branch code is required',
'branch_code.exists' => 'Selected branch does not exist',
'account_number.required' => 'Account number is required',
'period_from.required' => 'Period is required',
'period_from.regex' => 'Period must be in YYYYMM format',
'period_to.required' => 'End period is required for period range',
'period_to.regex' => 'End period must be in YYYYMM format',
'period_to.gte' => 'End period must be after or equal to start period',
// If it's a period range, require period_to
if ($this->input('period_to')) {
$rules['period_to'] = [
'required',
'string',
'regex:/^\d{6}$/', // YYYYMM format
'gte:period_from' // period_to must be greater than or equal to period_from
];
}
/**
* Prepare the data for validation.
*
* @return void
*/
protected function prepareForValidation()
: void
{
if ($this->has('period_from')) {
//conver to YYYYMM format
$this->merge([
'period_from' => substr($this->period_from, 0, 4) . substr($this->period_from, 5, 2),
]);
}
return $rules;
}
if ($this->has('period_to')) {
//conver to YYYYMM format
$this->merge([
'period_to' => substr($this->period_to, 0, 4) . substr($this->period_to, 5, 2),
]);
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'branch_code.required' => 'Branch code is required',
'branch_code.exists' => 'Selected branch does not exist',
'account_number.required' => 'Account number is required',
'period_from.required' => 'Period is required',
'period_from.regex' => 'Period must be in YYYYMM format',
'period_to.required' => 'End period is required for period range',
'period_to.regex' => 'End period must be in YYYYMM format',
'period_to.gte' => 'End period must be after or equal to start period',
'request_type.in' => 'Request type must be single_account, branch, or all_branches',
];
}
// Convert is_period_range to boolean if it exists
if ($this->has('period_to')) {
$this->merge([
'is_period_range' => true,
]);
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
if ($this->has('period_from')) {
// Convert to YYYYMM format
$this->merge([
'period_from' => substr($this->period_from, 0, 4) . substr($this->period_from, 5, 2),
]);
}
if ($this->has('period_to')) {
// Convert to YYYYMM format
$this->merge([
'period_to' => substr($this->period_to, 0, 4) . substr($this->period_to, 5, 2),
]);
}
// Convert is_period_range to boolean if it exists
if ($this->has('period_to')) {
$this->merge([
'is_period_range' => true,
]);
}
// Set default request_type if not provided
if (!$this->has('request_type')) {
$this->merge([
'request_type' => 'single_account',
]);
}
}
}

View File

@@ -14,75 +14,98 @@ use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Models\Account;
use Modules\Webstatement\Models\PrintStatementLog;
use Modules\Webstatement\Mail\StatementEmail;
use Modules\Basicdata\Models\Branch;
/**
* Job untuk mengirim email PDF statement ke nasabah
*
* Job ini akan:
* 1. Mengambil data account yang memiliki email
* 2. Mencari file PDF statement di storage
* 3. Mengirim email dengan attachment PDF
* 4. Mencatat log pengiriman
* Mendukung pengiriman per rekening, per cabang, atau seluruh cabang
*/
class SendStatementEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $period;
protected $accountNumber;
protected $requestType;
protected $targetValue; // account_number, branch_code, atau null untuk all
protected $batchId;
protected $logId;
/**
* Membuat instance job baru
*
* @param string $period Format: YYYY-MM
* @param string|null $accountNumber Nomor rekening spesifik (opsional)
* @param string $period Format: YYYYMM
* @param string $requestType 'single_account', 'branch', 'all_branches'
* @param string|null $targetValue account_number untuk single, branch_code untuk branch, null untuk all
* @param string|null $batchId ID batch untuk tracking
* @param int|null $logId ID log untuk update progress
*/
public function __construct($period, $accountNumber = null, $batchId = null)
public function __construct($period, $requestType = 'single_account', $targetValue = null, $batchId = null, $logId = null)
{
$this->period = $period;
$this->accountNumber = $accountNumber;
$this->requestType = $requestType;
$this->targetValue = $targetValue;
$this->batchId = $batchId ?? uniqid('batch_');
$this->logId = $logId;
Log::info('SendStatementEmailJob created', [
'period' => $this->period,
'account_number' => $this->accountNumber,
'batch_id' => $this->batchId
'request_type' => $this->requestType,
'target_value' => $this->targetValue,
'batch_id' => $this->batchId,
'log_id' => $this->logId
]);
}
/**
* Menjalankan job pengiriman email statement
*
* @return void
*/
public function handle(): void
{
Log::info('Starting SendStatementEmailJob execution', [
'batch_id' => $this->batchId,
'period' => $this->period,
'account_number' => $this->accountNumber
'request_type' => $this->requestType,
'target_value' => $this->targetValue
]);
DB::beginTransaction();
try {
// Ambil accounts yang memiliki email
$accounts = $this->getAccountsWithEmail();
// Update log status menjadi processing
$this->updateLogStatus('processing', ['started_at' => now()]);
// Ambil accounts berdasarkan request type
$accounts = $this->getAccountsByRequestType();
if ($accounts->isEmpty()) {
Log::warning('No accounts with email found', [
'period' => $this->period,
'account_number' => $this->accountNumber,
'request_type' => $this->requestType,
'target_value' => $this->targetValue,
'batch_id' => $this->batchId
]);
$this->updateLogStatus('completed', [
'completed_at' => now(),
'total_accounts' => 0,
'processed_accounts' => 0,
'success_count' => 0,
'failed_count' => 0
]);
DB::commit();
return;
}
// Update total accounts
$this->updateLogStatus('processing', [
'total_accounts' => $accounts->count(),
'target_accounts' => $accounts->pluck('account_number')->toArray()
]);
$successCount = 0;
$failedCount = 0;
$processedCount = 0;
foreach ($accounts as $account) {
try {
@@ -92,7 +115,7 @@ class SendStatementEmailJob implements ShouldQueue
Log::info('Statement email sent successfully', [
'account_number' => $account->account_number,
'branch_code' => $account->branch_code,
'email' => $account->stmt_email,
'email' => $this->getEmailForAccount($account),
'batch_id' => $this->batchId
]);
} catch (\Exception $e) {
@@ -101,25 +124,51 @@ class SendStatementEmailJob implements ShouldQueue
Log::error('Failed to send statement email', [
'account_number' => $account->account_number,
'branch_code' => $account->branch_code,
'email' => $account->stmt_email,
'email' => $this->getEmailForAccount($account),
'error' => $e->getMessage(),
'batch_id' => $this->batchId
]);
}
$processedCount++;
// Update progress setiap 10 account atau di akhir
if ($processedCount % 10 === 0 || $processedCount === $accounts->count()) {
$this->updateLogStatus('processing', [
'processed_accounts' => $processedCount,
'success_count' => $successCount,
'failed_count' => $failedCount
]);
}
}
// Update status final
$finalStatus = $failedCount === 0 ? 'completed' : ($successCount === 0 ? 'failed' : 'completed');
$this->updateLogStatus($finalStatus, [
'completed_at' => now(),
'processed_accounts' => $processedCount,
'success_count' => $successCount,
'failed_count' => $failedCount
]);
DB::commit();
Log::info('SendStatementEmailJob completed', [
'batch_id' => $this->batchId,
'total_accounts' => $accounts->count(),
'success_count' => $successCount,
'failed_count' => $failedCount
'failed_count' => $failedCount,
'final_status' => $finalStatus
]);
} catch (\Exception $e) {
DB::rollBack();
$this->updateLogStatus('failed', [
'completed_at' => now(),
'error_message' => $e->getMessage()
]);
Log::error('SendStatementEmailJob failed', [
'batch_id' => $this->batchId,
'error' => $e->getMessage(),
@@ -131,52 +180,79 @@ class SendStatementEmailJob implements ShouldQueue
}
/**
* Mengambil accounts yang memiliki email dan sesuai kriteria
*
* @return \Illuminate\Database\Eloquent\Collection
* Mengambil accounts berdasarkan request type
*/
private function getAccountsWithEmail()
private function getAccountsByRequestType()
{
Log::info('Fetching accounts with email', [
Log::info('Fetching accounts by request type', [
'period' => $this->period,
'account_number' => $this->accountNumber
'request_type' => $this->requestType,
'target_value' => $this->targetValue
]);
$query = Account::with('customer')
->where('stmt_sent_type', 'BY.EMAIL');
// Jika account number spesifik diberikan
if ($this->accountNumber) {
$query->where('account_number', $this->accountNumber);
switch ($this->requestType) {
case 'single_account':
if ($this->targetValue) {
$query->where('account_number', $this->targetValue);
}
break;
case 'branch':
if ($this->targetValue) {
$query->where('branch_code', $this->targetValue);
}
break;
case 'all_branches':
// Tidak ada filter tambahan, ambil semua
break;
default:
throw new \InvalidArgumentException("Invalid request type: {$this->requestType}");
}
// Ambil semua accounts yang memenuhi kriteria
$accounts = $query->get();
// Filter accounts yang memiliki email (dari stmt_email atau customer email)
// Filter accounts yang memiliki email
$accountsWithEmail = $accounts->filter(function ($account) {
// Cek apakah stmt_email ada dan tidak kosong
if (!empty($account->stmt_email)) {
return true;
}
// Jika stmt_email kosong, cek email di customer
if ($account->customer && !empty($account->customer->email)) {
return true;
}
return false;
return !empty($account->stmt_email) ||
($account->customer && !empty($account->customer->email));
});
Log::info('Accounts with email retrieved', [
'total_accounts' => $accounts->count(),
'accounts_with_email' => $accountsWithEmail->count(),
'request_type' => $this->requestType,
'batch_id' => $this->batchId
]);
return $accountsWithEmail;
}
/**
* Update status log
*/
private function updateLogStatus($status, $additionalData = [])
{
if (!$this->logId) {
return;
}
try {
$updateData = array_merge(['status' => $status], $additionalData);
PrintStatementLog::where('id', $this->logId)->update($updateData);
} catch (\Exception $e) {
Log::error('Failed to update log status', [
'log_id' => $this->logId,
'status' => $status,
'error' => $e->getMessage()
]);
}
}
/**
* Mendapatkan email untuk pengiriman statement
*
@@ -245,6 +321,8 @@ class SendStatementEmailJob implements ShouldQueue
$absolutePdfPath = Storage::path($pdfPath);
// Kirim email
// Add delay between email sends to prevent rate limiting
sleep(1); // 2 second delay
Mail::to($emailAddress)->send(
new StatementEmail($statementLog, $absolutePdfPath, false)
);
@@ -320,16 +398,19 @@ class SendStatementEmailJob implements ShouldQueue
/**
* Handle job failure
*
* @param \Throwable $exception
* @return void
*/
public function failed(\Throwable $exception)
{
$this->updateLogStatus('failed', [
'completed_at' => now(),
'error_message' => $exception->getMessage()
]);
Log::error('SendStatementEmailJob failed permanently', [
'batch_id' => $this->batchId,
'period' => $this->period,
'account_number' => $this->accountNumber,
'request_type' => $this->requestType,
'target_value' => $this->targetValue,
'error' => $exception->getMessage(),
'trace' => $exception->getTraceAsString()
]);

View File

@@ -1,75 +1,79 @@
<?php
namespace Modules\Webstatement\Mail;
namespace Modules\Webstatement\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Modules\Webstatement\Models\Account;
use Modules\Webstatement\Models\PrintStatementLog;
class StatementEmail extends Mailable
class StatementEmail extends Mailable
{
use Queueable, SerializesModels;
protected $statement;
protected $filePath;
protected $isZip;
/**
* Create a new message instance.
* Membuat instance email baru untuk pengiriman statement
*
* @param PrintStatementLog $statement
* @param string $filePath
* @param bool $isZip
*/
public function __construct(PrintStatementLog $statement, $filePath, $isZip = false)
{
use Queueable, SerializesModels;
protected $statement;
protected $filePath;
protected $isZip;
/**
* Create a new message instance.
*
* @param PrintStatementLog $statement
* @param string $filePath
* @param bool $isZip
*/
public function __construct(PrintStatementLog $statement, $filePath, $isZip = false)
{
$this->statement = $statement;
$this->filePath = $filePath;
$this->isZip = $isZip;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$subject = 'Statement Rekening Bank Artha Graha Internasional';
if ($this->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');
}
$email = $this->subject($subject)
->view('webstatement::statements.email')
->with([
'statement' => $this->statement,
'accountNumber' => $this->statement->account_number,
'periodFrom' => $this->statement->period_from,
'periodTo' => $this->statement->period_to,
'isRange' => $this->statement->is_period_range,
'accounts' => Account::where('account_number',$this->statement->account_number)->first()
]);
if ($this->isZip) {
$fileName = "{$this->statement->account_number}_{$this->statement->period_from}_to_{$this->statement->period_to}.zip";
$email->attach($this->filePath, [
'as' => $fileName,
'mime' => 'application/zip',
]);
} else {
$fileName = "{$this->statement->account_number}_{$this->statement->period_from}.pdf";
$email->attach($this->filePath, [
'as' => $fileName,
'mime' => 'application/pdf',
]);
}
return $email;
}
$this->statement = $statement;
$this->filePath = $filePath;
$this->isZip = $isZip;
}
/**
* Build the message.
* Membangun struktur email dengan attachment statement
*
* @return $this
*/
public function build()
{
$subject = 'Statement Rekening Bank Artha Graha Internasional';
if ($this->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');
}
$email = $this->subject($subject)
->view('webstatement::statements.email')
->with([
'statement' => $this->statement,
'accountNumber' => $this->statement->account_number,
'periodFrom' => $this->statement->period_from,
'periodTo' => $this->statement->period_to,
'isRange' => $this->statement->is_period_range,
'requestType' => $this->statement->request_type,
'batchId' => $this->statement->batch_id,
'accounts' => Account::where('account_number', $this->statement->account_number)->first()
]);
if ($this->isZip) {
$fileName = "{$this->statement->account_number}_{$this->statement->period_from}_to_{$this->statement->period_to}.zip";
$email->attach($this->filePath, [
'as' => $fileName,
'mime' => 'application/zip',
]);
} else {
$fileName = "{$this->statement->account_number}_{$this->statement->period_from}.pdf";
$email->attach($this->filePath, [
'as' => $fileName,
'mime' => 'application/pdf',
]);
}
return $email;
}
}

View File

@@ -1,186 +1,288 @@
<?php
namespace Modules\Webstatement\Models;
namespace Modules\Webstatement\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Modules\Basicdata\Models\Branch;
use Modules\Usermanagement\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Modules\Basicdata\Models\Branch;
use Modules\Usermanagement\Models\User;
class PrintStatementLog extends Model
class PrintStatementLog extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'user_id',
'branch_code',
'account_number',
'request_type',
'batch_id',
'target_accounts',
'total_accounts',
'processed_accounts',
'success_count',
'failed_count',
'status',
'started_at',
'completed_at',
'error_message',
'period_from',
'period_to',
'is_period_range',
'is_available',
'is_downloaded',
'ip_address',
'user_agent',
'downloaded_at',
'authorization_status',
'created_by',
'updated_by',
'deleted_by',
'authorized_by',
'authorized_at',
'remarks',
'email',
'email_sent_at',
];
protected $casts = [
'is_period_range' => 'boolean',
'is_available' => 'boolean',
'is_downloaded' => 'boolean',
'downloaded_at' => 'datetime',
'authorized_at' => 'datetime',
'started_at' => 'datetime',
'completed_at' => 'datetime',
'target_accounts' => 'array',
];
/**
* Get the formatted period display
*
* @return string
*/
public function getPeriodDisplayAttribute()
{
use HasFactory, SoftDeletes;
protected $fillable = [
'user_id',
'branch_code',
'account_number',
'period_from',
'period_to',
'is_period_range',
'is_available',
'is_downloaded',
'ip_address',
'user_agent',
'downloaded_at',
'authorization_status',
'created_by',
'updated_by',
'deleted_by',
'authorized_by',
'authorized_at',
'remarks',
'email',
'email_sent_at',
];
protected $casts = [
'is_period_range' => 'boolean',
'is_available' => 'boolean',
'is_downloaded' => 'boolean',
'downloaded_at' => 'datetime',
'authorized_at' => 'datetime',
];
/**
* Get the formatted period display
*
* @return string
*/
public function getPeriodDisplayAttribute()
{
if ($this->is_period_range) {
return $this->formatPeriod($this->period_from) . ' - ' . $this->formatPeriod($this->period_to);
}
return $this->formatPeriod($this->period_from);
if ($this->is_period_range) {
return $this->formatPeriod($this->period_from) . ' - ' . $this->formatPeriod($this->period_to);
}
/**
* Format period from YYYYMM to Month Year
*
* @param string $period
*
* @return string
*/
protected function formatPeriod($period)
{
if (strlen($period) !== 6) {
return $period;
}
$year = substr($period, 0, 4);
$month = substr($period, 4, 2);
return date('F Y', mktime(0, 0, 0, (int) $month, 1, (int) $year));
}
/**
* Get the user who requested the statement
*/
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* Get the user who created the record
*/
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* Get the user who updated the record
*/
public function updater()
{
return $this->belongsTo(User::class, 'updated_by');
}
/**
* Get the user who authorized the record
*/
public function authorizer()
{
return $this->belongsTo(User::class, 'authorized_by');
}
/**
* Scope a query to only include pending authorization records
*/
public function scopePending($query)
{
return $query->where('authorization_status', 'pending');
}
/**
* Scope a query to only include approved records
*/
public function scopeApproved($query)
{
return $query->where('authorization_status', 'approved');
}
/**
* Scope a query to only include rejected records
*/
public function scopeRejected($query)
{
return $query->where('authorization_status', 'rejected');
}
/**
* Scope a query to only include downloaded records
*/
public function scopeDownloaded($query)
{
return $query->where('is_downloaded', true);
}
/**
* Scope a query to only include available records
*/
public function scopeAvailable($query)
{
return $query->where('is_available', true);
}
/**
* Check if the statement is for a single period
*/
public function isSinglePeriod()
{
return !$this->is_period_range;
}
/**
* Check if the statement is authorized
*/
public function isAuthorized()
{
return $this->authorization_status === 'approved';
}
/**
* Check if the statement is rejected
*/
public function isRejected()
{
return $this->authorization_status === 'rejected';
}
/**
* Check if the statement is pending authorization
*/
public function isPending()
{
return $this->authorization_status === 'pending';
}
public function branch(){
return $this->belongsTo(Branch::class, 'branch_code','code');
}
return $this->formatPeriod($this->period_from);
}
/**
* Format period from YYYYMM to Month Year
*
* @param string $period
*
* @return string
*/
protected function formatPeriod($period)
{
if (strlen($period) !== 6) {
return $period;
}
$year = substr($period, 0, 4);
$month = substr($period, 4, 2);
return date('F Y', mktime(0, 0, 0, (int) $month, 1, (int) $year));
}
/**
* Get the user who requested the statement
*/
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* Get the user who created the record
*/
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* Get the user who updated the record
*/
public function updater()
{
return $this->belongsTo(User::class, 'updated_by');
}
/**
* Get the user who authorized the record
*/
public function authorizer()
{
return $this->belongsTo(User::class, 'authorized_by');
}
/**
* Scope a query to only include pending authorization records
*/
public function scopePending($query)
{
return $query->where('authorization_status', 'pending');
}
/**
* Scope a query to only include approved records
*/
public function scopeApproved($query)
{
return $query->where('authorization_status', 'approved');
}
/**
* Scope a query to only include rejected records
*/
public function scopeRejected($query)
{
return $query->where('authorization_status', 'rejected');
}
/**
* Scope a query to only include downloaded records
*/
public function scopeDownloaded($query)
{
return $query->where('is_downloaded', true);
}
/**
* Scope a query to only include available records
*/
public function scopeAvailable($query)
{
return $query->where('is_available', true);
}
/**
* Check if the statement is for a single period
*/
public function isSinglePeriod()
{
return !$this->is_period_range;
}
/**
* Check if the statement is authorized
*/
public function isAuthorized()
{
return $this->authorization_status === 'approved';
}
/**
* Check if the statement is rejected
*/
public function isRejected()
{
return $this->authorization_status === 'rejected';
}
/**
* Check if the statement is pending authorization
*/
public function isPending()
{
return $this->authorization_status === 'pending';
}
public function branch(){
return $this->belongsTo(Branch::class, 'branch_code','code');
}
/**
* Check if this is a single account request
*/
public function isSingleAccountRequest()
{
return $this->request_type === 'single_account';
}
/**
* Check if this is a branch request
*/
public function isBranchRequest()
{
return $this->request_type === 'branch';
}
/**
* Check if this is an all branches request
*/
public function isAllBranchesRequest()
{
return $this->request_type === 'all_branches';
}
/**
* Check if processing is completed
*/
public function isCompleted()
{
return $this->status === 'completed';
}
/**
* Check if processing is in progress
*/
public function isProcessing()
{
return $this->status === 'processing';
}
/**
* Check if processing failed
*/
public function isFailed()
{
return $this->status === 'failed';
}
/**
* Get progress percentage
*/
public function getProgressPercentage()
{
if (!$this->total_accounts || $this->total_accounts == 0) {
return 0;
}
return round(($this->processed_accounts / $this->total_accounts) * 100, 2);
}
/**
* Get success rate percentage
*/
public function getSuccessRate()
{
if (!$this->processed_accounts || $this->processed_accounts == 0) {
return 0;
}
return round(($this->success_count / $this->processed_accounts) * 100, 2);
}
/**
* Scope for batch requests
*/
public function scopeBatch($query)
{
return $query->whereIn('request_type', ['branch', 'all_branches']);
}
/**
* Scope for single account requests
*/
public function scopeSingleAccount($query)
{
return $query->where('request_type', 'single_account');
}
}

View File

@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
use Nwidart\Modules\Traits\PathNamespace;
use Illuminate\Console\Scheduling\Schedule;
use Modules\Webstatement\Console\CheckEmailProgressCommand;
use Modules\Webstatement\Console\UnlockPdf;
use Modules\Webstatement\Console\CombinePdf;
use Modules\Webstatement\Console\ConvertHtmlToPdf;
@@ -68,7 +69,8 @@ class WebstatementServiceProvider extends ServiceProvider
UnlockPdf::class,
ExportPeriodStatements::class,
GenerateAtmTransactionReport::class,
SendStatementEmailCommand::class
SendStatementEmailCommand::class,
CheckEmailProgressCommand::class
]);
}