diff --git a/app/Http/Controllers/SlikController.php b/app/Http/Controllers/SlikController.php new file mode 100644 index 0000000..99af83d --- /dev/null +++ b/app/Http/Controllers/SlikController.php @@ -0,0 +1,473 @@ +user = Auth::user(); + } + + /** + * Menampilkan halaman index slik + * + * @return \Illuminate\View\View + */ + public function index() + { + return view('lpj::slik.index'); + } + + /** + * Menampilkan detail slik + * + * @param int $id + * @return \Illuminate\View\View + */ + public function show($id) + { + $slik = Slik::findOrFail($id); + return view('lpj::slik.show', compact('slik')); + } + + /** + * Data untuk datatables dengan server-side processing + * + * @param Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function dataForDatatables(Request $request) + { + // Authorization check dapat ditambahkan sesuai kebutuhan + // if (is_null($this->user)) { + // abort(403, 'Unauthorized access.'); + // } + + // Retrieve data from the database + $query = Slik::query(); + + // Apply search filter if provided + if ($request->has('search') && !empty($request->get('search'))) { + $search = $request->get('search'); + $query->where(function ($q) use ($search) { + $q->where('sandi_bank', 'LIKE', "%$search%") + ->orWhere('no_rekening', 'LIKE', "%$search%") + ->orWhere('cif', 'LIKE', "%$search%") + ->orWhere('nama_debitur', 'LIKE', "%$search%") + ->orWhere('nama_cabang', 'LIKE', "%$search%") + ->orWhere('jenis_agunan', 'LIKE', "%$search%") + ->orWhere('nama_pemilik_agunan', 'LIKE', "%$search%") + ->orWhere('alamat_agunan', 'LIKE', "%$search%") + ->orWhere('lokasi_agunan', 'LIKE', "%$search%"); + }); + } + + // Apply year filter + if ($request->has('year') && !empty($request->get('year'))) { + $query->byYear($request->get('year')); + } + + // Apply month filter + if ($request->has('month') && !empty($request->get('month'))) { + $query->byMonth($request->get('month')); + } + + // Apply sandi bank filter + if ($request->has('sandi_bank') && !empty($request->get('sandi_bank'))) { + $query->where('sandi_bank', $request->get('sandi_bank')); + } + + // Apply kolektibilitas filter + if ($request->has('kolektibilitas') && !empty($request->get('kolektibilitas'))) { + $query->where('kolektibilitas', $request->get('kolektibilitas')); + } + + // Apply jenis agunan filter + if ($request->has('jenis_agunan') && !empty($request->get('jenis_agunan'))) { + $query->where('jenis_agunan', $request->get('jenis_agunan')); + } + + // Apply sorting if provided + if ($request->has('sortOrder') && !empty($request->get('sortOrder'))) { + $order = $request->get('sortOrder'); + $column = $request->get('sortField', 'created_at'); + $query->orderBy($column, $order); + } else { + $query->orderBy('created_at', 'desc'); + } + + // Get the total count of records + $totalRecords = $query->count(); + + // Apply pagination if provided + if ($request->has('page') && $request->has('size')) { + $page = $request->get('page'); + $size = $request->get('size'); + $offset = ($page - 1) * $size; // Calculate the offset + + $query->skip($offset)->take($size); + } + + // Get the filtered count of records + $filteredRecords = $query->count(); + + // Get the data for the current page with relationships + $data = $query->get(); + + // Transform data untuk datatables + $transformedData = $data->map(function ($item) { + return [ + 'id' => $item->id, + 'sandi_bank' => $item->sandi_bank, + 'tahun' => $item->tahun, + 'bulan' => $item->bulan, + 'no_rekening' => $item->no_rekening, + 'cif' => $item->cif, + 'nama_debitur' => $item->nama_debitur, + 'kolektibilitas' => $item->kolektibilitas, + 'kolektibilitas_badge' => $item->kolektibilitas_badge, + 'fasilitas' => $item->fasilitas, + 'jenis_agunan' => $item->jenis_agunan, + 'nama_pemilik_agunan' => $item->nama_pemilik_agunan, + 'nilai_agunan' => $item->nilai_agunan_formatted, + 'nilai_agunan_ljk' => $item->nilai_agunan_ljk_formatted, + 'alamat_agunan' => $item->alamat_agunan, + 'lokasi_agunan' => $item->lokasi_agunan, + 'nama_cabang' => $item->nama_cabang, + 'kode_cabang' => $item->kode_cabang, + 'created_by' => $item->creator?->name ?? '-', + 'created_at' => dateFormat($item->created_at, true) + ]; + }); + + // Calculate the page count + $pageCount = ceil($totalRecords / ($request->get('size', 10))); + + // Calculate the current page number + $currentPage = $request->get('page', 1); + + // Return the response data as a JSON object + return response()->json([ + 'draw' => $request->get('draw'), + 'recordsTotal' => $totalRecords, + 'recordsFiltered' => $filteredRecords, + 'pageCount' => $pageCount, + 'page' => $currentPage, + 'totalCount' => $totalRecords, + 'data' => $transformedData, + ]); + } + + /** + * Import data slik dari Excel dengan optimasi memory dan progress tracking + * + * @param Request $request + * @return \Illuminate\Http\RedirectResponse + */ + public function import(Request $request) + { + Log::info('SlikController: Starting import process with optimizations', [ + 'user_id' => Auth::id(), + 'request_size' => $request->header('Content-Length'), + 'has_file' => $request->hasFile('file'), + 'memory_limit' => ini_get('memory_limit'), + 'max_execution_time' => ini_get('max_execution_time') + ]); + + // Validasi file upload dengan logging detail dan error handling komprehensif + try { + // Cek apakah ada file yang diupload + if (!$request->hasFile('file')) { + Log::error('SlikController: Tidak ada file yang diupload', [ + 'user_id' => Auth::id(), + 'files_count' => count($request->allFiles()), + 'request_data' => $request->all() + ]); + throw ValidationException::withMessages(['file' => 'Tidak ada file yang diupload.']); + } + + $file = $request->file('file'); + + // Cek apakah file valid + if (!$file->isValid()) { + $error = $file->getError(); + $errorMessage = match($error) { + UPLOAD_ERR_INI_SIZE => 'File terlalu besar (melebihi upload_max_filesize).', + UPLOAD_ERR_FORM_SIZE => 'File terlalu besar (melebihi MAX_FILE_SIZE).', + UPLOAD_ERR_PARTIAL => 'File hanya terupload sebagian.', + UPLOAD_ERR_NO_FILE => 'Tidak ada file yang diupload.', + UPLOAD_ERR_NO_TMP_DIR => 'Direktori temp tidak tersedia.', + UPLOAD_ERR_CANT_WRITE => 'Gagal menulis file ke disk.', + UPLOAD_ERR_EXTENSION => 'Upload dibatalkan oleh ekstensi PHP.', + default => 'Error upload tidak diketahui: ' . $error + }; + + Log::error('SlikController: File upload tidak valid', [ + 'error' => $error, + 'error_message' => $errorMessage, + 'user_id' => Auth::id(), + 'file_info' => [ + 'name' => $file->getClientOriginalName(), + 'size' => $file->getSize(), + 'mime' => $file->getMimeType() + ] + ]); + throw ValidationException::withMessages(['file' => $errorMessage]); + } + + $maxFileSize = config('import.slik.max_file_size', 50) * 1024; // dalam KB + $request->validate([ + 'file' => 'required|file|mimes:xlsx,xls|max:' . $maxFileSize + ]); + Log::info('SlikController: Validasi file berhasil'); + } catch (\Illuminate\Validation\ValidationException $e) { + Log::error('SlikController: Validasi file gagal', [ + 'errors' => $e->errors(), + 'user_id' => Auth::id(), + 'request_size' => $request->header('Content-Length') + ]); + throw $e; + } + + try { + $uploadedFile = $request->file('file'); + $originalName = $uploadedFile->getClientOriginalName(); + $fileSize = $uploadedFile->getSize(); + + Log::info('SlikController: Memulai import data Slik', [ + 'user_id' => Auth::id(), + 'filename' => $originalName, + 'filesize' => $fileSize, + 'filesize_mb' => round($fileSize / 1024 / 1024, 2), + 'mime_type' => $uploadedFile->getMimeType(), + 'extension' => $uploadedFile->getClientOriginalExtension() + ]); + + // Generate unique import ID + $importId = uniqid('slik_import_'); + $userId = Auth::id() ?? 1; + + // Cek apakah menggunakan queue processing untuk file besar + $useQueue = config('import.slik.queue.enabled', false) && $fileSize > (5 * 1024 * 1024); // > 5MB + + // Pastikan direktori temp ada + $tempDir = storage_path('app/temp'); + if (!file_exists($tempDir)) { + mkdir($tempDir, 0755, true); + Log::info('SlikController: Direktori temp dibuat', ['path' => $tempDir]); + } + + // Simpan file sementara dengan nama unik + $tempFileName = 'slik_import_' . time() . '_' . uniqid() . '.' . $uploadedFile->getClientOriginalExtension(); + $tempFilePath = $tempDir . '/' . $tempFileName; + + Log::info('SlikController: Memindahkan file ke temp', [ + 'temp_path' => $tempFilePath, + 'use_queue' => $useQueue + ]); + + // Pindahkan file ke direktori temp + $uploadedFile->move($tempDir, $tempFilePath); + + // Verifikasi file berhasil dipindahkan + if (!file_exists($tempFilePath)) { + throw new Exception('File gagal dipindahkan ke direktori temp'); + } + + Log::info('SlikController: File berhasil dipindahkan', [ + 'file_size' => filesize($tempFilePath) + ]); + + if ($useQueue) { + Log::info('SlikController: Menggunakan queue processing untuk file besar', [ + 'import_id' => $importId, + 'file_size_mb' => round($fileSize / 1024 / 1024, 2) + ]); + + // Dispatch job ke queue + \Modules\Lpj\Jobs\ProcessSlikImport::dispatch($tempFilePath, $userId, $importId); + + return redirect()->back()->with('success', 'Import sedang diproses di background. ID: ' . $importId); + } + + // Import langsung untuk file kecil + Log::info('SlikController: Processing file directly', [ + 'import_id' => $importId, + 'file_size_mb' => round($fileSize / 1024 / 1024, 2) + ]); + + // Set optimasi memory untuk import langsung + $memoryLimit = config('import.slik.memory_limit', 256); + ini_set('memory_limit', $memoryLimit . 'M'); + ini_set('max_execution_time', config('import.slik.timeout', 30000)); + + // Enable garbage collection jika diizinkan + if (config('import.slik.enable_gc', true)) { + gc_enable(); + } + + // Proses import menggunakan SlikImport class + Log::info('SlikController: Memulai proses Excel import'); + $import = new SlikImport(); + Excel::import($import, $tempFilePath); + Log::info('SlikController: Excel import selesai'); + + // Force garbage collection setelah selesai + if (config('import.slik.enable_gc', true)) { + gc_collect_cycles(); + } + + // Hapus file temporary setelah import + if (file_exists($tempFilePath)) { + unlink($tempFilePath); + Log::info('SlikController: File temp berhasil dihapus'); + } + + Log::info('SlikController: Data Slik berhasil diimport', [ + 'user_id' => Auth::id(), + 'import_id' => $importId + ]); + + return redirect()->back()->with('success', 'Data Slik berhasil diimport dari file Excel.'); + + } catch (Exception $e) { + // Hapus file temporary jika ada error + if (isset($tempFilePath) && file_exists($tempFilePath)) { + unlink($tempFilePath); + Log::info('SlikController: File temp dihapus karena error'); + } + + Log::error('SlikController: Gagal import data Slik', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + 'user_id' => Auth::id(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'memory_usage' => memory_get_usage(true) + ]); + + return redirect()->back()->with('error', 'Gagal import data Slik: ' . $e->getMessage()); + } + } + + /** + * Menampilkan halaman form import + * + * @return \Illuminate\View\View + */ + public function importForm() + { + return view('lpj::slik.import'); + } + + /** + * Download template Excel untuk import + * + * @return \Symfony\Component\HttpFoundation\BinaryFileResponse + */ + public function downloadTemplate() + { + $templatePath = resource_path('metronic/slik.xlsx'); + + if (!file_exists($templatePath)) { + return redirect()->back()->with('error', 'Template file tidak ditemukan.'); + } + + return response()->download($templatePath, 'template_slik.xlsx'); + } + + /** + * Get import progress + * + * @param string $importId + * @return \Illuminate\Http\JsonResponse + */ + public function progress(string $importId) + { + try { + $progressService = new \Modules\Lpj\Services\ImportProgressService(); + $progress = $progressService->getProgress($importId); + + if (!$progress) { + return response()->json([ + 'success' => false, + 'message' => 'Progress import tidak ditemukan' + ], 404); + } + + return response()->json([ + 'success' => true, + 'progress' => $progress + ]); + + } catch (\Exception $e) { + Log::error('SlikController: Error getting progress', [ + 'import_id' => $importId, + 'error' => $e->getMessage() + ]); + + return response()->json([ + 'success' => false, + 'message' => 'Gagal mendapatkan progress: ' . $e->getMessage() + ], 500); + } + } + + /** + * Hapus semua data slik + * + * @return \Illuminate\Http\RedirectResponse + */ + public function truncate() + { + DB::beginTransaction(); + try { + Log::info('SlikController: Menghapus semua data Slik', [ + 'user_id' => Auth::id() + ]); + + Slik::truncate(); + + DB::commit(); + Log::info('SlikController: Semua data Slik berhasil dihapus', [ + 'user_id' => Auth::id() + ]); + + return redirect()->back()->with('success', 'Semua data Slik berhasil dihapus.'); + + } catch (Exception $e) { + DB::rollback(); + Log::error('SlikController: Gagal menghapus data Slik', [ + 'error' => $e->getMessage(), + 'user_id' => Auth::id() + ]); + + return redirect()->back()->with('error', 'Gagal menghapus data Slik: ' . $e->getMessage()); + } + } +} diff --git a/app/Imports/SlikImport.php b/app/Imports/SlikImport.php new file mode 100644 index 0000000..fba3670 --- /dev/null +++ b/app/Imports/SlikImport.php @@ -0,0 +1,415 @@ + 'UTF-8', + 'delimiter' => ',', + 'enclosure' => '"', + 'escape_character' => '\\', + ]; + } + + /** + * Process collection data dari Excel dengan optimasi memory + * + * @param Collection $collection + * @return void + */ + public function collection(Collection $collection) + { + // Set memory limit dari konfigurasi + $memoryLimit = config('import.slik.memory_limit', 1024); + $currentMemoryLimit = ini_get('memory_limit'); + + if ($currentMemoryLimit !== '-1' && $this->convertToBytes($currentMemoryLimit) < $memoryLimit * 1024 * 1024) { + ini_set('memory_limit', $memoryLimit . 'M'); + } + + // Set timeout handler + $timeout = config('import.slik.timeout', 1800); + set_time_limit($timeout); + + // Force garbage collection sebelum memulai + if (config('import.slik.enable_gc', true)) { + gc_collect_cycles(); + } + + Log::info('SlikImport: Memulai import data', [ + 'total_rows' => $collection->count(), + 'memory_usage' => memory_get_usage(true), + 'memory_peak' => memory_get_peak_usage(true), + 'memory_limit' => ini_get('memory_limit'), + 'php_version' => PHP_VERSION, + 'memory_limit_before' => $currentMemoryLimit, + 'config' => [ + 'memory_limit' => $memoryLimit, + 'chunk_size' => $this->chunkSize(), + 'batch_size' => $this->batchSize() + ] + ]); + + DB::beginTransaction(); + + try { + $processedRows = 0; + $skippedRows = 0; + $errorRows = 0; + $totalRows = $collection->count(); + + foreach ($collection as $index => $row) { + // Log progress setiap 25 baris untuk chunk lebih kecil + if ($index % 25 === 0) { + Log::info('SlikImport: Processing chunk', [ + 'current_row' => $index + 5, + 'progress' => round(($index / max($totalRows, 1)) * 100, 2) . '%', + 'processed' => $processedRows, + 'skipped' => $skippedRows, + 'errors' => $errorRows, + 'memory_usage' => memory_get_usage(true), + 'memory_peak' => memory_get_peak_usage(true), + 'memory_diff' => memory_get_peak_usage(true) - memory_get_usage(true) + ]); + } + + // Skip baris kosong + if ($this->isEmptyRow($row)) { + $skippedRows++; + Log::debug('SlikImport: Skipping empty row', ['row_number' => $index + 5]); + continue; + } + + // Validasi data + if (!$this->validateRow($row)) { + $errorRows++; + Log::warning('SlikImport: Invalid row data', [ + 'row_number' => $index + 5, + 'data' => $row->toArray() + ]); + continue; + } + + try { + // Map data dari Excel ke model + $slikData = $this->mapRowToSlik($row); + + // Update atau create berdasarkan no_rekening dan cif + $slik = Slik::updateOrCreate( + [ + 'no_rekening' => $slikData['no_rekening'], + 'cif' => $slikData['cif'] + ], + $slikData + ); + + $processedRows++; + + // Log detail untuk baris pertama sebagai sample + if ($index === 0) { + Log::info('SlikImport: Sample data processed', [ + 'slik_id' => $slik->id, + 'no_rekening' => $slik->no_rekening, + 'cif' => $slik->cif, + 'was_recently_created' => $slik->wasRecentlyCreated + ]); + } + + // Force garbage collection setiap 25 baris untuk mengurangi memory + if (config('import.slik.enable_gc', true) && $index > 0 && $index % 25 === 0) { + gc_collect_cycles(); + } + + // Unset data yang sudah tidak digunakan untuk mengurangi memory + if ($index > 0 && $index % 25 === 0) { + unset($slikData, $slik); + } + + // Reset collection internal untuk mengurangi memory + if ($index > 0 && $index % 100 === 0) { + $collection = collect($collection->slice($index + 1)->values()); + } + + } catch (\Exception $e) { + $errorRows++; + Log::error('SlikImport: Error processing row', [ + 'row_number' => $index + 5, + 'error' => $e->getMessage(), + 'data' => $row->toArray(), + 'memory_usage' => memory_get_usage(true) + ]); + } + } + + DB::commit(); + + // Force garbage collection setelah selesai + if (config('import.slik.enable_gc', true)) { + gc_collect_cycles(); + } + + // Cleanup variables + unset($collection); + + Log::info('SlikImport: Import berhasil diselesaikan', [ + 'total_rows' => $totalRows, + 'processed_rows' => $processedRows, + 'skipped_rows' => $skippedRows, + 'error_rows' => $errorRows, + 'final_memory_usage' => memory_get_usage(true), + 'peak_memory_usage' => memory_get_peak_usage(true), + 'memory_saved' => memory_get_peak_usage(true) - memory_get_usage(true), + 'memory_efficiency' => ($processedRows > 0) ? round(memory_get_peak_usage(true) / $processedRows, 2) : 0 + ]); + + } catch (\Exception $e) { + DB::rollBack(); + + // Force garbage collection jika error + if (config('import.slik.enable_gc', true)) { + gc_collect_cycles(); + } + + $errorType = 'general'; + if (str_contains(strtolower($e->getMessage()), 'memory')) { + $errorType = 'memory'; + } elseif (str_contains(strtolower($e->getMessage()), 'timeout') || str_contains(strtolower($e->getMessage()), 'maximum execution time')) { + $errorType = 'timeout'; + } + + Log::error('SlikImport: Error during import', [ + 'error' => $e->getMessage(), + 'error_type' => $errorType, + 'exception_type' => get_class($e), + 'trace' => $e->getTraceAsString(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'memory_usage' => memory_get_usage(true), + 'memory_peak' => memory_get_peak_usage(true), + 'memory_limit' => ini_get('memory_limit'), + 'timeout_limit' => ini_get('max_execution_time'), + 'is_memory_error' => str_contains(strtolower($e->getMessage()), 'memory'), + 'is_timeout_error' => str_contains(strtolower($e->getMessage()), 'timeout') || str_contains(strtolower($e->getMessage()), 'maximum execution time') + ]); + throw $e; + } + } + + /** + * Convert memory limit string ke bytes + * + * @param string $memoryLimit + * @return int + */ + private function convertToBytes(string $memoryLimit): int + { + $memoryLimit = trim($memoryLimit); + $lastChar = strtolower(substr($memoryLimit, -1)); + $number = (int) substr($memoryLimit, 0, -1); + + switch ($lastChar) { + case 'g': + return $number * 1024 * 1024 * 1024; + case 'm': + return $number * 1024 * 1024; + case 'k': + return $number * 1024; + default: + return (int) $memoryLimit; + } + } + + /** + * Cek apakah baris kosong + * + * @param Collection $row + * @return bool + */ + private function isEmptyRow(Collection $row): bool + { + return $row->filter(function ($value) { + return !empty(trim($value)); + })->isEmpty(); + } + + /** + * Validasi data baris + * + * @param Collection $row + * @return bool + */ + private function validateRow(Collection $row): bool + { + // Validasi minimal: sandi_bank, no_rekening, dan cif harus ada + return !empty(trim($row[0])) && // sandi_bank + !empty(trim($row[5])) && // no_rekening + !empty(trim($row[6])); // cif + } + + /** + * Map data dari baris Excel ke array untuk model Slik + * + * @param Collection $row + * @return array + */ + private function mapRowToSlik(Collection $row): array + { + return [ + 'sandi_bank' => trim($row[0]) ?: null, + 'tahun' => $this->parseInteger($row[1]), + 'bulan' => $this->parseInteger($row[2]), + 'flag_detail' => trim($row[3]) ?: null, + 'kode_register_agunan' => trim($row[4]) ?: null, + 'no_rekening' => trim($row[5]) ?: null, + 'cif' => trim($row[6]) ?: null, + 'kolektibilitas' => trim($row[7]) ?: null, + 'fasilitas' => trim($row[8]) ?: null, + 'jenis_segmen_fasilitas' => trim($row[9]) ?: null, + 'status_agunan' => trim($row[10]) ?: null, + 'jenis_agunan' => trim($row[11]) ?: null, + 'peringkat_agunan' => trim($row[12]) ?: null, + 'lembaga_pemeringkat' => trim($row[13]) ?: null, + 'jenis_pengikatan' => trim($row[14]) ?: null, + 'tanggal_pengikatan' => $this->parseDate($row[15]), + 'nama_pemilik_agunan' => trim($row[16]) ?: null, + 'bukti_kepemilikan' => trim($row[17]) ?: null, + 'alamat_agunan' => trim($row[18]) ?: null, + 'lokasi_agunan' => trim($row[19]) ?: null, + 'nilai_agunan' => $this->parseDecimal($row[20]), + 'nilai_agunan_menurut_ljk' => $this->parseDecimal($row[21]), + 'tanggal_penilaian_ljk' => $this->parseDate($row[22]), + 'nilai_agunan_penilai_independen' => $this->parseDecimal($row[23]), + 'nama_penilai_independen' => trim($row[24]) ?: null, + 'tanggal_penilaian_penilai_independen' => $this->parseDate($row[25]), + 'jumlah_hari_tunggakan' => $this->parseInteger($row[26]), + 'status_paripasu' => trim($row[27]) ?: null, + 'prosentase_paripasu' => $this->parseDecimal($row[28]), + 'status_kredit_join' => trim($row[29]) ?: null, + 'diasuransikan' => trim($row[30]) ?: null, + 'keterangan' => trim($row[31]) ?: null, + 'kantor_cabang' => trim($row[32]) ?: null, + 'operasi_data' => trim($row[33]) ?: null, + 'kode_cabang' => trim($row[34]) ?: null, + 'nama_debitur' => trim($row[35]) ?: null, + 'nama_cabang' => trim($row[36]) ?: null, + 'flag' => trim($row[37]) ?: null, + ]; + } + + /** + * Parse integer value + * + * @param mixed $value + * @return int|null + */ + private function parseInteger($value): ?int + { + if (empty(trim($value))) { + return null; + } + + return (int) $value; + } + + /** + * Parse decimal value + * + * @param mixed $value + * @return float|null + */ + private function parseDecimal($value): ?float + { + if (empty(trim($value))) { + return null; + } + + // Remove currency formatting + $cleaned = str_replace([',', '.'], ['', '.'], $value); + $cleaned = preg_replace('/[^0-9.]/', '', $cleaned); + + return (float) $cleaned; + } + + /** + * Parse date value + * + * @param mixed $value + * @return string|null + */ + private function parseDate($value): ?string + { + if (empty(trim($value))) { + return null; + } + + try { + // Try to parse various date formats + $date = \Carbon\Carbon::parse($value); + return $date->format('Y-m-d'); + } catch (\Exception $e) { + Log::warning('SlikImport: Invalid date format', [ + 'value' => $value, + 'error' => $e->getMessage() + ]); + return null; + } + } +} diff --git a/app/Jobs/ProcessSlikImport.php b/app/Jobs/ProcessSlikImport.php new file mode 100644 index 0000000..86773c2 --- /dev/null +++ b/app/Jobs/ProcessSlikImport.php @@ -0,0 +1,179 @@ +filePath = $filePath; + $this->userId = $userId; + $this->importId = $importId; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle(): void + { + Log::info('ProcessSlikImport: Memulai proses import via queue', [ + 'file_path' => $this->filePath, + 'user_id' => $this->userId, + 'import_id' => $this->importId, + 'memory_limit' => ini_get('memory_limit'), + 'max_execution_time' => ini_get('max_execution_time') + ]); + + try { + // Cek file size terlebih dahulu + $fileSize = filesize($this->filePath); + $maxFileSize = config('import.slik.max_file_size', 50) * 1024 * 1024; // Convert MB to bytes + + if ($fileSize > $maxFileSize) { + throw new \Exception('File terlalu besar: ' . number_format($fileSize / 1024 / 1024, 2) . ' MB. Maksimum: ' . config('import.slik.max_file_size', 50) . ' MB'); + } + + // Set optimasi memory untuk queue processing + $memoryLimit = config('import.slik.memory_limit', 1024); + ini_set('memory_limit', $memoryLimit . 'M'); + ini_set('max_execution_time', config('import.slik.timeout', 1800)); + + // Set timeout untuk XML Scanner + $xmlScannerTimeout = config('import.slik.xml_scanner.timeout', 1800); + $xmlScannerMemory = config('import.slik.xml_scanner.memory_limit', 1024); + + // Enable garbage collection jika diizinkan + if (config('import.slik.enable_gc', true)) { + gc_enable(); + } + + // Update progress status + $this->updateProgress('processing', 0, 'Memproses file Excel...'); + + Log::info('SlikImport: Processing file', [ + 'file' => basename($this->filePath), + 'file_size' => number_format(filesize($this->filePath) / 1024 / 1024, 2) . ' MB', + 'memory_limit' => $memoryLimit . 'M', + 'timeout' => config('import.slik.timeout', 1800), + 'enable_gc' => config('import.slik.enable_gc', true), + 'xml_scanner_timeout' => config('import.slik.xml_scanner.timeout', 1800), + 'chunk_size' => config('import.slik.chunk_size', 50), + 'batch_size' => config('import.slik.batch_size', 50), + ]); + + // Import file menggunakan SlikImport + $import = new SlikImport(); + Excel::import($import, $this->filePath); + + // Update progress selesai + $this->updateProgress('completed', 100, 'Import berhasil diselesaikan'); + + Log::info('ProcessSlikImport: Import berhasil diselesaikan', [ + 'import_id' => $this->importId, + 'file_path' => $this->filePath, + 'memory_usage' => memory_get_usage(true), + 'memory_peak' => memory_get_peak_usage(true) + ]); + + // Hapus file temporary setelah selesai + if (config('import.general.cleanup_temp_files', true)) { + Storage::delete($this->filePath); + Log::info('ProcessSlikImport: File temporary dihapus', ['file_path' => $this->filePath]); + } + + } catch (\Exception $e) { + // Update progress error + $this->updateProgress('failed', 0, 'Error: ' . $e->getMessage()); + + Log::error('ProcessSlikImport: Error saat proses import', [ + 'import_id' => $this->importId, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'memory_usage' => memory_get_usage(true) + ]); + + throw $e; + } + } + + /** + * Update progress import + * + * @param string $status + * @param int $percentage + * @param string $message + * @return void + */ + private function updateProgress(string $status, int $percentage, string $message): void + { + if (config('import.slik.progress.enabled', true)) { + $cacheKey = config('import.slik.progress.cache_key', 'slik_import_progress') . '_' . $this->importId; + $cacheTtl = config('import.slik.progress.cache_ttl', 3600); + + $progressData = [ + 'status' => $status, + 'percentage' => $percentage, + 'message' => $message, + 'timestamp' => now(), + 'user_id' => $this->userId + ]; + + cache()->put($cacheKey, $progressData, $cacheTtl); + } + } + + /** + * Handle job failure + * + * @param \Throwable $exception + * @return void + */ + public function failed(\Throwable $exception): void + { + Log::error('ProcessSlikImport: Job failed', [ + 'import_id' => $this->importId, + 'error' => $exception->getMessage(), + 'trace' => $exception->getTraceAsString() + ]); + + // Update progress ke failed + $this->updateProgress('failed', 0, 'Import gagal: ' . $exception->getMessage()); + + // Cleanup file temporary + if (Storage::exists($this->filePath)) { + Storage::delete($this->filePath); + } + } +} diff --git a/app/Models/Slik.php b/app/Models/Slik.php new file mode 100644 index 0000000..ad44f06 --- /dev/null +++ b/app/Models/Slik.php @@ -0,0 +1,190 @@ + 'date', + 'tanggal_penilaian_ljk' => 'date', + 'tanggal_penilaian_penilai_independen' => 'date', + 'nilai_agunan' => 'decimal:2', + 'nilai_agunan_menurut_ljk' => 'decimal:2', + 'nilai_agunan_penilai_independen' => 'decimal:2', + 'prosentase_paripasu' => 'decimal:2', + 'jumlah_hari_tunggakan' => 'integer', + ]; + + /** + * Accessor untuk format nilai agunan dengan currency Indonesia + */ + public function getNilaiAgunanFormattedAttribute(): string + { + return $this->nilai_agunan ? 'Rp ' . number_format($this->nilai_agunan, 0, ',', '.') : 'Rp 0'; + } + + /** + * Accessor untuk format nilai agunan menurut LJK dengan currency Indonesia + */ + public function getNilaiAgunanMenurutLjkFormattedAttribute(): string + { + return $this->nilai_agunan_menurut_ljk ? 'Rp ' . number_format($this->nilai_agunan_menurut_ljk, 0, ',', '.') : 'Rp 0'; + } + + /** + * Accessor untuk format nilai agunan penilai independen dengan currency Indonesia + */ + public function getNilaiAgunanPenilaiIndependenFormattedAttribute(): string + { + return $this->nilai_agunan_penilai_independen ? 'Rp ' . number_format($this->nilai_agunan_penilai_independen, 0, ',', '.') : 'Rp 0'; + } + + /** + * Accessor untuk status badge berdasarkan status agunan + */ + public function getStatusBadgeAttribute(): string + { + $statusClass = match($this->status_agunan) { + 'Aktif' => 'badge-success', + 'Tidak Aktif' => 'badge-danger', + 'Pending' => 'badge-warning', + default => 'badge-secondary' + }; + + return '' . ($this->status_agunan ?? 'Unknown') . ''; + } + + /** + * Scope untuk filter berdasarkan tahun + */ + public function scopeByYear($query, $year) + { + return $query->where('tahun', $year); + } + + /** + * Scope untuk filter berdasarkan bulan + */ + public function scopeByMonth($query, $month) + { + return $query->where('bulan', $month); + } + + /** + * Scope untuk filter berdasarkan sandi bank + */ + public function scopeBySandiBank($query, $sandiBank) + { + return $query->where('sandi_bank', $sandiBank); + } + + /** + * Scope untuk filter berdasarkan kode cabang + */ + public function scopeByKodeCabang($query, $kodeCabang) + { + return $query->where('kode_cabang', $kodeCabang); + } + + // Method creator() dan editor() sudah disediakan oleh trait Userstamps +} \ No newline at end of file diff --git a/app/Services/ImportProgressService.php b/app/Services/ImportProgressService.php new file mode 100644 index 0000000..17afd7d --- /dev/null +++ b/app/Services/ImportProgressService.php @@ -0,0 +1,236 @@ +cacheKeyPrefix = config('import.slik.progress.cache_key', 'slik_import_progress'); + $this->cacheTtl = config('import.slik.progress.cache_ttl', 3600); + } + + /** + * Start new import progress + * + * @param string $importId + * @param int $userId + * @param string $filename + * @param int $totalRows + * @return array + */ + public function start(string $importId, int $userId, string $filename, int $totalRows): array + { + $progressData = [ + 'import_id' => $importId, + 'user_id' => $userId, + 'filename' => $filename, + 'total_rows' => $totalRows, + 'processed_rows' => 0, + 'skipped_rows' => 0, + 'error_rows' => 0, + 'status' => 'started', + 'percentage' => 0, + 'message' => 'Memulai import...', + 'started_at' => now(), + 'updated_at' => now() + ]; + + $cacheKey = $this->getCacheKey($importId); + Cache::put($cacheKey, $progressData, $this->cacheTtl); + + Log::info('ImportProgressService: Import started', $progressData); + + return $progressData; + } + + /** + * Update progress import + * + * @param string $importId + * @param int $processedRows + * @param int $skippedRows + * @param int $errorRows + * @param string|null $message + * @return array + */ + public function update(string $importId, int $processedRows, int $skippedRows, int $errorRows, ?string $message = null): array + { + $cacheKey = $this->getCacheKey($importId); + $progressData = Cache::get($cacheKey); + + if (!$progressData) { + Log::warning('ImportProgressService: Progress data not found', ['import_id' => $importId]); + return []; + } + + $totalRows = $progressData['total_rows']; + $percentage = $totalRows > 0 ? round(($processedRows / $totalRows) * 100, 2) : 0; + + $progressData = array_merge($progressData, [ + 'processed_rows' => $processedRows, + 'skipped_rows' => $skippedRows, + 'error_rows' => $errorRows, + 'percentage' => $percentage, + 'message' => $message ?? "Memproses baris {$processedRows} dari {$totalRows}...", + 'updated_at' => now() + ]); + + Cache::put($cacheKey, $progressData, $this->cacheTtl); + + // Log progress setiap 10% + if ($percentage % 10 === 0) { + Log::info('ImportProgressService: Progress update', [ + 'import_id' => $importId, + 'percentage' => $percentage, + 'processed' => $processedRows, + 'total' => $totalRows + ]); + } + + return $progressData; + } + + /** + * Mark import as completed + * + * @param string $importId + * @param string|null $message + * @return array + */ + public function complete(string $importId, ?string $message = null): array + { + $cacheKey = $this->getCacheKey($importId); + $progressData = Cache::get($cacheKey); + + if (!$progressData) { + Log::warning('ImportProgressService: Progress data not found for completion', ['import_id' => $importId]); + return []; + } + + $progressData = array_merge($progressData, [ + 'status' => 'completed', + 'percentage' => 100, + 'message' => $message ?? 'Import berhasil diselesaikan', + 'completed_at' => now(), + 'updated_at' => now() + ]); + + Cache::put($cacheKey, $progressData, $this->cacheTtl); + + Log::info('ImportProgressService: Import completed', [ + 'import_id' => $importId, + 'total_rows' => $progressData['total_rows'], + 'processed_rows' => $progressData['processed_rows'], + 'skipped_rows' => $progressData['skipped_rows'], + 'error_rows' => $progressData['error_rows'] + ]); + + return $progressData; + } + + /** + * Mark import as failed + * + * @param string $importId + * @param string $errorMessage + * @return array + */ + public function fail(string $importId, string $errorMessage): array + { + $cacheKey = $this->getCacheKey($importId); + $progressData = Cache::get($cacheKey); + + if (!$progressData) { + Log::warning('ImportProgressService: Progress data not found for failure', ['import_id' => $importId]); + return []; + } + + $progressData = array_merge($progressData, [ + 'status' => 'failed', + 'message' => 'Import gagal: ' . $errorMessage, + 'failed_at' => now(), + 'updated_at' => now() + ]); + + Cache::put($cacheKey, $progressData, $this->cacheTtl); + + Log::error('ImportProgressService: Import failed', [ + 'import_id' => $importId, + 'error' => $errorMessage, + 'progress_data' => $progressData + ]); + + return $progressData; + } + + /** + * Get progress data + * + * @param string $importId + * @return array|null + */ + public function getProgress(string $importId): ?array + { + $cacheKey = $this->getCacheKey($importId); + return Cache::get($cacheKey); + } + + /** + * Get all active imports for user + * + * @param int $userId + * @return array + */ + public function getUserImports(int $userId): array + { + $pattern = $this->cacheKeyPrefix . '_*'; + $keys = Cache::get($pattern); + + $imports = []; + foreach ($keys as $key) { + $data = Cache::get($key); + if ($data && $data['user_id'] === $userId) { + $imports[] = $data; + } + } + + return $imports; + } + + /** + * Clear progress data + * + * @param string $importId + * @return bool + */ + public function clear(string $importId): bool + { + $cacheKey = $this->getCacheKey($importId); + $result = Cache::forget($cacheKey); + + Log::info('ImportProgressService: Progress data cleared', ['import_id' => $importId]); + + return $result; + } + + /** + * Generate cache key + * + * @param string $importId + * @return string + */ + private function getCacheKey(string $importId): string + { + return $this->cacheKeyPrefix . '_' . $importId; + } +} \ No newline at end of file diff --git a/config/config.php b/config/config.php index a08a121..4bdc12b 100644 --- a/config/config.php +++ b/config/config.php @@ -2,4 +2,62 @@ return [ 'name' => 'Lpj', + 'import' => [ + 'slik' => [ + // Memory limit untuk import (dalam MB) + 'memory_limit' => env('SLIK_IMPORT_MEMORY_LIMIT', 1024), + + // Ukuran chunk untuk processing (jumlah baris per chunk) + 'chunk_size' => env('SLIK_IMPORT_CHUNK_SIZE', 50), + + // Ukuran batch untuk database insert + 'batch_size' => env('SLIK_IMPORT_BATCH_SIZE', 50), + + // Timeout untuk import (dalam detik) + 'timeout' => env('SLIK_IMPORT_TIMEOUT', 1800), // 30 menit untuk file besar + + // Maksimum file size yang diizinkan (dalam MB) + 'max_file_size' => env('SLIK_IMPORT_MAX_FILE_SIZE', 50), + + // Enable garbage collection untuk optimasi memory + 'enable_gc' => env('SLIK_IMPORT_ENABLE_GC', true), + + // Enable progress logging + 'enable_progress_logging' => env('SLIK_IMPORT_ENABLE_PROGRESS_LOGGING', true), + + // Enable detailed error logging + 'enable_error_logging' => env('SLIK_IMPORT_ENABLE_ERROR_LOGGING', true), + + // XML Scanner settings untuk optimasi memory + 'xml_scanner' => [ + 'timeout' => env('SLIK_XML_SCANNER_TIMEOUT', 1800), // 30 menit + 'memory_limit' => env('SLIK_XML_SCANNER_MEMORY_LIMIT', 1024), // 1GB + 'chunk_size' => env('SLIK_XML_SCANNER_CHUNK_SIZE', 50), // Lebih kecil untuk XML + ], + + // Queue processing untuk file besar + 'queue' => [ + 'enabled' => env('SLIK_IMPORT_QUEUE_ENABLED', false), + 'connection' => env('SLIK_IMPORT_QUEUE_CONNECTION', 'database'), + 'queue_name' => env('SLIK_IMPORT_QUEUE_NAME', 'imports'), + 'chunk_size' => env('SLIK_IMPORT_QUEUE_CHUNK_SIZE', 500), + ], + + // Progress tracking + 'progress' => [ + 'enabled' => env('SLIK_IMPORT_PROGRESS_ENABLED', true), + 'update_interval' => env('SLIK_IMPORT_PROGRESS_INTERVAL', 50), // update setiap 50 baris + 'cache_key' => 'slik_import_progress', + 'cache_ttl' => 3600, // 1 jam + ], + ], + + // General import settings + 'general' => [ + 'default_memory_limit' => env('IMPORT_DEFAULT_MEMORY_LIMIT', 128), + 'max_execution_time' => env('IMPORT_MAX_EXECUTION_TIME', 300000), + 'temp_directory' => env('IMPORT_TEMP_DIRECTORY', storage_path('app/temp')), + 'cleanup_temp_files' => env('IMPORT_CLEANUP_TEMP_FILES', true), + ], + ], ]; diff --git a/database/migrations/2025_09_15_092525_create_sliks_table.php b/database/migrations/2025_09_15_092525_create_sliks_table.php new file mode 100644 index 0000000..c72d908 --- /dev/null +++ b/database/migrations/2025_09_15_092525_create_sliks_table.php @@ -0,0 +1,88 @@ +id(); + + // Field utama berdasarkan header Excel + $table->string('sandi_bank')->nullable(); // Sandi Bank + $table->string('tahun')->nullable(); // Tahun + $table->string('bulan')->nullable(); // Bulan + $table->string('flag_detail')->nullable(); // Flag Detail + $table->string('kode_register_agunan')->nullable(); // Kode Register Agunan + $table->string('no_rekening')->nullable(); // No Rekening + $table->string('cif')->nullable(); // CIF + $table->string('kolektibilitas')->nullable(); // Kolektibilitas + $table->string('fasilitas')->nullable(); // Fasilitas + $table->string('jenis_segmen_fasilitas')->nullable(); // Jenis Segmen Fasilitas + $table->string('status_agunan')->nullable(); // Status Agunan + $table->string('jenis_agunan')->nullable(); // Jenis Agunan + $table->string('peringkat_agunan')->nullable(); // Peringkat Agunan + $table->string('lembaga_pemeringkat')->nullable(); // Lembaga Pemeringkat + $table->string('jenis_pengikatan')->nullable(); // Jenis Pengikatan + $table->string('tanggal_pengikatan')->nullable(); // Tanggal Pengikatan + $table->string('nama_pemilik_agunan')->nullable(); // Nama Pemilik Agunan + $table->string('bukti_kepemilikan')->nullable(); // Bukti Kepemilikan + $table->text('alamat_agunan')->nullable(); // Alamat Agunan + $table->string('lokasi_agunan')->nullable(); // Lokasi Agunan + $table->string('nilai_agunan')->nullable(); // Nilai Agunan + $table->string('nilai_agunan_menurut_ljk')->nullable(); // Nilai Agunan Menurut LJK + $table->string('tanggal_penilaian_ljk')->nullable(); // Tanggal Penilaian LJK + $table->string('nilai_agunan_penilai_independen')->nullable(); // Nilai Agunan Penilai Independen + $table->string('nama_penilai_independen')->nullable(); // Nama Penilai Independen + $table->string('tanggal_penilaian_penilai_independen')->nullable(); // Tanggal Penilaian Penilai Independen + $table->string('jumlah_hari_tunggakan')->nullable(); // Jumlah Hari Tunggakan + $table->string('status_paripasu')->nullable(); // Status Paripasu + $table->string('prosentase_paripasu')->nullable(); // Prosentase Paripasu + $table->string('status_kredit_join')->nullable(); // Status Kredit Join + $table->string('diasuransikan')->nullable(); // Diasuransikan + $table->text('keterangan')->nullable(); // Keterangan + $table->string('kantor_cabang')->nullable(); // Kantor Cabang + $table->string('operasi_data')->nullable(); // Operasi Data + $table->string('kode_cabang')->nullable(); // Kode Cabang + $table->string('nama_debitur')->nullable(); // Nama Debitur + $table->string('nama_cabang')->nullable(); // Nama Cabang + $table->string('flag')->nullable(); // Flag + + // Standard Laravel fields + $table->timestamps(); + $table->string('created_by')->nullable(); + $table->string('updated_by')->nullable(); + $table->string('deleted_by')->nullable(); + $table->softDeletes(); + + // Indexes untuk performa query + $table->index(['sandi_bank']); + $table->index(['tahun']); + $table->index(['bulan']); + $table->index(['no_rekening']); + $table->index(['cif']); + $table->index(['kode_register_agunan']); + $table->index(['nama_debitur']); + $table->index(['kode_cabang']); + $table->index(['created_at']); + }); + } + + /** + * Reverse the migrations. + * + * Menghapus tabel sliks jika migration di-rollback + */ + public function down(): void + { + Schema::dropIfExists('sliks'); + } +}; diff --git a/module.json b/module.json index 2cad9d7..c56ae3f 100644 --- a/module.json +++ b/module.json @@ -65,6 +65,24 @@ "senior-officer" ] }, + { + "title": "SLIK", + "path": "slik", + "icon": "ki-filled ki-filter-tablet text-lg text-primary", + "classes": "", + "attributes": [], + "permission": "", + "roles": [ + "adk", + "administrator", + "pemohon-ao", + "pemohon-eo", + "admin", + "DD Appraisal", + "EO Appraisal", + "senior-officer" + ] + }, { "title": "Laporan Penilai Jaminan", "path": "laporan-penilai-jaminan", diff --git a/resources/views/slik/index.blade.php b/resources/views/slik/index.blade.php new file mode 100644 index 0000000..717879f --- /dev/null +++ b/resources/views/slik/index.blade.php @@ -0,0 +1,343 @@ +@extends('layouts.main') + +@section('breadcrumbs') + {{ Breadcrumbs::render('slik') }} +@endsection + +@section('content') +
| + + | ++ + No + + + | ++ + Sandi Bank + + + | ++ + Tahun + + + | ++ + Bulan + + + | ++ + No Rekening + + + | ++ + CIF + + + | ++ + Nama Debitur + + + | ++ + Nilai Agunan + + + | ++ + Status + + + | +Aksi | +
|---|