Compare commits

...

14 Commits

Author SHA1 Message Date
Daeng Deni Mardaeni
5752427297 refactor(report): konversi query raw SQL ke pure Eloquent ORM
Melakukan refactor besar pada job GenerateClosingBalanceReport untuk mengganti penggunaan raw SQL dan left join dengan implementasi Eloquent ORM penuh, guna meningkatkan maintainability, akurasi data, dan performa sistem.

🧱 Refactor Arsitektur Query:
- Menghilangkan semua penggunaan `leftJoin` yang menyebabkan duplikasi data
- Mengganti query menjadi pure Eloquent dengan relasi dan `with()` (eager loading)
- Menghindari N+1 problem melalui optimasi relasi `ft` (TempFundsTransfer) dan `dc` (DataCapture)

🧩 Integrasi Model:
- Menggunakan model: `StmtEntry`, `StmtEntryDetail`, `TempFundsTransfer`, `DataCapture`
- Seleksi model berdasarkan `groupName`:
  - `QRIS` → `StmtEntry`
  - selainnya → `StmtEntryDetail`
- Relasi dimanfaatkan langsung via properti model, dengan fallback logic

⚙️ Peningkatan Proses Data:
- Menyederhanakan metode `processTransactionData()` untuk memanfaatkan relasi langsung
- Tambahan null safety menggunakan null coalescing operator (`??`)
- Tetap mempertahankan chunk processing untuk efisiensi memori

🔒 Konsistensi & Logging:
- Tambahan DB transaction (`beginTransaction`, `commit`, `rollback`) untuk data integrity
- Logging komprehensif di tiap tahap proses: pemilihan model, query, pemrosesan, fallback
- Error handling dengan informasi error yang lebih informatif

🚀 Optimasi Performa:
- Selective field loading untuk minimalisasi beban memori
- Chunking data untuk skala besar
- Eager loading efisien tanpa join kompleks

 Tujuan & Manfaat:
- Meningkatkan maintainability & keterbacaan kode
- Menghilangkan duplikasi data akibat `leftJoin`
- Meningkatkan akurasi dan konsistensi data laporan
- Mengikuti Laravel best practices dalam penulisan query & relasi
2025-07-26 08:42:42 +07:00
Daeng Deni Mardaeni
eb89916b1c refactor(webstatement): split generateReportData dan dukung closing balance per group akun
- Refactor generateReportData jadi fungsi kecil (query builder, selector, row builder)
- Tambah dukungan multi group akun (DEFAULT, QRIS) pada closing balance job
- Pisahkan akun QRIS dan sesuaikan struktur dispatch dan account list
- Tingkatkan maintainability, scalability, dan clarity proses
2025-07-24 09:30:13 +07:00
Daeng Deni Mardaeni
80c866f646 feat(webstatement): fallback stmt_entry_id menggunakan field id pada CSV
Menambahkan dukungan fallback untuk nilai `stmt_entry_id` yang kosong/null dengan menggunakan field `id` dari CSV (jika tersedia di akhir file).

Perubahan yang dilakukan:
- Menambahkan 'id' sebagai bagian dari expected CSV headers
- Mengimplementasikan handleStmtEntryIdFallback() untuk logika pengganti
- Menggunakan field 'id' sebagai stmt_entry_id jika nilainya kosong atau null
- Menyesuaikan validasi jumlah kolom terhadap struktur CSV terbaru
- Melakukan pembersihan field 'id' sebelum data disimpan ke database
- Memperkuat validasi di addToBatch() agar stmt_entry_id selalu valid
- Menambahkan logging untuk proses fallback dan debugging
- Meningkatkan error handling untuk kasus data tidak valid
- Menjamin kompatibilitas dengan struktur model StmtEntryDetail
- Optimasi batch insert melalui pengecekan dan pembersihan data lebih ketat
2025-07-23 14:45:08 +07:00
Daeng Deni Mardaeni
e5c33bf631 feat(webstatement): tambah parameter period ke ProcessDailyMigration command
Menambahkan parameter --period pada command ProcessDailyMigration untuk fleksibilitas pemrosesan data harian.

Perubahan yang dilakukan:

- Menambahkan parameter --period dengan default '-1 day' pada command ProcessDailyMigration
- Memungkinkan input period dalam berbagai format:
  - Format Ymd (contoh: 20250120)
  - Format relative date (contoh: '-2 days', '-1 week')
  - Default fallback ke '-1 day' jika parameter kosong atau format tidak valid

- Update method index di MigrasiController untuk menerima dan memproses parameter period
- Menambahkan method determinePeriod untuk konversi dan validasi parameter period
- Menggunakan Carbon untuk parsing dan konversi tanggal
- Menambahkan logging detail untuk tracking parameter input dan hasil konversi period
- Menambahkan validasi dan error handling jika format periode tidak sesuai
- Mempertahankan backward compatibility agar command lama tetap berjalan seperti sebelumnya
- Update deskripsi command dan signature agar dokumentasi CLI lebih jelas

Tujuan perubahan:

- Memberikan fleksibilitas bagi tim operasional untuk menjalankan migrasi data dengan periode yang spesifik
- Memudahkan eksekusi ulang data harian atau data backdate tanpa modifikasi kode
- Memastikan proses migrasi lebih aman, transparan, dan dapat dipantau melalui logging
2025-07-21 11:30:55 +07:00
Daeng Deni Mardaeni
f37707b2f6 Merge remote-tracking branch 'composer/master'
# Conflicts:
#	database/migrations/2025_07_21_033413_create_stmt_entry_detail_table.php
2025-07-21 11:22:41 +07:00
Daeng Deni Mardaeni
ad9780ccd6 feat(webstatement): tambah stmt_entry_detail migrasi, model, dan job processing
Menambahkan fitur pengelolaan data stmt_entry_detail untuk integrasi transaksi dengan detail yang lebih lengkap.

Perubahan yang dilakukan:

- Membuat migrasi create_stmt_entry_detail_table dengan struktur field sesuai kebutuhan bisnis
- Menambahkan index pada kolom penting untuk meningkatkan performa query

- Membuat model StmtEntryDetail dengan relasi ke:
  - Account
  - TempFundsTransfer
  - TempTransaction
  - Teller
  - DataCapture
  - TempArrangement
- Mengimplementasikan $fillable dan $casts sesuai struktur tabel
- Menambahkan relasi untuk memudahkan integrasi antar modul

- Membuat job ProcessStmtEntryDetailDataJob untuk memproses file CSV dengan batch processing
- Mengimplementasikan chunking untuk menangani file besar secara efisien
- Membersihkan trans_reference dari karakter tidak valid sebelum penyimpanan
- Menggunakan updateOrCreate untuk mencegah duplikasi primary key
- Menggunakan database transaction untuk menjaga konsistensi data
- Menambahkan logging komprehensif untuk monitoring dan debugging
- Mengimplementasikan error handling yang robust untuk menghindari job failure tanpa informasi
- Memastikan penggunaan resource memory tetap optimal saat memproses data besar

- Menambahkan case baru di MigrasiController untuk memproses stmt_entry_detail
- Konsisten dengan pattern migrasi data yang sudah ada di sistem

Tujuan perubahan:

- Menyediakan sistem import dan pengolahan data stmt_entry_detail dengan proses yang aman dan efisien
- Memudahkan integrasi transaksi dengan detail tambahan di modul Webstatement
- Menjamin integritas data dengan penggunaan transaction, logging, dan error handling yang komprehensif
2025-07-21 11:21:42 +07:00
Daeng Deni Mardaeni
bcc6d814e9 feat(webstatement): tambah stmt_entry_detail migrasi, model, dan job processing
Menambahkan fitur pengelolaan data stmt_entry_detail untuk integrasi transaksi dengan detail yang lebih lengkap.

Perubahan yang dilakukan:

- Membuat migrasi create_stmt_entry_detail_table dengan struktur field sesuai kebutuhan bisnis
- Menambahkan index pada kolom penting untuk meningkatkan performa query

- Membuat model StmtEntryDetail dengan relasi ke:
  - Account
  - TempFundsTransfer
  - TempTransaction
  - Teller
  - DataCapture
  - TempArrangement
- Mengimplementasikan $fillable dan $casts sesuai struktur tabel
- Menambahkan relasi untuk memudahkan integrasi antar modul

- Membuat job ProcessStmtEntryDetailDataJob untuk memproses file CSV dengan batch processing
- Mengimplementasikan chunking untuk menangani file besar secara efisien
- Membersihkan trans_reference dari karakter tidak valid sebelum penyimpanan
- Menggunakan updateOrCreate untuk mencegah duplikasi primary key
- Menggunakan database transaction untuk menjaga konsistensi data
- Menambahkan logging komprehensif untuk monitoring dan debugging
- Mengimplementasikan error handling yang robust untuk menghindari job failure tanpa informasi
- Memastikan penggunaan resource memory tetap optimal saat memproses data besar

- Menambahkan case baru di MigrasiController untuk memproses stmt_entry_detail
- Konsisten dengan pattern migrasi data yang sudah ada di sistem

Tujuan perubahan:

- Menyediakan sistem import dan pengolahan data stmt_entry_detail dengan proses yang aman dan efisien
- Memudahkan integrasi transaksi dengan detail tambahan di modul Webstatement
- Menjamin integritas data dengan penggunaan transaction, logging, dan error handling yang komprehensif
2025-07-21 11:10:49 +07:00
Daeng Deni Mardaeni
5de1c19d09 feat(webstatement): tambah console command bulk untuk generate laporan closing balance
- Membuat GenerateClosingBalanceReportBulkCommand untuk bulk processing
- Support untuk memproses banyak rekening sekaligus berdasarkan daftar client
- Fitur client filter untuk memproses client tertentu saja
- Mode dry-run untuk preview rekening yang akan diproses
- Progress bar untuk monitoring proses bulk generation
- Interactive confirmation sebelum menjalankan job
- Error handling per rekening tanpa menghentikan proses keseluruhan
- Database transaction terpisah untuk setiap rekening
- Comprehensive logging untuk monitoring dan debugging
- Detailed summary sebelum dan sesudah pemrosesan
- Daftar client dan rekening sama dengan WebstatementController
- Integrasi dengan existing GenerateClosingBalanceReportJob
- Remarks field untuk tracking bulk generation dengan client info
- Validasi parameter lengkap dan user-friendly error messages
2025-07-18 07:36:40 +07:00
Daeng Deni Mardaeni
3c01c1728c feat(webstatement): tambah console command dan perbaikan field required untuk laporan closing balance
Menambahkan fitur command line untuk generate laporan closing balance sekaligus memperbaiki pengisian field yang required di database.

Perubahan yang dilakukan:
- Membuat command `webstatement:generate-closing-balance-report` dengan parameter:
  - `account_number`: nomor rekening (required)
  - `period`: format tanggal YYYYMMDD (required)
  - `--user_id=`: ID user (optional, default 1)
- Menambahkan field `report_date` dengan konversi dari parameter `period` menggunakan Carbon
- Menambahkan field `created_by` dan `updated_by` untuk kebutuhan audit trail
- Menambahkan field `ip_address` dan `user_agent` dengan default 'console' untuk identifikasi proses non-web
- Memperbaiki validasi parameter dengan regex dan proper escaping
- Menghindari error SQLSTATE[23502] terkait field not null di database schema
- Menggunakan database transaction untuk menjaga konsistensi data
- Mengupdate fungsi `closing_balance_report_logs` untuk menyimpan semua field yang dibutuhkan
- Integrasi dengan `GenerateClosingBalanceReportJob` untuk pemrosesan laporan secara background
- Menambahkan logging komprehensif untuk monitoring `report_date` dan proses lainnya
- Mendukung eksekusi manual dan penjadwalan via Laravel scheduler
- Kompatibel dengan proses laporan closing balance via web dan CLI

Tujuan perubahan:
- Mempermudah proses generate laporan closing balance melalui CLI secara manual atau terjadwal
- Memastikan seluruh field wajib di `closing_balance_report_logs` terisi dengan benar
- Menyediakan audit trail lengkap dan logging yang detail untuk proses via console
- Meningkatkan keandalan sistem dengan validasi dan error handling yang lebih baik
2025-07-18 07:36:02 +07:00
Daeng Deni Mardaeni
3beaf78872 feat(webstatement): implementasi job processing untuk laporan closing balance
Menambahkan fitur job processing untuk memproses laporan closing balance secara asynchronous dengan dukungan data besar.

Perubahan yang dilakukan:
- Membuat model `ClosingBalanceReportLog` untuk mencatat permintaan laporan dan status proses
- Membuat job `GenerateClosingBalanceReportJob` untuk memproses laporan closing balance di background queue
- Memodifikasi `LaporanClosingBalanceController` untuk mengintegrasikan job processing saat generate laporan
- Menambahkan migration `closing_balance_report_logs` untuk menyimpan log permintaan, path file, dan status
- Menggunakan query custom dari input user untuk pengambilan data transaksi
- Menambahkan field `closing_balance` yang dihitung otomatis (saldo awal + amount_lcy)
- Mengimplementasikan chunking data untuk memproses transaksi dalam jumlah besar secara efisien
- Menambahkan logging detail untuk memudahkan monitoring, debugging, dan audit trail
- Menggunakan database transaction untuk menjaga konsistensi data selama proses job
- Menambahkan fitur retry otomatis pada job jika terjadi kegagalan atau timeout
- Mengekspor hasil laporan ke file CSV dengan delimiter pipe `|` untuk kebutuhan integrasi sistem lain
- Menambahkan workflow approval untuk validasi laporan sebelum download
- Implementasi download tracking dan manajemen file untuk memudahkan kontrol akses

Tujuan perubahan:
- Memungkinkan pemrosesan laporan closing balance dengan jumlah data besar secara efisien dan aman
- Mengurangi beban proses synchronous pada server dengan pemanfaatan queue
- Menyediakan audit trail lengkap untuk setiap proses generate laporan
- Meningkatkan pengalaman pengguna dengan proses generate yang lebih responsif dan terkontrol
2025-07-17 19:49:22 +07:00
Daeng Deni Mardaeni
23a0679f74 feat(jobs): exclude field terakhir dari proses CSV pada company dan data capture
Perubahan yang dilakukan:

- ProcessCompanyDataJob:
  - Memodifikasi method processRow dengan menambahkan logika array_pop($row) untuk menghapus elemen terakhir dari array CSV.
  - Menambahkan validasi count($row) > 0 untuk memastikan array tidak kosong sebelum diproses.
  - Mempertahankan validasi jumlah kolom sesuai FIELD_MAP untuk menjaga konsistensi data.
  - Menambahkan log informasi saat field terakhir berhasil dihapus dari setiap baris.
  - Mencatat jumlah kolom baru setelah penghapusan untuk keperluan debugging dan monitoring.
  - Mengurangi overhead processing dengan mengabaikan kolom tambahan yang tidak diperlukan.
  - Memastikan integritas data tetap terjaga saat mapping ke model Branch.
  - Tidak mengubah struktur FIELD_MAP yang sudah ada untuk menjaga kompatibilitas.
  - Mempertahankan fungsi validasi dan error handling yang sudah berjalan.

- ProcessDataCaptureDataJob:
  - Memodifikasi method processRow dengan logika array_pop($row) untuk menghapus kolom terakhir dari array CSV.
  - Menambahkan pengecekan count($row) > 0 untuk validasi array.
  - Mempertahankan validasi jumlah kolom sesuai CSV_HEADERS untuk memastikan kesesuaian data.
  - Menambahkan log saat kolom terakhir berhasil dihapus untuk setiap baris yang diproses.
  - Mencatat jumlah kolom baru setelah penghapusan sebagai bagian dari monitoring.
  - Memastikan hanya kolom yang didefinisikan dalam CSV_HEADERS yang diproses untuk efisiensi.
  - Mengurangi beban proses dengan mengabaikan field yang tidak relevan pada CSV input.
  - Mempertahankan integritas data dan mapping ke model DataCapture.
  - Tidak mengubah sistem batch processing yang ada, tetap menggunakan CHUNK_SIZE = 1000.
  - Mempertahankan performa dengan bulk insert dan upsert untuk data besar.
  - Memastikan efisiensi memory dengan chunk processing.
  - Menjaga struktur CSV_HEADERS dan proses validasi agar tetap kompatibel dengan sistem yang sudah berjalan.

