Merge branch 'new'

# Conflicts:
#	app/Http/Controllers/PrintStatementController.php
#	app/Jobs/GenerateBiayaKartuCsvJob.php
#	app/Jobs/SendStatementEmailJob.php
#	app/Mail/StatementEmail.php
#	resources/views/statements/email.blade.php
#	resources/views/statements/index.blade.php
This commit is contained in:
Daeng Deni Mardaeni
2025-07-08 21:46:55 +07:00
17 changed files with 2241 additions and 1182 deletions

View File

@@ -1,22 +1,23 @@
<?php <?php
namespace Modules\Webstatement\Console; namespace Modules\Webstatement\Console;
use Exception; use Exception;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Modules\Webstatement\Jobs\SendStatementEmailJob; use Modules\Basicdata\Models\Branch;
use Modules\Webstatement\Models\Account; use Modules\Webstatement\Jobs\SendStatementEmailJob;
use Modules\Webstatement\Models\PrintStatementLog; use Modules\Webstatement\Models\Account;
use Modules\Basicdata\Models\Branch; use Modules\Webstatement\Models\PrintStatementLog;
use InvalidArgumentException;
/** /**
* Command untuk mengirim email statement PDF ke nasabah * Command untuk mengirim email statement PDF ke nasabah
* Mendukung pengiriman per rekening, per cabang, atau seluruh cabang * Mendukung pengiriman per rekening, per cabang, atau seluruh cabang
*/ */
class SendStatementEmailCommand extends Command class SendStatementEmailCommand extends Command
{ {
protected $signature = 'webstatement:send-email protected $signature = 'webstatement:send-email
{period : Format periode YYYYMM (contoh: 202401)} {period : Format periode YYYYMM (contoh: 202401)}
{--type=single : Tipe pengiriman: single, branch, all} {--type=single : Tipe pengiriman: single, branch, all}
{--account= : Nomor rekening (untuk type=single)} {--account= : Nomor rekening (untuk type=single)}
@@ -25,224 +26,224 @@ class SendStatementEmailCommand extends Command
{--queue=emails : Nama queue untuk job (default: emails)} {--queue=emails : Nama queue untuk job (default: emails)}
{--delay=0 : Delay dalam menit sebelum job dijalankan}'; {--delay=0 : Delay dalam menit sebelum job dijalankan}';
protected $description = 'Mengirim email statement PDF ke nasabah (per rekening, per cabang, atau seluruh cabang)'; protected $description = 'Mengirim email statement PDF ke nasabah (per rekening, per cabang, atau seluruh cabang)';
public function handle() public function handle()
{ {
$this->info('🚀 Memulai proses pengiriman email statement...'); $this->info('🚀 Memulai proses pengiriman email statement...');
try { try {
$period = $this->argument('period'); $period = $this->argument('period');
$type = $this->option('type'); $type = $this->option('type');
$accountNumber = $this->option('account'); $accountNumber = $this->option('account');
$branchCode = $this->option('branch'); $branchCode = $this->option('branch');
$batchId = $this->option('batch-id'); $batchId = $this->option('batch-id');
$queueName = $this->option('queue'); $queueName = $this->option('queue');
$delay = (int) $this->option('delay'); $delay = (int) $this->option('delay');
// Validasi parameter // Validasi parameter
if (!$this->validateParameters($period, $type, $accountNumber, $branchCode)) { if (!$this->validateParameters($period, $type, $accountNumber, $branchCode)) {
return Command::FAILURE;
}
// 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, $requestType, $targetValue, $batchId, $log->id)
->onQueue($queueName);
if ($delay > 0) {
$job->delay(now()->addMinutes($delay));
$this->info("⏰ Job dijadwalkan untuk dijalankan dalam {$delay} menit");
}
$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 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; return Command::FAILURE;
} }
}
// Tentukan request type dan target value private function validateParameters($period, $type, $accountNumber, $branchCode)
[$requestType, $targetValue] = $this->determineRequestTypeAndTarget($type, $accountNumber, $branchCode); {
// Validasi format periode
// Buat log entry if (!preg_match('/^\d{6}$/', $period)) {
$log = $this->createLogEntry($period, $requestType, $targetValue, $batchId); $this->error('❌ Format periode tidak valid. Gunakan format YYYYMM (contoh: 202401)');
return false;
// Dispatch job
$job = SendStatementEmailJob::dispatch($period, $requestType, $targetValue, $batchId, $log->id)
->onQueue($queueName);
if ($delay > 0) {
$job->delay(now()->addMinutes($delay));
$this->info("⏰ Job dijadwalkan untuk dijalankan dalam {$delay} menit");
} }
$this->displayJobInfo($period, $requestType, $targetValue, $queueName, $log); // Validasi type
$this->info('✅ Job pengiriman email statement berhasil didispatch!'); if (!in_array($type, ['single', 'branch', 'all'])) {
$this->info('📊 Gunakan command berikut untuk monitoring:'); $this->error('❌ Type tidak valid. Gunakan: single, branch, atau all');
$this->line(" php artisan queue:work {$queueName}"); return false;
$this->line(' php artisan webstatement:check-progress ' . $log->id); }
return Command::SUCCESS; // Validasi parameter berdasarkan type
switch ($type) {
case 'single':
if (!$accountNumber) {
$this->error('❌ Parameter --account diperlukan untuk type=single');
return false;
}
} catch (Exception $e) { $account = Account::with('customer')
$this->error('❌ Error saat mendispatch job: ' . $e->getMessage()); ->where('account_number', $accountNumber)
Log::error('SendStatementEmailCommand failed', [ ->first();
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString() if (!$account) {
]); $this->error("❌ Account {$accountNumber} tidak ditemukan");
return Command::FAILURE; return false;
}
$hasEmail = !empty($account->stmt_email) ||
($account->customer && !empty($account->customer->email));
if (!$hasEmail) {
$this->error("❌ Account {$accountNumber} tidak memiliki email");
return false;
}
$this->info("✅ Account {$accountNumber} ditemukan dengan email");
break;
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;
}
private function determineRequestTypeAndTarget($type, $accountNumber, $branchCode)
{
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}");
} }
} }
private function validateParameters($period, $type, $accountNumber, $branchCode)
{
// Validasi format periode
if (!preg_match('/^\d{6}$/', $period)) {
$this->error('❌ Format periode tidak valid. Gunakan format YYYYMM (contoh: 202401)');
return false;
}
// Validasi type
if (!in_array($type, ['single', 'branch', 'all'])) {
$this->error('❌ Type tidak valid. Gunakan: single, branch, atau all');
return false;
}
// Validasi parameter berdasarkan type
switch ($type) {
case 'single':
if (!$accountNumber) {
$this->error('❌ Parameter --account diperlukan untuk type=single');
return false;
}
$account = Account::with('customer')
->where('account_number', $accountNumber)
->first();
if (!$account) {
$this->error("❌ Account {$accountNumber} tidak ditemukan");
return false;
}
$hasEmail = !empty($account->stmt_email) ||
($account->customer && !empty($account->customer->email));
if (!$hasEmail) {
$this->error("❌ Account {$accountNumber} tidak memiliki email");
return false;
}
$this->info("✅ Account {$accountNumber} ditemukan dengan email");
break;
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;
}
private function determineRequestTypeAndTarget($type, $accountNumber, $branchCode)
{
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,110 @@
<?php
namespace Modules\Webstatement\Console;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\Webstatement\Jobs\UpdateAllAtmCardsBatchJob;
class UpdateAllAtmCardsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'atmcard:update-all
{--sync-log-id= : ID sync log yang akan digunakan}
{--batch-size=100 : Ukuran batch untuk processing}
{--queue=atmcard-update : Nama queue untuk job}
{--filters= : Filter JSON untuk kondisi kartu}
{--dry-run : Preview tanpa eksekusi aktual}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Jalankan job untuk update seluruh kartu ATM secara batch';
/**
* Execute the console command.
*
* @return int
*/
public function handle(): int
{
Log::info('Memulai command update seluruh kartu ATM');
try {
$syncLogId = $this->option('sync-log-id');
$batchSize = (int) $this->option('batch-size');
$queueName = $this->option('queue');
$filtersJson = $this->option('filters');
$isDryRun = $this->option('dry-run');
// Parse filters jika ada
$filters = [];
if ($filtersJson) {
$filters = json_decode($filtersJson, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->error('Format JSON filters tidak valid');
return Command::FAILURE;
}
}
// Validasi input
if ($batchSize <= 0) {
$this->error('Batch size harus lebih besar dari 0');
return Command::FAILURE;
}
$this->info('Konfigurasi job:');
$this->info("- Sync Log ID: " . ($syncLogId ?: 'Akan dibuat baru'));
$this->info("- Batch Size: {$batchSize}");
$this->info("- Queue: {$queueName}");
$this->info("- Filters: " . ($filtersJson ?: 'Tidak ada'));
$this->info("- Dry Run: " . ($isDryRun ? 'Ya' : 'Tidak'));
if ($isDryRun) {
$this->warn('Mode DRY RUN - Job tidak akan dijalankan');
return Command::SUCCESS;
}
// Konfirmasi sebelum menjalankan
if (!$this->confirm('Apakah Anda yakin ingin menjalankan job update seluruh kartu ATM?')) {
$this->info('Operasi dibatalkan');
return Command::SUCCESS;
}
// Dispatch job
$job = new UpdateAllAtmCardsBatchJob($syncLogId, $batchSize, $filters);
$job->onQueue($queueName);
dispatch($job);
$this->info('Job berhasil dijadwalkan!');
$this->info("Queue: {$queueName}");
$this->info('Gunakan command berikut untuk memonitor:');
$this->info('php artisan queue:work --queue=' . $queueName);
Log::info('Command update seluruh kartu ATM selesai', [
'sync_log_id' => $syncLogId,
'batch_size' => $batchSize,
'queue' => $queueName,
'filters' => $filters
]);
return Command::SUCCESS;
} catch (Exception $e) {
$this->error('Terjadi error: ' . $e->getMessage());
Log::error('Error dalam command update seluruh kartu ATM: ' . $e->getMessage(), [
'file' => $e->getFile(),
'line' => $e->getLine()
]);
return Command::FAILURE;
}
}
}

View File

@@ -12,6 +12,7 @@ use Modules\Webstatement\{
Http\Requests\PrintStatementRequest, Http\Requests\PrintStatementRequest,
Mail\StatementEmail, Mail\StatementEmail,
Models\PrintStatementLog, Models\PrintStatementLog,
Models\Account,
Models\AccountBalance, Models\AccountBalance,
Jobs\ExportStatementPeriodJob Jobs\ExportStatementPeriodJob
}; };
@@ -24,9 +25,15 @@ use ZipArchive;
*/ */
public function index(Request $request) public function index(Request $request)
{ {
$branches = Branch::orderBy('name')->get(); $branches = Branch::whereNotNull('customer_company')
->where('code', '!=', 'ID0019999')
->orderBy('name')
->get();
return view('webstatement::statements.index', compact('branches')); $branch = Branch::find(Auth::user()->branch_id);
$multiBranch = session('MULTI_BRANCH') ?? false;
return view('webstatement::statements.index', compact('branches', 'branch', 'multiBranch'));
} }
/** /**
@@ -35,33 +42,67 @@ use ZipArchive;
*/ */
public function store(PrintStatementRequest $request) public function store(PrintStatementRequest $request)
{ {
// Add account verification before storing
$accountNumber = $request->input('account_number'); // Assuming this is the field name for account number
// First, check if the account exists and get branch information
$account = Account::where('account_number', $accountNumber)->first();
if ($account) {
$branch_code = $account->branch_code;
$userBranchId = session('branch_id'); // Assuming branch ID is stored in session
$multiBranch = session('MULTI_BRANCH');
if (!$multiBranch) {
// Check if account branch matches user's branch
if ($account->branch_id !== $userBranchId) {
return redirect()->route('statements.index')
->with('error', 'Nomor rekening tidak sesuai dengan cabang Anda. Transaksi tidak dapat dilanjutkan.');
}
}
// Check if account belongs to restricted branch ID0019999
if ($account->branch_id === 'ID0019999') {
return redirect()->route('statements.index')
->with('error', 'Nomor rekening terdaftar pada cabang khusus. Silakan hubungi bagian HC untuk informasi lebih lanjut.');
}
// If all checks pass, proceed with storing data
// Your existing store logic here
} else {
// Account not found
return redirect()->route('statements.index')
->with('error', 'Nomor rekening tidak ditemukan dalam sistem.');
}
DB::beginTransaction(); DB::beginTransaction();
try { try {
$validated = $request->validated(); $validated = $request->validated();
// Add user tracking data dan field baru untuk single account request // Add user tracking data dan field baru untuk single account request
$validated['user_id'] = Auth::id(); $validated['user_id'] = Auth::id();
$validated['created_by'] = Auth::id(); $validated['created_by'] = Auth::id();
$validated['ip_address'] = $request->ip(); $validated['ip_address'] = $request->ip();
$validated['user_agent'] = $request->userAgent(); $validated['user_agent'] = $request->userAgent();
$validated['request_type'] = 'single_account'; // Default untuk request manual $validated['request_type'] = 'single_account'; // Default untuk request manual
$validated['status'] = 'pending'; // Status awal $validated['status'] = 'pending'; // Status awal
$validated['total_accounts'] = 1; // Untuk single account $validated['authorization_status'] = 'approved'; // Status otorisasi awal
$validated['total_accounts'] = 1; // Untuk single account
$validated['processed_accounts'] = 0; $validated['processed_accounts'] = 0;
$validated['success_count'] = 0; $validated['success_count'] = 0;
$validated['failed_count'] = 0; $validated['failed_count'] = 0;
$validated['stmt_sent_type'] = implode(',', $request->input('stmt_sent_type')); $validated['stmt_sent_type'] = implode(',', $request->input('stmt_sent_type'));
$validated['branch_code'] = $branch_code; // Awal tidak tersedia
// Create the statement log // Create the statement log
$statement = PrintStatementLog::create($validated); $statement = PrintStatementLog::create($validated);
// Log aktivitas // Log aktivitas
Log::info('Statement request created', [ Log::info('Statement request created', [
'statement_id' => $statement->id, 'statement_id' => $statement->id,
'user_id' => Auth::id(), 'user_id' => Auth::id(),
'account_number' => $statement->account_number, 'account_number' => $statement->account_number,
'request_type' => $statement->request_type 'request_type' => $statement->request_type
]); ]);
// Process statement availability check // Process statement availability check
@@ -69,21 +110,26 @@ use ZipArchive;
if(!$statement->is_available){ if(!$statement->is_available){
$this->printStatementRekening($statement->account_number,$statement->period_from,$statement->period_to,$statement->stmt_sent_type); $this->printStatementRekening($statement->account_number,$statement->period_from,$statement->period_to,$statement->stmt_sent_type);
} }
$statement = PrintStatementLog::find($statement->id);
if($statement->email){
$this->sendEmail($statement->id);
}
DB::commit(); DB::commit();
return redirect()->route('statements.index') return redirect()->route('statements.index')
->with('success', 'Statement request has been created successfully.'); ->with('success', 'Statement request has been created successfully.');
} catch (Exception $e) { } catch (Exception $e) {
DB::rollBack(); DB::rollBack();
Log::error('Failed to create statement request', [ Log::error('Failed to create statement request', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'user_id' => Auth::id() 'user_id' => Auth::id()
]); ]);
return redirect()->back() return redirect()->back()
->withInput() ->withInput()
->with('error', 'Failed to create statement request: ' . $e->getMessage()); ->with('error', 'Failed to create statement request: ' . $e->getMessage());
} }
} }
@@ -105,19 +151,26 @@ use ZipArchive;
DB::beginTransaction(); DB::beginTransaction();
try { try {
$disk = Storage::disk('sftpStatement'); $disk = Storage::disk('sftpStatement');
$filePath = "{$statement->period_from}/Print/{$statement->branch_code}/{$statement->account_number}.pdf"; $filePath = "{$statement->period_from}/{$statement->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf";
// Log untuk debugging
Log::info('Checking SFTP file path', [
'file_path' => $filePath,
'sftp_root' => config('filesystems.disks.sftpStatement.root'),
'full_path' => config('filesystems.disks.sftpStatement.root') . '/' . $filePath
]);
if ($statement->is_period_range && $statement->period_to) { if ($statement->is_period_range && $statement->period_to) {
$periodFrom = Carbon::createFromFormat('Ym', $statement->period_from); $periodFrom = Carbon::createFromFormat('Ym', $statement->period_from);
$periodTo = Carbon::createFromFormat('Ym', $statement->period_to); $periodTo = Carbon::createFromFormat('Ym', $statement->period_to);
$missingPeriods = []; $missingPeriods = [];
$availablePeriods = []; $availablePeriods = [];
for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) { for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) {
$periodFormatted = $period->format('Ym'); $periodFormatted = $period->format('Ym');
$periodPath = $periodFormatted . "/Print/{$statement->branch_code}/{$statement->account_number}.pdf"; $periodPath = $periodFormatted . "/{$statement->branch_code}/{$statement->account_number}_{$periodFormatted}.pdf";
if ($disk->exists($periodPath)) { if ($disk->exists($periodPath)) {
$availablePeriods[] = $periodFormatted; $availablePeriods[] = $periodFormatted;
@@ -130,55 +183,55 @@ use ZipArchive;
$notes = "Missing periods: " . implode(', ', $missingPeriods); $notes = "Missing periods: " . implode(', ', $missingPeriods);
$statement->update([ $statement->update([
'is_available' => false, 'is_available' => false,
'remarks' => $notes, 'remarks' => $notes,
'updated_by' => Auth::id(), 'updated_by' => Auth::id(),
'status' => 'failed' 'status' => 'failed'
]); ]);
Log::warning('Statement not available - missing periods', [ Log::warning('Statement not available - missing periods', [
'statement_id' => $statement->id, 'statement_id' => $statement->id,
'missing_periods' => $missingPeriods 'missing_periods' => $missingPeriods
]); ]);
} else { } else {
$statement->update([ $statement->update([
'is_available' => true, 'is_available' => true,
'updated_by' => Auth::id(), 'updated_by' => Auth::id(),
'status' => 'completed', 'status' => 'completed',
'processed_accounts' => 1, 'processed_accounts' => 1,
'success_count' => 1 'success_count' => 1
]); ]);
Log::info('Statement available - all periods found', [ Log::info('Statement available - all periods found', [
'statement_id' => $statement->id, 'statement_id' => $statement->id,
'available_periods' => $availablePeriods 'available_periods' => $availablePeriods
]); ]);
} }
} else if ($disk->exists($filePath)) { } else if ($disk->exists($filePath)) {
$statement->update([ $statement->update([
'is_available' => true, 'is_available' => true,
'updated_by' => Auth::id(), 'updated_by' => Auth::id(),
'status' => 'completed', 'status' => 'completed',
'processed_accounts' => 1, 'processed_accounts' => 1,
'success_count' => 1 'success_count' => 1
]); ]);
Log::info('Statement available', [ Log::info('Statement available', [
'statement_id' => $statement->id, 'statement_id' => $statement->id,
'file_path' => $filePath 'file_path' => $filePath
]); ]);
} else { } else {
$statement->update([ $statement->update([
'is_available' => false, 'is_available' => false,
'updated_by' => Auth::id(), 'updated_by' => Auth::id(),
'status' => 'failed', 'status' => 'failed',
'processed_accounts' => 1, 'processed_accounts' => 1,
'failed_count' => 1, 'failed_count' => 1,
'error_message' => 'Statement file not found' 'error_message' => 'Statement file not found'
]); ]);
Log::warning('Statement not available', [ Log::warning('Statement not available', [
'statement_id' => $statement->id, 'statement_id' => $statement->id,
'file_path' => $filePath 'file_path' => $filePath
]); ]);
} }
@@ -188,14 +241,14 @@ use ZipArchive;
Log::error('Error checking statement availability', [ Log::error('Error checking statement availability', [
'statement_id' => $statement->id, 'statement_id' => $statement->id,
'error' => $e->getMessage() 'error' => $e->getMessage()
]); ]);
$statement->update([ $statement->update([
'is_available' => false, 'is_available' => false,
'status' => 'failed', 'status' => 'failed',
'error_message' => $e->getMessage(), 'error_message' => $e->getMessage(),
'updated_by' => Auth::id() 'updated_by' => Auth::id()
]); ]);
} }
} }
@@ -226,33 +279,161 @@ use ZipArchive;
$statement->update([ $statement->update([
'is_downloaded' => true, 'is_downloaded' => true,
'downloaded_at' => now(), 'downloaded_at' => now(),
'updated_by' => Auth::id() 'updated_by' => Auth::id()
]); ]);
Log::info('Statement downloaded', [ Log::info('Statement downloaded', [
'statement_id' => $statement->id, 'statement_id' => $statement->id,
'user_id' => Auth::id(), 'user_id' => Auth::id(),
'account_number' => $statement->account_number 'account_number' => $statement->account_number
]); ]);
DB::commit(); DB::commit();
// Generate or fetch the statement file // Generate or fetch the statement file
$disk = Storage::disk('sftpStatement'); $disk = Storage::disk('sftpStatement');
$filePath = "{$statement->period_from}/Print/{$statement->branch_code}/{$statement->account_number}.pdf"; $filePath = "{$statement->period_from}/{$statement->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf";
if ($statement->is_period_range && $statement->period_to) { if ($statement->is_period_range && $statement->period_to) {
// Handle period range download (existing logic) // Log: Memulai proses download period range
// ... existing zip creation logic ... Log::info('Starting period range download', [
'statement_id' => $statement->id,
'period_from' => $statement->period_from,
'period_to' => $statement->period_to
]);
/**
* Handle period range download dengan membuat zip file
* yang berisi semua statement dalam rentang periode
*/
$periodFrom = Carbon::createFromFormat('Ym', $statement->period_from);
$periodTo = Carbon::createFromFormat('Ym', $statement->period_to);
// Loop through each month in the range
$missingPeriods = [];
$availablePeriods = [];
for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) {
$periodFormatted = $period->format('Ym');
$periodPath = $periodFormatted . "/{$statement->branch_code}/{$statement->account_number}_{$periodFormatted}.pdf";
if ($disk->exists($periodPath)) {
$availablePeriods[] = $periodFormatted;
Log::info('Period available for download', [
'period' => $periodFormatted,
'path' => $periodPath
]);
} else {
$missingPeriods[] = $periodFormatted;
Log::warning('Period not available for download', [
'period' => $periodFormatted,
'path' => $periodPath
]);
}
}
// If any period is available, create a zip and download it
if (count($availablePeriods) > 0) {
/**
* Membuat zip file temporary untuk download
* dengan semua statement yang tersedia dalam periode
*/
$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);
Log::info('Created temp directory for zip files');
}
// Create a new zip archive
$zip = new ZipArchive();
if ($zip->open($zipFilePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
Log::info('Zip archive created successfully', ['zip_path' => $zipFilePath]);
// Add each available statement to the zip
foreach ($availablePeriods as $period) {
$periodFilePath = "{$period}/{$statement->branch_code}/{$statement->account_number}_{$period}.pdf";
$localFilePath = storage_path("app/temp/{$statement->account_number}_{$period}.pdf");
try {
// Download the file from SFTP to local storage temporarily
file_put_contents($localFilePath, $disk->get($periodFilePath));
// Add the file to the zip
$zip->addFile($localFilePath, "{$statement->account_number}_{$period}.pdf");
Log::info('Added file to zip', [
'period' => $period,
'local_path' => $localFilePath
]);
} catch (Exception $e) {
Log::error('Failed to add file to zip', [
'period' => $period,
'error' => $e->getMessage()
]);
}
}
$zip->close();
Log::info('Zip archive closed successfully');
// Return the zip file for download
$response = response()->download($zipFilePath, $zipFileName)->deleteFileAfterSend(true);
// Clean up temporary PDF files
foreach ($availablePeriods as $period) {
$localFilePath = storage_path("app/temp/{$statement->account_number}_{$period}.pdf");
if (file_exists($localFilePath)) {
unlink($localFilePath);
Log::info('Cleaned up temporary file', ['file' => $localFilePath]);
}
}
Log::info('Period range download completed successfully', [
'statement_id' => $statement->id,
'available_periods' => count($availablePeriods),
'missing_periods' => count($missingPeriods)
]);
return $response;
} else {
Log::error('Failed to create zip archive', ['zip_path' => $zipFilePath]);
return back()->with('error', 'Failed to create zip archive for download.');
}
} else {
Log::warning('No statements available for download in period range', [
'statement_id' => $statement->id,
'missing_periods' => $missingPeriods
]);
return back()->with('error', 'No statements available for download in the specified period range.');
}
} else if ($disk->exists($filePath)) { } else if ($disk->exists($filePath)) {
/**
* Handle single period download
* Download file PDF tunggal untuk periode tertentu
*/
Log::info('Single period download', [
'statement_id' => $statement->id,
'file_path' => $filePath
]);
return $disk->download($filePath, "{$statement->account_number}_{$statement->period_from}.pdf"); return $disk->download($filePath, "{$statement->account_number}_{$statement->period_from}.pdf");
} else {
Log::warning('Statement file not found', [
'statement_id' => $statement->id,
'file_path' => $filePath
]);
return back()->with('error', 'Statement file not found.');
} }
} catch (Exception $e) { } catch (Exception $e) {
DB::rollBack(); DB::rollBack();
Log::error('Failed to download statement', [ Log::error('Failed to download statement', [
'statement_id' => $statement->id, 'statement_id' => $statement->id,
'error' => $e->getMessage() 'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]); ]);
return back()->with('error', 'Failed to download statement: ' . $e->getMessage()); return back()->with('error', 'Failed to download statement: ' . $e->getMessage());
@@ -298,6 +479,13 @@ use ZipArchive;
// Retrieve data from the database // Retrieve data from the database
$query = PrintStatementLog::query(); $query = PrintStatementLog::query();
if (!auth()->user()->hasRole('administrator')) {
$query->where(function($q) {
$q->where('user_id', Auth::id())
->orWhere('branch_code', Auth::user()->branch->code);
});
}
// Apply search filter if provided // Apply search filter if provided
if ($request->has('search') && !empty($request->get('search'))) { if ($request->has('search') && !empty($request->get('search'))) {
$search = $request->get('search'); $search = $request->get('search');
@@ -340,13 +528,13 @@ use ZipArchive;
// Map frontend column names to database column names if needed // Map frontend column names to database column names if needed
$columnMap = [ $columnMap = [
'branch' => 'branch_code', 'branch' => 'branch_code',
'account' => 'account_number', 'account' => 'account_number',
'period' => 'period_from', 'period' => 'period_from',
'auth_status' => 'authorization_status', 'auth_status' => 'authorization_status',
'request_type' => 'request_type', 'request_type' => 'request_type',
'status' => 'status', 'status' => 'status',
'remarks' => 'remarks', 'remarks' => 'remarks',
]; ];
$dbColumn = $columnMap[$column] ?? $column; $dbColumn = $columnMap[$column] ?? $column;
@@ -430,6 +618,7 @@ use ZipArchive;
public function sendEmail($id) public function sendEmail($id)
{ {
$statement = PrintStatementLog::findOrFail($id); $statement = PrintStatementLog::findOrFail($id);
// Check if statement has email // Check if statement has email
if (empty($statement->email)) { if (empty($statement->email)) {
return redirect()->back()->with('error', 'No email address provided for this statement.'); return redirect()->back()->with('error', 'No email address provided for this statement.');
@@ -442,7 +631,7 @@ use ZipArchive;
try { try {
$disk = Storage::disk('sftpStatement'); $disk = Storage::disk('sftpStatement');
$filePath = "{$statement->period_from}/Print/{$statement->branch_code}/{$statement->account_number}.pdf"; $filePath = "{$statement->period_from}/{$statement->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf";
if ($statement->is_period_range && $statement->period_to) { if ($statement->is_period_range && $statement->period_to) {
$periodFrom = Carbon::createFromFormat('Ym', $statement->period_from); $periodFrom = Carbon::createFromFormat('Ym', $statement->period_from);
@@ -454,7 +643,7 @@ use ZipArchive;
for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) { for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) {
$periodFormatted = $period->format('Ym'); $periodFormatted = $period->format('Ym');
$periodPath = $periodFormatted . "/Print/{$statement->branch_code}/{$statement->account_number}.pdf"; $periodPath = $periodFormatted . "/{$statement->branch_code}/{$statement->account_number}_{$periodFormatted}.pdf";
if ($disk->exists($periodPath)) { if ($disk->exists($periodPath)) {
$availablePeriods[] = $periodFormatted; $availablePeriods[] = $periodFormatted;
@@ -479,7 +668,7 @@ use ZipArchive;
if ($zip->open($zipFilePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) { if ($zip->open($zipFilePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
// Add each available statement to the zip // Add each available statement to the zip
foreach ($availablePeriods as $period) { foreach ($availablePeriods as $period) {
$filePath = "{$period}/Print/{$statement->branch_code}/{$statement->account_number}.pdf"; $filePath = "{$period}/{$statement->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf";
$localFilePath = storage_path("app/temp/{$statement->account_number}_{$period}.pdf"); $localFilePath = storage_path("app/temp/{$statement->account_number}_{$period}.pdf");
// Download the file from SFTP to local storage temporarily // Download the file from SFTP to local storage temporarily
@@ -545,8 +734,8 @@ use ZipArchive;
Log::info('Statement email sent successfully', [ Log::info('Statement email sent successfully', [
'statement_id' => $statement->id, 'statement_id' => $statement->id,
'email' => $statement->email, 'email' => $statement->email,
'user_id' => Auth::id() 'user_id' => Auth::id()
]); ]);
DB::commit(); DB::commit();

View File

@@ -21,7 +21,6 @@ class PrintStatementRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
$rules = [ $rules = [
'branch_code' => ['required', 'string', 'exists:branches,code'],
'account_number' => ['required', 'string'], 'account_number' => ['required', 'string'],
'is_period_range' => ['sometimes', 'boolean'], 'is_period_range' => ['sometimes', 'boolean'],
'email' => ['nullable', 'email'], 'email' => ['nullable', 'email'],
@@ -36,6 +35,7 @@ class PrintStatementRequest extends FormRequest
function ($attribute, $value, $fail) { function ($attribute, $value, $fail) {
$query = Statement::where('account_number', $this->input('account_number')) $query = Statement::where('account_number', $this->input('account_number'))
->where('authorization_status', '!=', 'rejected') ->where('authorization_status', '!=', 'rejected')
->where('is_available', true)
->where('period_from', $value); ->where('period_from', $value);
// If this is an update request, exclude the current record // If this is an update request, exclude the current record
@@ -77,8 +77,6 @@ class PrintStatementRequest extends FormRequest
public function messages(): array public function messages(): array
{ {
return [ return [
'branch_code.required' => 'Branch code is required',
'branch_code.exists' => 'Selected branch does not exist',
'account_number.required' => 'Account number is required', 'account_number.required' => 'Account number is required',
'period_from.required' => 'Period is required', 'period_from.required' => 'Period is required',
'period_from.regex' => 'Period must be in YYYYMM format', 'period_from.regex' => 'Period must be in YYYYMM format',
@@ -106,13 +104,13 @@ class PrintStatementRequest extends FormRequest
$this->merge([ $this->merge([
'period_to' => substr($this->period_to, 0, 4) . substr($this->period_to, 5, 2), 'period_to' => substr($this->period_to, 0, 4) . substr($this->period_to, 5, 2),
]); ]);
}
// Convert is_period_range to boolean if it exists // Only set is_period_range to true if period_to is different from period_from
if ($this->has('period_to')) { if ($this->period_to !== $this->period_from) {
$this->merge([ $this->merge([
'is_period_range' => true, 'is_period_range' => true,
]); ]);
}
} }
// Set default request_type if not provided // Set default request_type if not provided

View File

@@ -63,7 +63,9 @@
$this->updateCsvLogStart(); $this->updateCsvLogStart();
// Generate CSV file // Generate CSV file
$result = $this->generateAtmCardCsv(); // $result = $this->generateAtmCardCsv();
$result = $this->generateSingleAtmCardCsv();
// Update status CSV generation berhasil // Update status CSV generation berhasil
$this->updateCsvLogSuccess($result); $this->updateCsvLogSuccess($result);
@@ -443,4 +445,155 @@
Log::error('Pembuatan file CSV gagal: ' . $errorMessage); Log::error('Pembuatan file CSV gagal: ' . $errorMessage);
} }
/**
* Generate single CSV file with all ATM card data without branch separation
*
* @return array Information about the generated file and upload status
* @throws RuntimeException
*/
private function generateSingleAtmCardCsv(): array
{
Log::info('Memulai pembuatan file CSV tunggal untuk semua kartu ATM');
try {
// Ambil semua kartu yang memenuhi syarat
$cards = $this->getEligibleAtmCards();
if ($cards->isEmpty()) {
Log::warning('Tidak ada kartu ATM yang memenuhi syarat untuk periode ini');
throw new RuntimeException('Tidak ada kartu ATM yang memenuhi syarat untuk diproses');
}
// Buat nama file dengan timestamp
$dateTime = now()->format('Ymd_Hi');
$singleFilename = pathinfo($this->csvFilename, PATHINFO_FILENAME)
. '_ALL_BRANCHES_'
. $dateTime . '.'
. pathinfo($this->csvFilename, PATHINFO_EXTENSION);
$filename = storage_path('app/' . $singleFilename);
Log::info('Membuat file CSV: ' . $filename);
// Buka file untuk menulis
$handle = fopen($filename, 'w+');
if (!$handle) {
throw new RuntimeException("Tidak dapat membuat file CSV: $filename");
}
$recordCount = 0;
try {
// Tulis semua kartu ke dalam satu file
foreach ($cards as $card) {
$fee = $this->determineCardFee($card);
$csvRow = $this->createCsvRow($card, $fee);
if (fputcsv($handle, $csvRow, '|') === false) {
throw new RuntimeException("Gagal menulis data kartu ke file CSV: {$card->crdno}");
}
$recordCount++;
// Log progress setiap 1000 record
if ($recordCount % 1000 === 0) {
Log::info("Progress: {$recordCount} kartu telah diproses");
}
}
} finally {
fclose($handle);
}
Log::info("Selesai menulis {$recordCount} kartu ke file CSV");
// Bersihkan file CSV (hapus double quotes)
$this->cleanupCsvFile($filename);
Log::info('File CSV berhasil dibersihkan dari double quotes');
// Upload file ke SFTP (tanpa branch specific directory)
$uploadSuccess = true; // $this->uploadSingleFileToSftp($filename);
$result = [
'localFilePath' => $filename,
'recordCount' => $recordCount,
'uploadToSftp' => $uploadSuccess,
'timestamp' => now()->format('Y-m-d H:i:s'),
'fileName' => $singleFilename
];
Log::info('Pembuatan file CSV tunggal selesai', $result);
return $result;
} catch (Exception $e) {
Log::error('Error dalam generateSingleAtmCardCsv: ' . $e->getMessage(), [
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString()
]);
throw $e;
}
}
/**
* Upload single CSV file to SFTP server without branch directory
*
* @param string $localFilePath Path to the local CSV file
* @return bool True if upload successful, false otherwise
*/
private function uploadSingleFileToSftp(string $localFilePath): bool
{
try {
Log::info('Memulai upload file tunggal ke SFTP: ' . $localFilePath);
// Update status SFTP upload dimulai
$this->updateSftpLogStart();
// Ambil nama file dari path
$filename = basename($localFilePath);
// Ambil konten file
$fileContent = file_get_contents($localFilePath);
if ($fileContent === false) {
Log::error("Tidak dapat membaca file untuk upload: {$localFilePath}");
return false;
}
// Dapatkan disk SFTP
$disk = Storage::disk('sftpKartu');
// Tentukan path tujuan di server SFTP (root directory)
$remotePath = env('BIAYA_KARTU_REMOTE_PATH', '/');
$remoteFilePath = rtrim($remotePath, '/') . '/' . $filename;
Log::info('Mengunggah ke path remote: ' . $remoteFilePath);
// Upload file ke server SFTP
$result = $disk->put($remoteFilePath, $fileContent);
if ($result) {
$this->updateSftpLogSuccess();
Log::info("File CSV tunggal berhasil diunggah ke SFTP: {$remoteFilePath}");
return true;
} else {
$errorMsg = "Gagal mengunggah file CSV tunggal ke SFTP: {$remoteFilePath}";
$this->updateSftpLogFailed($errorMsg);
Log::error($errorMsg);
return false;
}
} catch (Exception $e) {
$errorMsg = "Error saat mengunggah file tunggal ke SFTP: " . $e->getMessage();
$this->updateSftpLogFailed($errorMsg);
Log::error($errorMsg, [
'file' => $e->getFile(),
'line' => $e->getLine(),
'periode' => $this->periode
]);
return false;
}
}
} }

View File

@@ -1,5 +1,4 @@
<?php <?php
namespace Modules\Webstatement\Jobs; namespace Modules\Webstatement\Jobs;
use Exception; use Exception;
@@ -11,6 +10,7 @@
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Models\StmtEntry; use Modules\Webstatement\Models\StmtEntry;
use Illuminate\Support\Facades\DB;
class ProcessStmtEntryDataJob implements ShouldQueue class ProcessStmtEntryDataJob implements ShouldQueue
{ {
@@ -184,33 +184,57 @@
} }
/** /**
* Save batched records to the database * Simpan batch data ke database menggunakan updateOrCreate
* untuk menghindari error unique constraint
*
* @return void
*/ */
private function saveBatch() private function saveBatch(): void
: void
{ {
Log::info('Memulai proses saveBatch dengan updateOrCreate');
DB::beginTransaction();
try { try {
if (!empty($this->entryBatch)) { if (!empty($this->entryBatch)) {
// Process in smaller chunks for better memory management $totalProcessed = 0;
foreach ($this->entryBatch as $entry) {
// Extract all stmt_entry_ids from the current chunk
$entryIds = array_column($entry, 'stmt_entry_id');
// Delete existing records with these IDs to avoid conflicts // Process each entry data directly (tidak ada nested array)
StmtEntry::whereIn('stmt_entry_id', $entryIds)->delete(); foreach ($this->entryBatch as $entryData) {
// Validasi bahwa entryData adalah array dan memiliki stmt_entry_id
if (is_array($entryData) && isset($entryData['stmt_entry_id'])) {
// Gunakan updateOrCreate untuk menghindari duplicate key error
StmtEntry::updateOrCreate(
[
'stmt_entry_id' => $entryData['stmt_entry_id']
],
$entryData
);
// Insert all records in the chunk at once $totalProcessed++;
StmtEntry::insert($entry); } else {
Log::warning('Invalid entry data structure', ['data' => $entryData]);
$this->errorCount++;
}
} }
// Reset entry batch after processing DB::commit();
Log::info("Berhasil memproses {$totalProcessed} record dengan updateOrCreate");
// Reset entry batch after successful processing
$this->entryBatch = []; $this->entryBatch = [];
} }
} catch (Exception $e) { } catch (Exception $e) {
DB::rollback();
Log::error("Error in saveBatch: " . $e->getMessage() . "\n" . $e->getTraceAsString()); Log::error("Error in saveBatch: " . $e->getMessage() . "\n" . $e->getTraceAsString());
$this->errorCount += count($this->entryBatch); $this->errorCount += count($this->entryBatch);
// Reset batch even if there's an error to prevent reprocessing the same failed records // Reset batch even if there's an error to prevent reprocessing the same failed records
$this->entryBatch = []; $this->entryBatch = [];
throw $e;
} }
} }

View File

@@ -1,424 +1,425 @@
<?php <?php
namespace Modules\Webstatement\Jobs; namespace Modules\Webstatement\Jobs;
use Illuminate\Bus\Queueable; use Exception;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Bus\Queueable;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\DB; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Log;
use Modules\Webstatement\Models\Account; use Illuminate\Support\Facades\Mail;
use Modules\Webstatement\Models\PrintStatementLog; use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Mail\StatementEmail; use InvalidArgumentException;
use Modules\Basicdata\Models\Branch; use Modules\Webstatement\Mail\StatementEmail;
use Modules\Webstatement\Models\Account;
/** use Modules\Webstatement\Models\PrintStatementLog;
* Job untuk mengirim email PDF statement ke nasabah use Throwable;
* Mendukung pengiriman per rekening, per cabang, atau seluruh cabang
* Menggunakan PHPMailer dengan dukungan NTLM/GSSAPI
*/
class SendStatementEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $period;
protected $requestType;
protected $targetValue; // account_number, branch_code, atau null untuk all
protected $batchId;
protected $logId;
/** /**
* Membuat instance job baru * Job untuk mengirim email PDF statement ke nasabah
* * Mendukung pengiriman per rekening, per cabang, atau seluruh cabang
* @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, $requestType = 'single_account', $targetValue = null, $batchId = null, $logId = null) class SendStatementEmailJob implements ShouldQueue
{ {
$this->period = $period; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
$this->requestType = $requestType;
$this->targetValue = $targetValue;
$this->batchId = $batchId ?? uniqid('batch_');
$this->logId = $logId;
Log::info('SendStatementEmailJob created with PHPMailer', [ protected $period;
'period' => $this->period, protected $requestType;
'request_type' => $this->requestType, protected $targetValue; // account_number, branch_code, atau null untuk all
'target_value' => $this->targetValue, protected $batchId;
'batch_id' => $this->batchId, protected $logId;
'log_id' => $this->logId
]);
}
/** /**
* Menjalankan job pengiriman email statement * Membuat instance job baru
*/ *
public function handle(): void * @param string $period Format: YYYYMM
{ * @param string $requestType 'single_account', 'branch', 'all_branches'
Log::info('Starting SendStatementEmailJob execution', [ * @param string|null $targetValue account_number untuk single, branch_code untuk branch, null untuk all
'batch_id' => $this->batchId, * @param string|null $batchId ID batch untuk tracking
'period' => $this->period, * @param int|null $logId ID log untuk update progress
'request_type' => $this->requestType, */
'target_value' => $this->targetValue public function __construct($period, $requestType = 'single_account', $targetValue = null, $batchId = null, $logId = null)
]); {
$this->period = $period;
$this->requestType = $requestType;
$this->targetValue = $targetValue;
$this->batchId = $batchId ?? uniqid('batch_');
$this->logId = $logId;
DB::beginTransaction(); Log::info('SendStatementEmailJob created', [
'period' => $this->period,
'request_type' => $this->requestType,
'target_value' => $this->targetValue,
'batch_id' => $this->batchId,
'log_id' => $this->logId
]);
}
try { /**
// Update log status menjadi processing * Menjalankan job pengiriman email statement
$this->updateLogStatus('processing', ['started_at' => now()]); */
public function handle()
: void
{
Log::info('Starting SendStatementEmailJob execution', [
'batch_id' => $this->batchId,
'period' => $this->period,
'request_type' => $this->requestType,
'target_value' => $this->targetValue
]);
// Ambil accounts berdasarkan request type DB::beginTransaction();
$accounts = $this->getAccountsByRequestType();
if ($accounts->isEmpty()) { try {
Log::warning('No accounts with email found', [ // Update log status menjadi processing
'period' => $this->period, $this->updateLogStatus('processing', ['started_at' => now()]);
'request_type' => $this->requestType,
'target_value' => $this->targetValue, // Ambil accounts berdasarkan request type
'batch_id' => $this->batchId $accounts = $this->getAccountsByRequestType();
if ($accounts->isEmpty()) {
Log::warning('No accounts with email found', [
'period' => $this->period,
'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()
]); ]);
$this->updateLogStatus('completed', [ $successCount = 0;
'completed_at' => now(), $failedCount = 0;
'total_accounts' => 0, $processedCount = 0;
'processed_accounts' => 0,
'success_count' => 0, foreach ($accounts as $account) {
'failed_count' => 0 try {
$this->sendStatementEmail($account);
$successCount++;
Log::info('Statement email sent successfully', [
'account_number' => $account->account_number,
'branch_code' => $account->branch_code,
'email' => $this->getEmailForAccount($account),
'batch_id' => $this->batchId
]);
} catch (Exception $e) {
$failedCount++;
Log::error('Failed to send statement email', [
'account_number' => $account->account_number,
'branch_code' => $account->branch_code,
'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(); DB::commit();
Log::info('SendStatementEmailJob completed', [
'batch_id' => $this->batchId,
'total_accounts' => $accounts->count(),
'success_count' => $successCount,
'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(),
'trace' => $e->getTraceAsString()
]);
throw $e;
}
}
/**
* Update status log
*/
private function updateLogStatus($status, $additionalData = [])
{
if (!$this->logId) {
return; return;
} }
// Update total accounts try {
$this->updateLogStatus('processing', [ $updateData = array_merge(['status' => $status], $additionalData);
'total_accounts' => $accounts->count(), PrintStatementLog::where('id', $this->logId)->update($updateData);
'target_accounts' => $accounts->pluck('account_number')->toArray() } catch (Exception $e) {
Log::error('Failed to update log status', [
'log_id' => $this->logId,
'status' => $status,
'error' => $e->getMessage()
]);
}
}
/**
* Mengambil accounts berdasarkan request type
*/
private function getAccountsByRequestType()
{
Log::info('Fetching accounts by request type', [
'period' => $this->period,
'request_type' => $this->requestType,
'target_value' => $this->targetValue
]); ]);
$successCount = 0; $query = Account::with('customer')
$failedCount = 0; ->where('stmt_sent_type', 'BY.EMAIL');
$processedCount = 0;
foreach ($accounts as $account) { switch ($this->requestType) {
try { case 'single_account':
$this->sendStatementEmail($account); if ($this->targetValue) {
$successCount++; $query->where('account_number', $this->targetValue);
}
break;
Log::info('Statement email sent successfully', [ case 'branch':
'account_number' => $account->account_number, if ($this->targetValue) {
'branch_code' => $account->branch_code, $query->where('branch_code', $this->targetValue);
'email' => $this->getEmailForAccount($account), }
'batch_id' => $this->batchId break;
]);
} catch (\Exception $e) {
$failedCount++;
Log::error('Failed to send statement email', [ case 'all_branches':
'account_number' => $account->account_number, // Tidak ada filter tambahan, ambil semua
'branch_code' => $account->branch_code, break;
'email' => $this->getEmailForAccount($account),
'error' => $e->getMessage(),
'batch_id' => $this->batchId
]);
}
$processedCount++; default:
throw new InvalidArgumentException("Invalid request type: {$this->requestType}");
// 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 $accounts = $query->get();
$finalStatus = $failedCount === 0 ? 'completed' : ($successCount === 0 ? 'failed' : 'completed');
$this->updateLogStatus($finalStatus, [ // Filter accounts yang memiliki email
'completed_at' => now(), $accountsWithEmail = $accounts->filter(function ($account) {
'processed_accounts' => $processedCount, return !empty($account->stmt_email) ||
'success_count' => $successCount, ($account->customer && !empty($account->customer->email));
'failed_count' => $failedCount });
Log::info('Accounts with email retrieved', [
'total_accounts' => $accounts->count(),
'accounts_with_email' => $accountsWithEmail->count(),
'request_type' => $this->requestType,
'batch_id' => $this->batchId
]); ]);
DB::commit(); return $accountsWithEmail;
}
Log::info('SendStatementEmailJob completed', [ /**
'batch_id' => $this->batchId, * Mengirim email statement untuk account tertentu
'total_accounts' => $accounts->count(), *
'success_count' => $successCount, * @param Account $account
'failed_count' => $failedCount, *
'final_status' => $finalStatus * @return void
* @throws \Exception
*/
private function sendStatementEmail(Account $account)
{
// Dapatkan email untuk pengiriman
$emailAddress = $this->getEmailForAccount($account);
if (!$emailAddress) {
throw new Exception("No email address found for account {$account->account_number}");
}
// Cek apakah file PDF ada
$pdfPath = $this->getPdfPath($account->account_number, $account->branch_code);
if (!Storage::exists($pdfPath)) {
throw new Exception("PDF file not found: {$pdfPath}");
}
// Buat atau update log statement
$statementLog = $this->createOrUpdateStatementLog($account);
// Dapatkan path absolut file
$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)
);
// Update status log dengan email yang digunakan
$statementLog->update([
'email_sent_at' => now(),
'email_status' => 'sent',
'email_address' => $emailAddress // Simpan email yang digunakan untuk tracking
]); ]);
} catch (\Exception $e) { Log::info('Email sent for account', [
DB::rollBack(); 'account_number' => $account->account_number,
'branch_code' => $account->branch_code,
'email' => $emailAddress,
'email_source' => !empty($account->stmt_email) ? 'account.stmt_email' : 'customer.email',
'pdf_path' => $pdfPath,
'batch_id' => $this->batchId
]);
}
/**
* Mendapatkan email untuk pengiriman statement
*
* @param Account $account
*
* @return string|null
*/
private function getEmailForAccount(Account $account)
{
// Prioritas pertama: stmt_email dari account
if (!empty($account->stmt_email)) {
Log::info('Using stmt_email from account', [
'account_number' => $account->account_number,
'email' => $account->stmt_email,
'batch_id' => $this->batchId
]);
return $account->stmt_email;
}
// Prioritas kedua: email dari customer
if ($account->customer && !empty($account->customer->email)) {
Log::info('Using email from customer', [
'account_number' => $account->account_number,
'customer_code' => $account->customer_code,
'email' => $account->customer->email,
'batch_id' => $this->batchId
]);
return $account->customer->email;
}
Log::warning('No email found for account', [
'account_number' => $account->account_number,
'customer_code' => $account->customer_code,
'batch_id' => $this->batchId
]);
return null;
}
/**
* Mendapatkan path file PDF statement
*
* @param string $accountNumber
* @param string $branchCode
*
* @return string
*/
private function getPdfPath($accountNumber, $branchCode)
{
return "combine/{$this->period}/{$branchCode}/{$accountNumber}_{$this->period}.pdf";
}
/**
* Membuat atau update log statement
*
* @param Account $account
*
* @return PrintStatementLog
*/
private function createOrUpdateStatementLog(Account $account)
{
$emailAddress = $this->getEmailForAccount($account);
$logData = [
'account_number' => $account->account_number,
'customer_code' => $account->customer_code,
'branch_code' => $account->branch_code,
'period' => $this->period,
'print_date' => now(),
'batch_id' => $this->batchId,
'email_address' => $emailAddress,
'email_source' => !empty($account->stmt_email) ? 'account' : 'customer'
];
$statementLog = PrintStatementLog::updateOrCreate(
[
'account_number' => $account->account_number,
'period_from' => $this->period,
'period_to' => $this->period
],
$logData
);
Log::info('Statement log created/updated', [
'log_id' => $statementLog->id,
'account_number' => $account->account_number,
'email_address' => $emailAddress,
'batch_id' => $this->batchId
]);
return $statementLog;
}
/**
* Handle job failure
*/
public function failed(Throwable $exception)
{
$this->updateLogStatus('failed', [ $this->updateLogStatus('failed', [
'completed_at' => now(), 'completed_at' => now(),
'error_message' => $e->getMessage() 'error_message' => $exception->getMessage()
]); ]);
Log::error('SendStatementEmailJob failed', [ Log::error('SendStatementEmailJob failed permanently', [
'batch_id' => $this->batchId, 'batch_id' => $this->batchId,
'error' => $e->getMessage(), 'period' => $this->period,
'trace' => $e->getTraceAsString() 'request_type' => $this->requestType,
]); 'target_value' => $this->targetValue,
'error' => $exception->getMessage(),
throw $e; 'trace' => $exception->getTraceAsString()
}
}
/**
* Mengambil accounts berdasarkan request type
*/
private function getAccountsByRequestType()
{
Log::info('Fetching accounts by request type', [
'period' => $this->period,
'request_type' => $this->requestType,
'target_value' => $this->targetValue
]);
$query = Account::with('customer')
->where('stmt_sent_type', 'BY.EMAIL');
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}");
}
$accounts = $query->get();
// Filter accounts yang memiliki email
$accountsWithEmail = $accounts->filter(function ($account) {
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
*
* @param Account $account
* @return string|null
*/
private function getEmailForAccount(Account $account)
{
// Prioritas pertama: stmt_email dari account
if (!empty($account->stmt_email)) {
Log::info('Using stmt_email from account', [
'account_number' => $account->account_number,
'email' => $account->stmt_email,
'batch_id' => $this->batchId
]);
return $account->stmt_email;
}
// Prioritas kedua: email dari customer
if ($account->customer && !empty($account->customer->email)) {
Log::info('Using email from customer', [
'account_number' => $account->account_number,
'customer_code' => $account->customer_code,
'email' => $account->customer->email,
'batch_id' => $this->batchId
]);
return $account->customer->email;
}
Log::warning('No email found for account', [
'account_number' => $account->account_number,
'customer_code' => $account->customer_code,
'batch_id' => $this->batchId
]);
return null;
}
/**
* Mengirim email statement untuk account tertentu
*
* @param Account $account
* @return void
* @throws \Exception
*/
private function sendStatementEmail(Account $account)
{
// Dapatkan email untuk pengiriman
$emailAddress = $this->getEmailForAccount($account);
if (!$emailAddress) {
throw new \Exception("No email address found for account {$account->account_number}");
}
// Cek apakah file PDF ada
$pdfPath = $this->getPdfPath($account->account_number, $account->branch_code);
if (!Storage::exists($pdfPath)) {
throw new \Exception("PDF file not found: {$pdfPath}");
}
// Buat atau update log statement
$statementLog = $this->createOrUpdateStatementLog($account);
// Dapatkan path absolut file
$absolutePdfPath = Storage::path($pdfPath);
// Buat instance StatementEmail dengan PHPMailer
$statementEmail = new StatementEmail($statementLog, $absolutePdfPath, false);
// Kirim email menggunakan PHPMailer
$emailSent = $statementEmail->send($emailAddress);
if (!$emailSent) {
throw new \Exception("Failed to send email to {$emailAddress} for account {$account->account_number}");
}
// Update status log dengan email yang digunakan
$statementLog->update([
'email_sent_at' => now(),
'email_status' => 'sent',
'email_address' => $emailAddress // Simpan email yang digunakan untuk tracking
]);
Log::info('Email sent via PHPMailer for account', [
'account_number' => $account->account_number,
'branch_code' => $account->branch_code,
'email' => $emailAddress,
'email_source' => !empty($account->stmt_email) ? 'account.stmt_email' : 'customer.email',
'pdf_path' => $pdfPath,
'batch_id' => $this->batchId
]);
// Add delay between email sends to prevent rate limiting
sleep(2); // 2 second delay for NTLM/GSSAPI connections
}
/**
* Mendapatkan path file PDF statement
*
* @param string $accountNumber
* @param string $branchCode
* @return string
*/
private function getPdfPath($accountNumber, $branchCode)
{
return "combine/{$this->period}/{$branchCode}/{$accountNumber}_{$this->period}.pdf";
}
/**
* Membuat atau update log statement
*
* @param Account $account
* @return PrintStatementLog
*/
private function createOrUpdateStatementLog(Account $account)
{
$emailAddress = $this->getEmailForAccount($account);
$logData = [
'account_number' => $account->account_number,
'customer_code' => $account->customer_code,
'branch_code' => $account->branch_code,
'period' => $this->period,
'print_date' => now(),
'batch_id' => $this->batchId,
'email_address' => $emailAddress,
'email_source' => !empty($account->stmt_email) ? 'account' : 'customer'
];
$statementLog = PrintStatementLog::updateOrCreate(
[
'account_number' => $account->account_number,
'period_from' => $this->period,
'period_to' => $this->period
],
$logData
);
Log::info('Statement log created/updated', [
'log_id' => $statementLog->id,
'account_number' => $account->account_number,
'email_address' => $emailAddress,
'batch_id' => $this->batchId
]);
return $statementLog;
}
/**
* Handle job failure
*/
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,
'request_type' => $this->requestType,
'target_value' => $this->targetValue,
'error' => $exception->getMessage(),
'trace' => $exception->getTraceAsString()
]);
}
}

View File

@@ -0,0 +1,379 @@
<?php
namespace Modules\Webstatement\Jobs;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Modules\Webstatement\Models\Atmcard;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Modules\Webstatement\Models\KartuSyncLog;
class UpdateAllAtmCardsBatchJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Konstanta untuk konfigurasi batch processing
*/
private const BATCH_SIZE = 100;
private const MAX_EXECUTION_TIME = 7200; // 2 jam dalam detik
private const DELAY_BETWEEN_JOBS = 2; // 2 detik delay antar job
private const MAX_DELAY_SPREAD = 300; // Spread maksimal 5 menit
/**
* ID log sinkronisasi
*
* @var int
*/
protected $syncLogId;
/**
* Model log sinkronisasi
*
* @var KartuSyncLog
*/
protected $syncLog;
/**
* Batch size untuk processing
*
* @var int
*/
protected $batchSize;
/**
* Filter kondisi kartu yang akan diupdate
*
* @var array
*/
protected $filters;
/**
* Create a new job instance.
*
* @param int|null $syncLogId ID log sinkronisasi
* @param int $batchSize Ukuran batch untuk processing
* @param array $filters Filter kondisi kartu
*/
public function __construct(?int $syncLogId = null, int $batchSize = self::BATCH_SIZE, array $filters = [])
{
$this->syncLogId = $syncLogId;
$this->batchSize = $batchSize > 0 ? $batchSize : self::BATCH_SIZE;
$this->filters = $filters;
}
/**
* Execute the job untuk update seluruh kartu ATM
*
* @return void
* @throws Exception
*/
public function handle(): void
{
set_time_limit(self::MAX_EXECUTION_TIME);
Log::info('Memulai job update seluruh kartu ATM', [
'sync_log_id' => $this->syncLogId,
'batch_size' => $this->batchSize,
'filters' => $this->filters
]);
try {
DB::beginTransaction();
// Load atau buat log sinkronisasi
$this->loadOrCreateSyncLog();
// Update status job dimulai
$this->updateJobStartStatus();
// Ambil total kartu yang akan diproses
$totalCards = $this->getTotalCardsCount();
if ($totalCards === 0) {
Log::info('Tidak ada kartu ATM yang perlu diupdate');
$this->updateJobCompletedStatus(0, 0);
DB::commit();
return;
}
Log::info("Ditemukan {$totalCards} kartu ATM yang akan diproses");
// Proses kartu dalam batch
$processedCount = $this->processCardsInBatches($totalCards);
// Update status job selesai
$this->updateJobCompletedStatus($totalCards, $processedCount);
Log::info('Job update seluruh kartu ATM selesai', [
'total_cards' => $totalCards,
'processed_count' => $processedCount,
'sync_log_id' => $this->syncLog->id
]);
DB::commit();
} catch (Exception $e) {
DB::rollBack();
$this->updateJobFailedStatus($e->getMessage());
Log::error('Gagal menjalankan job update seluruh kartu ATM: ' . $e->getMessage(), [
'file' => $e->getFile(),
'line' => $e->getLine(),
'sync_log_id' => $this->syncLogId,
'trace' => $e->getTraceAsString()
]);
throw $e;
}
}
/**
* Load atau buat log sinkronisasi baru
*
* @return void
* @throws Exception
*/
private function loadOrCreateSyncLog(): void
{
Log::info('Loading atau membuat sync log', ['sync_log_id' => $this->syncLogId]);
if ($this->syncLogId) {
$this->syncLog = KartuSyncLog::find($this->syncLogId);
if (!$this->syncLog) {
throw new Exception("Sync log dengan ID {$this->syncLogId} tidak ditemukan");
}
} else {
// Buat log sinkronisasi baru
$this->syncLog = KartuSyncLog::create([
'periode' => now()->format('Y-m'),
'sync_notes' => 'Batch update seluruh kartu ATM dimulai',
'is_sync' => false,
'sync_at' => null,
'is_csv' => false,
'csv_at' => null,
'is_ftp' => false,
'ftp_at' => null
]);
}
Log::info('Sync log berhasil dimuat/dibuat', ['sync_log_id' => $this->syncLog->id]);
}
/**
* Update status saat job dimulai
*
* @return void
*/
private function updateJobStartStatus(): void
{
Log::info('Memperbarui status job dimulai');
$this->syncLog->update([
'sync_notes' => $this->syncLog->sync_notes . "\nBatch update seluruh kartu ATM dimulai pada " . now()->format('Y-m-d H:i:s'),
'is_sync' => false,
'sync_at' => null
]);
}
/**
* Ambil total jumlah kartu yang akan diproses
*
* @return int
*/
private function getTotalCardsCount(): int
{
Log::info('Menghitung total kartu yang akan diproses', ['filters' => $this->filters]);
$query = $this->buildCardQuery();
$count = $query->count();
Log::info("Total kartu ditemukan: {$count}");
return $count;
}
/**
* Build query untuk mengambil kartu berdasarkan filter
*
* @return \Illuminate\Database\Eloquent\Builder
*/
private function buildCardQuery()
{
$query = Atmcard::where('crsts', 1) // Kartu aktif
->whereNotNull('accflag')
->where('accflag', '!=', '');
// Terapkan filter default untuk kartu yang perlu update branch/currency
if (empty($this->filters) || !isset($this->filters['skip_branch_currency_filter'])) {
$query->where(function ($q) {
$q->whereNull('branch')
->orWhere('branch', '')
->orWhereNull('currency')
->orWhere('currency', '');
});
}
// Terapkan filter tambahan jika ada
if (!empty($this->filters)) {
foreach ($this->filters as $field => $value) {
if ($field === 'skip_branch_currency_filter') {
continue;
}
if (is_array($value)) {
$query->whereIn($field, $value);
} else {
$query->where($field, $value);
}
}
}
return $query;
}
/**
* Proses kartu dalam batch
*
* @param int $totalCards
* @return int Jumlah kartu yang berhasil diproses
*/
private function processCardsInBatches(int $totalCards): int
{
Log::info('Memulai pemrosesan kartu dalam batch', [
'total_cards' => $totalCards,
'batch_size' => $this->batchSize
]);
$processedCount = 0;
$batchNumber = 1;
$totalBatches = ceil($totalCards / $this->batchSize);
// Proses kartu dalam chunk/batch
$this->buildCardQuery()->chunk($this->batchSize, function ($cards) use (&$processedCount, &$batchNumber, $totalBatches, $totalCards) {
Log::info("Memproses batch {$batchNumber}/{$totalBatches}", [
'cards_in_batch' => $cards->count(),
'processed_so_far' => $processedCount
]);
try {
// Dispatch job untuk setiap kartu dalam batch dengan delay
foreach ($cards as $index => $card) {
// Hitung delay berdasarkan nomor batch dan index untuk menyebar eksekusi job
$delay = (($batchNumber - 1) * $this->batchSize + $index) % self::MAX_DELAY_SPREAD;
$delay += self::DELAY_BETWEEN_JOBS; // Tambah delay minimum
// Dispatch job UpdateAtmCardBranchCurrencyJob
UpdateAtmCardBranchCurrencyJob::dispatch($card, $this->syncLog->id)
->delay(now()->addSeconds($delay))
->onQueue('default');
$processedCount++;
}
// Update progress di log setiap 10 batch
if ($batchNumber % 10 === 0) {
$this->updateProgressStatus($processedCount, $totalCards, $batchNumber, $totalBatches);
}
Log::info("Batch {$batchNumber} berhasil dijadwalkan", [
'cards_scheduled' => $cards->count(),
'total_processed' => $processedCount
]);
} catch (Exception $e) {
Log::error("Error saat memproses batch {$batchNumber}: " . $e->getMessage(), [
'batch_number' => $batchNumber,
'cards_count' => $cards->count(),
'error' => $e->getMessage()
]);
throw $e;
}
$batchNumber++;
});
Log::info('Selesai memproses semua batch', [
'total_processed' => $processedCount,
'total_batches' => $batchNumber - 1
]);
return $processedCount;
}
/**
* Update status progress pemrosesan
*
* @param int $processedCount
* @param int $totalCards
* @param int $batchNumber
* @param int $totalBatches
* @return void
*/
private function updateProgressStatus(int $processedCount, int $totalCards, int $batchNumber, int $totalBatches): void
{
Log::info('Memperbarui status progress', [
'processed' => $processedCount,
'total' => $totalCards,
'batch' => $batchNumber,
'total_batches' => $totalBatches
]);
$percentage = round(($processedCount / $totalCards) * 100, 2);
$progressNote = "\nProgress: {$processedCount}/{$totalCards} kartu dijadwalkan ({$percentage}%) - Batch {$batchNumber}/{$totalBatches}";
$this->syncLog->update([
'sync_notes' => $this->syncLog->sync_notes . $progressNote
]);
}
/**
* Update status saat job selesai
*
* @param int $totalCards
* @param int $processedCount
* @return void
*/
private function updateJobCompletedStatus(int $totalCards, int $processedCount): void
{
Log::info('Memperbarui status job selesai', [
'total_cards' => $totalCards,
'processed_count' => $processedCount
]);
$completionNote = "\nBatch update selesai pada " . now()->format('Y-m-d H:i:s') .
" - Total {$processedCount} kartu dari {$totalCards} berhasil dijadwalkan untuk update";
$this->syncLog->update([
'is_sync' => true,
'sync_at' => now(),
'sync_notes' => $this->syncLog->sync_notes . $completionNote
]);
}
/**
* Update status saat job gagal
*
* @param string $errorMessage
* @return void
*/
private function updateJobFailedStatus(string $errorMessage): void
{
Log::error('Memperbarui status job gagal', ['error' => $errorMessage]);
if ($this->syncLog) {
$failureNote = "\nBatch update gagal pada " . now()->format('Y-m-d H:i:s') .
" - Error: {$errorMessage}";
$this->syncLog->update([
'is_sync' => false,
'sync_notes' => $this->syncLog->sync_notes . $failureNote
]);
}
}
}

View File

@@ -4,13 +4,14 @@ namespace Modules\Webstatement\Jobs;
use Exception; use Exception;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Http;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Modules\Webstatement\Models\Account;
use Modules\Webstatement\Models\Atmcard;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Modules\Webstatement\Models\Atmcard;
class UpdateAtmCardBranchCurrencyJob implements ShouldQueue class UpdateAtmCardBranchCurrencyJob implements ShouldQueue
{ {
@@ -77,7 +78,7 @@ class UpdateAtmCardBranchCurrencyJob implements ShouldQueue
} }
/** /**
* Get account information from the API * Get account information from Account model or API
* *
* @param string $accountNumber * @param string $accountNumber
* @return array|null * @return array|null
@@ -85,10 +86,26 @@ class UpdateAtmCardBranchCurrencyJob implements ShouldQueue
private function getAccountInfo(string $accountNumber): ?array private function getAccountInfo(string $accountNumber): ?array
{ {
try { try {
// Coba dapatkan data dari model Account terlebih dahulu
$account = Account::where('account_number', $accountNumber)->first();
if ($account) {
// Jika account ditemukan, format data sesuai dengan format response dari API
return [
'responseCode' => '00',
'acctCompany' => $account->branch_code,
'acctCurrency' => $account->currency,
'acctType' => $account->open_category
// Tambahkan field lain yang mungkin diperlukan
];
}
// Jika tidak ditemukan di database, ambil dari Fiorano API
$url = env('FIORANO_URL') . self::API_BASE_PATH; $url = env('FIORANO_URL') . self::API_BASE_PATH;
$path = self::API_INQUIRY_PATH; $path = self::API_INQUIRY_PATH;
$data = [ $data = [
'accountNo' => $accountNumber 'accountNo' => $accountNumber,
]; ];
$response = Http::post($url . $path, $data); $response = Http::post($url . $path, $data);
@@ -110,6 +127,7 @@ class UpdateAtmCardBranchCurrencyJob implements ShouldQueue
$cardData = [ $cardData = [
'branch' => !empty($accountInfo['acctCompany']) ? $accountInfo['acctCompany'] : null, 'branch' => !empty($accountInfo['acctCompany']) ? $accountInfo['acctCompany'] : null,
'currency' => !empty($accountInfo['acctCurrency']) ? $accountInfo['acctCurrency'] : null, 'currency' => !empty($accountInfo['acctCurrency']) ? $accountInfo['acctCurrency'] : null,
'product_code' => !empty($accountInfo['acctType']) ? $accountInfo['acctType'] : null,
]; ];
$this->card->update($cardData); $this->card->update($cardData);

View File

@@ -1,195 +1,208 @@
<?php <?php
namespace Modules\Webstatement\Mail; namespace Modules\Webstatement\Mail;
use Modules\Webstatement\Models\Account; use Carbon\Carbon;
use Modules\Webstatement\Models\PrintStatementLog; use Exception;
use Modules\Webstatement\Services\PHPMailerService; use Illuminate\Bus\Queueable;
use Illuminate\Support\Facades\Log; use Illuminate\Mail\Mailable;
use Illuminate\Support\Facades\View; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Config;
/** use Log;
* Service untuk mengirim email statement menggunakan PHPMailer use Modules\Webstatement\Models\Account;
* dengan dukungan autentikasi NTLM/GSSAPI use Modules\Webstatement\Models\PrintStatementLog;
*/ use Symfony\Component\Mailer\Mailer;
class StatementEmail use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
{ use Symfony\Component\Mime\Email;
protected $statement;
protected $filePath;
protected $isZip;
protected $phpMailerService;
/**
* 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)
{
$this->statement = $statement;
$this->filePath = $filePath;
$this->isZip = $isZip;
$this->phpMailerService = new PHPMailerService();
}
/**
* Kirim email statement
*
* @param string $emailAddress
* @return bool
*/
public function send(string $emailAddress): bool
{
try {
// Generate subject
$subject = $this->generateSubject();
// Generate email body
$body = $this->generateEmailBody();
// Generate attachment name
$attachmentName = $this->generateAttachmentName();
// Determine MIME type
$mimeType = $this->isZip ? 'application/zip' : 'application/pdf';
// Send email using PHPMailer
$result = $this->phpMailerService->sendEmail(
$emailAddress,
$subject,
$body,
$this->filePath,
$attachmentName,
$mimeType
);
Log::info('Statement email sent via PHPMailer', [
'to' => $emailAddress,
'subject' => $subject,
'attachment' => $attachmentName,
'account_number' => $this->statement->account_number,
'period' => $this->statement->period_from,
'success' => $result
]);
return $result;
} catch (\Exception $e) {
Log::error('Failed to send statement email via PHPMailer', [
'to' => $emailAddress,
'account_number' => $this->statement->account_number,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return false;
}
}
/**
* Generate email subject
*
* @return string
*/
protected function generateSubject(): string
{
$subject = 'Statement Rekening Bank Artha Graha Internasional';
if ($this->statement->is_period_range) { if ($this->statement->is_period_range) {
$subject .= " - {$this->statement->period_from} to {$this->statement->period_to}"; $subject .= " - {$this->statement->period_from} to {$this->statement->period_to}";
} else { } else {
$subject .= " - " . \Carbon\Carbon::createFromFormat('Ym', $this->statement->period_from)->locale('id')->isoFormat('MMMM Y'); $subject .= " - " . \Carbon\Carbon::createFromFormat('Ym', $this->statement->period_from)->locale('id')->isoFormat('MMMM Y');
} class StatementEmail extends Mailable
// Add batch info for batch requests
if ($this->statement->request_type && $this->statement->request_type !== 'single_account') {
$subject .= " [{$this->statement->request_type}]";
}
if ($this->statement->batch_id) {
$subject .= " [Batch: {$this->statement->batch_id}]";
}
return $subject;
}
/**
* Generate email body HTML
*
* @return string
*/
protected function generateEmailBody(): string
{ {
try { use Queueable, SerializesModels;
// Get account data
$account = Account::where('account_number', $this->statement->account_number)->first();
// Prepare data for view protected $statement;
$data = [ protected $filePath;
'statement' => $this->statement, protected $isZip;
protected $message;
/**
* 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)
{
$this->statement = $statement;
$this->filePath = $filePath;
$this->isZip = $isZip;
}
/**
* Override the send method to use EsmtpTransport directly
* Using the working configuration from Python script with multiple fallback methods
*/
public function send($mailer)
{
// Get mail configuration
$host = Config::get('mail.mailers.smtp.host');
$port = Config::get('mail.mailers.smtp.port');
$username = Config::get('mail.mailers.smtp.username');
$password = Config::get('mail.mailers.smtp.password');
Log::info('StatementEmail: Attempting to send email with multiple fallback methods');
// Define connection methods like in Python script
$method =
// Method 3: STARTTLS with original port
[
'port' => $port,
'ssl' => false,
'name' => 'STARTTLS (Port $port)'
];
$lastException = null;
// Try each connection method until one succeeds
try {
Log::info('StatementEmail: Trying ' . $method['name']);
// Create EsmtpTransport with current method
$transport = new EsmtpTransport($host, $method['port'], $method['ssl']);
// Set username and password
if ($username) {
$transport->setUsername($username);
}
if ($password) {
$transport->setPassword($password);
}
// Disable SSL verification for development
$streamOptions = [
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true
]
];
$transport->getStream()->setStreamOptions($streamOptions);
// Build the email content
$this->build();
// Start transport connection
$transport->start();
// Create Symfony mailer
$symfonyMailer = new Mailer($transport);
// Convert Laravel message to Symfony Email
$email = $this->toSymfonyEmail();
// Send the email
$symfonyMailer->send($email);
// Close connection
$transport->stop();
Log::info('StatementEmail: Successfully sent email using ' . $method['name']);
return $this;
} catch (Exception $e) {
$lastException = $e;
Log::warning('StatementEmail: Failed to send with ' . $method['name'] . ': ' . $e->getMessage());
// Continue to next method
}
try {
return parent::send($mailer);
} catch (Exception $e) {
Log::error('StatementEmail: Laravel mailer also failed: ' . $e->getMessage());
// If we got here, throw the last exception from our custom methods
throw $lastException;
}
}
/**
* 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::createFromFormat('Ym', $this->statement->period_from)
->locale('id')
->isoFormat('MMMM Y');
}
$email = $this->subject($subject);
// Store the email in the message property for later use in toSymfonyEmail()
$this->message = $email;
return $email;
}
/**
* Convert Laravel message to Symfony Email
*/
protected function toSymfonyEmail()
{
// Build the message if it hasn't been built yet
$this->build();
// Create a new Symfony Email
$email = new Email();
// Set from address using config values instead of trying to call getFrom()
$fromAddress = Config::get('mail.from.address');
$fromName = Config::get('mail.from.name');
$email->from($fromName ? "$fromName <$fromAddress>" : $fromAddress);
// Set to addresses - use the to addresses from the mailer instead of trying to call getTo()
// We'll get the to addresses from the Mail facade when the email is sent
// For now, we'll just add a placeholder recipient that will be overridden by the Mail facade
$email->to($this->message->to[0]['address']);
$email->subject($this->message->subject);
// Set body - use a simple HTML content instead of trying to call getHtmlBody()
// In a real implementation, we would need to find a way to access the rendered HTML content
$email->html(view('webstatement::statements.email', [
'statement' => $this->statement,
'accountNumber' => $this->statement->account_number, 'accountNumber' => $this->statement->account_number,
'periodFrom' => $this->statement->period_from, 'periodFrom' => $this->statement->period_from,
'periodTo' => $this->statement->period_to, 'periodTo' => $this->statement->period_to,
'isRange' => $this->statement->is_period_range, 'isRange' => $this->statement->is_period_range,
'requestType' => $this->statement->request_type, 'requestType' => $this->statement->request_type,
'batchId' => $this->statement->batch_id, 'batchId' => $this->statement->batch_id,
'accounts' => $account 'accounts' => Account::where('account_number', $this->statement->account_number)->first()
]; ])->render());
//$email->text($this->message->getTextBody());
// Render view to HTML // Add attachments - use the file path directly instead of trying to call getAttachments()
return View::make('webstatement::statements.email', $data)->render(); if ($this->filePath && file_exists($this->filePath)) {
if ($this->isZip) {
$fileName = "{$this->statement->account_number}_{$this->statement->period_from}_to_{$this->statement->period_to}.zip";
$contentType = 'application/zip';
} else {
$fileName = "{$this->statement->account_number}_{$this->statement->period_from}.pdf";
$contentType = 'application/pdf';
}
$email->attachFromPath($this->filePath, $fileName, $contentType);
}
} catch (\Exception $e) { return $email;
Log::error('Failed to generate email body', [
'account_number' => $this->statement->account_number,
'error' => $e->getMessage()
]);
// Fallback to simple HTML
return $this->generateFallbackEmailBody();
} }
} }
/**
* Generate fallback email body
*
* @return string
*/
protected function generateFallbackEmailBody(): string
{
$periodText = $this->statement->is_period_range
? "periode {$this->statement->period_from} sampai {$this->statement->period_to}"
: "periode " . \Carbon\Carbon::createFromFormat('Ym', $this->statement->period_from)->locale('id')->isoFormat('MMMM Y');
return "
<html>
<body>
<h2>Statement Rekening Bank Artha Graha Internasional</h2>
<p>Yth. Nasabah,</p>
<p>Terlampir adalah statement rekening Anda untuk {$periodText}.</p>
<p>Nomor Rekening: {$this->statement->account_number}</p>
<p>Terima kasih atas kepercayaan Anda.</p>
<br>
<p>Salam,<br>Bank Artha Graha Internasional</p>
</body>
</html>
";
}
/**
* Generate attachment filename
*
* @return string
*/
protected function generateAttachmentName(): string
{
if ($this->isZip) {
return "{$this->statement->account_number}_{$this->statement->period_from}_to_{$this->statement->period_to}.zip";
} else {
return "{$this->statement->account_number}_{$this->statement->period_from}.pdf";
}
}
}

View File

@@ -4,6 +4,7 @@ namespace Modules\Webstatement\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Facades\Log;
// use Modules\Webstatement\Database\Factories\AtmcardFactory; // use Modules\Webstatement\Database\Factories\AtmcardFactory;
class Atmcard extends Model class Atmcard extends Model
@@ -15,7 +16,64 @@ class Atmcard extends Model
*/ */
protected $guarded = ['id']; protected $guarded = ['id'];
/**
* Relasi ke tabel JenisKartu untuk mendapatkan informasi biaya kartu
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function biaya(){ public function biaya(){
Log::info('Mengakses relasi biaya untuk ATM card', ['card_id' => $this->id]);
return $this->belongsTo(JenisKartu::class,'ctdesc','code'); return $this->belongsTo(JenisKartu::class,'ctdesc','code');
} }
/**
* Scope untuk mendapatkan kartu ATM yang aktif
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeActive($query)
{
Log::info('Menggunakan scope active untuk filter kartu ATM aktif');
return $query->where('crsts', 1);
}
/**
* Scope untuk mendapatkan kartu berdasarkan product_code
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $productCode
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByProductCode($query, $productCode)
{
Log::info('Menggunakan scope byProductCode', ['product_code' => $productCode]);
return $query->where('product_code', $productCode);
}
/**
* Accessor untuk mendapatkan product_code dengan format yang konsisten
*
* @param string $value
* @return string|null
*/
public function getProductCodeAttribute($value)
{
return $value ? strtoupper(trim($value)) : null;
}
/**
* Mutator untuk menyimpan product_code dengan format yang konsisten
*
* @param string $value
* @return void
*/
public function setProductCodeAttribute($value)
{
$this->attributes['product_code'] = $value ? strtoupper(trim($value)) : null;
Log::info('Product code diset untuk ATM card', [
'card_id' => $this->id ?? 'new',
'product_code' => $this->attributes['product_code']
]);
}
} }

View File

@@ -6,18 +6,19 @@ use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Nwidart\Modules\Traits\PathNamespace; use Nwidart\Modules\Traits\PathNamespace;
use Illuminate\Console\Scheduling\Schedule; use Illuminate\Console\Scheduling\Schedule;
use Modules\Webstatement\Console\CheckEmailProgressCommand;
use Modules\Webstatement\Console\UnlockPdf; use Modules\Webstatement\Console\UnlockPdf;
use Modules\Webstatement\Console\CombinePdf; use Modules\Webstatement\Console\CombinePdf;
use Modules\Webstatement\Console\ConvertHtmlToPdf; use Modules\Webstatement\Console\ConvertHtmlToPdf;
use Modules\Webstatement\Console\ExportDailyStatements; use Modules\Webstatement\Console\ExportDailyStatements;
use Modules\Webstatement\Console\ProcessDailyMigration; use Modules\Webstatement\Console\ProcessDailyMigration;
use Modules\Webstatement\Console\ExportPeriodStatements; use Modules\Webstatement\Console\ExportPeriodStatements;
use Modules\Webstatement\Console\UpdateAllAtmCardsCommand;
use Modules\Webstatement\Console\CheckEmailProgressCommand;
use Modules\Webstatement\Console\GenerateBiayakartuCommand; use Modules\Webstatement\Console\GenerateBiayakartuCommand;
use Modules\Webstatement\Console\SendStatementEmailCommand;
use Modules\Webstatement\Jobs\UpdateAtmCardBranchCurrencyJob; use Modules\Webstatement\Jobs\UpdateAtmCardBranchCurrencyJob;
use Modules\Webstatement\Console\GenerateAtmTransactionReport; use Modules\Webstatement\Console\GenerateAtmTransactionReport;
use Modules\Webstatement\Console\GenerateBiayaKartuCsvCommand; use Modules\Webstatement\Console\GenerateBiayaKartuCsvCommand;
use Modules\Webstatement\Console\SendStatementEmailCommand;
class WebstatementServiceProvider extends ServiceProvider class WebstatementServiceProvider extends ServiceProvider
{ {
@@ -70,7 +71,8 @@ class WebstatementServiceProvider extends ServiceProvider
ExportPeriodStatements::class, ExportPeriodStatements::class,
GenerateAtmTransactionReport::class, GenerateAtmTransactionReport::class,
SendStatementEmailCommand::class, SendStatementEmailCommand::class,
CheckEmailProgressCommand::class CheckEmailProgressCommand::class,
UpdateAllAtmCardsCommand::class
]); ]);
} }

View File

@@ -0,0 +1,63 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Menjalankan migration untuk menambahkan field product_code pada tabel atmcards
*
* @return void
*/
public function up(): void
{
Log::info('Memulai migration: menambahkan field product_code ke tabel atmcards');
DB::beginTransaction();
try {
Schema::table('atmcards', function (Blueprint $table) {
// Menambahkan field product_code setelah field ctdesc
$table->string('product_code')->nullable()->after('ctdesc')->comment('Kode produk kartu ATM');
});
DB::commit();
Log::info('Migration berhasil: field product_code telah ditambahkan ke tabel atmcards');
} catch (Exception $e) {
DB::rollback();
Log::error('Migration gagal: ' . $e->getMessage());
throw $e;
}
}
/**
* Membalikkan migration dengan menghapus field product_code dari tabel atmcards
*
* @return void
*/
public function down(): void
{
Log::info('Memulai rollback migration: menghapus field product_code dari tabel atmcards');
DB::beginTransaction();
try {
Schema::table('atmcards', function (Blueprint $table) {
$table->dropColumn('product_code');
});
DB::commit();
Log::info('Rollback migration berhasil: field product_code telah dihapus dari tabel atmcards');
} catch (Exception $e) {
DB::rollback();
Log::error('Rollback migration gagal: ' . $e->getMessage());
throw $e;
}
}
};

View File

@@ -30,7 +30,8 @@
"attributes": [], "attributes": [],
"permission": "", "permission": "",
"roles": [ "roles": [
"administrator" "administrator",
"customer_service"
] ]
}, },
{ {

View File

@@ -16,8 +16,8 @@
} }
.container { .container {
max-width: 90%; max-width: 100%;
margin: 20px auto; margin: 0px auto;
background-color: #ffffff; background-color: #ffffff;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
@@ -37,7 +37,7 @@
} }
.content { .content {
padding: 30px; padding: 5px;
font-size: 14px; font-size: 14px;
} }
@@ -89,7 +89,7 @@
Silahkan gunakan password Electronic Statement Anda untuk membukanya.<br><br> Silahkan gunakan password Electronic Statement Anda untuk membukanya.<br><br>
Password standar Elektronic Statement ini adalah <strong>ddMonyyyyxx</strong> (contoh: 01Aug1970xx) Password standar Elektronic Statement ini adalah <strong>ddMonyyyyxx</strong> (contoh: 01Aug1970xx)
dimana : dimana :
<ul style="list-style-type: none"> <ul style="list-style-type: none;">
<li>- dd : <strong>2 digit</strong> tanggal lahir anda, contoh: 01</li> <li>- dd : <strong>2 digit</strong> tanggal lahir anda, contoh: 01</li>
<li>- Mon : <li>- Mon :
<strong>3 huruf pertama</strong> bulan lahir anda dalam bahasa Ingris. Huruf pertama adalah <strong>3 huruf pertama</strong> bulan lahir anda dalam bahasa Ingris. Huruf pertama adalah
@@ -114,7 +114,7 @@
Please use your Electronic Statement password to open it.<br><br> Please use your Electronic Statement password to open it.<br><br>
The Electronic Statement standard password is <strong>ddMonyyyyxx</strong> (example: 01Aug1970xx) where: The Electronic Statement standard password is <strong>ddMonyyyyxx</strong> (example: 01Aug1970xx) where:
<ul style="list-style-type: none"> <ul style="list-style-type: none;">
<li>- dd : <strong>The first 2 digits</strong> of your birthdate, example: 01</li> <li>- dd : <strong>The first 2 digits</strong> of your birthdate, example: 01</li>
<li>- Mon : <li>- Mon :
<strong>The first 3 letters</strong> of your birth month in English. The first letter is <strong>The first 3 letters</strong> of your birth month in English. The first letter is
@@ -137,10 +137,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="footer">
<p>© {{ date('Y') }} Bank Artha Graha Internasional. All rights reserved.</p>
<p>Jika Anda memiliki pertanyaan, silakan hubungi customer service kami.</p>
</div>
</div> </div>
</body> </body>

View File

@@ -20,22 +20,31 @@
@endif @endif
<div class="grid grid-cols-1 gap-5"> <div class="grid grid-cols-1 gap-5">
<div class="form-group"> @if ($multiBranch)
<label class="form-label required" for="branch_code">Branch</label> <div class="form-group">
<select class="select tomselect @error('branch_code') is-invalid @enderror" id="branch_code" <label class="form-label required" for="branch_id">Branch/Cabang</label>
name="branch_code" required> <select class="input form-control tomselect @error('branch_id') is-invalid @enderror"
<option value="">Select Branch</option> id="branch_id" name="branch_id" required>
@foreach ($branches as $branch) <option value="">Pilih Branch/Cabang</option>
<option value="{{ $branch->code }}" @foreach ($branches as $branchOption)
{{ old('branch_code', $statement->branch_code ?? '') == $branch->code ? 'selected' : '' }}> <option value="{{ $branchOption->code }}"
{{ $branch->name }} {{ old('branch_id', $statement->branch_id ?? ($branch->code ?? '')) == $branchOption->code ? 'selected' : '' }}>
</option> {{ $branchOption->code }} - {{ $branchOption->name }}
@endforeach </option>
</select> @endforeach
@error('branch_code') </select>
<div class="invalid-feedback">{{ $message }}</div> @error('branch_id')
@enderror <div class="invalid-feedback">{{ $message }}</div>
</div> @enderror
</div>
@else
<div class="form-group">
<label class="form-label" for="branch_display">Branch/Cabang</label>
<input type="text" class="input form-control" id="branch_display"
value="{{ $branch->code ?? '' }} - {{ $branch->name ?? '' }}" readonly>
<input type="hidden" name="branch_id" value="{{ $branch->code ?? '' }}">
</div>
@endif
<div class="form-group"> <div class="form-group">
<label class="form-label required" for="stmt_sent_type">Statement Type</label> <label class="form-label required" for="stmt_sent_type">Statement Type</label>
@@ -127,295 +136,352 @@
<div class="min-w-full card card-grid" data-datatable="false" data-datatable-page-size="10" <div class="min-w-full card card-grid" data-datatable="false" data-datatable-page-size="10"
data-datatable-state-save="false" id="statement-table" data-api-url="{{ route('statements.datatables') }}"> data-datatable-state-save="false" id="statement-table" data-api-url="{{ route('statements.datatables') }}">
<div class="flex-wrap py-5 card-header"> <div class="flex-wrap py-5 card-header">
<h3 class="card-title"> <div class="min-w-full card card-grid" data-datatable="false" data-datatable-page-size="10"
Daftar Statement Request data-datatable-state-save="false" id="statement-table"
</h3> data-api-url="{{ route('statements.datatables') }}">
<div class="flex flex-wrap gap-2 lg:gap-5"> <div class="flex-wrap py-5 card-header">
<div class="flex"> <h3 class="card-title">
<label class="input input-sm"> <i class="ki-filled ki-magnifier"> </i> Daftar Statement Request
<input placeholder="Search Statement" id="search" type="text" value=""> </h3>
</label> <div class="flex flex-wrap gap-2 lg:gap-5">
</div> <div class="flex">
<label class="input input-sm"> <i class="ki-filled ki-magnifier"> </i>
<input placeholder="Search Statement" id="search" type="text"
value="">
</label>
</div>
</div>
</div>
<div class="card-body">
<div class="scrollable-x-auto">
<table class="table text-sm font-medium text-gray-700 align-middle table-auto table-border"
data-datatable-table="true">
<thead>
<tr>
<th class="w-14">
<input class="checkbox checkbox-sm" data-datatable-check="true" type="checkbox" />
</th>
<th class="min-w-[100px]" data-datatable-column="id">
<span class="sort"> <span class="sort-label"> ID </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="branch_name">
<span class="sort"> <span class="sort-label"> Branch </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="account_number">
<span class="sort"> <span class="sort-label"> Account Number </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="period">
<span class="sort"> <span class="sort-label"> Period </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="authorization_status">
<span class="sort"> <span class="sort-label"> Status </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="is_available">
<span class="sort"> <span class="sort-label"> Available </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="is_generated">
<span class="sort"> <span class="sort-label"> Generated </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="remarks">
<span class="sort"> <span class="sort-label"> Notes </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[180px]" data-datatable-column="created_at">
<span class="sort"> <span class="sort-label"> Created At </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[50px] text-center" data-datatable-column="actions">Action</th>
</tr>
</thead>
</table>
</div>
<div
class="flex-col gap-3 justify-center font-medium text-gray-600 card-footer md:justify-between md:flex-row text-2sm">
<div class="flex gap-2 items-center">
Show
<select class="w-16 select select-sm" data-datatable-size="true" name="perpage"> </select>
per page
</div>
<div class="flex gap-4 items-center">
<span data-datatable-info="true"> </span>
<div class="pagination" data-datatable-pagination="true">
</div> </div>
</div> </div>
</div> <div class="card-body">
</div> <div class="scrollable-x-auto">
</div> <table class="table text-sm font-medium text-gray-700 align-middle table-auto table-border"
</div> data-datatable-table="true">
</div> <thead>
@endsection <tr>
<th class="w-14">
<input class="checkbox checkbox-sm" data-datatable-check="true"
type="checkbox" />
</th>
<th class="min-w-[100px]" data-datatable-column="id">
<span class="sort"> <span class="sort-label"> ID </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="branch_name">
<span class="sort"> <span class="sort-label"> Branch </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="account_number">
<span class="sort"> <span class="sort-label"> Account Number </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="period">
<span class="sort"> <span class="sort-label"> Period </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="authorization_status">
<span class="sort"> <span class="sort-label"> Status </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="is_available">
<span class="sort"> <span class="sort-label"> Available </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="is_generated">
<span class="sort"> <span class="sort-label"> Generated </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="remarks">
<span class="sort"> <span class="sort-label"> Notes </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[180px]" data-datatable-column="created_at">
<span class="sort"> <span class="sort-label"> Created At </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[50px] text-center" data-datatable-column="actions">
Action</th>
</tr>
</thead>
</table>
</div>
<div
class="flex-col gap-3 justify-center font-medium text-gray-600 card-footer md:justify-between md:flex-row text-2sm">
<div class="flex gap-2 items-center">
<div
class="flex-col gap-3 justify-center font-medium text-gray-600 card-footer md:justify-between md:flex-row text-2sm">
<div class="flex gap-2 items-center">
Show
<select class="w-16 select select-sm" data-datatable-size="true"
name="perpage"> </select>
per page
<select class="w-16 select select-sm" data-datatable-size="true"
name="perpage"> </select>
per page
</div>
<div class="flex gap-4 items-center">
<div class="flex gap-4 items-center">
<span data-datatable-info="true"> </span>
<div class="pagination" data-datatable-pagination="true">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection
@push('scripts') @push('scripts')
<script type="text/javascript"> <script type="text/javascript">
function deleteData(data) { /**
Swal.fire({ * Fungsi untuk menghapus data statement
title: 'Are you sure?', * @param {number} data - ID statement yang akan dihapus
text: "You won't be able to revert this!", */
icon: 'warning', function deleteData(data) {
showCancelButton: true, Swal.fire({
confirmButtonColor: '#3085d6', title: 'Are you sure?',
cancelButtonColor: '#d33', text: "You won't be able to revert this!",
confirmButtonText: 'Yes, delete it!' icon: 'warning',
}).then((result) => { showCancelButton: true,
if (result.isConfirmed) { confirmButtonColor: '#3085d6',
$.ajaxSetup({ cancelButtonColor: '#d33',
headers: { confirmButtonText: 'Yes, delete it!'
'X-CSRF-TOKEN': '{{ csrf_token() }}' }).then((result) => {
} if (result.isConfirmed) {
}); $.ajaxSetup({
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
});
$.ajax(`statements/${data}`, { $.ajax(`statements/${data}`, {
type: 'DELETE' type: 'DELETE'
}).then((response) => { }).then((response) => {
swal.fire('Deleted!', 'Statement request has been deleted.', 'success').then(() => { swal.fire('Deleted!', 'Statement request has been deleted.', 'success').then(() => {
window.location.reload(); window.location.reload();
}); });
}).catch((error) => { }).catch((error) => {
console.error('Error:', error); console.error('Error:', error);
Swal.fire('Error!', 'An error occurred while deleting the record.', 'error'); Swal.fire('Error!', 'An error occurred while deleting the record.', 'error');
}); });
} }
}) })
} }
</script>
<script type="module"> /**
const element = document.querySelector('#statement-table'); * Konfirmasi email sebelum submit form
const searchInput = document.getElementById('search'); * Menampilkan SweetAlert jika email diisi untuk konfirmasi pengiriman
*/
document.addEventListener('DOMContentLoaded', function() {
const form = document.querySelector('form');
const emailInput = document.getElementById('email');
const apiUrl = element.getAttribute('data-api-url'); // Log: Inisialisasi event listener untuk konfirmasi email
const dataTableOptions = { console.log('Email confirmation listener initialized');
apiEndpoint: apiUrl,
pageSize: 10,
columns: {
select: {
render: (item, data, context) => {
const checkbox = document.createElement('input');
checkbox.className = 'checkbox checkbox-sm';
checkbox.type = 'checkbox';
checkbox.value = data.id.toString();
checkbox.setAttribute('data-datatable-row-check', 'true');
return checkbox.outerHTML.trim();
},
},
id: {
title: 'ID',
},
branch_name: {
title: 'Branch',
},
account_number: {
title: 'Account Number',
},
period: {
title: 'Period',
render: (item, data) => {
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const formatPeriod = (period) => { form.addEventListener('submit', function(e) {
if (!period) return ''; const emailValue = emailInput.value.trim();
const year = period.substring(0, 4);
const month = parseInt(period.substring(4, 6));
return `${monthNames[month - 1]} ${year}`;
};
const fromPeriod = formatPeriod(data.period_from); // Jika email diisi, tampilkan konfirmasi
const toPeriod = data.period_to ? ` - ${formatPeriod(data.period_to)}` : ''; if (emailValue) {
e.preventDefault(); // Hentikan submit form sementara
return fromPeriod + toPeriod; // Log: Email terdeteksi, menampilkan konfirmasi
}, console.log('Email detected:', emailValue);
},
authorization_status: {
title: 'Status',
render: (item, data) => {
let statusClass = 'badge badge-light-primary';
if (data.authorization_status === 'approved') { Swal.fire({
statusClass = 'badge badge-light-success'; title: 'Konfirmasi Pengiriman Email',
} else if (data.authorization_status === 'rejected') { text: `Apakah Anda yakin ingin mengirimkan statement ke email: ${emailValue}?`,
statusClass = 'badge badge-light-danger'; icon: 'question',
} else if (data.authorization_status === 'pending') { showCancelButton: true,
statusClass = 'badge badge-light-warning'; confirmButtonColor: '#3085d6',
} cancelButtonColor: '#d33',
confirmButtonText: 'Ya, Kirim Email',
cancelButtonText: 'Batal',
reverseButtons: true
}).then((result) => {
if (result.isConfirmed) {
// Log: User konfirmasi pengiriman email
console.log('User confirmed email sending');
return `<span class="${statusClass}">${data.authorization_status}</span>`; // Submit form setelah konfirmasi
}, form.submit();
}, } else {
is_available: { // Log: User membatalkan pengiriman email
title: 'Available', console.log('User cancelled email sending');
render: (item, data) => { }
let statusClass = data.is_available ? 'badge badge-light-success' : });
'badge badge-light-danger'; } else {
let statusText = data.is_available ? 'Yes' : 'No'; // Log: Tidak ada email, submit form normal
return `<span class="${statusClass}">${statusText}</span>`; console.log('No email provided, submitting form normally');
}, }
}, });
is_generated: { });
title: 'Generated', </script>
render: (item, data) => {
let statusClass = data.is_generated ? 'badge badge-light-success' : <script type="module">
'badge badge-light-danger'; const element = document.querySelector('#statement-table');
let statusText = data.is_generated ? 'Yes' : 'No'; const searchInput = document.getElementById('search');
return `<span class="${statusClass}">${statusText}</span>`;
}, const apiUrl = element.getAttribute('data-api-url');
}, const dataTableOptions = {
remarks: { apiEndpoint: apiUrl,
title: 'Notes', pageSize: 10,
}, columns: {
created_at: { select: {
title: 'Created At', render: (item, data, context) => {
render: (item, data) => { const checkbox = document.createElement('input');
return data.created_at ?? ''; checkbox.className = 'checkbox checkbox-sm';
}, checkbox.type = 'checkbox';
}, checkbox.value = data.id.toString();
actions: { checkbox.setAttribute('data-datatable-row-check', 'true');
title: 'Actions', return checkbox.outerHTML.trim();
render: (item, data) => { },
let buttons = `<div class="flex flex-nowrap justify-center"> },
id: {
title: 'ID',
},
branch_name: {
title: 'Branch',
},
account_number: {
title: 'Account Number',
},
period: {
title: 'Period',
render: (item, data) => {
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const formatPeriod = (period) => {
if (!period) return '';
const year = period.substring(0, 4);
const month = parseInt(period.substring(4, 6));
return `${monthNames[month - 1]} ${year}`;
};
const fromPeriod = formatPeriod(data.period_from);
const toPeriod = data.period_to ? ` - ${formatPeriod(data.period_to)}` : '';
return fromPeriod + toPeriod;
},
},
is_available: {
title: 'Available',
render: (item, data) => {
let statusClass = data.is_available ? 'badge badge-light-success' :
'badge badge-light-danger';
let statusText = data.is_available ? 'Yes' : 'No';
return `<span class="${statusClass}">${statusText}</span>`;
},
},
is_generated: {
title: 'Generated',
render: (item, data) => {
let statusClass = data.is_generated ? 'badge badge-light-success' :
'badge badge-light-danger';
let statusText = data.is_generated ? 'Yes' : 'No';
return `<span class="${statusClass}">${statusText}</span>`;
},
},
remarks: {
title: 'Notes',
},
created_at: {
title: 'Created At',
render: (item, data) => {
return data.created_at ?? '';
},
},
actions: {
title: 'Actions',
render: (item, data) => {
let buttons = `<div class="flex flex-nowrap justify-center">
<a class="btn btn-sm btn-icon btn-clear btn-info" href="statements/${data.id}"> <a class="btn btn-sm btn-icon btn-clear btn-info" href="statements/${data.id}">
<i class="ki-outline ki-eye"></i> <i class="ki-outline ki-eye"></i>
</a>`; </a>`;
// Show download button if statement is approved and available but not downloaded // Show download button if statement is approved and available but not downloaded
//if (data.authorization_status === 'approved' && data.is_available && !data.is_downloaded) { //if (data.authorization_status === 'approved' && data.is_available && !data.is_downloaded) {
if (data.is_available) { if (data.is_available) {
buttons += `<a class="btn btn-sm btn-icon btn-clear btn-success" href="statements/${data.id}/download"> buttons += `<a class="btn btn-sm btn-icon btn-clear btn-success" href="statements/${data.id}/download">
<i class="ki-outline ki-cloud-download"></i> <i class="ki-outline ki-cloud-download"></i>
</a>`; </a>`;
} }
// Show send email button if email is not empty and statement is available // Show send email button if email is not empty and statement is available
if (data.is_available && data.email) { if (data.is_available && data.email) {
buttons += `<a class="btn btn-sm btn-icon btn-clear btn-primary" href="statements/${data.id}/send-email" title="Send to Email"> buttons += `<a class="btn btn-sm btn-icon btn-clear btn-primary" href="statements/${data.id}/send-email" title="Send to Email">
<i class="ki-outline ki-paper-plane"></i> <i class="ki-outline ki-paper-plane"></i>
</a>`; </a>`;
} }
// Only show delete button if status is pending // Only show delete button if status is pending
if (data.authorization_status === 'pending') { if (data.authorization_status === 'pending') {
buttons += `<a onclick="deleteData(${data.id})" class="delete btn btn-sm btn-icon btn-clear btn-danger"> buttons += `<a onclick="deleteData(${data.id})" class="delete btn btn-sm btn-icon btn-clear btn-danger">
<i class="ki-outline ki-trash"></i> <i class="ki-outline ki-trash"></i>
</a>`; </a>`;
} }
buttons += `</div>`; buttons += `</div>`;
return buttons; return buttons;
}, },
} }
}, },
}; };
let dataTable = new KTDataTable(element, dataTableOptions); let dataTable = new KTDataTable(element, dataTableOptions);
// Custom search functionality // Custom search functionality
searchInput.addEventListener('input', function() { searchInput.addEventListener('input', function() {
const searchValue = this.value.trim(); searchInput.addEventListener('input', function() {
dataTable.search(searchValue, true); const searchValue = this.value.trim();
}); dataTable.search(searchValue, true);
});
// Handle the "select all" checkbox // Handle the "select all" checkbox
const selectAllCheckbox = document.querySelector('input[data-datatable-check="true"]'); const selectAllCheckbox = document.querySelector('input[data-datatable-check="true"]');
if (selectAllCheckbox) { if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() { selectAllCheckbox.addEventListener('change', function() {
const isChecked = this.checked; const isChecked = this.checked;
const rowCheckboxes = document.querySelectorAll('input[data-datatable-row-check="true"]'); const rowCheckboxes = document.querySelectorAll(
'input[data-datatable-row-check="true"]');
rowCheckboxes.forEach(checkbox => { rowCheckboxes.forEach(checkbox => {
checkbox.checked = isChecked; checkbox.checked = isChecked;
}); });
}); });
} }
</script> });
</script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Validate that end date is after start date // Validate that end date is after start date
const startDateInput = document.getElementById('start_date'); const startDateInput = document.getElementById('start_date');
const endDateInput = document.getElementById('end_date'); const endDateInput = document.getElementById('end_date');
function validateDates() { function validateDates() {
const startDate = new Date(startDateInput.value); const startDate = new Date(startDateInput.value);
const endDate = new Date(endDateInput.value); const endDate = new Date(endDateInput.value);
if (startDate > endDate) { if (startDate > endDate) {
endDateInput.setCustomValidity('End date must be after start date'); endDateInput.setCustomValidity('End date must be after start date');
} else { } else {
endDateInput.setCustomValidity(''); endDateInput.setCustomValidity('');
} }
} }
startDateInput.addEventListener('change', validateDates); startDateInput.addEventListener('change', validateDates);
endDateInput.addEventListener('change', validateDates); endDateInput.addEventListener('change', validateDates);
// Set max date for date inputs to today // Set max date for date inputs to today
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
startDateInput.setAttribute('max', today); startDateInput.setAttribute('max', today);
endDateInput.setAttribute('max', today); endDateInput.setAttribute('max', today);
}); });
</script> </script>
@endpush @endpush

View File

@@ -73,19 +73,6 @@
</div> </div>
</div> </div>
<div class="d-flex flex-column">
<div class="text-gray-500 fw-semibold">Status</div>
<div>
@if($statement->authorization_status === 'pending')
<span class="badge badge-warning">Pending Authorization</span>
@elseif($statement->authorization_status === 'approved')
<span class="badge badge-success">Approved</span>
@elseif($statement->authorization_status === 'rejected')
<span class="badge badge-danger">Rejected</span>
@endif
</div>
</div>
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<div class="text-gray-500 fw-semibold">Availability</div> <div class="text-gray-500 fw-semibold">Availability</div>
<div> <div>