Compare commits
12 Commits
b717749450
...
new
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4616137e0c | ||
|
|
19c962307e | ||
|
|
a79b1bd99e | ||
|
|
fd5b8e1dad | ||
|
|
8fb16028d9 | ||
|
|
6035c61cc4 | ||
|
|
2c8f49af20 | ||
|
|
4bfd937490 | ||
|
|
7b32cb8d39 | ||
|
|
4b889da5a5 | ||
|
|
dbdeceb4c0 | ||
|
|
f7a92a5336 |
110
app/Console/UpdateAllAtmCardsCommand.php
Normal file
110
app/Console/UpdateAllAtmCardsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
use Modules\Basicdata\Models\Branch;
|
||||
use Modules\Webstatement\Http\Requests\PrintStatementRequest;
|
||||
use Modules\Webstatement\Mail\StatementEmail;
|
||||
use Modules\Webstatement\Models\Account;
|
||||
use Modules\Webstatement\Models\PrintStatementLog;
|
||||
use ZipArchive;
|
||||
|
||||
@@ -24,9 +25,15 @@
|
||||
*/
|
||||
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,6 +42,38 @@
|
||||
*/
|
||||
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();
|
||||
|
||||
try {
|
||||
@@ -47,10 +86,12 @@
|
||||
$validated['user_agent'] = $request->userAgent();
|
||||
$validated['request_type'] = 'single_account'; // Default untuk request manual
|
||||
$validated['status'] = 'pending'; // Status awal
|
||||
$validated['authorization_status'] = 'approved'; // Status otorisasi awal
|
||||
$validated['total_accounts'] = 1; // Untuk single account
|
||||
$validated['processed_accounts'] = 0;
|
||||
$validated['success_count'] = 0;
|
||||
$validated['failed_count'] = 0;
|
||||
$validated['branch_code'] = $branch_code; // Awal tidak tersedia
|
||||
|
||||
// Create the statement log
|
||||
$statement = PrintStatementLog::create($validated);
|
||||
@@ -66,6 +107,10 @@
|
||||
// Process statement availability check
|
||||
$this->checkStatementAvailability($statement);
|
||||
|
||||
$statement = PrintStatementLog::find($statement->id);
|
||||
if($statement->email){
|
||||
$this->sendEmail($statement->id);
|
||||
}
|
||||
DB::commit();
|
||||
|
||||
return redirect()->route('statements.index')
|
||||
@@ -103,7 +148,14 @@
|
||||
|
||||
try {
|
||||
$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) {
|
||||
$periodFrom = Carbon::createFromFormat('Ym', $statement->period_from);
|
||||
@@ -114,7 +166,7 @@
|
||||
|
||||
for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) {
|
||||
$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)) {
|
||||
$availablePeriods[] = $periodFormatted;
|
||||
@@ -236,20 +288,148 @@
|
||||
|
||||
// Generate or fetch the statement file
|
||||
$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) {
|
||||
// Handle period range download (existing logic)
|
||||
// ... existing zip creation logic ...
|
||||
// Log: Memulai proses download period range
|
||||
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)) {
|
||||
/**
|
||||
* 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");
|
||||
} 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) {
|
||||
DB::rollBack();
|
||||
|
||||
Log::error('Failed to download statement', [
|
||||
'statement_id' => $statement->id,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
return back()->with('error', 'Failed to download statement: ' . $e->getMessage());
|
||||
@@ -295,6 +475,13 @@
|
||||
// Retrieve data from the database
|
||||
$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
|
||||
if ($request->has('search') && !empty($request->get('search'))) {
|
||||
$search = $request->get('search');
|
||||
@@ -426,6 +613,7 @@
|
||||
public function sendEmail($id)
|
||||
{
|
||||
$statement = PrintStatementLog::findOrFail($id);
|
||||
|
||||
// Check if statement has email
|
||||
if (empty($statement->email)) {
|
||||
return redirect()->back()->with('error', 'No email address provided for this statement.');
|
||||
@@ -438,7 +626,7 @@
|
||||
|
||||
try {
|
||||
$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) {
|
||||
$periodFrom = Carbon::createFromFormat('Ym', $statement->period_from);
|
||||
@@ -450,7 +638,7 @@
|
||||
|
||||
for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) {
|
||||
$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)) {
|
||||
$availablePeriods[] = $periodFormatted;
|
||||
@@ -475,7 +663,7 @@
|
||||
if ($zip->open($zipFilePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
|
||||
// Add each available statement to the zip
|
||||
foreach ($availablePeriods as $period) {
|
||||
$filePath = "{$period}/Print/{$statement->branch_code}/{$statement->account_number}.pdf";
|
||||
$filePath = "{$period}/{$statement->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf";
|
||||
$localFilePath = storage_path("app/temp/{$statement->account_number}_{$period}.pdf");
|
||||
|
||||
// Download the file from SFTP to local storage temporarily
|
||||
|
||||
@@ -21,7 +21,6 @@ class PrintStatementRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = [
|
||||
'branch_code' => ['required', 'string', 'exists:branches,code'],
|
||||
'account_number' => ['required', 'string'],
|
||||
'is_period_range' => ['sometimes', 'boolean'],
|
||||
'email' => ['nullable', 'email'],
|
||||
@@ -36,6 +35,7 @@ class PrintStatementRequest extends FormRequest
|
||||
function ($attribute, $value, $fail) {
|
||||
$query = Statement::where('account_number', $this->input('account_number'))
|
||||
->where('authorization_status', '!=', 'rejected')
|
||||
->where('is_available', true)
|
||||
->where('period_from', $value);
|
||||
|
||||
// If this is an update request, exclude the current record
|
||||
@@ -77,8 +77,6 @@ class PrintStatementRequest extends FormRequest
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'branch_code.required' => 'Branch code is required',
|
||||
'branch_code.exists' => 'Selected branch does not exist',
|
||||
'account_number.required' => 'Account number is required',
|
||||
'period_from.required' => 'Period is required',
|
||||
'period_from.regex' => 'Period must be in YYYYMM format',
|
||||
@@ -106,14 +104,14 @@ class PrintStatementRequest extends FormRequest
|
||||
$this->merge([
|
||||
'period_to' => substr($this->period_to, 0, 4) . substr($this->period_to, 5, 2),
|
||||
]);
|
||||
}
|
||||
|
||||
// Convert is_period_range to boolean if it exists
|
||||
if ($this->has('period_to')) {
|
||||
// Only set is_period_range to true if period_to is different from period_from
|
||||
if ($this->period_to !== $this->period_from) {
|
||||
$this->merge([
|
||||
'is_period_range' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Set default request_type if not provided
|
||||
if (!$this->has('request_type')) {
|
||||
|
||||
@@ -63,7 +63,9 @@
|
||||
$this->updateCsvLogStart();
|
||||
|
||||
// Generate CSV file
|
||||
$result = $this->generateAtmCardCsv();
|
||||
// $result = $this->generateAtmCardCsv();
|
||||
|
||||
$result = $this->generateSingleAtmCardCsv();
|
||||
|
||||
// Update status CSV generation berhasil
|
||||
$this->updateCsvLogSuccess($result);
|
||||
@@ -175,6 +177,8 @@
|
||||
->whereNotNull('currency')
|
||||
->where('currency', '!=', '')
|
||||
->whereIn('ctdesc', $cardTypes)
|
||||
->whereNotIn('product_code',['6002','6004','6042','6031'])
|
||||
->where('branch','!=','ID0019999')
|
||||
->get();
|
||||
}
|
||||
|
||||
@@ -413,4 +417,155 @@
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Webstatement\Jobs;
|
||||
|
||||
use Exception;
|
||||
@@ -11,6 +10,7 @@
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Modules\Webstatement\Models\StmtEntry;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
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()
|
||||
: void
|
||||
private function saveBatch(): void
|
||||
{
|
||||
Log::info('Memulai proses saveBatch dengan updateOrCreate');
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
if (!empty($this->entryBatch)) {
|
||||
// Process in smaller chunks for better memory management
|
||||
foreach ($this->entryBatch as $entry) {
|
||||
// Extract all stmt_entry_ids from the current chunk
|
||||
$entryIds = array_column($entry, 'stmt_entry_id');
|
||||
$totalProcessed = 0;
|
||||
|
||||
// Delete existing records with these IDs to avoid conflicts
|
||||
StmtEntry::whereIn('stmt_entry_id', $entryIds)->delete();
|
||||
// Process each entry data directly (tidak ada nested array)
|
||||
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
|
||||
StmtEntry::insert($entry);
|
||||
$totalProcessed++;
|
||||
} 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 = [];
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
DB::rollback();
|
||||
|
||||
Log::error("Error in saveBatch: " . $e->getMessage() . "\n" . $e->getTraceAsString());
|
||||
$this->errorCount += count($this->entryBatch);
|
||||
|
||||
// Reset batch even if there's an error to prevent reprocessing the same failed records
|
||||
$this->entryBatch = [];
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
379
app/Jobs/UpdateAllAtmCardsBatchJob.php
Normal file
379
app/Jobs/UpdateAllAtmCardsBatchJob.php
Normal 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
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,14 @@ namespace Modules\Webstatement\Jobs;
|
||||
|
||||
use Exception;
|
||||
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\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
|
||||
{
|
||||
@@ -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
|
||||
* @return array|null
|
||||
@@ -85,10 +86,26 @@ class UpdateAtmCardBranchCurrencyJob implements ShouldQueue
|
||||
private function getAccountInfo(string $accountNumber): ?array
|
||||
{
|
||||
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;
|
||||
$path = self::API_INQUIRY_PATH;
|
||||
$data = [
|
||||
'accountNo' => $accountNumber
|
||||
'accountNo' => $accountNumber,
|
||||
|
||||
];
|
||||
|
||||
$response = Http::post($url . $path, $data);
|
||||
@@ -110,6 +127,7 @@ class UpdateAtmCardBranchCurrencyJob implements ShouldQueue
|
||||
$cardData = [
|
||||
'branch' => !empty($accountInfo['acctCompany']) ? $accountInfo['acctCompany'] : null,
|
||||
'currency' => !empty($accountInfo['acctCurrency']) ? $accountInfo['acctCurrency'] : null,
|
||||
'product_code' => !empty($accountInfo['acctType']) ? $accountInfo['acctType'] : null,
|
||||
];
|
||||
|
||||
$this->card->update($cardData);
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Modules\Webstatement\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
// use Modules\Webstatement\Database\Factories\AtmcardFactory;
|
||||
|
||||
class Atmcard extends Model
|
||||
@@ -15,7 +16,64 @@ class Atmcard extends Model
|
||||
*/
|
||||
protected $guarded = ['id'];
|
||||
|
||||
/**
|
||||
* Relasi ke tabel JenisKartu untuk mendapatkan informasi biaya kartu
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function biaya(){
|
||||
Log::info('Mengakses relasi biaya untuk ATM card', ['card_id' => $this->id]);
|
||||
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']
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,19 @@ use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Nwidart\Modules\Traits\PathNamespace;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Modules\Webstatement\Console\CheckEmailProgressCommand;
|
||||
use Modules\Webstatement\Console\UnlockPdf;
|
||||
use Modules\Webstatement\Console\CombinePdf;
|
||||
use Modules\Webstatement\Console\ConvertHtmlToPdf;
|
||||
use Modules\Webstatement\Console\ExportDailyStatements;
|
||||
use Modules\Webstatement\Console\ProcessDailyMigration;
|
||||
use Modules\Webstatement\Console\ExportPeriodStatements;
|
||||
use Modules\Webstatement\Console\UpdateAllAtmCardsCommand;
|
||||
use Modules\Webstatement\Console\CheckEmailProgressCommand;
|
||||
use Modules\Webstatement\Console\GenerateBiayakartuCommand;
|
||||
use Modules\Webstatement\Console\SendStatementEmailCommand;
|
||||
use Modules\Webstatement\Jobs\UpdateAtmCardBranchCurrencyJob;
|
||||
use Modules\Webstatement\Console\GenerateAtmTransactionReport;
|
||||
use Modules\Webstatement\Console\GenerateBiayaKartuCsvCommand;
|
||||
use Modules\Webstatement\Console\SendStatementEmailCommand;
|
||||
|
||||
class WebstatementServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -70,7 +71,8 @@ class WebstatementServiceProvider extends ServiceProvider
|
||||
ExportPeriodStatements::class,
|
||||
GenerateAtmTransactionReport::class,
|
||||
SendStatementEmailCommand::class,
|
||||
CheckEmailProgressCommand::class
|
||||
CheckEmailProgressCommand::class,
|
||||
UpdateAllAtmCardsCommand::class
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -30,7 +30,8 @@
|
||||
"attributes": [],
|
||||
"permission": "",
|
||||
"roles": [
|
||||
"administrator"
|
||||
"administrator",
|
||||
"customer_service"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -89,14 +89,14 @@
|
||||
Silahkan gunakan password Electronic Statement Anda untuk membukanya.<br><br>
|
||||
Password standar Elektronic Statement ini adalah <strong>ddMonyyyyxx</strong> (contoh: 01Aug1970xx)
|
||||
dimana :
|
||||
<ul class="dashed-list">
|
||||
<li>dd : <strong>2 digit</strong> tanggal lahir anda, contoh: 01</li>
|
||||
<li>Mon :
|
||||
<ul style="list-style-type: none;">
|
||||
<li>- dd : <strong>2 digit</strong> tanggal lahir anda, contoh: 01</li>
|
||||
<li>- Mon :
|
||||
<strong>3 huruf pertama</strong> bulan lahir anda dalam bahasa Ingris. Huruf pertama adalah
|
||||
huruf besar dan selanjutnya huruf kecil, contoh : Aug
|
||||
</li>
|
||||
<li>yyyy : <strong>4 digit</strong> tahun kelahiran anda, contoh : 1970</li>
|
||||
<li>xx : <strong>2 digit terakhir</strong> dari nomer rekening anda, contoh : 12</li>
|
||||
<li>- yyyy : <strong>4 digit</strong> tahun kelahiran anda, contoh : 1970</li>
|
||||
<li>- xx : <strong>2 digit terakhir</strong> dari nomer rekening anda, contoh : 12</li>
|
||||
</ul>
|
||||
<br>
|
||||
|
||||
@@ -114,14 +114,14 @@
|
||||
Please use your Electronic Statement password to open it.<br><br>
|
||||
|
||||
The Electronic Statement standard password is <strong>ddMonyyyyxx</strong> (example: 01Aug1970xx) where:
|
||||
<ul class="dashed-list">
|
||||
<li>dd : <strong>The first 2 digits</strong> of your birthdate, example: 01</li>
|
||||
<li>Mon :
|
||||
<ul style="list-style-type: none;">
|
||||
<li>- dd : <strong>The first 2 digits</strong> of your birthdate, example: 01</li>
|
||||
<li>- Mon :
|
||||
<strong>The first 3 letters</strong> of your birth month in English. The first letter is
|
||||
uppercase and the rest are lowercase, example: Aug
|
||||
</li>
|
||||
<li>yyyy : <strong>4 digit</strong> of your birth year, example: 1970</li>
|
||||
<li>xx : <strong>The last 2 digits</strong> of your account number, example: 12.</li>
|
||||
<li>- yyyy : <strong>4 digit</strong> of your birth year, example: 1970</li>
|
||||
<li>- xx : <strong>The last 2 digits</strong> of your account number, example: 12.</li>
|
||||
</ul>
|
||||
<br>
|
||||
|
||||
|
||||
@@ -11,31 +11,47 @@
|
||||
<h3 class="card-title">Request Print Stetement</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ isset($statement) ? route('statements.update', $statement->id) : route('statements.store') }}" method="POST">
|
||||
<form
|
||||
action="{{ isset($statement) ? route('statements.update', $statement->id) : route('statements.store') }}"
|
||||
method="POST">
|
||||
@csrf
|
||||
@if (isset($statement))
|
||||
@method('PUT')
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-1 gap-5">
|
||||
|
||||
@if ($multiBranch)
|
||||
<div class="form-group">
|
||||
<label class="form-label required" for="branch_code">Branch</label>
|
||||
<select class="select tomselect @error('branch_code') is-invalid @enderror" id="branch_code" name="branch_code" required>
|
||||
<option value="">Select Branch</option>
|
||||
@foreach($branches as $branch)
|
||||
<option value="{{ $branch->code }}" {{ (old('branch_code', $statement->branch_code ?? '') == $branch->code) ? 'selected' : '' }}>
|
||||
{{ $branch->name }}
|
||||
<label class="form-label required" for="branch_id">Branch/Cabang</label>
|
||||
<select class="input form-control tomselect @error('branch_id') is-invalid @enderror"
|
||||
id="branch_id" name="branch_id" required>
|
||||
<option value="">Pilih Branch/Cabang</option>
|
||||
@foreach ($branches as $branchOption)
|
||||
<option value="{{ $branchOption->code }}"
|
||||
{{ old('branch_id', $statement->branch_id ?? ($branch->code ?? '')) == $branchOption->code ? 'selected' : '' }}>
|
||||
{{ $branchOption->code }} - {{ $branchOption->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('branch_code')
|
||||
@error('branch_id')
|
||||
<div class="invalid-feedback">{{ $message }}</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">
|
||||
<label class="form-label required" for="account_number">Account Number</label>
|
||||
<input type="text" class="input form-control @error('account_number') is-invalid @enderror" id="account_number" name="account_number" value="{{ old('account_number', $statement->account_number ?? '') }}" required>
|
||||
<input type="text" class="input form-control @error('account_number') is-invalid @enderror"
|
||||
id="account_number" name="account_number"
|
||||
value="{{ old('account_number', $statement->account_number ?? '') }}" required>
|
||||
@error('account_number')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
@@ -43,7 +59,9 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="email">Email</label>
|
||||
<input type="email" class="input form-control @error('email') is-invalid @enderror" id="email" name="email" value="{{ old('email', $statement->email ?? '') }}" placeholder="Optional email for send statement">
|
||||
<input type="email" class="input form-control @error('email') is-invalid @enderror"
|
||||
id="email" name="email" value="{{ old('email', $statement->email ?? '') }}"
|
||||
placeholder="Optional email for send statement">
|
||||
@error('email')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
@@ -53,24 +71,21 @@
|
||||
<label class="form-label required" for="start_date">Start Date</label>
|
||||
|
||||
<input class="input @error('period_from') border-danger bg-danger-light @enderror"
|
||||
type="month"
|
||||
name="period_from"
|
||||
type="month" name="period_from"
|
||||
value="{{ $statement->period_from ?? old('period_from') }}"
|
||||
max="{{ date('Y-m', strtotime('-1 month')) }}">
|
||||
@error('period_from')
|
||||
<em class="alert text-danger text-sm">{{ $message }}</em>
|
||||
<em class="text-sm alert text-danger">{{ $message }}</em>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label required" for="end_date">End Date</label>
|
||||
<input class="input @error('period_to') border-danger bg-danger-light @enderror"
|
||||
type="month"
|
||||
name="period_to"
|
||||
value="{{ $statement->period_to ?? old('period_to') }}"
|
||||
<input class="input @error('period_to') border-danger bg-danger-light @enderror" type="month"
|
||||
name="period_to" value="{{ $statement->period_to ?? old('period_to') }}"
|
||||
max="{{ date('Y-m', strtotime('-1 month')) }}">
|
||||
@error('period_to')
|
||||
<em class="alert text-danger text-sm">{{ $message }}</em>
|
||||
<em class="text-sm alert text-danger">{{ $message }}</em>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,8 +100,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-6">
|
||||
<div class="card card-grid min-w-full" data-datatable="false" data-datatable-page-size="10" data-datatable-state-save="false" id="statement-table" data-api-url="{{ route('statements.datatables') }}">
|
||||
<div class="card-header py-5 flex-wrap">
|
||||
<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') }}">
|
||||
<div class="flex-wrap py-5 card-header">
|
||||
<h3 class="card-title">
|
||||
Daftar Statement Request
|
||||
</h3>
|
||||
@@ -100,7 +116,8 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="scrollable-x-auto">
|
||||
<table class="table table-auto table-border align-middle text-gray-700 font-medium text-sm" data-datatable-table="true">
|
||||
<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">
|
||||
@@ -122,10 +139,6 @@
|
||||
<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>
|
||||
@@ -143,12 +156,14 @@
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer justify-center md:justify-between flex-col md:flex-row gap-3 text-gray-600 text-2sm font-medium">
|
||||
<div class="flex items-center gap-2">
|
||||
<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="select select-sm w-16" 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 items-center gap-4">
|
||||
<div class="flex gap-4 items-center">
|
||||
<span data-datatable-info="true"> </span>
|
||||
<div class="pagination" data-datatable-pagination="true">
|
||||
</div>
|
||||
@@ -162,6 +177,10 @@
|
||||
|
||||
@push('scripts')
|
||||
<script type="text/javascript">
|
||||
/**
|
||||
* Fungsi untuk menghapus data statement
|
||||
* @param {number} data - ID statement yang akan dihapus
|
||||
*/
|
||||
function deleteData(data) {
|
||||
Swal.fire({
|
||||
title: 'Are you sure?',
|
||||
@@ -192,6 +211,56 @@
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Konfirmasi email sebelum submit form
|
||||
* Menampilkan SweetAlert jika email diisi untuk konfirmasi pengiriman
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.querySelector('form');
|
||||
const emailInput = document.getElementById('email');
|
||||
|
||||
// Log: Inisialisasi event listener untuk konfirmasi email
|
||||
console.log('Email confirmation listener initialized');
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
const emailValue = emailInput.value.trim();
|
||||
|
||||
// Jika email diisi, tampilkan konfirmasi
|
||||
if (emailValue) {
|
||||
e.preventDefault(); // Hentikan submit form sementara
|
||||
|
||||
// Log: Email terdeteksi, menampilkan konfirmasi
|
||||
console.log('Email detected:', emailValue);
|
||||
|
||||
Swal.fire({
|
||||
title: 'Konfirmasi Pengiriman Email',
|
||||
text: `Apakah Anda yakin ingin mengirimkan statement ke email: ${emailValue}?`,
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
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');
|
||||
|
||||
// Submit form setelah konfirmasi
|
||||
form.submit();
|
||||
} else {
|
||||
// Log: User membatalkan pengiriman email
|
||||
console.log('User cancelled email sending');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Log: Tidak ada email, submit form normal
|
||||
console.log('No email provided, submitting form normally');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
@@ -243,26 +312,11 @@
|
||||
return fromPeriod + toPeriod;
|
||||
},
|
||||
},
|
||||
authorization_status: {
|
||||
title: 'Status',
|
||||
render: (item, data) => {
|
||||
let statusClass = 'badge badge-light-primary';
|
||||
|
||||
if (data.authorization_status === 'approved') {
|
||||
statusClass = 'badge badge-light-success';
|
||||
} else if (data.authorization_status === 'rejected') {
|
||||
statusClass = 'badge badge-light-danger';
|
||||
} else if (data.authorization_status === 'pending') {
|
||||
statusClass = 'badge badge-light-warning';
|
||||
}
|
||||
|
||||
return `<span class="${statusClass}">${data.authorization_status}</span>`;
|
||||
},
|
||||
},
|
||||
is_available: {
|
||||
title: 'Available',
|
||||
render: (item, data) => {
|
||||
let statusClass = data.is_available ? 'badge badge-light-success' : 'badge badge-light-danger';
|
||||
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>`;
|
||||
},
|
||||
|
||||
@@ -73,19 +73,6 @@
|
||||
</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="text-gray-500 fw-semibold">Availability</div>
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user