Tujuan perubahan:

- Memungkinkan sistem untuk mengabaikan kolom tambahan di akhir file CSV baik untuk data company maupun data capture tanpa mengganggu proses import.
- Menghindari error atau kegagalan parsing saat terdapat tambahan kolom dari sistem eksternal.
- Meningkatkan fleksibilitas sistem dalam menghadapi input CSV yang tidak selalu konsisten secara jumlah kolom.
- Menjaga performa dan efisiensi proses dengan tetap mempertahankan proses batch dan chunking.
- Memastikan seluruh perubahan tetap backward compatible dengan proses yang sudah ada.
2025-07-15 17:25:00 +07:00
Daeng Deni Mardaeni
1564ce2efa fix(jobs): perbaikan masalah file tidak ditemukan pada proses export statement
Perubahan yang dilakukan:

- Penambahan Debugging dan Verifikasi Storage:
  - Menambahkan log detail untuk tracking proses export CSV, termasuk informasi disk, client, account number, period, dan path penyimpanan.
  - Mengimplementasikan pembuatan file dummy untuk memverifikasi fungsi storage disk sebelum proses export dijalankan.
  - Menambahkan verifikasi keberadaan file setelah proses export selesai untuk memastikan file benar-benar tersimpan di storage.

- Perbaikan Manajemen Path:
  - Memastikan seluruh proses pembuatan direktori menggunakan Laravel Storage facade secara konsisten.
  - Menghapus dependency pada proses sistem chmod, chown, dan chgrp yang berpotensi menimbulkan masalah portabilitas.
  - Menggunakan path absolut dan konsisten untuk menghindari konflik direktori atau kesalahan path relatif.

- Peningkatan Error Handling:
  - Menambahkan log error khusus jika storage disk tidak berfungsi dengan baik atau tidak dapat diakses.
  - Mengimplementasikan cleanup file dummy setelah proses verifikasi storage selesai.
  - Menambahkan informasi log tambahan untuk kebutuhan debugging dan troubleshooting di lingkungan production.

- Optimasi Performa:
  - Menggunakan Laravel Storage facade sebagai standar pembuatan direktori dan file untuk efisiensi dan konsistensi.
  - Mengurangi overhead dari pemanggilan fungsi sistem operasi yang tidak diperlukan.
  - Memastikan fungsionalitas utama tetap berjalan dengan lebih andal tanpa perubahan pada logika bisnis utama.

Tujuan perubahan:

- Memastikan file export statement benar-benar tersimpan dan dapat diakses, tidak hanya tercatat sukses di log.
- Mengatasi masalah di mana proses log mencatat keberhasilan export tetapi file tidak ditemukan di sistem file.
- Meningkatkan reliabilitas sistem export dengan verifikasi berlapis pada proses penyimpanan.
- Menyederhanakan proses penulisan file agar lebih portable, aman, dan mudah di-maintain di berbagai environment.
2025-07-15 17:02:23 +07:00
Daeng Deni Mardaeni
e6c46701ce refactor(jobs): hapus manajemen izin file (chmod, chown, chgrp)
Perubahan yang dilakukan:
- ExportStatementJob.php:
  - Menghapus kode chmod(0777), chown, dan chgrp pada direktori basePath dan accountPath.
  - Menyederhanakan fungsi exportToCsv() dengan hanya menggunakan Storage::makeDirectory() tanpa pengaturan izin manual.

- ExportStatementPeriodJob.php:
  - Menghapus proses chmod(0777), chown, dan chgrp dari generatePdf() dan exportToCsv().
  - Mempertahankan mkdir() dengan parameter 0777 untuk kompatibilitas permission default, tanpa manipulasi ownership.
  - Menghapus pengecekan posix_getuid() dan function_exists() yang sebelumnya digunakan untuk memvalidasi chown.

- GenerateMultiAccountPdfJob.php:
  - Menghapus semua proses chmod(0777), chown, dan chgrp pada direktori penyimpanan.
  - Menyederhanakan fungsi generateAccountPdf() dengan menghilangkan pengaturan ownership manual.

Tujuan perubahan:
- Menyederhanakan kode dan menghilangkan proses manajemen izin file yang tidak perlu.
- Menghilangkan ketergantungan pada fungsi sistem operasi terkait chmod, chown, dan chgrp.
- Mengurangi kompleksitas dan meminimalkan potensi error saat runtime.
- Meningkatkan portabilitas aplikasi agar dapat berjalan di berbagai environment seperti Linux, Windows, dan Docker tanpa kendala perizinan file.
- Menghilangkan risiko keamanan akibat perubahan ownership file secara manual.
- Mempermudah deployment dan maintenance dengan kode yang lebih clean dan aman.

Catatan:
- Pembuatan direktori tetap menggunakan Laravel Storage facade atau mkdir() untuk kebutuhan tertentu.
- Tidak ada perubahan pada logika bisnis utama dari proses export PDF dan CSV.
- Semua fitur export tetap berjalan seperti sebelumnya tanpa pengaturan file permission manual.
2025-07-15 16:40:04 +07:00
Daeng Deni Mardaeni
35bb173056 feat(webstatement): tambah fitur Laporan Closing Balance
Perubahan yang dilakukan:

**Controller LaporanClosingBalanceController:**
- Membuat controller baru untuk laporan closing balance dengan method index(), dataForDatatables(), export(), dan show().
- Menggunakan model AccountBalance dengan field actual_balance dan cleared_balance.
- Implementasi filter nomor rekening dan rentang tanggal.
- Menambahkan DB transaction dan rollback untuk keamanan data.
- Logging dan error handling komprehensif untuk proses data dan export.

**View laporan-closing-balance/index.blade.php:**
- Form filter dengan input nomor rekening dan rentang tanggal (default 30 hari terakhir).
- Implementasi DataTables dengan kolom: Nomor Rekening, Periode, Saldo Cleared, Saldo Aktual, Tanggal Update, dan Action.
- Tombol Filter, Reset, dan Export CSV.
- JavaScript untuk format currency IDR, format tanggal, dan dynamic export URL.
- Menggunakan TailwindCSS dan KTDataTable untuk desain yang responsive.

**View laporan-closing-balance/show.blade.php:**
- Halaman detail per record dengan visual saldo yang menarik (color-coded cards).
- Menampilkan Saldo Cleared, Saldo Aktual, dan Selisih Saldo secara otomatis.
- Informasi rekening dan periode disertai fitur copy ke clipboard.
- Tombol aksi: Kembali, Export, dan Print (dengan print style khusus).
- Responsive untuk berbagai ukuran layar.

**Routing dan Navigasi:**
- Menambahkan routing resource dengan prefix 'laporan-closing-balance' di web.php.
- Tambahan route untuk datatables, export, dan show dengan middleware auth.
- Breadcrumb dinamis untuk index dan show, menampilkan nomor rekening dan periode.

**Penyesuaian Model:**
- Menggunakan relasi Account di model AccountBalance melalui account_number.
- Menyesuaikan field dari opening_balance ke cleared_balance sesuai skema.
- Tetap mempertahankan actual_balance untuk saldo akhir.

**Fitur Keamanan dan Performance:**
- Input validation dan sanitization untuk semua request.
- Pagination dan filter query untuk efisiensi dan mencegah memory overflow.
- Error logging dengan context untuk debugging lebih mudah.

**User Experience:**
- Interface user-friendly dengan feedback visual dan loading state.
- Export CSV untuk kebutuhan analisis lebih lanjut.
- Print-friendly layout untuk kebutuhan cetak data.
- Clipboard integration untuk kemudahan salin data.

Tujuan perubahan:
- Menyediakan fitur monitoring dan analisis closing balance secara komprehensif di modul Webstatement.
- Mempermudah user dalam melihat detail saldo akhir dengan filtering, export, dan cetak yang optimal.
2025-07-15 09:32:01 +07:00
25 changed files with 3527 additions and 182 deletions

View File

@@ -0,0 +1,535 @@
<?php
namespace Modules\Webstatement\Console;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\Usermanagement\Models\User;
use Modules\Webstatement\Jobs\GenerateClosingBalanceReportJob;
use Modules\Webstatement\Models\ClosingBalanceReportLog;
use Carbon\Carbon;
/**
* Console command untuk generate laporan closing balance untuk banyak rekening sekaligus
* Command ini dapat dijalankan secara manual atau dijadwalkan
* Mendukung periode range dan daftar rekening custom
*/
class GenerateClosingBalanceReportBulkCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'webstatement:generate-closing-balance-bulk
{start_date : Tanggal mulai periode format YYYYMMDD, contoh: 20250512}
{end_date : Tanggal akhir periode format YYYYMMDD, contoh: 20250712}
{--accounts= : Daftar rekening dipisahkan koma (opsional, jika tidak ada akan gunakan default list)}
{--client= : Filter berdasarkan client tertentu (opsional)}
{--user_id=1 : ID user yang menjalankan command (default: 1)}
{--dry-run : Tampilkan daftar rekening yang akan diproses tanpa menjalankan job}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate Closing Balance report untuk banyak rekening sekaligus dengan periode range';
/**
* Daftar rekening default yang akan diproses
*
* @var array
*/
private $defaultAccounts = [
'IDR1723200010001',
'IDR1728100010001',
'IDR1728200010001',
'IDR1733100010001',
'IDR1728300010001',
'IDR1733100030001',
'IDR1723300010001',
'IDR1733100020001',
'IDR1733100040001',
'IDR1733200010001',
'IDR1733200020001',
'IDR1733500010001',
'IDR1733600010001',
'IDR1733300010001',
'IDR1733400010001',
'IDR1354100010001',
'IDR1354300010001',
'IDR1354400010001',
'IDR1728500010001',
'IDR1728600010001',
'IDR1720500010001',
'1078333878',
'1081647484',
'1085552121',
'1085677889',
'1086677889',
'IDR1744200010001',
'IDR1744300010001',
'IDR1744100010001',
'IDR1744400010001',
'IDR1364100010001',
'IDR1723100010001',
'IDR1354200010001'
];
private $qrisAccount = [
'IDR1354500010001',
'IDR1354500020001',
'IDR1354500030001',
'IDR1354500040001',
'IDR1354500050001',
'IDR1354500060001',
'IDR1354500070001',
'IDR1354500080001',
'IDR1354500090001',
'IDR1354500100001',
];
/**
* Execute the console command.
* Menjalankan proses generate laporan closing balance untuk banyak rekening dengan periode range
*
* @return int
*/
public function handle()
{
$this->info('Starting Bulk Closing Balance report generation with date range...');
// Get parameters
$startDate = $this->argument('start_date');
$endDate = $this->argument('end_date');
$accountsOption = $this->option('accounts');
$clientFilter = $this->option('client');
$userId = $this->option('user_id');
$isDryRun = $this->option('dry-run');
// Validate parameters
if (!$this->validateParameters($startDate, $endDate, $userId)) {
return Command::FAILURE;
}
try {
// Get account list
$accountList = $this->getAccountList($accountsOption, $clientFilter);
if (empty($accountList)) {
$this->warn('No accounts found for processing.');
return Command::SUCCESS;
}
// Generate date range
$dateRange = $this->generateDateRange($startDate, $endDate);
// Show summary
$this->showSummary($accountList, $dateRange, $isDryRun);
if ($isDryRun) {
$this->info('Dry run completed. No jobs were dispatched.');
return Command::SUCCESS;
}
// Confirm execution
if (!$this->confirm('Do you want to proceed with generating reports for all accounts and periods?')) {
$this->info('Operation cancelled.');
return Command::SUCCESS;
}
// Process accounts for all dates in range
$results = $this->processAccountsWithDateRange($accountList, $dateRange, $userId);
// Show results
$this->showResults($results);
return Command::SUCCESS;
} catch (Exception $e) {
$this->error('Error in bulk closing balance report generation: ' . $e->getMessage());
Log::error('Console command: Error in bulk closing balance report generation', [
'start_date' => $startDate,
'end_date' => $endDate,
'client_filter' => $clientFilter,
'user_id' => $userId,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return Command::FAILURE;
}
}
/**
* Validate command parameters
* Validasi parameter command termasuk validasi range tanggal
*
* @param string $startDate
* @param string $endDate
* @param int $userId
* @return bool
*/
private function validateParameters(string $startDate, string $endDate, int $userId): bool
{
// Validate date format (YYYYMMDD)
if (!preg_match('/^\\d{8}$/', $startDate)) {
$this->error('Invalid start_date format. Use YYYYMMDD format (example: 20250512)');
return false;
}
if (!preg_match('/^\\d{8}$/', $endDate)) {
$this->error('Invalid end_date format. Use YYYYMMDD format (example: 20250712)');
return false;
}
// Validate start date
$startYear = substr($startDate, 0, 4);
$startMonth = substr($startDate, 4, 2);
$startDay = substr($startDate, 6, 2);
if (!checkdate($startMonth, $startDay, $startYear)) {
$this->error('Invalid start_date.');
return false;
}
// Validate end date
$endYear = substr($endDate, 0, 4);
$endMonth = substr($endDate, 4, 2);
$endDay = substr($endDate, 6, 2);
if (!checkdate($endMonth, $endDay, $endYear)) {
$this->error('Invalid end_date.');
return false;
}
// Validate date range
$startCarbon = Carbon::createFromFormat('Ymd', $startDate);
$endCarbon = Carbon::createFromFormat('Ymd', $endDate);
if ($startCarbon->gt($endCarbon)) {
$this->error('Start date cannot be greater than end date.');
return false;
}
// Validate range not too long (max 3 months)
if ($startCarbon->diffInDays($endCarbon) > 90) {
$this->error('Date range cannot exceed 90 days.');
return false;
}
// Validate user exists
$user = User::find($userId);
if (!$user) {
$this->error("User with ID {$userId} not found.");
return false;
}
return true;
}
/**
* Generate date range array from start to end date
* Menghasilkan array tanggal dari start sampai end date
*
* @param string $startDate
* @param string $endDate
* @return array
*/
private function generateDateRange(string $startDate, string $endDate): array
{
$dates = [];
$current = Carbon::createFromFormat('Ymd', $startDate);
$end = Carbon::createFromFormat('Ymd', $endDate);
while ($current->lte($end)) {
$dates[] = $current->format('Ymd');
$current->addDay();
}
Log::info('Generated date range for bulk processing', [
'start_date' => $startDate,
'end_date' => $endDate,
'total_dates' => count($dates)
]);
return $dates;
}
/**
* Get account list based on options
* Mengambil daftar rekening berdasarkan parameter atau menggunakan default
*
* @param string|null $accountsOption
* @param string|null $clientFilter
* @return array
*/
private function getAccountList(?string $accountsOption, ?string $clientFilter): array
{
// Jika ada parameter accounts, gunakan itu
if ($accountsOption) {
$accounts = array_map('trim', explode(',', $accountsOption));
$accounts = array_filter($accounts); // Remove empty values
Log::info('Using custom account list from parameter', [
'total_accounts' => count($accounts),
'accounts' => $accounts
]);
return ['CUSTOM' => $accounts];
}
// Jika tidak ada parameter accounts, gunakan default list
$accountList = ['DEFAULT' => $this->defaultAccounts, 'QRIS' => $this->qrisAccount];
// Filter by client jika ada (untuk backward compatibility)
if ($clientFilter) {
// Untuk saat ini, client filter tidak digunakan karena kita pakai list baru
// Tapi tetap log untuk tracking
Log::info('Client filter specified but using default account list', [
'client_filter' => $clientFilter
]);
}
Log::info('Using default account list', [
'total_accounts' => count($this->defaultAccounts)
]);
return $accountList;
}
/**
* Show summary of accounts and dates to be processed
* Menampilkan ringkasan rekening dan tanggal yang akan diproses
*
* @param array $accountList
* @param array $dateRange
* @param bool $isDryRun
*/
private function showSummary(array $accountList, array $dateRange, bool $isDryRun): void
{
$this->info('\n=== SUMMARY ===');
$this->info("Date Range: {$dateRange[0]} to {$dateRange[count($dateRange)-1]} ({" . count($dateRange) . "} days)");
$this->info("Mode: " . ($isDryRun ? 'DRY RUN' : 'LIVE'));
$this->info('');
$totalAccounts = 0;
foreach ($accountList as $groupName => $accounts) {
$accountCount = count($accounts);
$totalAccounts += $accountCount;
$this->info("Group: {$groupName} ({$accountCount} accounts)");
// Show first 10 accounts, then summarize if more
$displayAccounts = array_slice($accounts, 0, 10);
foreach ($displayAccounts as $account) {
$this->line(" - {$account}");
}
if (count($accounts) > 10) {
$remaining = count($accounts) - 10;
$this->line(" ... and {$remaining} more accounts");
}
}
$totalJobs = $totalAccounts * count($dateRange);
$this->info("\nTotal accounts: {$totalAccounts}");
$this->info("Total dates: " . count($dateRange));
$this->info("Total jobs to be created: {$totalJobs}");
$this->info('===============\n');
}
/**
* Process all accounts for all dates in range
* Memproses semua rekening untuk semua tanggal dalam range
*
* @param array $accountList
* @param array $dateRange
* @param int $userId
* @return array
*/
private function processAccountsWithDateRange(array $accountList, array $dateRange, int $userId): array
{
$results = [
'success' => [],
'failed' => [],
'total' => 0
];
$totalJobs = $this->getTotalAccountCount($accountList) * count($dateRange);
$this->info('Starting report generation for date range...');
$progressBar = $this->output->createProgressBar($totalJobs);
$progressBar->start();
foreach ($dateRange as $period) {
foreach ($accountList as $groupName => $accounts) {
foreach ($accounts as $accountNumber) {
$results['total']++;
try {
DB::beginTransaction();
// Create report log entry
$reportLog = $this->createReportLog($accountNumber, $period, $userId, $groupName);
if (!$reportLog) {
throw new Exception('Failed to create report log entry');
}
// Dispatch the job
GenerateClosingBalanceReportJob::dispatch($accountNumber, $period, $reportLog->id, $groupName);
DB::commit();
$results['success'][] = [
'group' => $groupName,
'account' => $accountNumber,
'period' => $period,
'report_log_id' => $reportLog->id
];
Log::info('Bulk command: Report job dispatched successfully', [
'group' => $groupName,
'account_number' => $accountNumber,
'period' => $period,
'report_log_id' => $reportLog->id,
'user_id' => $userId
]);
} catch (Exception $e) {
DB::rollback();
$results['failed'][] = [
'group' => $groupName,
'account' => $accountNumber,
'period' => $period,
'error' => $e->getMessage()
];
Log::error('Bulk command: Error processing account', [
'group' => $groupName,
'account_number' => $accountNumber,
'period' => $period,
'user_id' => $userId,
'error' => $e->getMessage()
]);
}
$progressBar->advance();
}
}
}
$progressBar->finish();
$this->info('\n');
return $results;
}
/**
* Create report log entry
* Membuat entry log laporan
*
* @param string $accountNumber
* @param string $period
* @param int $userId
* @param string $groupName
* @return ClosingBalanceReportLog|null
*/
private function createReportLog(string $accountNumber, string $period, int $userId, string $groupName): ?ClosingBalanceReportLog
{
try {
// Convert period string to Carbon date
$reportDate = Carbon::createFromFormat('Ymd', $period);
$reportLog = ClosingBalanceReportLog::create([
'account_number' => $accountNumber,
'period' => $period,
'report_date' => $reportDate,
'status' => 'pending',
'user_id' => $userId,
'created_by' => $userId,
'updated_by' => $userId,
'ip_address' => request()->ip() ?? '127.0.0.1',
'user_agent' => 'Console Command - Bulk Range',
'remarks' => "Bulk generation for group: {$groupName}, period: {$period}",
'created_at' => now(),
'updated_at' => now()
]);
return $reportLog;
} catch (Exception $e) {
Log::error('Bulk command: Error creating report log', [
'account_number' => $accountNumber,
'period' => $period,
'user_id' => $userId,
'group_name' => $groupName,
'error' => $e->getMessage()
]);
return null;
}
}
/**
* Get total account count
* Menghitung total jumlah rekening
*
* @param array $accountList
* @return int
*/
private function getTotalAccountCount(array $accountList): int
{
$total = 0;
foreach ($accountList as $accounts) {
$total += count($accounts);
}
return $total;
}
/**
* Show processing results
* Menampilkan hasil pemrosesan
*
* @param array $results
*/
private function showResults(array $results): void
{
$this->info('\n=== RESULTS ===');
$this->info("Total processed: {$results['total']}");
$this->info("Successful: " . count($results['success']));
$this->info("Failed: " . count($results['failed']));
if (!empty($results['failed'])) {
$this->error('\nFailed jobs:');
foreach (array_slice($results['failed'], 0, 10) as $failed) {
$this->error(" - {$failed['group']}: {$failed['account']} ({$failed['period']}) - {$failed['error']}");
}
if (count($results['failed']) > 10) {
$remaining = count($results['failed']) - 10;
$this->error(" ... and {$remaining} more failed jobs");
}
}
if (!empty($results['success'])) {
$this->info('\nSample successful jobs:');
foreach (array_slice($results['success'], 0, 5) as $success) {
$this->info(" - {$success['group']}: {$success['account']} ({$success['period']}) - Log ID: {$success['report_log_id']}");
}
if (count($results['success']) > 5) {
$remaining = count($results['success']) - 5;
$this->info(" ... and {$remaining} more successful jobs");
}
}
$this->info('\nCheck the closing_balance_report_logs table for progress.');
$this->info('===============\n');
}
}

View File

@@ -0,0 +1,211 @@
<?php
namespace Modules\Webstatement\Console;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\Usermanagement\Models\User;
use Modules\Webstatement\Jobs\GenerateClosingBalanceReportJob;
use Modules\Webstatement\Models\ClosingBalanceReportLog;
use Carbon\Carbon;
/**
* Console command untuk generate laporan closing balance
* Command ini dapat dijalankan secara manual atau dijadwalkan
*/
class GenerateClosingBalanceReportCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'webstatement:generate-closing-balance-report
{account_number : Nomor rekening untuk generate laporan}
{period : Period laporan format YYYYMMDD, contoh: 20250515}
{--user_id=1 : ID user yang menjalankan command (default: 1)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate Closing Balance report untuk nomor rekening dan periode tertentu';
/**
* Execute the console command.
* Menjalankan proses generate laporan closing balance
*
* @return int
*/
public function handle()
{
$this->info('Starting Closing Balance report generation...');
// Get parameters
$accountNumber = $this->argument('account_number');
$period = $this->argument('period');
$userId = $this->option('user_id');
// Validate parameters
if (!$this->validateParameters($accountNumber, $period, $userId)) {
return Command::FAILURE;
}
try {
DB::beginTransaction();
// Log start of process
Log::info('Console command: Starting closing balance report generation', [
'account_number' => $accountNumber,
'period' => $period,
'user_id' => $userId,
'command' => 'webstatement:generate-closing-balance-report'
]);
// Create report log entry
$reportLog = $this->createReportLog($accountNumber, $period, $userId);
if (!$reportLog) {
$this->error('Failed to create report log entry');
DB::rollback();
return Command::FAILURE;
}
// Dispatch the job
GenerateClosingBalanceReportJob::dispatch($accountNumber, $period, $reportLog->id);
DB::commit();
$this->info("Closing Balance report generation job queued successfully!");
$this->info("Account Number: {$accountNumber}");
$this->info("Period: {$period}");
$this->info("Report Log ID: {$reportLog->id}");
$this->info('The report will be generated in the background.');
$this->info('Check the closing_balance_report_logs table for progress.');
// Log successful dispatch
Log::info('Console command: Closing balance report job dispatched successfully', [
'account_number' => $accountNumber,
'period' => $period,
'report_log_id' => $reportLog->id,
'user_id' => $userId
]);
return Command::SUCCESS;
} catch (Exception $e) {
DB::rollback();
$this->error('Error queuing Closing Balance report job: ' . $e->getMessage());
// Log error
Log::error('Console command: Error generating closing balance report', [
'account_number' => $accountNumber,
'period' => $period,
'user_id' => $userId,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return Command::FAILURE;
}
}
/**
* Validate command parameters
* Validasi parameter command
*
* @param string $accountNumber
* @param string $period
* @param int $userId
* @return bool
*/
private function validateParameters(string $accountNumber, string $period, int $userId): bool
{
// Validate account number
if (empty($accountNumber)) {
$this->error('Account number parameter is required.');
return false;
}
// Validate period format (YYYYMMDD)
if (!preg_match('/^\\d{8}$/', $period)) {
$this->error('Invalid period format. Use YYYYMMDD format (example: 20250515)');
return false;
}
// Validate date
$year = substr($period, 0, 4);
$month = substr($period, 4, 2);
$day = substr($period, 6, 2);
if (!checkdate($month, $day, $year)) {
$this->error('Invalid date in period parameter.');
return false;
}
// Validate user exists
$user = User::find($userId);
if (!$user) {
$this->error("User with ID {$userId} not found.");
return false;
}
return true;
}
/**
* Create report log entry
* Membuat entry log laporan
*
* @param string $accountNumber
* @param string $period
* @param int $userId
* @return ClosingBalanceReportLog|null
*/
private function createReportLog(string $accountNumber, string $period, int $userId): ?ClosingBalanceReportLog
{
try {
// Convert period string to Carbon date
$reportDate = Carbon::createFromFormat('Ymd', $period);
$reportLog = ClosingBalanceReportLog::create([
'account_number' => $accountNumber,
'period' => $period,
'report_date' => $reportDate, // Required field yang sebelumnya missing
'status' => 'pending',
'user_id' => $userId,
'created_by' => $userId, // Required field yang sebelumnya missing
'updated_by' => $userId,
'ip_address' => request()->ip() ?? '127.0.0.1', // Default untuk console
'user_agent' => 'Console Command',
'created_at' => now(),
'updated_at' => now()
]);
Log::info('Console command: Report log created', [
'report_log_id' => $reportLog->id,
'account_number' => $accountNumber,
'period' => $period,
'report_date' => $reportDate->format('Y-m-d'),
'user_id' => $userId
]);
return $reportLog;
} catch (Exception $e) {
Log::error('Console command: Error creating report log', [
'account_number' => $accountNumber,
'period' => $period,
'user_id' => $userId,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return null;
}
}
}

View File

@@ -1,51 +1,67 @@
<?php
namespace Modules\Webstatement\Console;
namespace Modules\Webstatement\Console;
use Exception;
use Illuminate\Console\Command;
use Modules\Webstatement\Http\Controllers\MigrasiController;
use Exception;
use Illuminate\Console\Command;
use Modules\Webstatement\Http\Controllers\MigrasiController;
use Illuminate\Support\Facades\Log;
class ProcessDailyMigration extends Command
class ProcessDailyMigration extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'webstatement:process-daily-migration
{--process_parameter= : To process migration parameter true/false}
{--period= : Period to process (default: -1 day, format: Ymd or relative date)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Process data migration for the specified period (default: previous day)';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'webstatement:process-daily-migration
{--process_parameter= : To process migration parameter true/false}';
$processParameter = $this->option('process_parameter');
$period = $this->option('period');
/**
* The console command description.
*
* @var string
*/
protected $description = 'Process data migration for the previous day\'s period';
// Log start of process
Log::info('Starting daily data migration process', [
'process_parameter' => $processParameter ?? 'false',
'period' => $period ?? '-1 day'
]);
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$processParameter = $this->option('process_parameter');
$this->info('Starting daily data migration process...');
$this->info('Process Parameter: ' . ($processParameter ?? 'False'));
$this->info('Period: ' . ($period ?? '-1 day (default)'));
$this->info('Starting daily data migration process...');
$this->info('Process Parameter: ' . ($processParameter ?? 'False'));
try {
$controller = app(MigrasiController::class);
$response = $controller->index($processParameter, $period);
try {
$controller = app(MigrasiController::class);
$response = $controller->index($processParameter);
$responseData = json_decode($response->getContent(), true);
$message = $responseData['message'] ?? 'Process completed';
$responseData = json_decode($response->getContent(), true);
$this->info($responseData['message'] ?? 'Process completed');
$this->info($message);
Log::info('Daily migration process completed successfully', ['message' => $message]);
return Command::SUCCESS;
} catch (Exception $e) {
$this->error('Error processing daily migration: ' . $e->getMessage());
return Command::FAILURE;
}
return Command::SUCCESS;
} catch (Exception $e) {
$errorMessage = 'Error processing daily migration: ' . $e->getMessage();
$this->error($errorMessage);
Log::error($errorMessage, ['exception' => $e->getTraceAsString()]);
return Command::FAILURE;
}
}
}

View File

@@ -0,0 +1,571 @@
<?php
namespace Modules\Webstatement\Http\Controllers;
use App\Http\Controllers\Controller;
use Carbon\Carbon;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Modules\Webstatement\Jobs\GenerateClosingBalanceReportJob;
use Modules\Webstatement\Models\ClosingBalanceReportLog;
/**
* Controller untuk mengelola laporan closing balance
* Menggunakan job processing untuk menangani laporan dengan banyak transaksi
*/
class LaporanClosingBalanceController extends Controller
{
/**
* Menampilkan halaman utama laporan closing balance
* dengan form untuk membuat permintaan laporan
*
* @return \Illuminate\View\View
*/
public function index()
{
Log::info('Mengakses halaman laporan closing balance');
return view('webstatement::laporan-closing-balance.index');
}
/**
* Membuat permintaan laporan closing balance baru
* Menggunakan job untuk memproses laporan secara asynchronous
*
* @param Request $request
* @return \Illuminate\Http\RedirectResponse
*/
public function store(Request $request)
{
Log::info('Membuat permintaan laporan closing balance', [
'user_id' => Auth::id(),
'request_data' => $request->all()
]);
try {
DB::beginTransaction();
$validated = $request->validate([
'account_number' => ['required', 'string', 'max:50'],
'report_date' => ['required', 'date_format:Y-m-d'],
]);
// Convert date to Ymd format for period
$period = Carbon::createFromFormat('Y-m-d', $validated['report_date'])->format('Ymd');
// Add user tracking data
$reportData = [
'account_number' => $validated['account_number'],
'period' => $period,
'report_date' => $validated['report_date'],
'user_id' => Auth::id(),
'created_by' => Auth::id(),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'status' => 'pending',
];
// Create the report request log
$reportRequest = ClosingBalanceReportLog::create($reportData);
// Dispatch the job to generate the report
GenerateClosingBalanceReportJob::dispatch(
$validated['account_number'],
$period,
$reportRequest->id
);
$reportRequest->update([
'status' => 'processing',
'updated_by' => Auth::id()
]);
DB::commit();
Log::info('Permintaan laporan closing balance berhasil dibuat', [
'report_id' => $reportRequest->id,
'account_number' => $validated['account_number'],
'period' => $period
]);
return redirect()->route('laporan-closing-balance.index')
->with('success', 'Permintaan laporan closing balance berhasil dibuat dan sedang diproses.');
} catch (Exception $e) {
DB::rollback();
Log::error('Error saat membuat permintaan laporan closing balance', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return redirect()->back()
->withInput()
->with('error', 'Terjadi kesalahan saat membuat permintaan laporan: ' . $e->getMessage());
}
}
/**
* Menampilkan form untuk membuat permintaan laporan baru
*
* @return \Illuminate\View\View
*/
public function create()
{
Log::info('Menampilkan form pembuatan laporan closing balance');
return view('webstatement::laporan-closing-balance.create');
}
/**
* Menampilkan detail permintaan laporan
*
* @param ClosingBalanceReportLog $closingBalanceReport
* @return \Illuminate\View\View
*/
public function show(ClosingBalanceReportLog $closingBalanceReport)
{
Log::info('Menampilkan detail laporan closing balance', [
'report_id' => $closingBalanceReport->id
]);
$closingBalanceReport->load(['user', 'creator', 'authorizer']);
return view('webstatement::laporan-closing-balance.show', compact('closingBalanceReport'));
}
/**
* Authorize permintaan laporan
*
* @param Request $request
* @param ClosingBalanceReportLog $closingBalanceReport
* @return \Illuminate\Http\RedirectResponse
*/
public function authorize(Request $request, ClosingBalanceReportLog $closingBalanceReport)
{
Log::info('Authorize laporan closing balance', [
'report_id' => $closingBalanceReport->id,
'user_id' => Auth::id()
]);
try {
DB::beginTransaction();
$request->validate([
'authorization_status' => ['required', Rule::in(['approved', 'rejected'])],
'remarks' => ['nullable', 'string', 'max:255'],
]);
// Update authorization status
$closingBalanceReport->update([
'authorization_status' => $request->authorization_status,
'authorized_by' => Auth::id(),
'authorized_at' => now(),
'remarks' => $request->remarks,
'updated_by' => Auth::id()
]);
DB::commit();
$statusText = $request->authorization_status === 'approved' ? 'disetujui' : 'ditolak';
Log::info('Laporan closing balance berhasil diauthorize', [
'report_id' => $closingBalanceReport->id,
'status' => $request->authorization_status
]);
return redirect()->route('laporan-closing-balance.show', $closingBalanceReport->id)
->with('success', "Permintaan laporan closing balance berhasil {$statusText}.");
} catch (Exception $e) {
DB::rollback();
Log::error('Error saat authorize laporan', [
'report_id' => $closingBalanceReport->id,
'error' => $e->getMessage()
]);
return back()->with('error', 'Terjadi kesalahan saat authorize laporan.');
}
}
/**
* Menyediakan data untuk datatables
*
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function dataForDatatables(Request $request)
{
Log::info('Mengambil data untuk datatables laporan closing balance', [
'filters' => $request->all()
]);
try {
// Retrieve data from the database
$query = ClosingBalanceReportLog::query();
// Apply search filter if provided (handle JSON search parameters)
if ($request->has('search') && !empty($request->get('search'))) {
$search = $request->get('search');
// Check if search is JSON format
if (is_string($search) && json_decode($search, true) !== null) {
$searchParams = json_decode($search, true);
// Apply account number filter
if (!empty($searchParams['account_number'])) {
$query->where('account_number', 'LIKE', "%{$searchParams['account_number']}%");
}
// Apply date range filter
if (!empty($searchParams['start_date'])) {
$startPeriod = Carbon::createFromFormat('Y-m-d', $searchParams['start_date'])->format('Ymd');
$query->where('period', '>=', $startPeriod);
}
if (!empty($searchParams['end_date'])) {
$endPeriod = Carbon::createFromFormat('Y-m-d', $searchParams['end_date'])->format('Ymd');
$query->where('period', '<=', $endPeriod);
}
} else {
// Handle regular string search (fallback)
$query->where(function ($q) use ($search) {
$q->where('account_number', 'LIKE', "%$search%")
->orWhere('period', 'LIKE', "%$search%")
->orWhere('status', 'LIKE', "%$search%")
->orWhere('authorization_status', 'LIKE', "%$search%");
});
}
}
// Apply individual parameter filters (for backward compatibility)
if ($request->has('account_number') && !empty($request->get('account_number'))) {
$query->where('account_number', 'LIKE', "%{$request->get('account_number')}%");
}
if ($request->has('start_date') && !empty($request->get('start_date'))) {
$startPeriod = Carbon::createFromFormat('Y-m-d', $request->get('start_date'))->format('Ymd');
$query->where('period', '>=', $startPeriod);
}
if ($request->has('end_date') && !empty($request->get('end_date'))) {
$endPeriod = Carbon::createFromFormat('Y-m-d', $request->get('end_date'))->format('Ymd');
$query->where('period', '<=', $endPeriod);
}
// Apply column filters if provided
if ($request->has('filters') && !empty($request->get('filters'))) {
$filters = json_decode($request->get('filters'), true);
foreach ($filters as $filter) {
if (!empty($filter['value'])) {
if ($filter['column'] === 'status') {
$query->where('status', $filter['value']);
} else if ($filter['column'] === 'authorization_status') {
$query->where('authorization_status', $filter['value']);
} else if ($filter['column'] === 'account_number') {
$query->where('account_number', 'LIKE', "%{$filter['value']}%");
}
}
}
}
// Apply sorting if provided
if ($request->has('sortOrder') && !empty($request->get('sortOrder'))) {
$order = $request->get('sortOrder');
$column = $request->get('sortField');
// Map frontend column names to database column names if needed
$columnMap = [
'account_number' => 'account_number',
'period' => 'period',
'status' => 'status',
];
$dbColumn = $columnMap[$column] ?? $column;
$query->orderBy($dbColumn, $order);
} else {
// Default sorting
$query->latest('created_at');
}
// 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;
$query->skip($offset)->take($size);
}
// Get the filtered count of records
$filteredRecords = $query->count();
// Eager load relationships
$query->with(['user', 'authorizer']);
// Get the data for the current page
$data = $query->get()->map(function ($item) {
$processingHours = $item->status === 'processing' ? $item->updated_at->diffInHours(now()) : 0;
$isProcessingTimeout = $item->status === 'processing' && $processingHours >= 1;
return [
'id' => $item->id,
'account_number' => $item->account_number,
'period' => $item->period,
'report_date' => Carbon::createFromFormat('Ymd', $item->period)->format('Y-m-d'),
'status' => $item->status,
'status_display' => $item->status . ($isProcessingTimeout ? ' (Timeout)' : ''),
'processing_hours' => $processingHours,
'is_processing_timeout' => $isProcessingTimeout,
'authorization_status' => $item->authorization_status,
'is_downloaded' => $item->is_downloaded,
'created_at' => $item->created_at->format('Y-m-d H:i:s'),
'created_by' => $item->user->name ?? 'N/A',
'authorized_by' => $item->authorizer ? $item->authorizer->name : null,
'authorized_at' => $item->authorized_at ? $item->authorized_at->format('Y-m-d H:i:s') : null,
'file_path' => $item->file_path,
'record_count' => $item->record_count,
'can_retry' => in_array($item->status, ['failed', 'pending']) || $isProcessingTimeout || ($item->status === 'completed' && !$item->file_path),
];
});
// Calculate the page count
$pageCount = ceil($filteredRecords / ($request->get('size') ?: 1));
$currentPage = $request->get('page') ?: 1;
Log::info('Data laporan closing balance berhasil diambil', [
'total_records' => $totalRecords,
'filtered_records' => $filteredRecords
]);
return response()->json([
'draw' => $request->get('draw'),
'recordsTotal' => $totalRecords,
'recordsFiltered' => $filteredRecords,
'pageCount' => $pageCount,
'page' => $currentPage,
'totalCount' => $totalRecords,
'data' => $data,
]);
} catch (Exception $e) {
Log::error('Error saat mengambil data datatables', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return response()->json([
'error' => 'Terjadi kesalahan saat mengambil data laporan',
'message' => $e->getMessage()
], 500);
}
}
/**
* Hapus permintaan laporan
*
* @param ClosingBalanceReportLog $closingBalanceReport
* @return \Illuminate\Http\JsonResponse
*/
public function destroy(ClosingBalanceReportLog $closingBalanceReport)
{
Log::info('Menghapus laporan closing balance', [
'report_id' => $closingBalanceReport->id
]);
try {
DB::beginTransaction();
// Delete the file if exists
if ($closingBalanceReport->file_path && Storage::exists($closingBalanceReport->file_path)) {
Storage::delete($closingBalanceReport->file_path);
}
// Delete the report request
$closingBalanceReport->delete();
DB::commit();
Log::info('Laporan closing balance berhasil dihapus', [
'report_id' => $closingBalanceReport->id
]);
return response()->json([
'message' => 'Laporan closing balance berhasil dihapus.',
]);
} catch (Exception $e) {
DB::rollback();
Log::error('Error saat menghapus laporan', [
'report_id' => $closingBalanceReport->id,
'error' => $e->getMessage()
]);
return response()->json([
'error' => 'Terjadi kesalahan saat menghapus laporan',
'message' => $e->getMessage()
], 500);
}
}
/**
* Retry generating laporan closing balance
*
* @param ClosingBalanceReportLog $closingBalanceReport
* @return \Illuminate\Http\RedirectResponse
*/
public function retry(ClosingBalanceReportLog $closingBalanceReport)
{
Log::info('Retry laporan closing balance', [
'report_id' => $closingBalanceReport->id
]);
try {
// Check if retry is allowed
$allowedStatuses = ['failed', 'pending'];
$isProcessingTooLong = $closingBalanceReport->status === 'processing' &&
$closingBalanceReport->updated_at->diffInHours(now()) >= 1;
if (!in_array($closingBalanceReport->status, $allowedStatuses) && !$isProcessingTooLong) {
return back()->with('error', 'Laporan hanya dapat diulang jika status failed, pending, atau processing lebih dari 1 jam.');
}
DB::beginTransaction();
// If it was processing for too long, mark it as failed first
if ($isProcessingTooLong) {
$closingBalanceReport->update([
'status' => 'failed',
'error_message' => 'Processing timeout - melebihi batas waktu 1 jam',
'updated_by' => Auth::id()
]);
}
// Reset the report status and clear previous data
$closingBalanceReport->update([
'status' => 'processing',
'error_message' => null,
'file_path' => null,
'file_size' => null,
'record_count' => null,
'updated_by' => Auth::id()
]);
// Dispatch the job again
GenerateClosingBalanceReportJob::dispatch(
$closingBalanceReport->account_number,
$closingBalanceReport->period,
$closingBalanceReport->id
);
DB::commit();
Log::info('Laporan closing balance berhasil diulang', [
'report_id' => $closingBalanceReport->id
]);
return back()->with('success', 'Job laporan closing balance berhasil diulang.');
} catch (Exception $e) {
DB::rollback();
Log::error('Error saat retry laporan', [
'report_id' => $closingBalanceReport->id,
'error' => $e->getMessage()
]);
$closingBalanceReport->update([
'status' => 'failed',
'error_message' => $e->getMessage(),
'updated_by' => Auth::id()
]);
return back()->with('error', 'Gagal mengulang generate laporan: ' . $e->getMessage());
}
}
/**
* Download laporan berdasarkan nomor rekening dan periode
*
* @param string $accountNumber
* @param string $period
* @return \Illuminate\Http\Response
*/
public function download($accountNumber, $period)
{
Log::info('Download laporan closing balance', [
'account_number' => $accountNumber,
'period' => $period,
'user_id' => Auth::id()
]);
try {
// Cari laporan berdasarkan account number dan period
$closingBalanceReport = ClosingBalanceReportLog::where('account_number', $accountNumber)
->where('period', $period)
->where('status', 'completed')
->whereNotNull('file_path')
->first();
if (!$closingBalanceReport) {
Log::warning('Laporan tidak ditemukan atau belum selesai', [
'account_number' => $accountNumber,
'period' => $period
]);
return back()->with('error', 'Laporan tidak ditemukan atau belum selesai diproses.');
}
DB::beginTransaction();
// Update download status
$closingBalanceReport->update([
'is_downloaded' => true,
'downloaded_at' => now(),
'updated_by' => Auth::id()
]);
DB::commit();
// Download the file
$filePath = $closingBalanceReport->file_path;
if (Storage::exists($filePath)) {
$fileName = "closing_balance_report_{$accountNumber}_{$period}.csv";
Log::info('File laporan berhasil didownload', [
'account_number' => $accountNumber,
'period' => $period,
'file_path' => $filePath
]);
return Storage::download($filePath, $fileName);
}
Log::error('File laporan tidak ditemukan di storage', [
'account_number' => $accountNumber,
'period' => $period,
'file_path' => $filePath
]);
return back()->with('error', 'File laporan tidak ditemukan.');
} catch (Exception $e) {
DB::rollback();
Log::error('Error saat download laporan', [
'account_number' => $accountNumber,
'period' => $period,
'error' => $e->getMessage()
]);
return back()->with('error', 'Terjadi kesalahan saat mengunduh laporan: ' . $e->getMessage());
}
}
}

View File

@@ -24,7 +24,8 @@
ProcessTellerDataJob,
ProcessTransactionDataJob,
ProcessSectorDataJob,
ProcessProvinceDataJob};
ProcessProvinceDataJob,
ProcessStmtEntryDetailDataJob};
class MigrasiController extends Controller
{
@@ -38,6 +39,7 @@
'customer' => ProcessCustomerDataJob::class,
'account' => ProcessAccountDataJob::class,
'stmtEntry' => ProcessStmtEntryDataJob::class,
'stmtEntryDetail' => ProcessStmtEntryDetailDataJob::class, // Tambahan baru
'dataCapture' => ProcessDataCaptureDataJob::class,
'fundsTransfer' => ProcessFundsTransferDataJob::class,
'teller' => ProcessTellerDataJob::class,
@@ -63,6 +65,7 @@
'customer',
'account',
'stmtEntry',
'stmtEntryDetail', // Tambahan baru
'dataCapture',
'fundsTransfer',
'teller',
@@ -98,30 +101,99 @@
return response()->json(['error' => $e->getMessage()], 500);
}
}
public function index($processParameter = false)
/**
* Proses migrasi data dengan parameter dan periode yang dapat dikustomisasi
*
* @param bool|string $processParameter Flag untuk memproses parameter
* @param string|null $period Periode yang akan diproses (default: -1 day)
* @return JsonResponse
*/
public function index($processParameter = false, $period = null)
{
$disk = Storage::disk('sftpStatement');
try {
Log::info('Starting migration process', [
'process_parameter' => $processParameter,
'period' => $period
]);
if ($processParameter) {
foreach (self::PARAMETER_PROCESSES as $process) {
$this->processData($process, '_parameter');
$disk = Storage::disk('sftpStatement');
if ($processParameter) {
Log::info('Processing parameter data');
foreach (self::PARAMETER_PROCESSES as $process) {
$this->processData($process, '_parameter');
}
Log::info('Parameter processes completed successfully');
return response()->json(['message' => 'Parameter processes completed successfully']);
}
return response()->json(['message' => 'Parameter processes completed successfully']);
}
$period = date('Ymd', strtotime('-1 day'));
if (!$disk->exists($period)) {
// Tentukan periode yang akan diproses
$targetPeriod = $this->determinePeriod($period);
Log::info('Processing data for period', ['period' => $targetPeriod]);
if (!$disk->exists($targetPeriod)) {
$errorMessage = "Period {$targetPeriod} folder not found in SFTP storage";
Log::warning($errorMessage);
return response()->json([
"message" => $errorMessage
], 404);
}
foreach (self::DATA_PROCESSES as $process) {
$this->processData($process, $targetPeriod);
}
$successMessage = "Data processing for period {$targetPeriod} has been queued successfully";
Log::info($successMessage);
return response()->json([
"message" => "Period {$period} folder not found in SFTP storage"
], 404);
'message' => $successMessage
]);
} catch (Exception $e) {
Log::error('Error in migration index method: ' . $e->getMessage());
return response()->json(['error' => $e->getMessage()], 500);
}
}
/**
* Tentukan periode berdasarkan input atau gunakan default
*
* @param string|null $period Input periode
* @return string Periode dalam format Ymd
*/
private function determinePeriod($period = null): string
{
if ($period === null) {
// Default: -1 day
$calculatedPeriod = date('Ymd', strtotime('-1 day'));
Log::info('Using default period', ['period' => $calculatedPeriod]);
return $calculatedPeriod;
}
foreach (self::DATA_PROCESSES as $process) {
$this->processData($process, $period);
// Jika periode sudah dalam format Ymd (8 digit)
if (preg_match('/^\d{8}$/', $period)) {
Log::info('Using provided period in Ymd format', ['period' => $period]);
return $period;
}
return response()->json([
'message' => "Data processing for period {$period} has been queued successfully"
]);
// Jika periode dalam format relative date (contoh: -2 days, -1 week, etc.)
try {
$calculatedPeriod = date('Ymd', strtotime($period));
Log::info('Calculated period from relative date', [
'input' => $period,
'calculated' => $calculatedPeriod
]);
return $calculatedPeriod;
} catch (Exception $e) {
Log::warning('Invalid period format, using default', [
'input' => $period,
'error' => $e->getMessage()
]);
return date('Ymd', strtotime('-1 day'));
}
}
}

View File

@@ -114,6 +114,12 @@
],
'SWADAYA_PANDU' => [
'0081272689',
],
"AWAN_LINTANG_SOLUSI"=> [
"1084269430"
],
"MONETA"=> [
"1085667890"
]
];
}

View File

@@ -81,10 +81,10 @@
$existingDataCount = $this->getExistingProcessedCount($accountQuery);
// Hanya proses jika data belum lengkap diproses
if ($existingDataCount !== $totalCount) {
//if ($existingDataCount !== $totalCount) {
$this->deleteExistingProcessedData($accountQuery);
$this->processAndSaveStatementEntries($totalCount);
}
//}
}
private function getTotalEntryCount(array $criteria)
@@ -156,7 +156,7 @@
'description' => $this->generateNarrative($item),
'end_balance' => $runningBalance,
'actual_date' => $actualDate,
'recipt_no' => $item->ft?->recipt_no ?? '-',
'recipt_no' => $item->ft?->recipt_no ?? '-',
'created_at' => now(),
'updated_at' => now(),
];
@@ -357,8 +357,7 @@
/**
* Export processed data to CSV file
*/
private function exportToCsv()
: void
private function exportToCsv(): void
{
// Determine the base path based on client
$basePath = !empty($this->client)
@@ -367,36 +366,11 @@
$accountPath = "{$basePath}/{$this->account_number}";
// Create client directory if it doesn't exist
if (!empty($this->client)) {
// Di fungsi exportToCsv untuk basePath
Storage::disk($this->disk)->makeDirectory($basePath);
// Tambahkan permission dan ownership setelah membuat directory
if ($this->disk === 'local') {
$fullPath = storage_path("app/{$basePath}");
if (is_dir($fullPath)) {
chmod($fullPath, 0777);
if (function_exists('chown') && posix_getuid() === 0) {
@chown(dirname($fullPath), 'www-data'); // Gunakan www-data instead of root
@chgrp(dirname($fullPath), 'www-data');
}
}
}
// PERBAIKAN: Selalu pastikan direktori dibuat
Storage::disk($this->disk)->makeDirectory($basePath);
Storage::disk($this->disk)->makeDirectory($accountPath);
// Untuk accountPath
Storage::disk($this->disk)->makeDirectory($accountPath);
// Tambahkan permission dan ownership setelah membuat directory
if ($this->disk === 'local') {
$fullPath = storage_path("app/{$accountPath}");
if (is_dir($fullPath)) {
chmod($fullPath, 0777);
if (function_exists('chown') && posix_getuid() === 0) {
@chown(dirname($fullPath), 'www-data'); // Gunakan www-data instead of root
@chgrp(dirname($fullPath), 'www-data');
}
}
}
}
$filePath = "{$accountPath}/{$this->fileName}";
@@ -405,13 +379,38 @@
Storage::disk($this->disk)->delete($filePath);
}
$csvContent = "NO|TRANSACTION.DATE|REFERENCE.NUMBER|TRANSACTION.AMOUNT|TRANSACTION.TYPE|DESCRIPTION|END.BALANCE|ACTUAL.DATE|NO.RECEIPT\n";
// Ambil data yang sudah diproses dalam chunk untuk mengurangi penggunaan memori
// Tambahkan di awal fungsi exportToCsv
Log::info("Starting CSV export", [
'disk' => $this->disk,
'client' => $this->client,
'account_number' => $this->account_number,
'period' => $this->period,
'base_path' => $basePath,
'account_path' => $accountPath,
'file_path' => $filePath
]);
// Cek apakah disk storage berfungsi
$testFile = 'test_' . time() . '.txt';
Storage::disk($this->disk)->put($testFile, 'test content');
if (Storage::disk($this->disk)->exists($testFile)) {
Log::info("Storage disk is working");
Storage::disk($this->disk)->delete($testFile);
} else {
Log::error("Storage disk is not working properly");
}
// PERBAIKAN: Buat file header terlebih dahulu
$csvContent = "NO|TRANSACTION.DATE|REFERENCE.NUMBER|TRANSACTION.AMOUNT|TRANSACTION.TYPE|DESCRIPTION|END.BALANCE|ACTUAL.DATE|NO.RECEIPT\n";
Storage::disk($this->disk)->put($filePath, $csvContent);
// Ambil data yang sudah diproses dalam chunk
ProcessedStatement::where('account_number', $this->account_number)
->where('period', $this->period)
->orderBy('sequence_no')
->chunk($this->chunkSize, function ($statements) use (&$csvContent, $filePath) {
->chunk($this->chunkSize, function ($statements) use ($filePath) {
$csvContent = '';
foreach ($statements as $statement) {
$csvContent .= implode('|', [
$statement->sequence_no,
@@ -426,12 +425,31 @@
]) . "\n";
}
// Tulis ke file secara bertahap untuk mengurangi penggunaan memori
Storage::disk($this->disk)->append($filePath, $csvContent);
$csvContent = ''; // Reset content setelah ditulis
// Append ke file
if (!empty($csvContent)) {
Storage::disk($this->disk)->append($filePath, $csvContent);
}
});
Log::info("Statement exported to {$this->disk} disk: {$filePath}");
// PERBAIKAN: Verifikasi file benar-benar ada
if (Storage::disk($this->disk)->exists($filePath)) {
$fileSize = Storage::disk($this->disk)->size($filePath);
Log::info("Statement exported successfully", [
'disk' => $this->disk,
'file_path' => $filePath,
'file_size' => $fileSize,
'account_number' => $this->account_number,
'period' => $this->period
]);
} else {
Log::error("File was not created despite successful processing", [
'disk' => $this->disk,
'file_path' => $filePath,
'account_number' => $this->account_number,
'period' => $this->period
]);
throw new \Exception("Failed to create CSV file: {$filePath}");
}
}
/**

View File

@@ -201,25 +201,27 @@ class ExportStatementPeriodJob implements ShouldQueue
$processedData = [];
foreach ($entries as $item) {
$globalSequence++;
$runningBalance += (float) $item->amount_lcy;
$globalSequence++;
$runningBalance += (float) $item->amount_lcy;
$actualDate = $this->formatActualDate($item);
$transactionDate = $this->formatTransactionDate($item);
$actualDate = $this->formatActualDate($item);
$processedData[] = [
'account_number' => $this->account_number,
'period' => $this->period,
'sequence_no' => $globalSequence,
'transaction_date' => $item->booking_date,
'reference_number' => $item->trans_reference,
'transaction_amount' => $item->amount_lcy,
'transaction_type' => $item->amount_lcy < 0 ? 'D' : 'C',
'description' => $this->generateNarrative($item),
'end_balance' => $runningBalance,
'actual_date' => $actualDate,
'created_at' => now(),
'updated_at' => now(),
];
$processedData[] = [
'account_number' => $this->account_number,
'period' => $this->period,
'sequence_no' => $globalSequence,
'transaction_date' => $item->booking_date,
'reference_number' => $item->trans_reference,
'transaction_amount' => $item->amount_lcy,
'transaction_type' => $item->amount_lcy < 0 ? 'D' : 'C',
'description' => $this->generateNarrative($item),
'end_balance' => $runningBalance,
'actual_date' => $actualDate,
'recipt_no' => $item->ft?->recipt_no ?? '-',
'created_at' => now(),
'updated_at' => now(),
];
}
return $processedData;
@@ -480,27 +482,10 @@ class ExportStatementPeriodJob implements ShouldQueue
// Buat direktori temp jika belum ada
if (!is_dir(dirname($tempPath))) {
mkdir(dirname($tempPath), 0777, true);
// Tambahkan pengecekan function dan error handling
if (function_exists('chown') && posix_getuid() === 0) {
@chown(dirname($tempPath), 'www-data'); // Gunakan www-data instead of root
@chgrp(dirname($tempPath), 'www-data');
}
}
// Pastikan direktori storage ada
Storage::makeDirectory($storagePath);
// Tambahkan permission dan ownership setelah membuat directory
if ($this->disk === 'local') {
$fullPath = storage_path("app/{$storagePath}");
if (is_dir($fullPath)) {
chmod($fullPath, 0777);
// Tambahkan pengecekan function dan error handling
if (function_exists('chown') && posix_getuid() === 0) {
@chown($fullPath, 'www-data'); // Gunakan www-data instead of root
@chgrp($fullPath, 'www-data');
}
}
}
$period = $this->period;
@@ -629,17 +614,7 @@ class ExportStatementPeriodJob implements ShouldQueue
$storagePath = "statements/{$this->period}/{$account->branch_code}";
Storage::disk($this->disk)->makeDirectory($storagePath);
// Tambahkan permission dan ownership setelah membuat directory
if ($this->disk === 'local') {
$fullPath = storage_path("app/{$storagePath}");
if (is_dir($fullPath)) {
chmod($fullPath, 0777);
if (function_exists('chown') && posix_getuid() === 0) {
@chown(dirname($fullPath), 'www-data'); // Gunakan www-data instead of root
@chgrp(dirname($fullPath), 'www-data');
}
}
}
$filePath = "{$storagePath}/{$this->fileName}";
// Delete existing file if it exists
@@ -669,7 +644,6 @@ class ExportStatementPeriodJob implements ShouldQueue
// Write to file incrementally to reduce memory usage
Storage::disk($this->disk)->append($filePath, $csvContent);
$csvContent = ''; // Reset content after writing
});
Log::info("Statement exported to {$this->disk} disk: {$filePath}");

View File

@@ -191,11 +191,11 @@
$subQuery->where('product_code', '!=', '6021')
->orWhere(function($nestedQuery) {
$nestedQuery->where('product_code', '6021')
->where('ctdesc', '!=', 'gold');
->where('ctdesc', '!=', 'GOLD');
});
});
$cards = $query->get();

View File

@@ -0,0 +1,606 @@
<?php
namespace Modules\Webstatement\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Carbon\Carbon;
use Exception;
use Modules\Webstatement\Models\AccountBalance;
use Modules\Webstatement\Models\ClosingBalanceReportLog;
use Modules\Webstatement\Models\StmtEntry;
use Modules\Webstatement\Models\StmtEntryDetail;
use Modules\Webstatement\Models\TempFundsTransfer;
use Modules\Webstatement\Models\DataCapture;
/**
* Job untuk generate laporan closing balance
* Mengambil data transaksi dan menghitung closing balance
*/
class GenerateClosingBalanceReportJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $accountNumber;
protected $period;
protected $reportLogId;
protected $groupName;
protected $chunkSize = 1000;
protected $disk = 'local';
/**
* Create a new job instance.
*
* @param string $accountNumber
* @param string $period
* @param int $reportLogId
*/
public function __construct(string $accountNumber, string $period, int $reportLogId, string $groupName='DEFAULT')
{
$this->accountNumber = $accountNumber;
$this->period = $period;
$this->reportLogId = $reportLogId;
$this->groupName = $groupName ?? 'DEFAULT';
}
/**
* Execute the job.
* Memproses data transaksi dan generate laporan closing balance
*/
public function handle(): void
{
$reportLog = ClosingBalanceReportLog::find($this->reportLogId);
if (!$reportLog) {
Log::error('Closing balance report log not found', ['id' => $this->reportLogId]);
return;
}
try {
Log::info('Starting closing balance report generation', [
'account_number' => $this->accountNumber,
'period' => $this->period,
'report_log_id' => $this->reportLogId
]);
DB::beginTransaction();
// Update status to processing
$reportLog->update([
'status' => 'processing',
'updated_at' => now()
]);
// Get opening balance
$openingBalance = $this->getOpeningBalance();
// Generate report data
$reportData = $this->generateReportData($openingBalance);
// Export to CSV
$filePath = $this->exportToCsv($reportData);
// Update report log with success
$reportLog->update([
'status' => 'completed',
'file_path' => $filePath,
'file_size' => Storage::disk($this->disk)->size($filePath),
'record_count' => count($reportData),
'updated_at' => now()
]);
DB::commit();
Log::info('Closing balance report generation completed successfully', [
'account_number' => $this->accountNumber,
'period' => $this->period,
'file_path' => $filePath,
'record_count' => count($reportData)
]);
} catch (Exception $e) {
DB::rollback();
Log::error('Error generating closing balance report', [
'account_number' => $this->accountNumber,
'period' => $this->period,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
$reportLog->update([
'status' => 'failed',
'error_message' => $e->getMessage(),
'updated_at' => now()
]);
throw $e;
}
}
/**
* Get opening balance from account balance table
* Mengambil saldo awal dari tabel account balance
*/
private function getOpeningBalance(): float
{
Log::info('Getting opening balance', [
'account_number' => $this->accountNumber,
'period' => $this->period
]);
// Get previous period based on current period
$previousPeriod = $this->period === '20250512'
? Carbon::createFromFormat('Ymd', $this->period)->subDays(2)->format('Ymd')
: Carbon::createFromFormat('Ymd', $this->period)->subDay()->format('Ymd');
$accountBalance = AccountBalance::where('account_number', $this->accountNumber)
->where('period', $previousPeriod)
->first();
if (!$accountBalance) {
Log::warning('Account balance not found, using 0 as opening balance', [
'account_number' => $this->accountNumber,
'period' => $this->period
]);
return 0.0;
}
$openingBalance = (float) $accountBalance->actual_balance;
Log::info('Opening balance retrieved', [
'account_number' => $this->accountNumber,
'opening_balance' => $openingBalance
]);
return $openingBalance;
}
/**
* Build transaction query using pure Eloquent relationships
* Membangun query transaksi menggunakan relasi Eloquent murni
*/
private function buildTransactionQuery()
{
Log::info('Building transaction query using pure Eloquent relationships', [
'group_name' => $this->groupName,
'account_number' => $this->accountNumber,
'period' => $this->period
]);
// Tentukan model berdasarkan group name
$modelClass = $this->getModelByGroup();
// Build query menggunakan pure Eloquent dengan eager loading
$query = $modelClass::with([
'ft' => function($query) {
$query->select([
'_id',
'ref_no',
'debit_acct_no',
'debit_value_date',
'credit_acct_no',
'bif_rcv_acct',
'bif_rcv_name',
'credit_value_date',
'at_unique_id',
'bif_ref_no',
'atm_order_id',
'recipt_no',
'api_iss_acct',
'api_benff_acct',
'authoriser',
'remarks',
'payment_details',
'merchant_id',
'term_id',
'date_time'
]);
},
'dc' => function($query) {
$query->select([
'id',
'date_time'
]);
}
])
->select([
'id',
'trans_reference',
'booking_date',
'amount_lcy',
'date_time'
])
->where('account_number', $this->accountNumber)
->where('booking_date', $this->period)
->orderBy('booking_date')
->orderBy('date_time');
Log::info('Transaction query built successfully using pure Eloquent', [
'model_class' => $modelClass,
'account_number' => $this->accountNumber,
'period' => $this->period
]);
return $query;
}
/**
* Get model class based on group name
* Mendapatkan class model berdasarkan group name
*/
private function getModelByGroup()
{
Log::info('Determining model by group', [
'group_name' => $this->groupName
]);
$model = $this->groupName === 'QRIS' ? StmtEntryDetail::class : StmtEntry::class;
Log::info('Model determined', [
'group_name' => $this->groupName,
'model_class' => $model
]);
return $model;
}
/**
* Process transaction data from ORM result
* Memproses data transaksi dari hasil ORM
*/
private function processTransactionData($transaction): array
{
Log::info('Processing transaction data', [
'trans_reference' => $transaction->trans_reference,
'has_ft_relation' => !is_null($transaction->ft),
'has_dc_relation' => !is_null($transaction->dc)
]);
// Hitung debit dan credit amount
$debitAmount = $transaction->amount_lcy < 0 ? abs($transaction->amount_lcy) : null;
$creditAmount = $transaction->amount_lcy > 0 ? $transaction->amount_lcy : null;
// Ambil date_time dari prioritas: ft -> dc -> stmt
$dateTime = $transaction->ft?->date_time ??
$transaction->dc?->date_time ??
$transaction->date_time;
$processedData = [
'trans_reference' => $transaction->trans_reference,
'booking_date' => $transaction->booking_date,
'amount_lcy' => $transaction->amount_lcy,
'debit_amount' => $debitAmount,
'credit_amount' => $creditAmount,
'date_time' => $dateTime,
// Data dari TempFundsTransfer melalui relasi
'debit_acct_no' => $transaction->ft?->debit_acct_no,
'debit_value_date' => $transaction->ft?->debit_value_date,
'credit_acct_no' => $transaction->ft?->credit_acct_no,
'bif_rcv_acct' => $transaction->ft?->bif_rcv_acct,
'bif_rcv_name' => $transaction->ft?->bif_rcv_name,
'credit_value_date' => $transaction->ft?->credit_value_date,
'at_unique_id' => $transaction->ft?->at_unique_id,
'bif_ref_no' => $transaction->ft?->bif_ref_no,
'atm_order_id' => $transaction->ft?->atm_order_id,
'recipt_no' => $transaction->ft?->recipt_no,
'api_iss_acct' => $transaction->ft?->api_iss_acct,
'api_benff_acct' => $transaction->ft?->api_benff_acct,
'authoriser' => $transaction->ft?->authoriser,
'remarks' => $transaction->ft?->remarks,
'payment_details' => $transaction->ft?->payment_details,
'ref_no' => $transaction->ft?->ref_no,
'merchant_id' => $transaction->ft?->merchant_id,
'term_id' => $transaction->ft?->term_id,
];
Log::info('Transaction data processed successfully', [
'trans_reference' => $transaction->trans_reference,
'final_date_time' => $dateTime,
'debit_amount' => $debitAmount,
'credit_amount' => $creditAmount
]);
return $processedData;
}
/**
* Updated generateReportData method using pure ORM
* Method generateReportData yang diperbarui menggunakan ORM murni
*/
private function generateReportData(): array
{
Log::info('Starting report data generation using pure ORM', [
'account_number' => $this->accountNumber,
'period' => $this->period,
'group_name' => $this->groupName,
'chunk_size' => $this->chunkSize
]);
$reportData = [];
$runningBalance = $this->getOpeningBalance();
$sequenceNo = 1;
try {
DB::beginTransaction();
// Build query menggunakan pure ORM
$query = $this->buildTransactionQuery();
// Process data dalam chunks untuk efisiensi memory
$query->chunk($this->chunkSize, function ($transactions) use (&$reportData, &$runningBalance, &$sequenceNo) {
Log::info('Processing transaction chunk', [
'chunk_size' => $transactions->count(),
'current_sequence' => $sequenceNo,
'current_balance' => $runningBalance
]);
foreach ($transactions as $transaction) {
// Process transaction data
$processedData = $this->processTransactionData($transaction);
// Update running balance
$amount = (float) $transaction->amount_lcy;
$runningBalance += $amount;
// Format transaction date
$transactionDate = $this->formatDateTime($processedData['date_time']);
// Build report data row
$reportData[] = $this->buildReportDataRow(
(object) $processedData,
$sequenceNo,
$transactionDate,
$runningBalance
);
$sequenceNo++;
}
Log::info('Chunk processed successfully', [
'processed_count' => $transactions->count(),
'total_records_so_far' => count($reportData),
'current_balance' => $runningBalance
]);
});
DB::commit();
Log::info('Report data generation completed using pure ORM', [
'total_records' => count($reportData),
'final_balance' => $runningBalance,
'final_sequence' => $sequenceNo - 1
]);
} catch (Exception $e) {
DB::rollback();
Log::error('Error generating report data using pure ORM', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'account_number' => $this->accountNumber,
'period' => $this->period
]);
throw $e;
}
return $reportData;
}
/**
* Get table name based on group name
* Mendapatkan nama tabel berdasarkan group name
*/
private function getTableNameByGroup(): string
{
return $this->groupName === 'QRIS' ? 'stmt_entry' : 'stmt_entry_details';
}
/**
* Get select fields for the query
* Mendapatkan field select untuk query
*/
private function getSelectFields(): array
{
return [
's.trans_reference',
's.booking_date',
's.amount_lcy',
'ft.debit_acct_no',
'ft.debit_value_date',
DB::raw('CASE WHEN s.amount_lcy::numeric < 0 THEN s.amount_lcy::numeric ELSE NULL END AS debit_amount'),
'ft.credit_acct_no',
'ft.bif_rcv_acct',
'ft.bif_rcv_name',
'ft.credit_value_date',
DB::raw('CASE WHEN s.amount_lcy::numeric > 0 THEN s.amount_lcy::numeric ELSE NULL END AS credit_amount'),
'ft.at_unique_id',
'ft.bif_ref_no',
'ft.atm_order_id',
'ft.recipt_no',
'ft.api_iss_acct',
'ft.api_benff_acct',
DB::raw('COALESCE(ft.date_time, dc.date_time, s.date_time) AS date_time'),
'ft.authoriser',
'ft.remarks',
'ft.payment_details',
'ft.ref_no',
'ft.merchant_id',
'ft.term_id'
];
}
/**
* Build report data row from transaction
* Membangun baris data laporan dari transaksi
*/
private function buildReportDataRow($transaction, int $sequenceNo, string $transactionDate, float $runningBalance): array
{
return [
'sequence_no' => $sequenceNo,
'trans_reference' => $transaction->trans_reference,
'booking_date' => $transaction->booking_date,
'transaction_date' => $transactionDate,
'amount_lcy' => $transaction->amount_lcy,
'debit_acct_no' => $transaction->debit_acct_no,
'debit_value_date' => $transaction->debit_value_date,
'debit_amount' => $transaction->debit_amount,
'credit_acct_no' => $transaction->credit_acct_no,
'bif_rcv_acct' => $transaction->bif_rcv_acct,
'bif_rcv_name' => $transaction->bif_rcv_name,
'credit_value_date' => $transaction->credit_value_date,
'credit_amount' => $transaction->credit_amount,
'at_unique_id' => $transaction->at_unique_id,
'bif_ref_no' => $transaction->bif_ref_no,
'atm_order_id' => $transaction->atm_order_id,
'recipt_no' => $transaction->recipt_no,
'api_iss_acct' => $transaction->api_iss_acct,
'api_benff_acct' => $transaction->api_benff_acct,
'authoriser' => $transaction->authoriser,
'remarks' => $transaction->remarks,
'payment_details' => $transaction->payment_details,
'ref_no' => $transaction->ref_no,
'merchant_id' => $transaction->merchant_id,
'term_id' => $transaction->term_id,
'closing_balance' => $runningBalance
];
}
/**
* Format datetime string
* Memformat string datetime
*/
private function formatDateTime(?string $datetime): string
{
if (!$datetime) {
return '';
}
try {
return Carbon::createFromFormat('ymdHi', $datetime)->format('d/m/Y H:i');
} catch (Exception $e) {
Log::warning('Error formatting datetime', [
'datetime' => $datetime,
'error' => $e->getMessage()
]);
return $datetime;
}
}
/**
* Export report data to CSV file
* Export data laporan ke file CSV
*/
private function exportToCsv(array $reportData): string
{
Log::info('Starting CSV export for closing balance report', [
'account_number' => $this->accountNumber,
'period' => $this->period,
'record_count' => count($reportData)
]);
// Create directory structure
$basePath = "closing_balance_reports";
$accountPath = "{$basePath}/{$this->accountNumber}";
Storage::disk($this->disk)->makeDirectory($basePath);
Storage::disk($this->disk)->makeDirectory($accountPath);
// Generate filename
$fileName = "closing_balance_{$this->accountNumber}_{$this->period}.csv";
$filePath = "{$accountPath}/{$fileName}";
// Delete existing file if exists
if (Storage::disk($this->disk)->exists($filePath)) {
Storage::disk($this->disk)->delete($filePath);
}
// Create CSV header
$csvHeader = [
'NO',
'TRANS_REFERENCE',
'BOOKING_DATE',
'TRANSACTION_DATE',
'AMOUNT_LCY',
'DEBIT_ACCT_NO',
'DEBIT_VALUE_DATE',
'DEBIT_AMOUNT',
'CREDIT_ACCT_NO',
'BIF_RCV_ACCT',
'BIF_RCV_NAME',
'CREDIT_VALUE_DATE',
'CREDIT_AMOUNT',
'AT_UNIQUE_ID',
'BIF_REF_NO',
'ATM_ORDER_ID',
'RECIPT_NO',
'API_ISS_ACCT',
'API_BENFF_ACCT',
'AUTHORISER',
'REMARKS',
'PAYMENT_DETAILS',
'REF_NO',
'MERCHANT_ID',
'TERM_ID',
'CLOSING_BALANCE'
];
$csvContent = implode('|', $csvHeader) . "\n";
// Add data rows
foreach ($reportData as $row) {
$csvRow = [
$row['sequence_no'],
$row['trans_reference'] ?? '',
$row['booking_date'] ?? '',
$row['transaction_date'] ?? '',
$row['amount_lcy'] ?? '',
$row['debit_acct_no'] ?? '',
$row['debit_value_date'] ?? '',
$row['debit_amount'] ?? '',
$row['credit_acct_no'] ?? '',
$row['bif_rcv_acct'] ?? '',
$row['bif_rcv_name'] ?? '',
$row['credit_value_date'] ?? '',
$row['credit_amount'] ?? '',
$row['at_unique_id'] ?? '',
$row['bif_ref_no'] ?? '',
$row['atm_order_id'] ?? '',
$row['recipt_no'] ?? '',
$row['api_iss_acct'] ?? '',
$row['api_benff_acct'] ?? '',
$row['authoriser'] ?? '',
$row['remarks'] ?? '',
$row['payment_details'] ?? '',
$row['ref_no'] ?? '',
$row['merchant_id'] ?? '',
$row['term_id'] ?? '',
$row['closing_balance'] ?? ''
];
$csvContent .= implode('|', $csvRow) . "\n";
}
// Save file
Storage::disk($this->disk)->put($filePath, $csvContent);
// Verify file creation
if (!Storage::disk($this->disk)->exists($filePath)) {
throw new Exception("Failed to create CSV file: {$filePath}");
}
Log::info('CSV export completed successfully', [
'file_path' => $filePath,
'file_size' => Storage::disk($this->disk)->size($filePath)
]);
return $filePath;
}
}

View File

@@ -239,16 +239,6 @@ class GenerateMultiAccountPdfJob implements ShouldQueue
// Ensure directory exists
Storage::disk('local')->makeDirectory($storagePath);
// Tambahkan permission dan ownership setelah membuat directory
$fullPath = storage_path("app/{$storagePath}");
if (is_dir($fullPath)) {
chmod($fullPath, 0777);
// Tambahkan pengecekan function dan error handling untuk chown
if (function_exists('chown') && posix_getuid() === 0) {
@chown($fullPath, 'www-data'); // Gunakan www-data instead of root
@chgrp($fullPath, 'www-data');
}
}
// Generate PDF path
$pdfPath = storage_path("app/{$fullStoragePath}");

View File

@@ -144,6 +144,12 @@
private function processRow(array $row, int $rowCount, string $filePath)
: void
{
// Exclude the last field from CSV
if (count($row) > 0) {
array_pop($row);
Log::info("Excluded last field from row $rowCount. New column count: " . count($row));
}
$csvHeaders = array_keys(self::FIELD_MAP);
if (count($csvHeaders) !== count($row)) {

View File

@@ -185,6 +185,12 @@
private function processRow(array $row, int $rowCount, string $filePath)
: void
{
// Exclude the last field from CSV
if (count($row) > 0) {
array_pop($row);
Log::info("Excluded last field from row $rowCount. New column count: " . count($row));
}
if (count(self::CSV_HEADERS) !== count($row)) {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " .
count(self::CSV_HEADERS) . ", Got: " . count($row));

View File

@@ -0,0 +1,399 @@
<?php
namespace Modules\Webstatement\Jobs;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Models\StmtEntryDetail;
use Illuminate\Support\Facades\DB;
class ProcessStmtEntryDetailDataJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private const CSV_DELIMITER = '~';
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
private const FILENAME = 'ST.STMT.ENTRY.DETAIL.csv';
private const DISK_NAME = 'sftpStatement';
private const CHUNK_SIZE = 1000; // Process data in chunks to reduce memory usage
private string $period = '';
private int $processedCount = 0;
private int $errorCount = 0;
private array $entryBatch = [];
/**
* Create a new job instance.
*
* @param string $period Periode data yang akan diproses
*/
public function __construct(string $period = '')
{
$this->period = $period;
Log::info('ProcessStmtEntryDetailDataJob initialized', ['period' => $period]);
}
/**
* Execute the job.
*
* @return void
* @throws Exception
*/
public function handle(): void
{
try {
Log::info('Memulai ProcessStmtEntryDetailDataJob', ['period' => $this->period]);
$this->initializeJob();
if ($this->period === '') {
Log::warning('No period provided for statement entry detail data processing');
return;
}
$this->processPeriod();
$this->logJobCompletion();
Log::info('ProcessStmtEntryDetailDataJob selesai berhasil');
} catch (Exception $e) {
Log::error('Error in ProcessStmtEntryDetailDataJob: ' . $e->getMessage());
throw $e;
}
}
/**
* Inisialisasi job dengan pengaturan awal
*
* @return void
*/
private function initializeJob(): void
{
set_time_limit(self::MAX_EXECUTION_TIME);
$this->processedCount = 0;
$this->errorCount = 0;
$this->entryBatch = [];
Log::info('Job initialized', [
'max_execution_time' => self::MAX_EXECUTION_TIME,
'chunk_size' => self::CHUNK_SIZE
]);
}
/**
* Proses data untuk periode tertentu
*
* @return void
*/
private function processPeriod(): void
{
$disk = Storage::disk(self::DISK_NAME);
$filename = "{$this->period}." . self::FILENAME;
$filePath = "{$this->period}/$filename";
Log::info('Memulai proses periode', ['file_path' => $filePath]);
if (!$this->validateFile($disk, $filePath)) {
return;
}
$tempFilePath = $this->createTemporaryFile($disk, $filePath, $filename);
$this->processFile($tempFilePath, $filePath);
$this->cleanup($tempFilePath);
Log::info('Proses periode selesai', ['file_path' => $filePath]);
}
/**
* Validasi keberadaan file
*
* @param mixed $disk Storage disk instance
* @param string $filePath Path file yang akan divalidasi
* @return bool
*/
private function validateFile($disk, string $filePath): bool
{
Log::info("Processing statement entry detail file: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("File not found: $filePath");
return false;
}
Log::info("File validated successfully: $filePath");
return true;
}
/**
* Buat file temporary untuk proses
*
* @param mixed $disk Storage disk instance
* @param string $filePath Path file sumber
* @param string $filename Nama file
* @return string Path file temporary
*/
private function createTemporaryFile($disk, string $filePath, string $filename): string
{
$tempFilePath = storage_path("app/temp_$filename");
file_put_contents($tempFilePath, $disk->get($filePath));
Log::info('Temporary file created', ['temp_path' => $tempFilePath]);
return $tempFilePath;
}
/**
* Proses file CSV
*
* @param string $tempFilePath Path file temporary
* @param string $filePath Path file asli
* @return void
*/
private function processFile(string $tempFilePath, string $filePath): void
{
$handle = fopen($tempFilePath, "r");
if ($handle === false) {
Log::error("Unable to open file: $filePath");
return;
}
$headers = (new StmtEntryDetail())->getFillable();
// Tambahkan field 'id' ke headers untuk menangani kolom tambahan di akhir CSV
$expectedHeaders = array_merge($headers, ['id']);
$rowCount = 0;
$chunkCount = 0;
Log::info('Memulai proses file', [
'file_path' => $filePath,
'headers_count' => count($headers),
'expected_headers_count' => count($expectedHeaders)
]);
while (($row = fgetcsv($handle, 0, self::CSV_DELIMITER)) !== false) {
$rowCount++;
$this->processRow($row, $expectedHeaders, $rowCount, $filePath);
// Process in chunks to avoid memory issues
if (count($this->entryBatch) >= self::CHUNK_SIZE) {
$this->saveBatch();
$chunkCount++;
Log::info("Processed chunk $chunkCount ({$this->processedCount} records so far)");
}
}
// Process any remaining records
if (!empty($this->entryBatch)) {
$this->saveBatch();
}
fclose($handle);
Log::info("Completed processing $filePath. Processed {$this->processedCount} records with {$this->errorCount} errors.");
}
/**
* Proses setiap baris data dengan penanganan field id tambahan
*
* @param array $row Data baris
* @param array $expectedHeaders Header kolom yang diharapkan (termasuk id)
* @param int $rowCount Nomor baris
* @param string $filePath Path file
* @return void
*/
private function processRow(array $row, array $expectedHeaders, int $rowCount, string $filePath): void
{
// Validasi jumlah kolom - sekarang menggunakan expectedHeaders yang sudah include field 'id'
if (count($expectedHeaders) !== count($row)) {
Log::warning("Row $rowCount in $filePath has incorrect column count. Expected: " .
count($expectedHeaders) . ", Got: " . count($row));
$this->errorCount++;
return;
}
// Kombinasikan data dengan headers
$data = array_combine($expectedHeaders, $row);
// Log untuk debugging struktur data
Log::debug('Processing row data', [
'row_count' => $rowCount,
'stmt_entry_id' => $data['stmt_entry_id'] ?? 'not_set',
'id' => $data['id'] ?? 'not_set'
]);
// Logika untuk menggunakan field 'id' sebagai fallback jika stmt_entry_id kosong
$this->handleStmtEntryIdFallback($data);
// Hapus field 'id' dari data sebelum disimpan karena tidak ada di fillable model
unset($data['id']);
$this->cleanTransReference($data);
$this->addToBatch($data, $rowCount, $filePath);
}
/**
* Menangani logika fallback untuk stmt_entry_id menggunakan field id
*
* @param array $data Data yang akan diproses
* @return void
*/
private function handleStmtEntryIdFallback(array &$data): void
{
// Jika stmt_entry_id kosong atau null, gunakan value dari field 'id'
if (empty($data['stmt_entry_id']) || $data['stmt_entry_id'] === '' || $data['stmt_entry_id'] === null) {
if (isset($data['id']) && !empty($data['id'])) {
$data['stmt_entry_id'] = $data['id'];
Log::info('Using id as stmt_entry_id fallback', [
'original_stmt_entry_id' => $data['stmt_entry_id'] ?? 'empty',
'fallback_id' => $data['id']
]);
} else {
Log::warning('Both stmt_entry_id and id are empty', [
'stmt_entry_id' => $data['stmt_entry_id'] ?? 'not_set',
'id' => $data['id'] ?? 'not_set'
]);
}
}
}
/**
* Tambahkan record ke batch untuk proses bulk insert
*
* @param array $data Data record
* @param int $rowCount Nomor baris
* @param string $filePath Path file
* @return void
*/
private function addToBatch(array $data, int $rowCount, string $filePath): void
{
try {
// Validasi bahwa stmt_entry_id tidak kosong dan bukan header
if (isset($data['stmt_entry_id']) &&
$data['stmt_entry_id'] !== 'stmt_entry_id' &&
!empty($data['stmt_entry_id'])) {
// Add timestamp fields
$now = now();
$data['created_at'] = $now;
$data['updated_at'] = $now;
// Add to entry batch
$this->entryBatch[] = $data;
$this->processedCount++;
Log::debug('Record added to batch', [
'row' => $rowCount,
'stmt_entry_id' => $data['stmt_entry_id']
]);
} else {
Log::warning('Skipping row due to invalid stmt_entry_id', [
'row' => $rowCount,
'stmt_entry_id' => $data['stmt_entry_id'] ?? 'not_set'
]);
$this->errorCount++;
}
} catch (Exception $e) {
$this->errorCount++;
Log::error("Error processing Statement Entry Detail at row $rowCount in $filePath: " . $e->getMessage());
}
}
/**
* Bersihkan trans_reference dari karakter tidak diinginkan
*
* @param array $data Data yang akan dibersihkan
* @return void
*/
private function cleanTransReference(array &$data): void
{
if (isset($data['trans_reference'])) {
// Clean trans_reference from \\BNK if present
$data['trans_reference'] = preg_replace('/\\\\.*$/', '', $data['trans_reference']);
Log::debug('Trans reference cleaned', ['original' => $data['trans_reference']]);
}
}
/**
* Simpan batch data ke database menggunakan updateOrCreate
* untuk menghindari error unique constraint
*
* @return void
* @throws Exception
*/
private function saveBatch(): void
{
Log::info('Memulai proses saveBatch dengan updateOrCreate');
DB::beginTransaction();
try {
if (!empty($this->entryBatch)) {
$totalProcessed = 0;
// Process each entry data directly
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
StmtEntryDetail::updateOrCreate(
[
'stmt_entry_id' => $entryData['stmt_entry_id']
],
$entryData
);
$totalProcessed++;
} else {
Log::warning('Invalid entry data structure', ['data' => $entryData]);
$this->errorCount++;
}
}
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;
}
}
/**
* Bersihkan file temporary
*
* @param string $tempFilePath Path file temporary
* @return void
*/
private function cleanup(string $tempFilePath): void
{
if (file_exists($tempFilePath)) {
unlink($tempFilePath);
Log::info('Temporary file cleaned up', ['temp_path' => $tempFilePath]);
}
}
/**
* Log penyelesaian job
*
* @return void
*/
private function logJobCompletion(): void
{
Log::info("Statement Entry Detail data processing completed. " .
"Total processed: {$this->processedCount}, Total errors: {$this->errorCount}");
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Modules\Webstatement\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\Usermanagement\Models\User;
/**
* Model untuk menyimpan log permintaan laporan closing balance
* Menyimpan informasi status, file path, dan tracking user
*/
class ClosingBalanceReportLog extends Model
{
/**
* The attributes that are mass assignable.
*/
protected $fillable = [
'account_number',
'period',
'report_date',
'status',
'authorization_status',
'file_path',
'file_size',
'record_count',
'error_message',
'is_downloaded',
'downloaded_at',
'user_id',
'created_by',
'updated_by',
'authorized_by',
'authorized_at',
'ip_address',
'user_agent',
'remarks',
];
/**
* The attributes that should be cast.
*/
protected $casts = [
'report_date' => 'date',
'downloaded_at' => 'datetime',
'authorized_at' => 'datetime',
'is_downloaded' => 'boolean',
'file_size' => 'integer',
'record_count' => 'integer',
];
/**
* Get the user who created this report request.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* Get the user who created this report request.
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* Get the user who authorized this report request.
*/
public function authorizer(): BelongsTo
{
return $this->belongsTo(User::class, 'authorized_by');
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace Modules\Webstatement\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class StmtEntryDetail extends Model
{
use HasFactory;
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'stmt_entry_detail';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'stmt_entry_id',
'account_number',
'company_code',
'amount_lcy',
'transaction_code',
'narrative',
'product_category',
'value_date',
'amount_fcy',
'exchange_rate',
'trans_reference',
'booking_date',
'stmt_no',
'date_time',
'currency',
'crf_type',
'consol_key',
];
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* Relasi ke model Account
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function account()
{
return $this->belongsTo(Account::class, 'account_number', 'account_number');
}
/**
* Relasi ke model TempFundsTransfer
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function ft()
{
return $this->belongsTo(TempFundsTransfer::class, 'trans_reference', 'ref_no');
}
/**
* Relasi ke model TempTransaction
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function transaction()
{
return $this->belongsTo(TempTransaction::class, 'transaction_code', 'transaction_code');
}
/**
* Relasi ke model Teller
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function tt()
{
return $this->belongsTo(Teller::class, 'trans_reference', 'id_teller');
}
/**
* Relasi ke model DataCapture
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function dc()
{
return $this->belongsTo(DataCapture::class, 'trans_reference', 'id');
}
/**
* Relasi ke model TempArrangement
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function aa()
{
return $this->belongsTo(TempArrangement::class, 'trans_reference', 'arrangement_id');
}
}

View File

@@ -6,20 +6,24 @@ use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
use Nwidart\Modules\Traits\PathNamespace;
use Illuminate\Console\Scheduling\Schedule;
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\AutoSendStatementEmailCommand;
use Modules\Webstatement\Console\GenerateBiayakartuCommand;
use Modules\Webstatement\Console\SendStatementEmailCommand;
use Modules\Webstatement\Console\{
UnlockPdf,
CombinePdf,
ConvertHtmlToPdf,
ExportDailyStatements,
ProcessDailyMigration,
ExportPeriodStatements,
UpdateAllAtmCardsCommand,
CheckEmailProgressCommand,
GenerateBiayakartuCommand,
SendStatementEmailCommand,
GenerateAtmTransactionReport,
GenerateBiayaKartuCsvCommand,
AutoSendStatementEmailCommand,
GenerateClosingBalanceReportCommand,
GenerateClosingBalanceReportBulkCommand,
};
use Modules\Webstatement\Jobs\UpdateAtmCardBranchCurrencyJob;
use Modules\Webstatement\Console\GenerateAtmTransactionReport;
use Modules\Webstatement\Console\GenerateBiayaKartuCsvCommand;
class WebstatementServiceProvider extends ServiceProvider
{
@@ -74,7 +78,9 @@ class WebstatementServiceProvider extends ServiceProvider
SendStatementEmailCommand::class,
CheckEmailProgressCommand::class,
UpdateAllAtmCardsCommand::class,
AutoSendStatementEmailCommand::class
AutoSendStatementEmailCommand::class,
GenerateClosingBalanceReportCommand::class,
GenerateClosingBalanceReportBulkCommand::class,
]);
}

View File

@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('closing_balance_report_logs', function (Blueprint $table) {
$table->id();
$table->string('account_number', 50);
$table->string('period', 8); // Format: YYYYMMDD
$table->date('report_date');
$table->enum('status', ['pending', 'processing', 'completed', 'failed'])->default('pending');
$table->enum('authorization_status', ['pending', 'approved', 'rejected'])->nullable();
$table->string('file_path')->nullable();
$table->bigInteger('file_size')->nullable();
$table->integer('record_count')->nullable();
$table->text('error_message')->nullable();
$table->boolean('is_downloaded')->default(false);
$table->timestamp('downloaded_at')->nullable();
$table->unsignedBigInteger('user_id');
$table->unsignedBigInteger('created_by');
$table->unsignedBigInteger('updated_by')->nullable();
$table->unsignedBigInteger('authorized_by')->nullable();
$table->timestamp('authorized_at')->nullable();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->text('remarks')->nullable();
$table->timestamps();
// Indexes
$table->index(['account_number', 'period']);
$table->index('status');
$table->index('authorization_status');
$table->index('created_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('closing_balance_report_logs');
}
};

View File

@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateStmtEntryDetailTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('stmt_entry_detail', function (Blueprint $table) {
$table->id();
$table->string('stmt_entry_id')->nullable();
$table->string('account_number')->nullable();
$table->string('company_code')->nullable();
$table->string('amount_lcy')->nullable();
$table->string('transaction_code')->nullable();
$table->string('narrative')->nullable();
$table->string('product_category')->nullable();
$table->string('value_date')->nullable();
$table->string('amount_fcy')->nullable();
$table->string('exchange_rate')->nullable();
$table->string('trans_reference')->nullable();
$table->string('booking_date')->nullable();
$table->string('stmt_no')->nullable();
$table->string('date_time')->nullable();
$table->string('currency')->nullable();
$table->string('crf_type')->nullable();
$table->string('consol_key')->nullable();
$table->timestamps();
// Index untuk performa query
$table->index('stmt_entry_id');
$table->index('account_number');
$table->index('trans_reference');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('stmt_entry_detail');
}
}

View File

@@ -77,6 +77,16 @@
"roles": [
"administrator"
]
},{
"title": "Laporan Closing Balance",
"path": "laporan-closing-balance",
"icon": "ki-filled ki-printer text-lg text-primary",
"classes": "",
"attributes": [],
"permission": "",
"roles": [
"administrator"
]
}
],
"master": [

View File

@@ -0,0 +1,303 @@
@extends('layouts.main')
@section('breadcrumbs')
{{ Breadcrumbs::render('laporan-closing-balance.index') }}
@endsection
@section('content')
<div class="grid">
<div class="min-w-full card card-grid" data-datatable="false" data-datatable-page-size="10"
data-datatable-state-save="false" id="laporan-closing-balance-table"
data-api-url="{{ route('laporan-closing-balance.datatables') }}">
<div class="flex-wrap py-5 card-header">
<h3 class="card-title">
Laporan Closing Balance
</h3>
<div class="flex flex-wrap gap-2 lg:gap-5">
<!-- Filter Form -->
<div class="flex flex-wrap gap-2.5 items-end">
<!-- Nomor Rekening Filter -->
<div class="flex flex-col">
<input type="text" id="account-number-filter" class="input w-[200px]"
placeholder="Masukkan nomor rekening">
</div>
<!-- Tanggal Mulai Filter -->
<div class="flex flex-col">
<input type="date" id="start-date-filter" class="input w-[150px]">
</div>
<!-- Tanggal Akhir Filter -->
<div class="flex flex-col">
<input type="date" id="end-date-filter" class="input w-[150px]">
</div>
<!-- Tombol Filter -->
<button type="button" id="apply-filter" class="btn btn-primary">
<i class="ki-filled ki-magnifier"></i>
Filter
</button>
<!-- Tombol Reset -->
<button type="button" id="reset-filter" class="btn btn-light">
<i class="ki-filled ki-arrows-circle"></i>
Reset
</button>
</div>
</div>
</div>
<div class="card-body">
<div class="scrollable-x-auto">
<table class="table text-sm font-medium text-gray-700 align-middle table-auto table-border"
data-datatable-table="true">
<thead>
<tr>
<th class="w-14">
<input class="checkbox checkbox-sm" data-datatable-check="true" type="checkbox" />
</th>
<th class="min-w-[200px]" data-datatable-column="account_number">
<span class="sort">
<span class="sort-label">Nomor Rekening</span>
<span class="sort-icon"></span>
</span>
</th>
<th class="min-w-[150px]" data-datatable-column="period">
<span class="sort">
<span class="sort-label">Periode</span>
<span class="sort-icon"></span>
</span>
</th>
<th class="min-w-[150px]" data-datatable-column="updated_at">
<span class="sort">
<span class="sort-label">Tanggal Update</span>
<span class="sort-icon"></span>
</span>
</th>
<th class="min-w-[100px] text-center" data-datatable-column="actions">Action</th>
</tr>
</thead>
</table>
</div>
<div
class="flex-col gap-3 justify-center font-medium text-gray-600 card-footer md:justify-between md:flex-row text-2sm">
<div class="flex gap-2 items-center">
Show
<select class="w-16 select select-sm" data-datatable-size="true" name="perpage"></select> per page
</div>
<div class="flex gap-4 items-center">
<span data-datatable-info="true"></span>
<div class="pagination" data-datatable-pagination="true">
</div>
</div>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script type="text/javascript">
/**
* Fungsi untuk memformat angka menjadi format mata uang
* @param {number} amount - Jumlah yang akan diformat
* @returns {string} - String yang sudah diformat
*/
function formatCurrency(amount) {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 2
}).format(amount);
}
/**
* Fungsi untuk memformat periode dari format YYYYMMDD ke format yang lebih readable
* @param {string} period - Periode dalam format YYYYMMDD
* @returns {string} - Periode yang sudah diformat
*/
function formatPeriod(period) {
if (!period || period.length !== 8) return period;
const year = period.substring(0, 4);
const month = period.substring(4, 6);
const day = period.substring(6, 8);
return `${day}/${month}/${year}`;
}
/**
* Fungsi untuk memformat tanggal
* @param {string} dateString - String tanggal
* @returns {string} - Tanggal yang sudah diformat
*/
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
</script>
<script type="module">
const element = document.querySelector('#laporan-closing-balance-table');
const accountNumberFilter = document.getElementById('account-number-filter');
const startDateFilter = document.getElementById('start-date-filter');
const endDateFilter = document.getElementById('end-date-filter');
const applyFilterBtn = document.getElementById('apply-filter');
const resetFilterBtn = document.getElementById('reset-filter');
const exportBtn = document.getElementById('export-btn');
const apiUrl = element.getAttribute('data-api-url');
// Set default date range (last 30 days) SEBELUM inisialisasi DataTable
const today = new Date();
const thirtyDaysAgo = new Date(today.getTime() - (30 * 24 * 60 * 60 * 1000));
endDateFilter.value = today.toISOString().split('T')[0];
startDateFilter.value = thirtyDaysAgo.toISOString().split('T')[0];
// Prepare initial filters
let initialFilters = {
start_date: startDateFilter.value,
end_date: endDateFilter.value
};
// Konfigurasi DataTable dengan filter awal
const dataTableOptions = {
apiEndpoint: apiUrl,
pageSize: 10,
searchParams: initialFilters, // Set filter awal di sini
columns: {
select: {
render: (item, data, context) => {
const checkbox = document.createElement('input');
checkbox.className = 'checkbox checkbox-sm';
checkbox.type = 'checkbox';
checkbox.value = data.id ? data.id.toString() : '';
checkbox.setAttribute('data-datatable-row-check', 'true');
return checkbox.outerHTML.trim();
},
},
account_number: {
title: 'Nomor Rekening',
render: (item, data) => {
return `<span class="font-medium">${data.account_number || '-'}</span>`;
}
},
period: {
title: 'Periode',
render: (item, data) => {
return `<span class="text-gray-700">${formatPeriod(data.period)}</span>`;
}
},
updated_at: {
title: 'Tanggal Update',
render: (item, data) => {
return `<span class="text-sm text-gray-600">${formatDate(data.created_at)}</span>`;
}
},
actions: {
title: 'Action',
render: (item, data) => {
const downloadUrl =
`{{ route('laporan-closing-balance.download', ['accountNumber' => '__ACCOUNT__', 'period' => '__PERIOD__']) }}`
.replace('__ACCOUNT__', data.account_number)
.replace('__PERIOD__', data.period);
return `<div class="flex flex-nowrap justify-center">
<a class="btn btn-sm btn-icon btn-clear btn-success"
href="${downloadUrl}"
title="Download Laporan"
download>
<i class="ki-outline ki-file-down"></i>
</a>
</div>`;
},
}
},
};
// Inisialisasi DataTable dengan filter awal sudah terset
let dataTable = new KTDataTable(element, dataTableOptions);
// Update export URL dengan filter awal
updateExportUrl(initialFilters);
/**
* Fungsi untuk menerapkan filter
*/
function applyFilters() {
let filters = {};
if (accountNumberFilter.value.trim()) {
filters.account_number = accountNumberFilter.value.trim();
}
if (startDateFilter.value) {
filters.start_date = startDateFilter.value;
}
if (endDateFilter.value) {
filters.end_date = endDateFilter.value;
}
console.log('Applying filters:', filters);
dataTable.search(filters);
// Update export URL dengan filter
updateExportUrl(filters);
}
/**
* Fungsi untuk mereset filter
*/
function resetFilters() {
accountNumberFilter.value = '';
startDateFilter.value = '';
endDateFilter.value = '';
dataTable.search({});
updateExportUrl({});
}
/**
* Fungsi untuk update URL export dengan parameter filter
*/
function updateExportUrl(filters) {
const baseUrl = '{{ route('laporan-closing-balance.export') }}';
const params = new URLSearchParams();
Object.keys(filters).forEach(key => {
if (filters[key]) {
params.append(key, filters[key]);
}
});
const newUrl = params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
exportBtn.href = newUrl;
}
// Event listeners
applyFilterBtn.addEventListener('click', applyFilters);
resetFilterBtn.addEventListener('click', resetFilters);
// Auto apply filter saat enter di input
[accountNumberFilter, startDateFilter, endDateFilter].forEach(input => {
input.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
applyFilters();
}
});
});
// HAPUS bagian ini yang menyebabkan double API call:
// setTimeout(() => {
// applyFilters();
// }, 100);
</script>
@endpush

View File

@@ -0,0 +1,291 @@
@extends('layouts.main')
@section('breadcrumbs')
{{ Breadcrumbs::render('laporan-closing-balance.show', $closingBalance) }}
@endsection
@section('content')
<div class="grid">
<!-- Header Card -->
<div class="card mb-5">
<div class="card-header">
<h3 class="card-title">
Detail Laporan Closing Balance
</h3>
<div class="flex items-center gap-2">
<a href="{{ route('laporan-closing-balance.index') }}" class="btn btn-light">
<i class="ki-filled ki-left"></i>
Kembali
</a>
</div>
</div>
</div>
<!-- Detail Information Card -->
<div class="card">
<div class="card-header">
<h3 class="card-title">
Informasi Rekening
</h3>
</div>
<div class="card-body">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Informasi Dasar -->
<div class="space-y-4">
<div class="flex flex-col">
<label class="text-sm font-medium text-gray-600 mb-1">Nomor Rekening</label>
<div class="text-lg font-semibold text-gray-900">
{{ $closingBalance->account_number }}
</div>
</div>
<div class="flex flex-col">
<label class="text-sm font-medium text-gray-600 mb-1">Periode</label>
<div class="text-lg font-semibold text-gray-900">
@php
$period = $closingBalance->period;
if (strlen($period) === 8) {
$formatted = substr($period, 6, 2) . '/' . substr($period, 4, 2) . '/' . substr($period, 0, 4);
echo $formatted;
} else {
echo $period;
}
@endphp
</div>
</div>
<div class="flex flex-col">
<label class="text-sm font-medium text-gray-600 mb-1">Tanggal Update Terakhir</label>
<div class="text-lg font-semibold text-gray-900">
{{ $closingBalance->updated_at ? $closingBalance->updated_at->format('d/m/Y H:i:s') : '-' }}
</div>
</div>
</div>
<!-- Informasi Saldo -->
<div class="space-y-4">
<div class="bg-blue-50 p-4 rounded-lg border border-blue-200">
<label class="text-sm font-medium text-blue-700 mb-2 block">Saldo Cleared</label>
<div class="text-2xl font-bold text-blue-800">
@php
$clearedBalance = $closingBalance->cleared_balance ?? 0;
echo 'Rp ' . number_format($clearedBalance, 2, ',', '.');
@endphp
</div>
</div>
<div class="bg-green-50 p-4 rounded-lg border border-green-200">
<label class="text-sm font-medium text-green-700 mb-2 block">Saldo Aktual</label>
<div class="text-2xl font-bold text-green-800">
@php
$actualBalance = $closingBalance->actual_balance ?? 0;
echo 'Rp ' . number_format($actualBalance, 2, ',', '.');
@endphp
</div>
</div>
<div class="bg-gray-50 p-4 rounded-lg border border-gray-200">
<label class="text-sm font-medium text-gray-700 mb-2 block">Selisih Saldo</label>
<div class="text-2xl font-bold {{ ($actualBalance - $clearedBalance) >= 0 ? 'text-green-800' : 'text-red-800' }}">
@php
$difference = $actualBalance - $clearedBalance;
$sign = $difference >= 0 ? '+' : '';
echo $sign . 'Rp ' . number_format($difference, 2, ',', '.');
@endphp
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Additional Information Card -->
@if($closingBalance->created_at || $closingBalance->updated_at)
<div class="card mt-5">
<div class="card-header">
<h3 class="card-title">
Informasi Sistem
</h3>
</div>
<div class="card-body">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
@if($closingBalance->created_at)
<div class="flex flex-col">
<label class="text-sm font-medium text-gray-600 mb-1">Tanggal Dibuat</label>
<div class="text-base text-gray-900">
{{ $closingBalance->created_at->format('d/m/Y H:i:s') }}
</div>
<div class="text-sm text-gray-500">
{{ $closingBalance->created_at->diffForHumans() }}
</div>
</div>
@endif
@if($closingBalance->updated_at)
<div class="flex flex-col">
<label class="text-sm font-medium text-gray-600 mb-1">Tanggal Diperbarui</label>
<div class="text-base text-gray-900">
{{ $closingBalance->updated_at->format('d/m/Y H:i:s') }}
</div>
<div class="text-sm text-gray-500">
{{ $closingBalance->updated_at->diffForHumans() }}
</div>
</div>
@endif
</div>
</div>
</div>
@endif
<!-- Action Buttons -->
<div class="card mt-5">
<div class="card-body">
<div class="flex flex-wrap gap-3 justify-center lg:justify-start">
<a href="{{ route('laporan-closing-balance.index') }}" class="btn btn-light">
<i class="ki-filled ki-left"></i>
Kembali ke Daftar
</a>
<a href="{{ route('laporan-closing-balance.export', ['account_number' => $closingBalance->account_number, 'start_date' => $closingBalance->period, 'end_date' => $closingBalance->period]) }}"
class="btn btn-primary">
<i class="ki-filled ki-file-down"></i>
Export Data Ini
</a>
<button type="button" class="btn btn-info" onclick="window.print()">
<i class="ki-filled ki-printer"></i>
Print
</button>
</div>
</div>
</div>
</div>
@endsection
@push('styles')
<style>
/* Print styles */
@media print {
.btn, .card-header .flex, nav, .breadcrumb {
display: none !important;
}
.card {
box-shadow: none !important;
border: 1px solid #ddd !important;
}
.card-body {
padding: 1rem !important;
}
body {
font-size: 12px !important;
}
.text-2xl {
font-size: 1.5rem !important;
}
.text-lg {
font-size: 1.125rem !important;
}
}
</style>
@endpush
@push('scripts')
<script type="text/javascript">
/**
* Fungsi untuk memformat angka menjadi format mata uang Indonesia
* @param {number} amount - Jumlah yang akan diformat
* @returns {string} - String yang sudah diformat
*/
function formatCurrency(amount) {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 2
}).format(amount);
}
/**
* Fungsi untuk copy nomor rekening ke clipboard
*/
function copyAccountNumber() {
const accountNumber = '{{ $closingBalance->account_number }}';
if (navigator.clipboard) {
navigator.clipboard.writeText(accountNumber).then(function() {
// Show success message
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: 'Nomor rekening berhasil disalin ke clipboard',
timer: 2000,
showConfirmButton: false
});
} else {
alert('Nomor rekening berhasil disalin ke clipboard');
}
}).catch(function(err) {
console.error('Error copying to clipboard: ', err);
fallbackCopyTextToClipboard(accountNumber);
});
} else {
fallbackCopyTextToClipboard(accountNumber);
}
}
/**
* Fallback function untuk copy text jika clipboard API tidak tersedia
*/
function fallbackCopyTextToClipboard(text) {
const textArea = document.createElement('textarea');
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = '0';
textArea.style.left = '0';
textArea.style.position = 'fixed';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: 'Nomor rekening berhasil disalin ke clipboard',
timer: 2000,
showConfirmButton: false
});
} else {
alert('Nomor rekening berhasil disalin ke clipboard');
}
} else {
console.error('Fallback: Copying text command was unsuccessful');
}
} catch (err) {
console.error('Fallback: Oops, unable to copy', err);
}
document.body.removeChild(textArea);
}
// Add click event to account number for easy copying
document.addEventListener('DOMContentLoaded', function() {
const accountNumberElements = document.querySelectorAll('.account-number-clickable');
accountNumberElements.forEach(element => {
element.style.cursor = 'pointer';
element.title = 'Klik untuk menyalin nomor rekening';
element.addEventListener('click', copyAccountNumber);
});
});
</script>
@endpush

View File

@@ -130,6 +130,11 @@
padding: 5px;
text-align: left;
font-size: 10px;
word-wrap: break-word;
word-break: break-word;
white-space: normal;
overflow-wrap: break-word;
hyphens: auto;
}
table th {
@@ -278,7 +283,7 @@
$totalDebit = 0;
$totalKredit = 0;
$line = 1;
$linePerPage = 26;
$linePerPage = 23;
@endphp
@php
// Hitung tanggal periode berdasarkan $period

View File

@@ -125,3 +125,18 @@
$trail->parent('home');
$trail->push('Statement Email Logs', route('email-statement-logs.index'));
});
// Home > Laporan Closing Balance
Breadcrumbs::for('laporan-closing-balance.index', function (BreadcrumbTrail $trail) {
$trail->parent('home');
$trail->push('Laporan Closing Balance', route('laporan-closing-balance.index'));
});
// Home > Laporan Closing Balance > Detail
Breadcrumbs::for('laporan-closing-balance.show', function (BreadcrumbTrail $trail, $closingBalance) {
$trail->parent('laporan-closing-balance.index');
$trail->push('Detail - ' . $closingBalance->account_number, route('laporan-closing-balance.show', [
'accountNumber' => $closingBalance->account_number,
'period' => $closingBalance->period
]));
});

View File

@@ -1,18 +1,20 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Webstatement\Http\Controllers\PeriodeStatementController;
use Modules\Webstatement\Http\Controllers\PrintStatementController;
use Modules\Webstatement\Http\Controllers\SyncLogsController;
use Modules\Webstatement\Http\Controllers\JenisKartuController;
use Modules\Webstatement\Http\Controllers\KartuAtmController;
use Modules\Webstatement\Http\Controllers\MigrasiController;
use Modules\Webstatement\Http\Controllers\CustomerController;
use Modules\Webstatement\Http\Controllers\EmailBlastController;
use Modules\Webstatement\Http\Controllers\WebstatementController;
use Modules\Webstatement\Http\Controllers\DebugStatementController;
use Modules\Webstatement\Http\Controllers\EmailStatementLogController;
use Modules\Webstatement\Http\Controllers\AtmTransactionReportController;
use Modules\Webstatement\Http\Controllers\{
PeriodeStatementController,
PrintStatementController,
SyncLogsController,
JenisKartuController,
KartuAtmController,
CustomerController,
EmailBlastController,
WebstatementController,
DebugStatementController,
EmailStatementLogController,
AtmTransactionReportController,
LaporanClosingBalanceController
};
@@ -91,7 +93,7 @@ Route::middleware(['auth'])->group(function () {
});
Route::resource('statements', PrintStatementController::class);
// ATM Transaction Report Routes
Route::group(['prefix' => 'atm-reports', 'as' => 'atm-reports.', 'middleware' => ['auth']], function () {
@@ -110,6 +112,15 @@ Route::middleware(['auth'])->group(function () {
Route::post('/{id}/resend-email', [EmailStatementLogController::class, 'resendEmail'])->name('resend-email');
});
Route::resource('email-statement-logs', EmailStatementLogController::class)->only(['index', 'show']);
// Laporan Closing Balance Routes
Route::group(['prefix' => 'laporan-closing-balance', 'as' => 'laporan-closing-balance.', 'middleware' => ['auth']], function () {
Route::get('/datatables', [LaporanClosingBalanceController::class, 'dataForDatatables'])->name('datatables');
Route::get('/export', [LaporanClosingBalanceController::class, 'export'])->name('export');
Route::get('/{accountNumber}/{period}/download', [LaporanClosingBalanceController::class, 'download'])->name('download');
Route::get('/{accountNumber}/{period}', [LaporanClosingBalanceController::class, 'show'])->name('show');
});
Route::resource('laporan-closing-balance', LaporanClosingBalanceController::class)->only(['index']);
});
Route::get('/stmt-export-csv', [WebstatementController::class, 'index'])->name('webstatement.index');