Compare commits

34 Commits

Author SHA1 Message Date
Daeng Deni Mardaeni
36abab1280 fix(webstatement): perbaiki nama field dari no_receipt menjadi recipt_no
Perubahan yang dilakukan:
- Mengubah nama field dari no_receipt menjadi recipt_no pada model ProcessedStatement.
- Memperbaiki nama kolom pada migrasi dari no_receipt menjadi recipt_no.
- Menyesuaikan nama index dari no_receipt ke recipt_no untuk konsistensi.
- Memperbarui method down() pada migrasi agar menghapus kolom dan index dengan nama yang benar.

File yang dimodifikasi:
- app/Models/ProcessedStatement.php: Mengubah no_receipt menjadi recipt_no pada fillable fields.
- database/migrations/2025_07_14_022029_add_no_receipt_to_processed_statements_table.php:
  - Mengubah nama kolom dan index dari no_receipt ke recipt_no.
  - Memperbaiki rollback agar konsisten dengan perubahan.

Tujuan perubahan:
- Menjaga konsistensi penamaan field dengan struktur data yang sudah ada.
- Menyesuaikan penamaan dengan field recipt_no dari relasi ft untuk mencegah potensi error mapping.
2025-07-14 10:06:00 +07:00
Daeng Deni Mardaeni
7818d1677b feat(webstatement): tambah field no_receipt pada processed_statements
Perubahan yang dilakukan:
- Menambahkan field no_receipt pada tabel processed_statements melalui migrasi baru.
- Menambahkan no_receipt ke dalam fillable array di model ProcessedStatement.
- Mengintegrasikan field recipt_no dari relasi ft ke dalam data yang disimpan di processed_statements.
- Menambahkan kolom NO.RECEIPT pada header CSV export statement.
- Menyertakan data recipt_no ke dalam output CSV export statement.
- Mengomentari kode yang sebelumnya menambahkan receipt number ke narrative description untuk menghindari duplikasi.
- Menggunakan nilai default '-' jika recipt_no tidak tersedia.

File yang dimodifikasi:
- app/Jobs/ExportStatementJob.php: Menambahkan mapping recipt_no ke no_receipt, update header CSV, dan menyertakan data dalam export.
- app/Models/ProcessedStatement.php: Menambahkan no_receipt ke fillable fields.
- Modules/Webstatement/database/migrations/2025_07_14_022029_add_no_receipt_to_processed_statements_table.php: Menambahkan kolom no_receipt di database.

Tujuan perubahan:
- Memisahkan nomor receipt transaksi dari narrative description untuk kemudahan pelaporan dan audit.
- Memberikan fleksibilitas lebih dalam pengolahan data statement, khususnya untuk kebutuhan export dan compliance.
2025-07-14 09:24:38 +07:00
Daeng Deni Mardaeni
92afe58e66 feat(webstatement): tambah konfigurasi permission direktori dan perbaikan error chown
Perubahan yang dilakukan:
- Menambahkan set permission 0777, chown, dan chgrp pada direktori temp dan storage di berbagai job dan controller.
- Diterapkan di PrintStatementController, ExportStatementJob, ExportStatementPeriodJob, dan GenerateMultiAccountPdfJob.
- Semua direktori yang dibuat memiliki permission 777 untuk akses penuh dan ownership user www-data.
- Menambahkan pengecekan function_exists('chown') dan posix_getuid() === 0 sebelum menjalankan chown/chgrp.
- Menggunakan @ operator untuk suppress error jika operasi chown gagal.
- Menambahkan fallback mechanism agar aplikasi tetap berjalan meskipun tidak memiliki privilege root.
- Mengubah target ownership dari root ke www-data untuk menghindari error "Operation not permitted".
- Menambahkan pengecekan keberadaan direktori sebelum mengatur permission dan ownership.
- Menambahkan error handling yang konsisten dan robust untuk semua operasi file system terkait direktori.
- Memastikan perubahan bekerja untuk local disk maupun storage disk lainnya.

Tujuan perubahan:
- Menjamin direktori yang digunakan oleh sistem memiliki akses dan kepemilikan yang tepat di environment production.
- Mencegah error `chown(): Operation not permitted` saat aplikasi berjalan tanpa akses root.
- Meningkatkan stabilitas proses PDF generation dan file storage, serta kompatibilitas dengan environment server yang terbatas.
2025-07-12 13:41:46 +07:00
Daeng Deni Mardaeni
c264d63fa6 fix(webstatement): perbaiki validasi password untuk stmt_sent_type
Perubahan yang dilakukan:
- Mengubah input validasi dari `request_type` menjadi `stmt_sent_type` pada `PrintStatementRequest`.
- Memperbarui pesan error sesuai dengan perubahan nama input menjadi lebih relevan dan jelas.

Tujuan perubahan:
- Memastikan validasi dan pesan error sesuai dengan nama field yang digunakan.
- Menghindari potensi kekeliruan pengguna karena perbedaan nama field yang tidak sinkron.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-07-11 09:51:32 +07:00
Daeng Deni Mardaeni
6bd8b77d87 feat(webstatement): optimasi struktur penyimpanan PDF statement
Perubahan yang dilakukan:
- Mengubah logika penyimpanan file ke dalam struktur direktori berdasarkan branch_code.
- Memperbarui metode downloadFromStorage untuk menyesuaikan perubahan struktur direktori.
- Menyederhanakan nama file PDF dengan menghapus prefix "statement".
- Menambahkan relasi branch dalam proses account_number untuk mengambil branch_code.
- Menghilangkan potensi inkonsistensi struktur penyimpanan yang sebelumnya bergantung pada account_number.

Tujuan perubahan:
- Meningkatkan keteraturan struktur direktori file untuk mempermudah pengelolaan storage.
- Memastikan file statement tersimpan secara konsisten berdasarkan cabang dan periode.
- Menyederhanakan penamaan file agar lebih bersih tanpa mengorbankan informasi penting.
2025-07-11 09:08:40 +07:00
Daeng Deni Mardaeni
efabba4c39 feat(webstatement): tambah opsi Browsershot untuk stabilitas PDF generation
Perubahan yang dilakukan:
- Menambahkan setOption('headless', true) untuk menjalankan Chrome dalam mode headless.
- Menambahkan opsi noSandbox() untuk menghindari masalah permission di environment server.
- Menggunakan waitUntilNetworkIdle() agar memastikan seluruh resource dimuat sebelum proses render.
- Menerapkan konfigurasi ini pada ExportStatementPeriodJob, GenerateMultiAccountPdfJob, dan PrintStatementController.
- Meningkatkan stabilitas dan performa proses PDF generation di environment production.
- Mengurangi potensi error dan timeout saat melakukan rendering PDF.
- Memastikan semua asset dan resource termuat sempurna sebelum PDF dihasilkan.

Tujuan perubahan:
- Menjamin proses PDF generation berjalan lebih andal di berbagai environment server.
- Mengurangi risiko kegagalan akibat resource belum termuat saat rendering.
- Menyelaraskan konfigurasi Browsershot dengan best practice untuk environment production.
2025-07-11 08:28:04 +07:00
Daeng Deni Mardaeni
2b39c5190b feat(webstatement): tambah validasi password wajib untuk request_type tertentu
Perubahan yang dilakukan:
- Menambahkan validasi pada PrintStatementRequest agar password wajib diisi jika field request_type tidak kosong.
- Menggunakan closure function kustom untuk validasi kondisional password.
- Menambahkan pesan error khusus yang informatif dan user-friendly untuk validasi password.
- Memperbarui validasi request_type agar mendukung tipe multi_account.
- Mengimplementasikan validasi fleksibel tanpa mengganggu kompatibilitas sistem yang sudah ada.
- Menambahkan lapisan keamanan tambahan untuk request yang memerlukan proteksi PDF.

Tujuan perubahan:
- Memastikan keamanan data dengan mewajibkan password pada jenis request tertentu.
- Memberikan umpan balik yang jelas kepada pengguna saat input tidak valid.
- Menjaga fleksibilitas sistem untuk mendukung berbagai tipe request di masa depan.
2025-07-10 20:10:39 +07:00
Daeng Deni Mardaeni
9c5f8b1de4 feat(webstatement): tambah proteksi password untuk ZIP file multi-account
Perubahan yang dilakukan:
- Memodifikasi fungsi createZipFile() di GenerateMultiAccountPdfJob untuk menambahkan proteksi password.
- Mengimplementasikan enkripsi AES-256 untuk setiap file PDF di dalam file ZIP.
- Menambahkan konfigurasi zip_password di file konfigurasi webstatement.
- Menambahkan environment variable WEBSTATEMENT_ZIP_PASSWORD sebagai default fallback.
- Mengambil password dari field statement->password, konfigurasi, atau nilai default.
- Menambahkan logging untuk mencatat aktivitas proteksi file ZIP.
- Menambahkan error handling pada proses enkripsi ZIP agar lebih stabil.
- Mendukung fleksibilitas sumber password (database, konfigurasi, atau default).
- Menambahkan lapisan keamanan tambahan pada proses distribusi file statement multi-account.
- Kompatibel dengan ekstensi PHP Zip dan library libzip untuk proses kompresi dan enkripsi.

Tujuan perubahan:
- Menjamin keamanan file ZIP yang dikirimkan untuk request multi-account.
- Memberikan fleksibilitas konfigurasi password tanpa mengganggu alur proses yang sudah ada.
- Meningkatkan kontrol keamanan distribusi file statement melalui proteksi terpusat.
2025-07-10 20:05:50 +07:00
Daeng Deni Mardaeni
5469045b5a feat(webstatement): tambah AutoSendStatementEmailCommand dan job auto pengiriman email statement
Perubahan yang dilakukan:
- Menambahkan command AutoSendStatementEmailCommand untuk otomatisasi pengiriman email statement.
- Menambahkan job AutoSendStatementEmailJob untuk menangani proses pengiriman email secara asynchronous.
- Menambahkan opsi --force dan --dry-run pada command untuk fleksibilitas eksekusi dan pengujian.
- Mengintegrasikan command baru ke dalam WebstatementServiceProvider dan Console Kernel.
- Mengimplementasikan scheduler untuk menjalankan job setiap menit secara otomatis.
- Menambahkan kondisi auto send: is_available dan is_generated = true, email_sent_at = null.
- Mendukung pengiriman statement multi-period dalam bentuk ZIP attachment.
- Mengoptimalkan proses download dan integrasi file PDF dengan logging yang lebih detail.
- Menambahkan logika prioritas local disk dibandingkan SFTP untuk pengambilan file secara efisien.
- Menambahkan validasi tambahan untuk flow pengiriman email single dan multi period.
- Mengimplementasikan error handling dan logging yang komprehensif.
- Menggunakan database transaction untuk menjamin konsistensi data selama proses kirim email.
- Menambahkan mekanisme prevent overlap dan timeout protection saat job berjalan.
- Menghapus file sementara secara otomatis setelah email berhasil dikirim.
- Membatasi proses pengiriman maksimal 50 statement per run untuk menjaga performa.

Tujuan perubahan:
- Mengotomatiskan pengiriman email statement pelanggan secara periodik dan aman.
- Menyediakan fleksibilitas eksekusi manual dan simulasi pengujian sebelum produksi.
- Menjamin efisiensi, stabilitas, dan monitoring penuh selama proses pengiriman.
2025-07-10 19:49:31 +07:00
Daeng Deni Mardaeni
56665cd77a feat(webstatement): tambahkan dukungan download zip untuk multi_account
Perubahan yang dilakukan:
- Menambahkan pengecekan tipe request multi_account pada PrintStatementController.
- Menambahkan logika unduhan file zip melalui metode downloadMultiAccountZip().
- Memastikan alur unduhan file zip tidak mengganggu proses unduhan statement untuk tipe lainnya.

Tujuan perubahan:
- Mendukung fitur baru untuk mengunduh file zip pada permintaan multi_account.
- Menjaga kompatibilitas dengan alur unduhan statement yang sudah ada.
2025-07-10 19:32:06 +07:00
Daeng Deni Mardaeni
011f749786 feat(webstatement): tambahkan hubungan branch dan account di model
Perubahan yang dilakukan:
- Menambahkan relasi branch di model Account berdasarkan kolom branch_code.
- Menambahkan relasi account di model PrintStatementLog untuk akses data account dari log.
- Memperbaiki referensi branch_name di PrintStatementController agar menggunakan relasi dari model Account.
- Menonaktifkan eager loading pada query di PrintStatementController untuk optimasi performa.

Tujuan perubahan:
- Memastikan data branch dan account dapat diakses langsung melalui relasi antar model.
- Menghindari potensi masalah N+1 query saat mengambil data terkait branch.
- Meningkatkan efisiensi kode dan menjaga konsistensi data dalam proses statement.
2025-07-10 19:30:58 +07:00
Daeng Deni Mardaeni
5b235def37 feat(webstatement): tambah field password untuk proteksi PDF statement
Perubahan yang dilakukan:
- Menambahkan kolom password (nullable) pada tabel print_statement_logs melalui migrasi baru.
- Menambahkan field password di model PrintStatementLog dengan atribut hidden untuk keamanan serialisasi.
- Menambahkan input password pada form request print statement.
- Menambahkan validasi sisi klien agar password minimal 6 karakter.
- Menambahkan konfirmasi melalui SweetAlert untuk pengisian password dan email tujuan.
- Menambahkan index pada kolom password untuk optimasi pencarian jika dibutuhkan.
- Menggunakan field password untuk proteksi file PDF melalui PDFPasswordProtect.
- Menambahkan helper text dan placeholder pada form untuk meningkatkan pengalaman pengguna.
- Menambahkan atribut autocomplete="new-password" untuk menghindari autofill browser yang tidak aman.
- Menjaga kompatibilitas ke belakang dengan membuat field bersifat opsional (nullable).

Tujuan perubahan:
- Memberikan opsi proteksi file PDF dengan password yang diatur oleh pengguna.
- Meningkatkan keamanan distribusi file statement melalui email.
- Memastikan pengalaman pengguna tetap aman dan nyaman saat mengatur proteksi.
2025-07-10 14:33:26 +07:00
Daeng Deni Mardaeni
593a4f0d9c feat(webstatement): tambah enkripsi password pada PDF statement
Perubahan yang dilakukan:
- Menambahkan PDFPasswordProtect::encrypt di dalam ExportStatementPeriodJob.
- Mengikuti pola implementasi yang telah digunakan pada CombinePdfJob.
- PDF statement kini otomatis diproteksi menggunakan password.
- Password diambil dari konfigurasi: webstatement.pdf_password.
- Menambahkan logging untuk memantau proses proteksi PDF.
- Menjamin pengelolaan file sementara berjalan aman dan rapi.
- Menjaga kompatibilitas ke belakang (backward compatible) dengan sistem PDF yang sudah ada.

Tujuan perubahan:
- Meningkatkan keamanan file PDF dengan proteksi password standar perusahaan.
- Memastikan proses enkripsi berjalan otomatis tanpa mengubah alur penggunaan yang ada.
- Memberikan visibilitas terhadap proses proteksi melalui log sistem.
2025-07-10 14:13:16 +07:00
Daeng Deni Mardaeni
d4e6a3d73d feat(webstatement): ekstrak generatePassword ke helper
Perubahan yang dilakukan:
- Memindahkan fungsi `generatePassword` dari `CombinePdfController` ke `helpers.php` untuk peningkatan reusabilitas.
- Menambahkan dependency `use Carbon\Carbon` dan `use Modules\Webstatement\Models\Account` di `helpers.php`.
- Menyesuaikan pemanggilan fungsi `generatePassword` di `CombinePdfController` dengan versi helper.

Tujuan perubahan:
- Mengurangi duplikasi kode dengan menjadikan fungsi `generatePassword` dapat diakses secara global.
- Mempermudah perawatan kode melalui pemisahan tanggung jawab fungsi.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-07-10 14:12:10 +07:00
Daeng Deni Mardaeni
0aa7d22094 feat(webstatement): tambah fungsi generatePdf ke ExportStatementPeriodJob
Perubahan yang dilakukan:
- Menambahkan fungsi generatePdf() untuk proses generate PDF dalam job ExportStatementPeriodJob.
- Mengintegrasikan logika PDF generation dari PrintStatementController ke dalam job.
- Menggunakan data ProcessedStatement yang telah diproses sebagai sumber untuk pembuatan PDF.
- Menambahkan import statement untuk Browsershot, Account, Customer, dan Branch.
- Mengimplementasikan fungsi prepareHeaderTableBackground() untuk mengonversi gambar header menjadi base64.
- Menggunakan database transaction untuk menjaga konsistensi saat generate dan menyimpan PDF.
- Menyimpan PDF ke storage dengan struktur direktori yang terorganisir berdasarkan parameter tertentu.
- Memperbarui PrintStatementLog dengan status akhir dan path file PDF yang dihasilkan.
- Menambahkan error handling dan logging secara menyeluruh untuk memantau proses.
- Menghapus file sementara (temporary) setelah PDF berhasil disimpan ke storage.
- Menambahkan dukungan timeout dan konfigurasi Browsershot yang optimal.
- Melakukan validasi terhadap data account, customer, dan branch sebelum proses generate PDF dilakukan.

Tujuan perubahan:
- Memindahkan logika generate PDF ke dalam background job agar lebih efisien dan terstruktur.
- Menjamin integritas data dan hasil PDF yang valid melalui proses terstandarisasi.
- Mengurangi beban proses di controller serta mendukung proses batch secara asynchronous.
2025-07-10 13:15:47 +07:00
Daeng Deni Mardaeni
5ea8136c13 feat(webstatement): tambah fungsi nama provinsi dan perbaikan tampilan alamat customers
Perubahan yang dilakukan:
- Menambahkan fungsi getProvinceCoreName di helpers.php untuk mengambil nama provinsi berdasarkan kode, menggunakan model ProvinceCore.
- Menyesuaikan tampilan alamat customer di template stmt.blade.php:
  - Menambahkan RT/RW dari alamat rumah atau KTP jika tersedia.
  - Menggunakan nama provinsi dari fungsi baru agar data lebih konsisten.
  - Merapikan format alamat dengan menggunakan fungsi trim.
- Memperbaiki struktur HTML pada bagian alamat untuk meningkatkan keterbacaan dan perawatan kode.

Tujuan perubahan:
- Menjamin data provinsi yang ditampilkan berasal dari referensi yang valid dan terpusat.
- Meningkatkan kelengkapan dan kejelasan informasi alamat pada tampilan statement pelanggan.
- Menstandarkan format alamat agar seragam dengan kebijakan internal perusahaan.
2025-07-10 10:28:52 +07:00
Daeng Deni Mardaeni
4b7e6c983b feat(webstatement): tambah ProcessProvinceDataJob untuk import data provinsi
Perubahan yang dilakukan:
- Membuat job baru ProcessProvinceDataJob dengan referensi dari ProcessSectorDataJob.
- Menggunakan model ProvinceCore untuk menyimpan data provinsi.
- Mendukung format file ST.PROVINCE.csv dengan delimiter khusus tilde (~).
- Menambahkan validasi untuk kolom: id, date_time, province, dan province_name.
- Mengabaikan baris header pada file saat proses import.
- Menggunakan database transaction untuk menjaga konsistensi data.
- Menambahkan counter untuk memantau jumlah record yang dilewati (skipped).
- Mengimplementasikan error handling dan logging yang detail.
- Menggunakan updateOrCreate untuk mencegah duplikasi data.
- Menambahkan method failed() untuk menangani kasus job failure.
- Melakukan mapping field province ke code dan province_name ke name.
- Melakukan validasi data wajib sebelum menyimpan ke database.

Tujuan perubahan:
- Memfasilitasi proses import data provinsi dari file eksternal secara otomatis dan aman.
- Menjamin data yang masuk telah tervalidasi dan bebas duplikasi.
- Menyediakan log dan feedback yang cukup saat terjadi kegagalan.
2025-07-10 10:03:27 +07:00
Daeng Deni Mardaeni
8d84c0a1ba feat(webstatement): tambah migration dan model ProvinceCore
Perubahan yang dilakukan:
- Membuat migration untuk tabel province_core dengan field code dan name.
- Menambahkan model ProvinceCore dengan beberapa scope dan method helper.
- Mengimplementasikan logging untuk semua operasi database yang berkaitan.
- Menambahkan dukungan transaction rollback untuk menjaga integritas data.
- Membuat seeder untuk data provinsi seluruh Indonesia.
- Menambahkan validasi dan method utility untuk keperluan dropdown.
- Menggunakan PostgreSQL ILIKE untuk pencarian yang bersifat case-insensitive.
- Menambahkan index pada kolom tertentu untuk optimasi performa query.
- Mengimplementasikan event model untuk memantau operasi CRUD.
- Menyesuaikan struktur file agar sesuai dengan arsitektur Laravel modules.

Tujuan perubahan:
- Menyediakan data master provinsi yang dapat digunakan secara global.
- Memastikan efisiensi dan keamanan data pada proses insert/update.
- Mendukung pengembangan fitur yang membutuhkan referensi data provinsi.
2025-07-10 09:56:47 +07:00
Daeng Deni Mardaeni
1f140af94a fix(migration): perbaiki error PostgreSQL enum untuk request_type
Perubahan yang dilakukan:
- Memperbaiki typo nama tabel dari print_stetement_logs menjadi print_statement_logs.
- Menggunakan raw SQL untuk menambahkan nilai enum baru ke tipe data request_type.
- Menambahkan constraint check sebagai alternatif validasi agar kompatibel dengan PostgreSQL.
- Menambahkan rollback transaction untuk menjaga integritas data saat migrasi gagal.
- Menambahkan logging untuk memantau proses migrasi enum.
- Memperbaiki syntax error pada perintah ALTER TYPE di PostgreSQL.
- Menambahkan kondisi IF NOT EXISTS untuk menghindari error duplikat nilai enum.
- Mengimplementasikan strategi rollback yang aman untuk migrasi enum PostgreSQL.

Tujuan perubahan:
- Memastikan proses migrasi enum berjalan lancar di PostgreSQL tanpa error duplikasi atau syntax.
- Menjamin keamanan data selama proses migrasi berjalan.
- Menyediakan log yang jelas untuk debugging bila terjadi kesalahan.
2025-07-10 09:19:38 +07:00
Daeng Deni Mardaeni
c1a173c8f7 feat(webstatement): tambah optimasi pemrosesan multi_account dan validasi statement
Perubahan yang dilakukan:
- Memodifikasi PrintStatementController untuk mendukung request_type baru: multi_account.
- Menambahkan validasi stmt_sent_type dan branch_code khusus pada request multi_account.
- Menambahkan pengecekan branch_id: ID0019999 dengan penanganan error yang lebih spesifik.
- Menambahkan metode processMultiAccountStatement untuk pemrosesan berdasarkan branch_code dan stmt_sent_type.

Optimasi PDF:
- Melakukan refaktor pada GenerateMultiAccountPdfJob agar mendukung kalkulasi tanggal dinamis (startDate dan endDate).
- Mengimplementasikan Browsershot untuk opsi tambahan background dan optimasi waktu proses.
- Menambahkan validasi status dan update log pada PrintStatementLog setelah PDF berhasil dibuat.
- Menambahkan penanganan penggunaan memori secara granular untuk proses batch PDF dan pembersihan resource otomatis.

Logging dan Validasi:
- Menambahkan logging pada proses kalkulasi tanggal multi_account.
- Logging tambahan dan rollback untuk error yang terjadi saat proses statement atau PDF.
- Mengubah penggunaan Auth:: untuk konsistensi role checking.
- Mengubah validasi stmt_sent_type dari JSON menjadi array dengan implode().

UI dan Output:
- Memodifikasi blade template agar mendukung tampilan stmt_sent_type untuk kasus multi_account.
- Menambahkan logika kolom dinamis berdasarkan account_number atau stmt_sent_type.

Refaktor umum:
- Memisahkan logika antara single dan multi account di PrintStatementController.
- Perbaikan minor pada query SQL untuk entri ProcessedStatement.

Tujuan perubahan:
- Mendukung pemrosesan batch statement multi account secara lebih efisien dan terstruktur.
- Menjamin validasi dan logging yang lebih kuat.
- Meningkatkan performa pembuatan PDF dan kontrol terhadap penggunaan resource.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-07-10 09:12:16 +07:00
Daeng Deni Mardaeni
974bf1cc35 feat(webstatement): tambah helper untuk menghitung tanggal periode dan perbaikan validasi permintaan
Perubahan yang dilakukan:
- Menambahkan fungsi `calculatePeriodDates` di `helpers.php` untuk menghitung tanggal awal dan akhir berdasarkan periode.
- Mendukung periode khusus seperti `202505`, dengan aturan tanggal mulai dari tanggal 9 hingga akhir bulan.
- Menambahkan logging di `calculatePeriodDates` untuk memudahkan debugging saat proses penentuan tanggal.
- Memperbaiki validasi di `PrintStatementRequest` dengan menyesuaikan tipe data dan aturan logika.
- Merapikan aturan validasi pada `account_number` agar lebih konsisten dan mudah dibaca.
- Menambahkan `helpers.php` ke properti `files` di `module.json` agar dapat diakses secara global.

Tujuan perubahan:
- Mendukung logika tanggal periode khusus dengan aturan tertentu.
- Menyelaraskan validasi permintaan dengan standar internal yang lebih baik dan lebih stabil.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-07-10 09:08:20 +07:00
Daeng Deni Mardaeni
0ace1d5c70 feat(migration): tambah multi_account ke enum request_type
- Menambahkan nilai 'multi_account' ke enum request_type pada tabel print_statement_logs
- Mendukung pemrosesan statement untuk multiple account sekaligus
- Enum request_type sekarang mencakup: single_account, branch, all_branches, multi_account
- Memungkinkan sistem untuk menangani berbagai jenis request statement
- Meningkatkan fleksibilitas dalam pemrosesan batch statement
2025-07-10 09:00:47 +07:00
Daeng Deni Mardaeni
595ab89390 feat(database): tambah field alamat dan referensi lokal ke tabel customers
Menambahkan migration untuk field tambahan pada tabel customers:
- Menambahkan field home_rt dan home_rw untuk RT/RW alamat rumah
- Menambahkan field ktp_rt dan ktp_rw untuk RT/RW alamat KTP
- Menambahkan field local_ref dengan tipe TEXT untuk referensi lokal panjang
- Semua field dibuat nullable untuk fleksibilitas data
- Menambahkan index untuk kombinasi RT/RW untuk performa query
- Menambahkan comment pada setiap field untuk dokumentasi
- Menyediakan method down() lengkap untuk rollback migration
- Menggunakan tipe data yang sesuai untuk setiap field
2025-07-10 09:00:31 +07:00
Daeng Deni Mardaeni
34571483eb feat(webstatement): optimalkan proses PDF statement dan hapus fitur tidak terpakai
- **Optimalisasi Pembuatan dan Pengunduhan PDF:**
  - Tambahkan validasi untuk mengecek keberadaan file sebelum pembuatan ulang PDF.
  - Tambahkan mekanisme pengunduhan langsung file PDF jika sudah tersedia di storage.
  - Mengelompokkan path penyimpanan dan nama file PDF secara dinamis berdasarkan periode dan nomor rekening.
  - Integrasi update status `is_available` pada tabel `PrintStatementLog` setelah file berhasil dibuat.

- **Refaktor dan Perbaikan Logika Pembuatan PDF:**
  - Perbarui fungsi `generateStatementPdf` untuk mendukung parameter tambahan terkait penyimpanan file.
  - Hapus duplikasi logika terkait pembuatan path storage dan file PDF.
  - Tambahkan handling error untuk memastikan file PDF berhasil dibuat.

- **Hapus Fitur Preview PDF:**
  - Menghapus fungsi `previewPdf()` dan rute terkait karena sudah tidak digunakan.
  - Membersihkan bagian UI yang merujuk pada fitur ini.

- **Peningkatan UI dan Tampilan:**
  - Menghapus kolom `authorization_status` pada tabel karena tidak relevan.
  - Penyesuaian styling pada kolom `is_available` untuk menampilkan status ketersediaan file.

- **Perubahan pada Rute:**
  - Membersihkan rute tidak terpakai terkait preview dan generate PDF langsung.

- **Refaktor Umum:**
  - Menghapus kode redundan yang berhubungan dengan penyimpanan file sementara.
  - Penyesuaian struktur fungsi untuk meningkatkan keterbacaan dan efisiensi.

Perubahan ini memastikan proses PDF statement lebih efisien dengan validasi ketat, pengelolaan file yang lebih baik, serta penyederhanaan sistem dengan menghapus fitur yang tidak relevan.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-07-09 20:23:52 +07:00
Daeng Deni Mardaeni
062bac2138 feat(webstatement): optimasi tampilan tabel statement dan penyesuaian mekanisme pagination
- **Penyesuaian Tampilan pada Blade Template:**
  - Menambahkan pengaturan padding khusus pada baris tambahan `narrative-line` untuk meningkatkan keterbacaan.
  - Memperbaiki properti kolom, termasuk `min-width` dan `max-width` pada kolom keterangan untuk mencegah overflow.
  - Mengubah alignment kolom referensi agar lebih konsisten dengan tampilan keseluruhan.

- **Optimasi Pagination:**
  - Menambahkan variabel `$linePerPage` untuk menentukan jumlah baris per halaman yang lebih fleksibel.
  - Mengganti hardcoded nilai 18 menjadi referensi ke `$linePerPage` di seluruh proses logika pagination.
  - Mengurangi maksimal panjang karakter baris narasi dari 35 menjadi 30 untuk meningkatkan tata letak.

- **Peningkatan Kode Pemrosesan Narasi:**
  - Memastikan bahwa teks narasi tambahan dirapikan dengan penghapusan karakter bracket `[ ]` menggunakan fungsi `str_replace`.
  - Menambahkan class CSS `narrative-line` pada baris tambahan narasi untuk format spesifik.

- **Refaktor Logika Pemrosesan:**
  - Optimasi perhitungan total halaman dengan menangani baris per halaman ($linePerPage).
  - Memperbaiki mekanisme pengecekan dan iterasi baris kosong pada akhir tabel untuk menyesuaikan jumlah baris tetap.

- **Perbaikan Minor:**
  - Menggunakan substring 10 karakter alih-alih `date()` untuk menampilkan tanggal untuk optimalisasi kinerja.
  - Menambahkan margin dan padding default pada semua baris untuk konsistensi format antar data.

Perubahan ini memperbaiki tata letak dan fleksibilitas tabel statement, baik untuk narasi panjang maupun pagination, memastikan tampilannya tetap user-friendly tanpa memengaruhi struktur data backend.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-07-09 19:54:42 +07:00
Daeng Deni Mardaeni
8ee0dd2218 feat(webstatement): implementasi pemrosesan multi account statement berdasarkan stmt_sent_type
- Modifikasi method printStatementRekening untuk mendukung request_type multi_account
- Tambah method processMultiAccountStatement untuk mengambil data account berdasarkan branch_code dan stmt_sent_type
- Tambah method processSingleAccountStatement untuk memisahkan logika single account
- Implementasi GenerateMultiAccountPdfJob untuk generate PDF multiple account secara parallel
- Tambah fungsi generateAccountPdf untuk generate PDF per account dengan Browsershot
- Tambah fungsi createZipFile untuk mengompres multiple PDF menjadi satu ZIP file
- Tambah method downloadMultiAccountZip untuk download ZIP file hasil pemrosesan
- Implementasi validasi stmt_sent_type dengan support JSON array format
- Tambah logging komprehensif untuk monitoring proses multi account
- Tambah error handling dengan database transaction rollback
- Update PrintStatementLog dengan informasi target_accounts dan status pemrosesan
- Tambah rute baru untuk download ZIP file multi account
- Support untuk pemrosesan chunk account untuk optimasi memory usage
- Implementasi status tracking untuk success_count dan failed_count
- Tambah validasi keberadaan account berdasarkan kriteria yang ditentukan
2025-07-09 17:51:57 +07:00
Daeng Deni Mardaeni
51697f017e feat(webstatement): tambahkan fitur pembuatan, penyimpanan, dan pengunduhan PDF statement
- **Fitur Baru:**
  - Menambahkan kemampuan untuk membuat PDF statement secara dinamis menggunakan Browsershot.
  - Fitur penyimpanan otomatis PDF ke dalam local storage dengan struktur direktori berdasarkan periode dan account number.
  - Menyediakan fitur unduhan langsung dari storage atau melalui preview di browser.
  - Mendukung penghapusan PDF dari storage dengan log terintegrasi.

- **Perubahan pada Controller:**
  - Ditambah method baru `generated` untuk membangun PDF atau tampilan HTML statement.
  - Integrasi penghitungan periode saldo (`calculateSaldoPeriod`) untuk menghasilkan data laporan yang lebih akurat.
  - Perubahan pada `printStatementRekening` untuk mendukung pengiriman objek statement secara penuh.
  - Menambahkan method tambahan untuk preview, download, dan delete PDF langsung dari storage.

- **Validasi Permintaan:**
  - Menambah validasi wajib pada `branch_code` untuk memastikan data cabang sesuai.
  - Menyesuaikan logika validasi di `PrintStatementRequest` untuk mendukung input cabang dan parameter lain.

- **Cakupan Logging:**
  - Meningkatkan logging pada setiap proses penting:
    - Mulai dari validasi data, proses pembuatan PDF, hingga penyimpanan.
    - Deteksi error secara spesifik pada setiap tahapan proses.
  - Menambah log debugging untuk nama file, ukuran file, dan path penyimpanan.

- **Peningkatan pada UI:**
  - Penyesuaian field `branch_code` pada form UI untuk menggantikan `branch_id` dengan validasi error yang lebih eksplisit.
  - Membuat halaman baru untuk tampilan preview PDF di interface.

- **Optimisasi dan Refaktor:**
  - Grouping import library untuk meningkatkan keterbacaan.
  - Manajemen direktori penyimpanan PDF dipastikan berjalan dinamis dan fleksibel.
  - Penghilangan redundansi logika pada proses pencarian saldo awal bulan.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-07-09 14:54:48 +07:00
Daeng Deni Mardaeni
e2c9f3480d feat(webstatement): optimalkan validasi, logging, dan UI pada request statement
- **Peningkatan Validasi:**
  - Menambahkan validasi kompleks pada field `account_number` untuk memastikan input wajib jika `stmt_sent_type` tidak diisi.
  - Validasi baru untuk `stmt_sent_type` mendukung array nilai dengan parameter `in` yang diperbolehkan.
  - Menambahkan pengecekan duplikasi pada `PrintStatementRequest` dengan filter tambahan `user_id` untuk scope yang lebih jelas.

- **Peningkatan Logging:**
  - Mengganti penggunaan `\Log` dengan `Log` untuk konsistensi namespace.
  - Menambahkan logging user pada query statement log di controller.
  - Logging lebih terperinci pada proses ekspor dan error handling.

- **Perubahan UI pada Form `statements/index`:**
  - Menambahkan highlight warna merah pada field input yang memiliki error validasi (`border-danger`, `bg-danger-light`).
  - Memperbaiki tampilan dropdown untuk `branch_id` dan `stmt_sent_type`, termasuk pesan error yang lebih spesifik.
  - Menghapus validasi wajib pada field `stmt_sent_type` dan menambah fleksibilitas form pengisian.

- **Optimalisasi Query Backend:**
  - Menambah filter `whereNotNull('user_id')` pada query `PrintStatementLog` untuk meminimalisir data invalid.

- **Updated Blade Template:**
  - Tombol dan validasi form lebih ramah pengguna dengan feedback langsung.
  - Menambahkan badge status styling untuk kolom status otorisasi di datatable.
  - Dinamika field seperti dropdown bebas error dalam kondisi tertentu.

Perubahan ini meningkatkan keakuratan validasi, logging proses lebih rinci untuk debugging, dan memberikan pengalaman pengguna yang lebih baik pada interface request statement.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-07-09 10:50:22 +07:00
Daeng Deni Mardaeni
40f552cb66 Merge branch 'new'
# Conflicts:
#	app/Http/Controllers/PrintStatementController.php
#	app/Jobs/GenerateBiayaKartuCsvJob.php
#	app/Jobs/SendStatementEmailJob.php
#	app/Mail/StatementEmail.php
#	resources/views/statements/email.blade.php
#	resources/views/statements/index.blade.php
2025-07-08 21:46:55 +07:00
Daeng Deni Mardaeni
65b846f0c7 feat(webstatement): tambahkan pengaturan ekspor dan optimasi fungsionalitas print statement
- **Pembaruan pada `ExportStatementPeriodJob`:**
  - Menambahkan atribut baru `toCsv` untuk mendukung validasi sebelum proses ekspor CSV.
  - Menyesuaikan method `__construct` untuk menerima parameter tambahan `toCsv`.
  - Menambahkan validasi ekspor CSV dengan conditional check pada `toCsv` sebelum menjalankan `exportToCsv`.
  - Memperbaiki logika di `getTotalEntryCount` menggunakan `booking_date` untuk query lebih akurat.
  - Menambahkan logging terperinci pada proses penghitungan jumlah entri untuk meningkatkan debugging.

- **Integrasi Log Print Statement:**
  - Mengupdate status kolom `is_generated` pada model `PrintStatementLog` setelah entri diproses.
  - Menambahkan mekanisme pembaruan data log print statement melalui validasi entry statement.

- **Peningkatan pada Controller `PrintStatementController`:**
  - Memampukan proses ekspor otomatis jika statement tidak tersedia dengan metode baru `printStatementRekening`.
  - Menambahkan parameter `stmt_sent_type` untuk log print pada proses pencatatan data.
  - Mengimplementasikan pemrosesan period statement melalui job `ExportStatementPeriodJob`.

- **Perubahan pada UI/Blade `statements/index`:**
  - Menambahkan opsi pemilihan multiple untuk tipe laporan `stmt_sent_type`.
  - Mengupdate dan merapikan komponen form untuk input branch, akun, email, dan periode laporan.
  - Menambahkan kolom baru `is_generated` pada tabel untuk menampilkan status log hasil pembuatan laporan.

- **Pembaruan pada Datatable dan Skrip Frontend:**
  - Menambahkan render visual dengan badge untuk status `is_generated`.
  - Memperbaiki dan mengoptimalkan element HTML untuk datatable termasuk pagination dan search.
  - Menambahkan konfirmasi aksi dengan Ajax untuk retry pembuatan laporan jika diperlukan.

- **Optimisasi dan Refactor:**
  - Menggunakan group import pada controller untuk meningkatkan keterbacaan.
  - Memperbaiki alignment dan indentasi pada beberapa file blade.
  - Menghapus kode yang tidak digunakan atau redundan seperti conditional unprocessed data.

Dengan perubahan ini, sistem print statement lebih fleksibel, mencatat log lebih baik, dan mendukung fitur tracking pengeluaran laporan.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-07-08 17:40:11 +07:00
Daeng Deni Mardaeni
a3060322f9 feat(webstatement): tambahkan kolom baru dan migrasi untuk log print statement
- **Perubahan Model:**
  - Menambahkan atribut `stmt_sent_type` dan `is_generated` pada model `PrintStatementLog`.
  - Menyesuaikan properti `$fillable` untuk mendukung atribut baru tersebut.
  - Menambahkan properti `is_generated` ke dalam `$casts` untuk tipe data boolean.

- **Migrasi Database:**
  - Membuat file migrasi baru `2025_07_08_090357_add_stmt_sent_type_to_print_statement_logs_table`.
    - Menambahkan kolom `stmt_sent_type` (string, nullable) setelah kolom `status`.
    - Menambahkan kolom `is_generated` (boolean, nullable, default false) setelah kolom `is_available`.
  - Menambahkan rollback pada fungsi `down()` untuk menghapus kolom `stmt_sent_type` dan `is_generated`.

- **Tujuan Perubahan:**
  - Mendukung pencatatan tipe pengiriman statement melalui kolom `stmt_sent_type`.
  - Menambah fleksibilitas dalam pelacakan status pembuatan laporan dengan atribut `is_generated`.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-07-08 17:39:23 +07:00
Daeng Deni Mardaeni
428792ed1b feat(webstatement): ubah filter product_code 6021 berdasarkan ctdesc
Mengubah logika filter untuk product_code 6021 agar hanya mengecualikan yang memiliki ctdesc 'gold',
sementara product_code 6021 dengan ctdesc lainnya tetap diproses.

Perubahan yang dilakukan:
- Menghapus '6021' dari array whereNotIn product_code yang mengecualikan semua
- Menambahkan filter kondisional kompleks menggunakan nested where clause
- Implementasi logika: (product_code != '6021') OR (product_code = '6021' AND ctdesc != 'gold')
- Menambahkan logging detail untuk tracking filter khusus yang diterapkan
- Memperbarui dokumentasi function untuk menjelaskan logika bisnis baru
- Menambahkan informasi filter khusus dalam log hasil pengambilan data

Dengan perubahan ini:
- Product_code 6021 dengan ctdesc 'gold' akan dikecualikan dari biaya admin
- Product_code 6021 dengan ctdesc selain 'gold' tetap dikenakan biaya admin
- Product_code lainnya (6002, 6004, 6042, 6031) tetap dikecualikan sepenuhnya
- Meningkatkan fleksibilitas dalam pengelolaan biaya berdasarkan jenis kartu
2025-07-08 14:12:51 +07:00
Daeng Deni Mardaeni
0cbb7c9a3c feat(webstatement): tambahkan opsi abaikan validasi sertifikat SSL pada PHPMailer
- **Penyesuaian Konfigurasi PHPMailer**:
  - Menambahkan logika baru untuk mengabaikan validasi sertifikat SSL agar mendukung lingkungan yang menggunakan sertifikat self-signed atau tidak valid.
  - Menambahkan pengecekan properti konfigurasi `ignore_certificate_errors`.
  - Konfigurasi tambahan meliputi:
    - `verify_peer` diatur ke `false`.
    - `verify_peer_name` diatur ke `false`.
    - `allow_self_signed` diatur ke `true`.

- **Peningkatan Debugging**:
  - Mengaktifkan mode debug jika aplikasi dalam mode debug (`config('app.debug')`).

- **Tujuan Perubahan**:
  - Memfasilitasi pengelolaan email di lingkungan development atau pengujian.
  - Mendukung sertifikat SSL non-standar tanpa mengganggu fungsionalitas pengiriman email lainnya.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-11 13:52:55 +07:00
Daeng Deni Mardaeni
fabc35e729 feat(webstatement): tingkatkan proses pengiriman email dengan PHPMailer
- **Migrasi ke PHPMailer:**
  - Mengganti penggunaan `Illuminate\Support\Facades\Mail` ke PHPMailer untuk pengiriman email.
  - Menambahkan service baru `PHPMailerService` dengan dukungan autentikasi NTLM/GSSAPI.
  - Mengintegrasikan logika pengiriman email ke dalam `StatementEmail` menggunakan PHPMailer.
  - Memindahkan logika attachment dan body email ke helper method pada kelas `StatementEmail`.

- **Perbaikan Logging dan Penanganan Error:**
  - Menambah logging lebih mendetail pada proses pengiriman email, termasuk informasi seperti penerima, subjek, dan status pengiriman.
  - Menambahkan fallback untuk pembuatan konten HTML jika terjadi kegagalan rendering pada template Blade.
  - Menambahkan pengecekan dan logging untuk kegagalan pengiriman email dengan mekanisme exception handling.

- **Peningkatan Template Email:**
  - Memperbaiki elemen ulasan pada template email untuk mendukung tampilan yang lebih bersih menggunakan `list-style-type: none`.
  - Memodifikasi markup footer untuk memberikan batas terformat lebih baik.

- **Optimasi Proses Backend:**
  - Menambahkan delay antar pengiriman email untuk menghindari rate limiting pada koneksi NTLM/GSSAPI.
  - Menyediakan format nama attachment dinamis berdasarkan rekening dan periode laporan.
  - Memanfaatkan konfigurasi enkripsi dinamis, dengan fallback untuk pengujian/development.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-11 11:41:57 +07:00
31 changed files with 4743 additions and 522 deletions

View File

@@ -0,0 +1,71 @@
<?php
namespace Modules\Webstatement\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\Webstatement\Jobs\AutoSendStatementEmailJob;
class AutoSendStatementEmailCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'webstatement:auto-send-email
{--force : Force run even if already running}
{--dry-run : Show what would be sent without actually sending}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Automatically send statement emails for available statements';
/**
* Execute the console command untuk menjalankan auto send email
*
* Command ini akan:
* 1. Dispatch AutoSendStatementEmailJob
* 2. Log aktivitas command
* 3. Handle dry-run mode untuk testing
*/
public function handle(): int
{
try {
$this->info('Starting auto send statement email process...');
Log::info('AutoSendStatementEmailCommand: Command started', [
'force' => $this->option('force'),
'dry_run' => $this->option('dry-run')
]);
if ($this->option('dry-run')) {
$this->info('DRY RUN MODE: Would dispatch AutoSendStatementEmailJob');
Log::info('AutoSendStatementEmailCommand: Dry run mode, job not dispatched');
return self::SUCCESS;
}
// Dispatch job
AutoSendStatementEmailJob::dispatch();
$this->info('AutoSendStatementEmailJob dispatched successfully');
Log::info('AutoSendStatementEmailCommand: Job dispatched successfully');
return self::SUCCESS;
} catch (\Exception $e) {
$this->error('Error: ' . $e->getMessage());
Log::error('AutoSendStatementEmailCommand: Command failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return self::FAILURE;
}
}
}

96
app/Helpers/helpers.php Normal file
View File

@@ -0,0 +1,96 @@
<?php
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
use Modules\Webstatement\Models\Account;
use Modules\Webstatement\Models\ProvinceCore;
if(!function_exists('calculatePeriodDates')) {
/**
* Fungsi untuk menghitung tanggal periode berdasarkan periode yang diberikan
* Jika periode 202505, mulai dari tanggal 9 sampai akhir bulan
* Jika periode lain, mulai dari tanggal 1 sampai akhir bulan
*/
function calculatePeriodDates($period)
{
$year = substr($period, 0, 4);
$month = substr($period, 4, 2);
// Log untuk debugging
Log::info('Calculating period dates', [
'period' => $period,
'year' => $year,
'month' => $month,
]);
if ($period === '202505') {
// Untuk periode 202505, mulai dari tanggal 9
$startDate = \Carbon\Carbon::createFromDate($year, $month, 9,'Asia/Jakarta');
} else {
// Untuk periode lain, mulai dari tanggal 1
$startDate = \Carbon\Carbon::createFromDate($year, $month, 1,'Asia/Jakarta');
}
// Tanggal akhir selalu akhir bulan
$endDate = \Carbon\Carbon::createFromDate($year, $month, 1)->endOfMonth();
return [
'start' => $startDate,
'end' => $endDate,
];
}
}
if(!function_exists('getProvinceCoreName')){
function getProvinceCoreName($code){
return $code;
}
}
if(!function_exists('generatePassword')){
function generatePassword(Account $account)
{
$customer = $account->customer;
$accountNumber = $account->account_number;
// Get last 2 digits of account number
$lastTwoDigits = substr($accountNumber, -2);
// Determine which date to use based on sector
$dateToUse = null;
if ($customer && $customer->sector) {
$firstDigitSector = substr($customer->sector, 0, 1);
if ($firstDigitSector === '1') {
// Use date_of_birth if available, otherwise birth_incorp_date
$dateToUse = $customer->date_of_birth ?: $customer->birth_incorp_date;
} else {
// Use birth_incorp_date for sector > 1
$dateToUse = $customer->birth_incorp_date;
}
}
// If no date found, fallback to account number
if (!$dateToUse) {
Log::warning("No date found for account {$accountNumber}, using account number as password");
return $accountNumber;
}
try {
// Parse the date and format it
$date = Carbon::parse($dateToUse);
$day = $date->format('d');
$month = $date->format('M'); // 3-letter month abbreviation
$year = $date->format('Y');
// Format: ddMmmyyyyXX (e.g., 05Oct202585)
$password = $day . $month . $year . $lastTwoDigits;
return $password;
} catch (\Exception $e) {
Log::error("Error parsing date for account {$accountNumber}: {$e->getMessage()}");
return $accountNumber; // Fallback to account number
}
}
}

View File

@@ -135,7 +135,7 @@ class CombinePdfController extends Controller
try {
// Generate password based on customer relation data
$password = $this->generatePassword($account);
$password = generatePassword($account);
// Dispatch job to combine PDFs or apply password protection
CombinePdfJob::dispatch($pdfFiles, $outputDir, $outputFilename, $password, $output_destination, $branchCode, $period);
@@ -158,58 +158,4 @@ class CombinePdfController extends Controller
'period' => $period
]);
}
/**
* Generate password based on customer relation data
* Format: date+end 2 digit account_number
* Example: 05Oct202585
*
* @param Account $account
* @return string
*/
private function generatePassword(Account $account)
{
$customer = $account->customer;
$accountNumber = $account->account_number;
// Get last 2 digits of account number
$lastTwoDigits = substr($accountNumber, -2);
// Determine which date to use based on sector
$dateToUse = null;
if ($customer && $customer->sector) {
$firstDigitSector = substr($customer->sector, 0, 1);
if ($firstDigitSector === '1') {
// Use date_of_birth if available, otherwise birth_incorp_date
$dateToUse = $customer->date_of_birth ?: $customer->birth_incorp_date;
} else {
// Use birth_incorp_date for sector > 1
$dateToUse = $customer->birth_incorp_date;
}
}
// If no date found, fallback to account number
if (!$dateToUse) {
Log::warning("No date found for account {$accountNumber}, using account number as password");
return $accountNumber;
}
try {
// Parse the date and format it
$date = Carbon::parse($dateToUse);
$day = $date->format('d');
$month = $date->format('M'); // 3-letter month abbreviation
$year = $date->format('Y');
// Format: ddMmmyyyyXX (e.g., 05Oct202585)
$password = $day . $month . $year . $lastTwoDigits;
return $password;
} catch (\Exception $e) {
Log::error("Error parsing date for account {$accountNumber}: {$e->getMessage()}");
return $accountNumber; // Fallback to account number
}
}
}

View File

@@ -1,4 +1,5 @@
<?php
namespace Modules\Webstatement\Http\Controllers;
use App\Http\Controllers\Controller;
@@ -6,7 +7,7 @@
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Storage;
use Log;
use Illuminate\Support\Facades\Log;
use Modules\Webstatement\Jobs\{ProcessAccountDataJob,
ProcessArrangementDataJob,
ProcessAtmTransactionJob,
@@ -22,7 +23,8 @@
ProcessStmtNarrParamDataJob,
ProcessTellerDataJob,
ProcessTransactionDataJob,
ProcessSectorDataJob};
ProcessSectorDataJob,
ProcessProvinceDataJob};
class MigrasiController extends Controller
{
@@ -42,7 +44,8 @@
'atmTransaction' => ProcessAtmTransactionJob::class,
'arrangement' => ProcessArrangementDataJob::class,
'billDetail' => ProcessBillDetailDataJob::class,
'sector' => ProcessSectorDataJob::class
'sector' => ProcessSectorDataJob::class,
'province' => ProcessProvinceDataJob::class
];
private const PARAMETER_PROCESSES = [
@@ -50,7 +53,8 @@
'stmtNarrParam',
'stmtNarrFormat',
'ftTxnTypeCondition',
'sector'
'sector',
'province'
];
private const DATA_PROCESSES = [

File diff suppressed because it is too large Load Diff

View File

@@ -21,38 +21,71 @@ class PrintStatementRequest extends FormRequest
public function rules(): array
{
$rules = [
'account_number' => ['required', 'string'],
'branch_code' => ['required', 'string'],
// account_number required jika stmt_sent_type tidak diisi atau kosong
'account_number' => [
function ($attribute, $value, $fail) {
$stmtSentType = $this->input('stmt_sent_type');
// Jika stmt_sent_type kosong atau tidak ada, maka account_number wajib diisi
if (empty($stmtSentType) || (is_array($stmtSentType) && count(array_filter($stmtSentType)) === 0)) {
if (empty($value)) {
$fail('Account number is required when statement type is not specified.');
}
}
}
],
'stmt_sent_type' => ['nullable', 'array'],
'stmt_sent_type.*' => ['string', 'in:ALL,BY.EMAIL,BY.MAIL.TO.DOM.ADDR,BY.MAIL.TO.KTP.ADDR,NO.PRINT,PRINT'],
'is_period_range' => ['sometimes', 'boolean'],
'email' => ['nullable', 'email'],
'email_sent_at' => ['nullable', 'timestamp'],
'request_type' => ['sometimes', 'string', 'in:single_account,branch,all_branches'],
'request_type' => ['sometimes', 'string', 'in:single_account,branch,all_branches,multi_account'],
'batch_id' => ['nullable', 'string'],
// Password wajib diisi jika request_type diisi
'password' => [
function ($attribute, $value, $fail) {
$requestType = $this->input('stmt_sent_type');
// Jika request_type diisi, maka password wajib diisi
if (!empty($requestType) && empty($value)) {
$fail('Password is required when statement sent type is specified.');
}
}
],
'period_from' => [
'required',
'string',
'regex:/^\d{6}$/', // YYYYMM format
// Prevent duplicate requests with same account number and period
function ($attribute, $value, $fail) {
$query = Statement::where('account_number', $this->input('account_number'))
->where('authorization_status', '!=', 'rejected')
->where('is_available', true)
->where('period_from', $value);
// Hanya cek duplikasi jika account_number ada
if (!empty($this->input('account_number'))) {
$query = Statement::where('account_number', $this->input('account_number'))
->where('authorization_status', '!=', 'rejected')
->where(function($query) {
$query->where('is_available', true)
->orWhere('is_generated', true);
})
->where('user_id', $this->user()->id)
->where('period_from', $value);
// If this is an update request, exclude the current record
if ($this->route('statement')) {
$query->where('id', '!=', $this->route('statement'));
}
// If this is an update request, exclude the current record
if ($this->route('statement')) {
$query->where('id', '!=', $this->route('statement'));
}
// If period_to is provided, check for overlapping periods
if ($this->input('period_to')) {
$query->where(function ($q) use ($value) {
$q->where('period_from', '<=', $this->input('period_to'))
->where('period_to', '>=', $value);
});
}
// If period_to is provided, check for overlapping periods
if ($this->input('period_to')) {
$query->where(function ($q) use ($value) {
$q->where('period_from', '<=', $this->input('period_to'))
->where('period_to', '>=', $value);
});
}
if ($query->exists()) {
$fail('A statement request with this account number and period already exists.');
if ($query->exists()) {
$fail('A statement request with this account number and period already exists.');
}
}
}
],
@@ -77,13 +110,17 @@ class PrintStatementRequest extends FormRequest
public function messages(): array
{
return [
'account_number.required' => 'Account number is required',
'branch_code.required' => 'Branch code is required',
'branch_code.string' => 'Branch code must be a string',
'account_number.required' => 'Account number is required when statement type is not specified',
'stmt_sent_type.*.in' => 'Invalid statement type selected',
'period_from.required' => 'Period is required',
'period_from.regex' => 'Period must be in YYYYMM format',
'period_to.required' => 'End period is required for period range',
'period_to.regex' => 'End period must be in YYYYMM format',
'period_to.gte' => 'End period must be after or equal to start period',
'request_type.in' => 'Request type must be single_account, branch, or all_branches',
'request_type.in' => 'Request type must be single_account, branch, all_branches, or multi_account',
'password.required' => 'Password is required when statement sent type is specified',
];
}

View File

@@ -0,0 +1,348 @@
<?php
namespace Modules\Webstatement\Jobs;
use Exception;
use ZipArchive;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use Illuminate\Queue\InteractsWithQueue;
use Modules\Webstatement\Models\Account;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Modules\Webstatement\Mail\StatementEmail;
use Modules\Webstatement\Models\PrintStatementLog;
class AutoSendStatementEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Timeout untuk job dalam detik (10 menit)
*/
public $timeout = 600;
/**
* Jumlah maksimal retry jika job gagal
*/
public $tries = 3;
/**
* Create a new job instance.
*/
public function __construct()
{
// Constructor kosong karena job ini tidak memerlukan parameter
}
/**
* Execute the job untuk mengirim email statement secara otomatis
*
* Job ini akan:
* 1. Mencari statement yang siap dikirim (is_available/is_generated = true, email_sent_at = null)
* 2. Memvalidasi keberadaan email
* 3. Mengirim email dengan attachment PDF
* 4. Update status email_sent_at
*/
public function handle(): void
{
try {
Log::info('AutoSendStatementEmailJob: Memulai proses auto send email');
// Ambil statement yang siap dikirim email
$statements = $this->getPendingEmailStatements();
Log::info($statements);
if ($statements->isEmpty()) {
Log::info('AutoSendStatementEmailJob: Tidak ada statement yang perlu dikirim email');
return;
}
Log::info('AutoSendStatementEmailJob: Ditemukan statement untuk dikirim', [
'count' => $statements->count(),
'statement_ids' => $statements->pluck('id')->toArray()
]);
// Proses setiap statement
foreach ($statements as $statement) {
$this->processSingleStatement($statement);
}
Log::info('AutoSendStatementEmailJob: Selesai memproses semua statement');
} catch (Exception $e) {
Log::error('AutoSendStatementEmailJob: Error dalam proses auto send email', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
throw $e;
}
}
/**
* Mengambil statement yang siap untuk dikirim email
*
* @return \Illuminate\Database\Eloquent\Collection
*/
private function getPendingEmailStatements()
{
return PrintStatementLog::where(function ($query) {
// Statement yang sudah available atau generated
$query->where('is_available', true)
->orWhere('is_generated', true);
})
->whereNotNull('email') // Harus ada email
->where('email', '!=', '') // Email tidak kosong
->whereNull('email_sent_at') // Belum pernah dikirim
->whereNull('deleted_at') // Tidak soft deleted
->orderBy('created_at', 'desc') // Prioritas yang lama dulu
->limit(1) // Batasi maksimal 50 per run untuk performa
->get();
}
/**
* Memproses pengiriman email untuk satu statement
*
* @param PrintStatementLog $statement
*/
private function processSingleStatement(PrintStatementLog $statement): void
{
DB::beginTransaction();
try {
Log::info('AutoSendStatementEmailJob: Memproses statement', [
'statement_id' => $statement->id,
'account_number' => $statement->account_number,
'email' => $statement->email
]);
// Inisialisasi disk local dan SFTP
$localDisk = Storage::disk('local');
$sftpDisk = Storage::disk('sftpStatement');
$filePath = "{$statement->period_from}/{$statement->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf";
/**
* Fungsi helper untuk mendapatkan file dari disk dengan prioritas local
* @param string $path - Path file yang dicari
* @return array - [disk, exists, content]
*/
$getFileFromDisk = function($path) use ($localDisk, $sftpDisk) {
// Cek di local disk terlebih dahulu
if ($localDisk->exists("statements/{$path}")) {
Log::info('AutoSendStatementEmailJob: File found in local disk', ['path' => "statements/{$path}"]);
return [
'disk' => $localDisk,
'exists' => true,
'path' => "statements/{$path}",
'source' => 'local'
];
}
// Jika tidak ada di local, cek di SFTP
if ($sftpDisk->exists($path)) {
Log::info('AutoSendStatementEmailJob: File found in SFTP disk', ['path' => $path]);
return [
'disk' => $sftpDisk,
'exists' => true,
'path' => $path,
'source' => 'sftp'
];
}
Log::warning('AutoSendStatementEmailJob: File not found in any disk', ['path' => $path]);
return [
'disk' => null,
'exists' => false,
'path' => $path,
'source' => 'none'
];
};
if ($statement->is_period_range && $statement->period_to) {
$this->processMultiPeriodStatement($statement, $getFileFromDisk);
} else {
$this->processSinglePeriodStatement($statement, $getFileFromDisk);
}
// Update statement record to mark as emailed
$statement->update([
'email_sent_at' => now(),
'updated_by' => 1 // System user ID, bisa disesuaikan
]);
Log::info('AutoSendStatementEmailJob: Email berhasil dikirim', [
'statement_id' => $statement->id,
'email' => $statement->email
]);
DB::commit();
} catch (Exception $e) {
DB::rollBack();
Log::error('AutoSendStatementEmailJob: Gagal mengirim email untuk statement', [
'statement_id' => $statement->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
// Jangan throw exception untuk statement individual agar tidak menghentikan proses lainnya
// Hanya log error saja
}
}
/**
* Memproses statement dengan multiple period (range)
*
* @param PrintStatementLog $statement
* @param callable $getFileFromDisk
*/
private function processMultiPeriodStatement(PrintStatementLog $statement, callable $getFileFromDisk): void
{
$periodFrom = Carbon::createFromFormat('Ym', $statement->period_from);
$periodTo = Carbon::createFromFormat('Ym', $statement->period_to);
// Loop through each month in the range
$missingPeriods = [];
$availablePeriods = [];
$periodFiles = []; // Menyimpan info file untuk setiap periode
for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) {
$periodFormatted = $period->format('Ym');
$periodPath = "{$periodFormatted}/{$statement->branch_code}/{$statement->account_number}_{$periodFormatted}.pdf";
$fileInfo = $getFileFromDisk($periodPath);
if ($fileInfo['exists']) {
$availablePeriods[] = $periodFormatted;
$periodFiles[$periodFormatted] = $fileInfo;
} else {
$missingPeriods[] = $periodFormatted;
}
}
// If any period is available, create a zip and send it
if (count($availablePeriods) > 0) {
// Create a temporary zip file
$zipFileName = "{$statement->account_number}_{$statement->period_from}_to_{$statement->period_to}.zip";
$zipFilePath = storage_path("app/temp/{$zipFileName}");
// Ensure the temp directory exists
if (!file_exists(storage_path('app/temp'))) {
mkdir(storage_path('app/temp'), 0755, true);
}
// Create a new zip archive
$zip = new ZipArchive();
if ($zip->open($zipFilePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
// Add each available statement to the zip
foreach ($availablePeriods as $period) {
$fileInfo = $periodFiles[$period];
$localFilePath = storage_path("app/temp/{$statement->account_number}_{$period}.pdf");
// Download/copy the file to local temp storage
file_put_contents($localFilePath, $fileInfo['disk']->get($fileInfo['path']));
Log::info('AutoSendStatementEmailJob: File retrieved for zip', [
'period' => $period,
'source' => $fileInfo['source'],
'path' => $fileInfo['path']
]);
// Add the file to the zip
$zip->addFile($localFilePath, "{$statement->account_number}_{$period}.pdf");
}
$zip->close();
// Send email with zip attachment
Mail::to($statement->email)
->send(new StatementEmail($statement, $zipFilePath, true));
// Clean up temporary files
foreach ($availablePeriods as $period) {
$localFilePath = storage_path("app/temp/{$statement->account_number}_{$period}.pdf");
if (file_exists($localFilePath)) {
unlink($localFilePath);
}
}
// Delete the zip file after sending
if (file_exists($zipFilePath)) {
unlink($zipFilePath);
}
Log::info('AutoSendStatementEmailJob: Multi-period statement email sent successfully', [
'statement_id' => $statement->id,
'periods' => $availablePeriods,
'sources' => array_map(fn($p) => $periodFiles[$p]['source'], $availablePeriods)
]);
} else {
throw new Exception('Failed to create zip archive for email.');
}
} else {
throw new Exception('No statements available for sending.');
}
}
/**
* Memproses statement dengan single period
*
* @param PrintStatementLog $statement
* @param callable $getFileFromDisk
*/
private function processSinglePeriodStatement(PrintStatementLog $statement, callable $getFileFromDisk): void
{
$account = Account::where('account_number',$statement->account_number)->first();
$filePath = "{$statement->period_from}/{$account->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf";
$fileInfo = $getFileFromDisk($filePath);
if ($fileInfo['exists']) {
$localFilePath = storage_path("app/temp/{$statement->account_number}_{$statement->period_from}.pdf");
// Ensure the temp directory exists
if (!file_exists(storage_path('app/temp'))) {
mkdir(storage_path('app/temp'), 0755, true);
}
// Download/copy the file to local temp storage
file_put_contents($localFilePath, $fileInfo['disk']->get($fileInfo['path']));
Log::info('AutoSendStatementEmailJob: Single period file retrieved', [
'source' => $fileInfo['source'],
'path' => $fileInfo['path']
]);
// Send email with PDF attachment
Mail::to($statement->email)
->send(new StatementEmail($statement, $localFilePath, false));
// Delete the temporary file
if (file_exists($localFilePath)) {
unlink($localFilePath);
}
} else {
throw new Exception('Statement file not found.');
}
}
/**
* Handle job failure
*
* @param Exception $exception
*/
public function failed(Exception $exception): void
{
Log::error('AutoSendStatementEmailJob: Job failed completely', [
'error' => $exception->getMessage(),
'trace' => $exception->getTraceAsString()
]);
}
}

View File

@@ -156,6 +156,7 @@
'description' => $this->generateNarrative($item),
'end_balance' => $runningBalance,
'actual_date' => $actualDate,
'recipt_no' => $item->ft?->recipt_no ?? '-',
'created_at' => now(),
'updated_at' => now(),
];
@@ -242,9 +243,9 @@
$narr[] = $item->narrative;
}
if ($item->ft?->recipt_no) {
/*if ($item->ft?->recipt_no) {
$narr[] = 'Receipt No: ' . $item->ft->recipt_no;
}
}*/
return implode(' ', array_filter($narr));
}
@@ -364,14 +365,38 @@
? "statements/{$this->client}"
: "statements";
$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');
}
}
}
// Create account directory
$accountPath = "{$basePath}/{$this->account_number}";
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}";
@@ -380,7 +405,7 @@
Storage::disk($this->disk)->delete($filePath);
}
$csvContent = "NO|TRANSACTION.DATE|REFERENCE.NUMBER|TRANSACTION.AMOUNT|TRANSACTION.TYPE|DESCRIPTION|END.BALANCE|ACTUAL.DATE\n";
$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
ProcessedStatement::where('account_number', $this->account_number)
@@ -396,7 +421,8 @@
$statement->transaction_type,
$statement->description,
$statement->end_balance,
$statement->actual_date
$statement->actual_date,
$statement->recipt_no
]) . "\n";
}

View File

@@ -4,19 +4,33 @@ namespace Modules\Webstatement\Jobs;
use Carbon\Carbon;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Bus\{
Queueable
};
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\{
InteractsWithQueue,
SerializesModels
};
use Illuminate\Support\Facades\{
DB,
Log,
Storage
};
use Spatie\Browsershot\Browsershot;
use Modules\Webstatement\Models\{
PrintStatementLog,
ProcessedStatement,
StmtEntry,
TempFundsTransfer,
TempStmtNarrFormat,
TempStmtNarrParam,
Account,
Customer
};
use Modules\Basicdata\Models\Branch;
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 Modules\Webstatement\Models\ProcessedStatement;
use Modules\Webstatement\Models\StmtEntry;
use Modules\Webstatement\Models\TempFundsTransfer;
use Modules\Webstatement\Models\TempStmtNarrFormat;
use Modules\Webstatement\Models\TempStmtNarrParam;
use Owenoj\PDFPasswordProtect\Facade\PDFPasswordProtect;
class ExportStatementPeriodJob implements ShouldQueue
{
@@ -31,6 +45,8 @@ class ExportStatementPeriodJob implements ShouldQueue
protected $chunkSize = 1000;
protected $startDate;
protected $endDate;
protected $toCsv;
protected $statementId;
/**
* Create a new job instance.
@@ -41,14 +57,16 @@ class ExportStatementPeriodJob implements ShouldQueue
* @param string $client
* @param string $disk
*/
public function __construct(string $account_number, string $period, string $saldo, string $client = '', string $disk = 'local')
public function __construct(int $statementId, string $account_number, string $period, string $saldo, string $client = '', string $disk = 'local', bool $toCsv = true)
{
$this->statementId = $statementId;
$this->account_number = $account_number;
$this->period = $period;
$this->saldo = $saldo;
$this->disk = $disk;
$this->client = $client;
$this->fileName = "{$account_number}_{$period}.csv";
$this->toCsv = $toCsv;
// Calculate start and end dates based on period
$this->calculatePeriodDates();
@@ -64,7 +82,7 @@ class ExportStatementPeriodJob implements ShouldQueue
// Special case for May 2025 - start from 12th
if ($this->period === '202505') {
$this->startDate = Carbon::createFromDate($year, $month, 12)->startOfDay();
$this->startDate = Carbon::createFromDate($year, $month, 9)->startOfDay();
} else {
// For all other periods, start from 1st of the month
$this->startDate = Carbon::createFromDate($year, $month, 1)->startOfDay();
@@ -84,7 +102,12 @@ class ExportStatementPeriodJob implements ShouldQueue
Log::info("Date range: {$this->startDate->format('Y-m-d')} to {$this->endDate->format('Y-m-d')}");
$this->processStatementData();
$this->exportToCsv();
if($this->toCsv){
$this->exportToCsv();
}
// Generate PDF setelah data diproses
$this->generatePdf();
Log::info("Export statement period job completed successfully for account: {$this->account_number}, period: {$this->period}");
} catch (Exception $e) {
@@ -112,12 +135,20 @@ class ExportStatementPeriodJob implements ShouldQueue
private function getTotalEntryCount(): int
{
return StmtEntry::where('account_number', $this->account_number)
->whereBetween('date_time', [
$this->startDate->format('ymdHi'),
$this->endDate->format('ymdHi')
])
->count();
$query = StmtEntry::where('account_number', $this->account_number)
->whereBetween('booking_date', [
$this->startDate->format('Ymd'),
$this->endDate->format('Ymd')
]);
Log::info("Getting total entry count with query: " . $query->toSql(), [
'bindings' => $query->getBindings(),
'account' => $this->account_number,
'start_date' => $this->startDate->format('Ymd'),
'end_date' => $this->endDate->format('Ymd')
]);
return $query->count();
}
private function getExistingProcessedCount(array $criteria): int
@@ -141,11 +172,11 @@ class ExportStatementPeriodJob implements ShouldQueue
Log::info("Processing {$totalCount} statement entries for account: {$this->account_number}");
StmtEntry::with(['ft', 'transaction'])
$entry = StmtEntry::with(['ft', 'transaction'])
->where('account_number', $this->account_number)
->whereBetween('date_time', [
$this->startDate->format('ymdHi'),
$this->endDate->format('ymdHi')
->whereBetween('booking_date', [
$this->startDate->format('Ymd'),
$this->endDate->format('Ymd')
])
->orderBy('date_time', 'ASC')
->orderBy('trans_reference', 'ASC')
@@ -156,6 +187,13 @@ class ExportStatementPeriodJob implements ShouldQueue
DB::table('processed_statements')->insert($processedData);
}
});
if($entry){
$printLog = PrintStatementLog::find($this->statementId);
if($printLog){
$printLog->update(['is_generated' => true]);
}
}
}
private function prepareProcessedData($entries, &$runningBalance, &$globalSequence): array
@@ -166,14 +204,13 @@ class ExportStatementPeriodJob implements ShouldQueue
$globalSequence++;
$runningBalance += (float) $item->amount_lcy;
$transactionDate = $this->formatTransactionDate($item);
$actualDate = $this->formatActualDate($item);
$processedData[] = [
'account_number' => $this->account_number,
'period' => $this->period,
'sequence_no' => $globalSequence,
'transaction_date' => $transactionDate,
'transaction_date' => $item->booking_date,
'reference_number' => $item->trans_reference,
'transaction_amount' => $item->amount_lcy,
'transaction_type' => $item->amount_lcy < 0 ? 'D' : 'C',
@@ -378,26 +415,232 @@ class ExportStatementPeriodJob implements ShouldQueue
return str_replace('<NL>', ' ', $result);
}
/**
* Generate PDF statement untuk account yang diproses
* Menggunakan data yang sudah diproses dari ProcessedStatement
*
* @return void
* @throws Exception
*/
private function generatePdf(): void
{
try {
DB::beginTransaction();
Log::info('ExportStatementPeriodJob: Memulai generate PDF', [
'account_number' => $this->account_number,
'period' => $this->period,
'statement_id' => $this->statementId
]);
// Ambil data account dan customer
$account = Account::where('account_number', $this->account_number)->first();
if (!$account) {
throw new Exception("Account tidak ditemukan: {$this->account_number}");
}
$customer = Customer::where('customer_code', $account->customer_code)->first();
if (!$customer) {
throw new Exception("Customer tidak ditemukan untuk account: {$this->account_number}");
}
// Ambil data branch
$branch = Branch::where('code', $account->branch_code)->first();
if (!$branch) {
throw new Exception("Branch tidak ditemukan: {$account->branch_code}");
}
// Ambil statement entries yang sudah diproses
$stmtEntries = ProcessedStatement::where('account_number', $this->account_number)
->where('period', $this->period)
->orderBy('sequence_no')
->get();
if ($stmtEntries->isEmpty()) {
throw new Exception("Tidak ada data statement yang diproses untuk account: {$this->account_number}");
}
// Prepare header table background (convert to base64 if needed)
$headerImagePath = public_path('assets/media/images/bg-header-table.png');
$headerTableBg = file_exists($headerImagePath)
? base64_encode(file_get_contents($headerImagePath))
: null;
// Hitung saldo awal bulan
$saldoAwalBulan = (object) ['actual_balance' => (float) $this->saldo];
// Generate filename
$filename = "{$this->account_number}_{$this->period}.pdf";
// Tentukan path storage
$storagePath = "statements/{$this->period}/{$account->branch_code}";
$tempPath = storage_path("app/temp/{$filename}");
$fullStoragePath = "{$storagePath}/{$filename}";
// 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;
// Render HTML view
$html = view('webstatement::statements.stmt', compact(
'stmtEntries',
'account',
'customer',
'headerTableBg',
'branch',
'period',
'saldoAwalBulan'
))->render();
Log::info('ExportStatementPeriodJob: HTML view berhasil di-render', [
'account_number' => $this->account_number,
'html_length' => strlen($html)
]);
// Di dalam fungsi generatePdf(), setelah Browsershot::html()->save($tempPath)
// Generate PDF menggunakan Browsershot
Browsershot::html($html)
->showBackground()
->setOption('addStyleTag', json_encode(['content' => '@page { margin: 0; }']))
->setOption('protocolTimeout', 2147483) // 2 menit timeout
->setOption('headless', true)
->noSandbox()
->format('A4')
->margins(0, 0, 0, 0)
->waitUntil('load')
->waitUntilNetworkIdle()
->timeout(2147483)
->save($tempPath);
// Verifikasi file berhasil dibuat
if (!file_exists($tempPath)) {
throw new Exception('PDF file gagal dibuat');
}
$printLog = PrintStatementLog::find($this->statementId);
// Apply password protection jika diperlukan
$password = $printLog->password ?? generatePassword($account); // Ambil dari config atau set default
if (!empty($password)) {
$tempProtectedPath = storage_path("app/temp/protected_{$filename}");
// Encrypt PDF dengan password
PDFPasswordProtect::encrypt($tempPath, $tempProtectedPath, $password);
// Ganti file original dengan yang sudah diproteksi
if (file_exists($tempProtectedPath)) {
unlink($tempPath); // Hapus file original
rename($tempProtectedPath, $tempPath); // Rename protected file ke original path
Log::info('ExportStatementPeriodJob: PDF password protection applied', [
'account_number' => $this->account_number,
'period' => $this->period
]);
}
}
$fileSize = filesize($tempPath);
// Pindahkan file ke storage permanen
$pdfContent = file_get_contents($tempPath);
Storage::put($fullStoragePath, $pdfContent);
// Update print statement log
if ($printLog) {
$printLog->update([
'is_available' => true,
'is_generated' => true,
'pdf_path' => $fullStoragePath,
'file_size' => $fileSize
]);
}
// Hapus file temporary
if (file_exists($tempPath)) {
unlink($tempPath);
}
Log::info('ExportStatementPeriodJob: PDF berhasil dibuat dan disimpan', [
'account_number' => $this->account_number,
'period' => $this->period,
'storage_path' => $fullStoragePath,
'file_size' => $fileSize,
'statement_id' => $this->statementId
]);
DB::commit();
} catch (Exception $e) {
DB::rollBack();
Log::error('ExportStatementPeriodJob: Gagal generate PDF', [
'error' => $e->getMessage(),
'account_number' => $this->account_number,
'period' => $this->period,
'statement_id' => $this->statementId,
'trace' => $e->getTraceAsString()
]);
// Update print statement log dengan status error
$printLog = PrintStatementLog::find($this->statementId);
if ($printLog) {
$printLog->update([
'is_available' => false,
'error_message' => $e->getMessage()
]);
}
throw new Exception('Gagal generate PDF: ' . $e->getMessage());
}
}
/**
* Export processed data to CSV file
*/
private function exportToCsv(): void
{
// Determine the base path based on client
$basePath = !empty($this->client)
? "statements/{$this->client}"
: "statements";
$account = Account::where('account_number', $this->account_number)->first();
// Create client directory if it doesn't exist
if (!empty($this->client)) {
Storage::disk($this->disk)->makeDirectory($basePath);
$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');
}
}
}
// Create account directory
$accountPath = "{$basePath}/{$this->account_number}";
Storage::disk($this->disk)->makeDirectory($accountPath);
$filePath = "{$accountPath}/{$this->fileName}";
$filePath = "{$storagePath}/{$this->fileName}";
// Delete existing file if it exists
if (Storage::disk($this->disk)->exists($filePath)) {

View File

@@ -161,25 +161,53 @@
/**
* Get eligible ATM cards from database
* Mengambil data kartu ATM yang memenuhi syarat untuk dikenakan biaya admin
* dengan filter khusus untuk mengecualikan product_code 6021 yang ctdesc nya gold
*
* @return \Illuminate\Database\Eloquent\Collection
*/
private function getEligibleAtmCards()
{
// Log: Memulai proses pengambilan data kartu ATM yang eligible
Log::info('Starting to fetch eligible ATM cards', [
'periode' => $this->periode
]);
$cardTypes = array_keys($this->getDefaultFees());
return Atmcard::where('crsts', 1)
->whereNotNull('accflag')
->where('accflag', '!=', '')
->where('flag','')
->whereNotNull('branch')
->where('branch', '!=', '')
->whereNotNull('currency')
->where('currency', '!=', '')
->whereIn('ctdesc', $cardTypes)
->whereNotIn('product_code',['6002','6004','6042','6031'])
->where('branch','!=','ID0019999')
->get();
$query = Atmcard::where('crsts', 1)
->whereNotNull('accflag')
->where('accflag', '!=', '')
->where('flag','')
->whereNotNull('branch')
->where('branch', '!=', '')
->whereNotNull('currency')
->where('currency', '!=', '')
->whereIn('ctdesc', $cardTypes)
->whereNotIn('product_code',['6002','6004','6042','6031']) // Hapus 6021 dari sini
->where('branch','!=','ID0019999')
// Filter khusus: Kecualikan product_code 6021 yang ctdesc nya gold
->where(function($subQuery) {
$subQuery->where('product_code', '!=', '6021')
->orWhere(function($nestedQuery) {
$nestedQuery->where('product_code', '6021')
->where('ctdesc', '!=', 'gold');
});
});
$cards = $query->get();
// Log: Hasil pengambilan data kartu ATM
Log::info('Eligible ATM cards fetched successfully', [
'total_cards' => $cards->count(),
'periode' => $this->periode,
'excluded_product_codes' => ['6002','6004','6042','6031'],
'special_filter' => 'product_code 6021 dengan ctdesc gold dikecualikan'
]);
return $cards;
}
/**

View File

@@ -0,0 +1,769 @@
<?php
namespace Modules\Webstatement\Jobs;
use Exception;
use ZipArchive;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Support\Facades\{
DB,
Log,
Storage
};
use Spatie\Browsershot\Browsershot;
use Modules\Basicdata\Models\Branch;
use Illuminate\Queue\{
SerializesModels,
InteractsWithQueue
};
use Modules\Webstatement\Models\{
StmtEntry,
AccountBalance,
PrintStatementLog,
ProcessedStatement,
TempStmtNarrParam,
TempStmtNarrFormat
};
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
class GenerateMultiAccountPdfJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $statement;
protected $accounts;
protected $period;
protected $clientName;
protected $chunkSize = 10; // Process 10 accounts at a time
protected $startDate;
protected $endDate;
/**
* Create a new job instance.
*
* @param PrintStatementLog $statement
* @param \Illuminate\Database\Eloquent\Collection $accounts
* @param string $period
* @param string $clientName
*/
public function __construct($statement, $accounts, $period, $clientName)
{
$this->statement = $statement;
$this->accounts = $accounts;
$this->period = $period;
$this->clientName = $clientName;
// Calculate period dates using same logic as ExportStatementPeriodJob
$this->calculatePeriodDates();
}
/**
* Calculate start and end dates for the given period
* Menggunakan logika yang sama dengan ExportStatementPeriodJob
*/
private function calculatePeriodDates(): void
{
$year = substr($this->period, 0, 4);
$month = substr($this->period, 4, 2);
// Special case for May 2025 - start from 9th
if ($this->period === '202505') {
$this->startDate = Carbon::createFromDate($year, $month, 9)->startOfDay();
} else {
// For all other periods, start from 1st of the month
$this->startDate = Carbon::createFromDate($year, $month, 1)->startOfDay();
}
// End date is always the last day of the month
$this->endDate = Carbon::createFromDate($year, $month, 1)->endOfMonth()->endOfDay();
Log::info('Period dates calculated for PDF generation', [
'period' => $this->period,
'start_date' => $this->startDate->format('Y-m-d'),
'end_date' => $this->endDate->format('Y-m-d')
]);
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
Log::info('Starting multi account PDF generation', [
'statement_id' => $this->statement->id,
'total_accounts' => $this->accounts->count(),
'period' => $this->period,
'date_range' => $this->startDate->format('Y-m-d') . ' to ' . $this->endDate->format('Y-m-d')
]);
$pdfFiles = [];
$successCount = 0;
$failedCount = 0;
$errors = [];
// Process each account
foreach ($this->accounts as $account) {
try {
$pdfPath = $this->generateAccountPdf($account);
if ($pdfPath) {
$pdfFiles[] = $pdfPath;
$successCount++;
Log::info('PDF generated successfully for account', [
'account_number' => $account->account_number,
'pdf_path' => $pdfPath
]);
}
// Memory cleanup after each account
gc_collect_cycles();
} catch (Exception $e) {
$failedCount++;
$errors[] = [
'account_number' => $account->account_number,
'error' => $e->getMessage()
];
Log::error('Failed to generate PDF for account', [
'account_number' => $account->account_number,
'error' => $e->getMessage()
]);
}
}
// Create ZIP file if there are PDFs
$zipPath = null;
if (!empty($pdfFiles)) {
$zipPath = $this->createZipFile($pdfFiles);
}
// Update statement log
$this->statement->update([
'processed_accounts' => $this->accounts->count(),
'success_count' => $successCount,
'failed_count' => $failedCount,
'status' => $failedCount > 0 ? 'completed_with_errors' : 'completed',
'completed_at' => now(),
'is_available' => $zipPath ? true : false,
'is_generated' => $zipPath ? true : false,
'error_message' => !empty($errors) ? json_encode($errors) : null
]);
Log::info('Multi account PDF generation completed', [
'statement_id' => $this->statement->id,
'success_count' => $successCount,
'failed_count' => $failedCount,
'zip_path' => $zipPath
]);
} catch (Exception $e) {
Log::error('Multi account PDF generation failed', [
'statement_id' => $this->statement->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
// Update statement with error status
$this->statement->update([
'status' => 'failed',
'completed_at' => now(),
'error_message' => $e->getMessage()
]);
throw $e;
}
}
/**
* Generate PDF untuk satu account
* Menggunakan data dari ProcessedStatement yang sudah diproses oleh ExportStatementPeriodJob
*
* @param Account $account
* @return string|null Path to generated PDF
*/
protected function generateAccountPdf($account)
{
try {
// Prepare account query untuk processing
$accountQuery = [
'account_number' => $account->account_number,
'period' => $this->period
];
// Get total entry count
$totalCount = $this->getTotalEntryCount($account->account_number);
// Delete existing processed data dan process ulang
$this->deleteExistingProcessedData($accountQuery);
$this->processAndSaveStatementEntries($account, $totalCount);
// Get statement entries from ProcessedStatement (data yang sudah diproses)
$stmtEntries = $this->getProcessedStatementEntries($account->account_number);
// Get saldo awal bulan menggunakan logika yang sama dengan ExportStatementPeriodJob
$saldoAwalBulan = $this->getSaldoAwalBulan($account->account_number);
// Get branch info
$branch = Branch::where('code', $account->branch_code)->first();
// Prepare images for PDF
$images = $this->prepareImagesForPdf();
$headerImagePath = public_path('assets/media/images/bg-header-table.png');
$headerTableBg = file_exists($headerImagePath)
? base64_encode(file_get_contents($headerImagePath))
: null;
// Render HTML
$html = view('webstatement::statements.stmt', [
'stmtEntries' => $stmtEntries,
'account' => $account,
'customer' => $account->customer,
'images' => $images,
'branch' => $branch,
'period' => $this->period,
'saldoAwalBulan' => $saldoAwalBulan,
'headerTableBg' => $headerTableBg,
])->render();
// Generate PDF filename
$filename = "statement_{$account->account_number}_{$this->period}_" . now()->format('YmdHis') . '.pdf';
$storagePath = "statements/{$this->period}/multi_account/{$this->statement->id}";
$fullStoragePath = "{$storagePath}/{$filename}";
// 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}");
// Generate PDF using Browsershot
Browsershot::html($html)
->showBackground()
->setOption('addStyleTag', json_encode(['content' => '@page { margin: 0; }']))
->setOption('protocolTimeout', 2147483) // 2 menit timeout
->setOption('headless', true)
->noSandbox()
->format('A4')
->margins(0, 0, 0, 0)
->waitUntil('load')
->waitUntilNetworkIdle()
->timeout(2147483)
->save($pdfPath);
// Verify file was created
if (!file_exists($pdfPath)) {
throw new Exception('PDF file was not created');
}
// Clear variables to free memory
unset($html, $stmtEntries, $images);
return $pdfPath;
} catch (Exception $e) {
Log::error('Failed to generate PDF for account', [
'account_number' => $account->account_number,
'error' => $e->getMessage()
]);
throw $e;
}
}
/**
* Get total entry count untuk account
* Menggunakan logika yang sama dengan ExportStatementPeriodJob
*
* @param string $accountNumber
* @return int
*/
protected function getTotalEntryCount($accountNumber): int
{
$query = StmtEntry::where('account_number', $accountNumber)
->whereBetween('booking_date', [
$this->startDate->format('Ymd'),
$this->endDate->format('Ymd')
]);
Log::info("Getting total entry count for PDF generation", [
'account' => $accountNumber,
'start_date' => $this->startDate->format('Ymd'),
'end_date' => $this->endDate->format('Ymd')
]);
return $query->count();
}
/**
* Delete existing processed data untuk account
* Menggunakan logika yang sama dengan ExportStatementPeriodJob
*
* @param array $criteria
* @return void
*/
protected function deleteExistingProcessedData(array $criteria): void
{
Log::info('Deleting existing processed data for PDF generation', [
'account_number' => $criteria['account_number'],
'period' => $criteria['period']
]);
ProcessedStatement::where('account_number', $criteria['account_number'])
->where('period', $criteria['period'])
->delete();
}
/**
* Process dan save statement entries untuk account
* Menggunakan logika yang sama dengan ExportStatementPeriodJob
*
* @param Account $account
* @param int $totalCount
* @return void
*/
protected function processAndSaveStatementEntries($account, int $totalCount): void
{
// Get saldo awal dari AccountBalance
$saldoAwalBulan = $this->getSaldoAwalBulan($account->account_number);
$runningBalance = (float) $saldoAwalBulan->actual_balance;
$globalSequence = 0;
Log::info("Processing {$totalCount} statement entries for PDF generation", [
'account_number' => $account->account_number,
'starting_balance' => $runningBalance
]);
StmtEntry::with(['ft', 'transaction'])
->where('account_number', $account->account_number)
->whereBetween('booking_date', [
$this->startDate->format('Ymd'),
$this->endDate->format('Ymd')
])
->orderBy('date_time', 'ASC')
->orderBy('trans_reference', 'ASC')
->chunk(1000, function ($entries) use (&$runningBalance, &$globalSequence, $account) {
$processedData = $this->prepareProcessedData($entries, $runningBalance, $globalSequence, $account->account_number);
if (!empty($processedData)) {
DB::table('processed_statements')->insert($processedData);
}
});
}
/**
* Prepare processed data untuk batch insert
* Menggunakan logika yang sama dengan ExportStatementPeriodJob
*
* @param $entries
* @param float $runningBalance
* @param int $globalSequence
* @param string $accountNumber
* @return array
*/
protected function prepareProcessedData($entries, &$runningBalance, &$globalSequence, $accountNumber): array
{
$processedData = [];
foreach ($entries as $item) {
$globalSequence++;
$runningBalance += (float) $item->amount_lcy;
$actualDate = $this->formatActualDate($item);
$processedData[] = [
'account_number' => $accountNumber,
'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(),
];
}
return $processedData;
}
/**
* Format actual date dari item
* Menggunakan logika yang sama dengan ExportStatementPeriodJob
*
* @param $item
* @return string
*/
protected function formatActualDate($item): string
{
try {
$prefix = substr($item->trans_reference ?? '', 0, 2);
$relationMap = [
'FT' => 'ft',
'TT' => 'tt',
'DC' => 'dc',
'AA' => 'aa'
];
$datetime = $item->date_time;
if (isset($relationMap[$prefix])) {
$relation = $relationMap[$prefix];
$datetime = $item->$relation?->date_time ?? $datetime;
}
return Carbon::createFromFormat(
'ymdHi',
$datetime
)->format('d/m/Y H:i');
} catch (Exception $e) {
Log::warning("Error formatting actual date: " . $e->getMessage());
return Carbon::now()->format('d/m/Y H:i');
}
}
/**
* Generate narrative untuk statement entry
* Menggunakan logika yang sama dengan ExportStatementPeriodJob
*
* @param $item
* @return string
*/
protected function generateNarrative($item)
{
$narr = [];
if ($item->transaction) {
if ($item->transaction->stmt_narr) {
$narr[] = $item->transaction->stmt_narr;
}
if ($item->narrative) {
$narr[] = $item->narrative;
}
if ($item->transaction->narr_type) {
$narr[] = $this->getFormatNarrative($item->transaction->narr_type, $item);
}
} else if ($item->narrative) {
$narr[] = $item->narrative;
}
if ($item->ft?->recipt_no) {
$narr[] = 'Receipt No: ' . $item->ft->recipt_no;
}
return implode(' ', array_filter($narr));
}
/**
* Get formatted narrative berdasarkan narrative type
* Menggunakan logika yang sama dengan ExportStatementPeriodJob
*
* @param $narr
* @param $item
* @return string
*/
protected function getFormatNarrative($narr, $item)
{
$narrParam = TempStmtNarrParam::where('_id', $narr)->first();
if (!$narrParam) {
return '';
}
$fmt = '';
if ($narrParam->_id == 'FTIN') {
$fmt = 'FT.IN';
} else if ($narrParam->_id == 'FTOUT') {
$fmt = 'FT.OUT';
} else if ($narrParam->_id == 'TTTRFOUT') {
$fmt = 'TT.O.TRF';
} else if ($narrParam->_id == 'TTTRFIN') {
$fmt = 'TT.I.TRF';
} else if ($narrParam->_id == 'APITRX'){
$fmt = 'API.TSEL';
} else if ($narrParam->_id == 'ONUSCR'){
$fmt = 'ONUS.CR';
} else if ($narrParam->_id == 'ONUSDR'){
$fmt = 'ONUS.DR';
}else {
$fmt = $narrParam->_id;
}
$narrFormat = TempStmtNarrFormat::where('_id', $fmt)->first();
if (!$narrFormat) {
return '';
}
// Get the format string from the database
$formatString = $narrFormat->text_data ?? '';
// Parse the format string
// Split by the separator ']'
$parts = explode(']', $formatString);
$result = '';
foreach ($parts as $index => $part) {
if (empty($part)) {
continue;
}
if ($index === 0) {
// For the first part, take only what's before the '!'
$splitPart = explode('!', $part);
if (count($splitPart) > 0) {
// Remove quotes, backslashes, and other escape characters
$cleanPart = trim($splitPart[0]).' ';
// Remove quotes at the beginning and end
$cleanPart = preg_replace('/^["\'\\\\]+|["\'\\\\]+$/', '', $cleanPart);
// Remove any remaining backslashes
$cleanPart = str_replace('\\', '', $cleanPart);
// Remove any remaining quotes
$cleanPart = str_replace('"', '', $cleanPart);
$result .= $cleanPart;
}
} else {
// For other parts, these are field placeholders
$fieldName = strtolower(str_replace('.', '_', $part));
// Get the corresponding parameter value from narrParam
$paramValue = null;
// Check if the field exists as a property in narrParam
if (property_exists($narrParam, $fieldName)) {
$paramValue = $narrParam->$fieldName;
} else if (isset($narrParam->$fieldName)) {
$paramValue = $narrParam->$fieldName;
}
// If we found a value, add it to the result
if ($paramValue !== null) {
$result .= $paramValue;
} else {
// If no value found, try to use the original field name as a fallback
if ($fieldName !== 'recipt_no') {
$prefix = substr($item->trans_reference ?? '', 0, 2);
$relationMap = [
'FT' => 'ft',
'TT' => 'tt',
'DC' => 'dc',
'AA' => 'aa'
];
if (isset($relationMap[$prefix])) {
$relation = $relationMap[$prefix];
$result .= ($item->$relation?->$fieldName ?? '') . ' ';
}
}
}
}
}
return str_replace('<NL>', ' ', $result);
}
/**
* Get processed statement entries untuk account
* Menggunakan data dari tabel ProcessedStatement yang sudah diproses
*
* @param string $accountNumber
* @return \Illuminate\Database\Eloquent\Collection
*/
protected function getProcessedStatementEntries($accountNumber)
{
Log::info('Getting processed statement entries', [
'account_number' => $accountNumber,
'period' => $this->period
]);
return ProcessedStatement::where('account_number', $accountNumber)
->where('period', $this->period)
->orderBy('sequence_no', 'ASC')
->get();
}
/**
* Get saldo awal bulan untuk account
* Menggunakan logika yang sama dengan ExportStatementPeriodJob
*
* @param string $accountNumber
* @return object
*/
protected function getSaldoAwalBulan($accountNumber)
{
// Menggunakan logika yang sama dengan ExportStatementPeriodJob
// Ambil saldo dari ProcessedStatement entry pertama dikurangi transaction_amount
$firstEntry = ProcessedStatement::where('account_number', $accountNumber)
->where('period', $this->period)
->orderBy('sequence_no', 'ASC')
->first();
if ($firstEntry) {
$saldoAwal = $firstEntry->end_balance - $firstEntry->transaction_amount;
return (object) ['actual_balance' => $saldoAwal];
}
// Fallback ke AccountBalance jika tidak ada ProcessedStatement
$saldoPeriod = $this->calculateSaldoPeriod($this->period);
$saldo = AccountBalance::where('account_number', $accountNumber)
->where('period', $saldoPeriod)
->first();
return $saldo ?: (object) ['actual_balance' => 0];
}
/**
* Calculate saldo period berdasarkan aturan bisnis
* Menggunakan logika yang sama dengan ExportStatementPeriodJob
*
* @param string $period
* @return string
*/
protected function calculateSaldoPeriod($period)
{
if ($period === '202505') {
return '20250510';
}
// For periods after 202505, get last day of previous month
if ($period > '202505') {
$year = substr($period, 0, 4);
$month = substr($period, 4, 2);
$firstDay = Carbon::createFromFormat('Ym', $period)->startOfMonth();
return $firstDay->copy()->subDay()->format('Ymd');
}
return $period . '01';
}
/**
* Prepare images as base64 for PDF
*
* @return array
*/
protected function prepareImagesForPdf()
{
$images = [];
$imagePaths = [
'headerTableBg' => 'assets/media/images/bg-header-table.png',
'watermark' => 'assets/media/images/watermark.png',
'logoArthagraha' => 'assets/media/images/logo-arthagraha.png',
'logoAgi' => 'assets/media/images/logo-agi.png',
'bannerFooter' => 'assets/media/images/banner-footer.png'
];
foreach ($imagePaths as $key => $path) {
$fullPath = public_path($path);
if (file_exists($fullPath)) {
$images[$key] = base64_encode(file_get_contents($fullPath));
} else {
$images[$key] = null;
Log::warning('Image file not found', ['path' => $fullPath]);
}
}
return $images;
}
/**
* Create ZIP file dari multiple PDF files dengan password protection
*
* @param array $pdfFiles
* @return string|null Path to ZIP file
*/
protected function createZipFile($pdfFiles)
{
try {
$zipFilename = "statements_{$this->period}_multi_account_{$this->statement->id}_" . now()->format('YmdHis') . '.zip';
$zipStoragePath = "statements/{$this->period}/multi_account/{$this->statement->id}";
$fullZipPath = "{$zipStoragePath}/{$zipFilename}";
// Ensure directory exists
Storage::disk('local')->makeDirectory($zipStoragePath);
$zipPath = storage_path("app/{$fullZipPath}");
// Get password from statement or use default
$password = $this->statement->password ?? config('webstatement.zip_password', 'statement123');
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE) !== TRUE) {
throw new Exception('Cannot create ZIP file');
}
// Set password for the ZIP file
if (!empty($password)) {
$zip->setPassword($password);
Log::info('ZIP password protection enabled', [
'statement_id' => $this->statement->id,
'zip_path' => $zipPath
]);
}
foreach ($pdfFiles as $pdfFile) {
if (file_exists($pdfFile)) {
$filename = basename($pdfFile);
$zip->addFile($pdfFile, $filename);
// Set encryption for each file in ZIP
if (!empty($password)) {
$zip->setEncryptionName($filename, ZipArchive::EM_AES_256);
}
}
}
$zip->close();
// Verify ZIP file was created
if (!file_exists($zipPath)) {
throw new Exception('ZIP file was not created');
}
Log::info('ZIP file created successfully with password protection', [
'zip_path' => $zipPath,
'pdf_count' => count($pdfFiles),
'statement_id' => $this->statement->id,
'password_protected' => !empty($password)
]);
// Clean up individual PDF files after creating ZIP
foreach ($pdfFiles as $pdfFile) {
if (file_exists($pdfFile)) {
unlink($pdfFile);
}
}
return $zipPath;
} catch (Exception $e) {
Log::error('Failed to create ZIP file', [
'error' => $e->getMessage(),
'statement_id' => $this->statement->id
]);
return null;
}
}
}

View File

@@ -0,0 +1,300 @@
<?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 Illuminate\Support\Facades\DB;
use Modules\Webstatement\Models\ProvinceCore;
class ProcessProvinceDataJob 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.PROVINCE.csv';
private const DISK_NAME = 'sftpStatement';
private string $period = '';
private int $processedCount = 0;
private int $errorCount = 0;
private int $skippedCount = 0;
/**
* Membuat instance job baru untuk memproses data provinsi
*
* @param string $period Periode data yang akan diproses
*/
public function __construct(string $period = '')
{
$this->period = $period;
Log::info('ProcessProvinceDataJob: Job dibuat untuk periode: ' . $period);
}
/**
* Menjalankan job untuk memproses file ST.PROVINCE.csv
* Menggunakan transaction untuk memastikan konsistensi data
*
* @return void
* @throws Exception
*/
public function handle(): void
{
DB::beginTransaction();
try {
Log::info('ProcessProvinceDataJob: Memulai pemrosesan data provinsi');
$this->initializeJob();
if ($this->period === '') {
Log::warning('ProcessProvinceDataJob: Tidak ada periode yang diberikan untuk pemrosesan data provinsi');
DB::rollback();
return;
}
$this->processPeriod();
$this->logJobCompletion();
DB::commit();
Log::info('ProcessProvinceDataJob: Transaction berhasil di-commit');
} catch (Exception $e) {
DB::rollback();
Log::error('ProcessProvinceDataJob: Error dalam pemrosesan, transaction di-rollback: ' . $e->getMessage());
throw $e;
}
}
/**
* Inisialisasi pengaturan job
* Mengatur timeout dan reset counter
*
* @return void
*/
private function initializeJob(): void
{
set_time_limit(self::MAX_EXECUTION_TIME);
$this->processedCount = 0;
$this->errorCount = 0;
$this->skippedCount = 0;
Log::info('ProcessProvinceDataJob: Job diinisialisasi dengan timeout ' . self::MAX_EXECUTION_TIME . ' detik');
}
/**
* Memproses file untuk periode tertentu
* Mengambil file dari SFTP dan memproses data
*
* @return void
*/
private function processPeriod(): void
{
$disk = Storage::disk(self::DISK_NAME);
$filePath = "$this->period/" . self::FILENAME;
Log::info('ProcessProvinceDataJob: Memproses periode ' . $this->period);
if (!$this->validateFile($disk, $filePath)) {
return;
}
$tempFilePath = $this->createTemporaryFile($disk, $filePath);
$this->processFile($tempFilePath, $filePath);
$this->cleanup($tempFilePath);
}
/**
* Validasi keberadaan file di storage
*
* @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("ProcessProvinceDataJob: Memvalidasi file provinsi: $filePath");
if (!$disk->exists($filePath)) {
Log::warning("ProcessProvinceDataJob: File tidak ditemukan: $filePath");
return false;
}
Log::info("ProcessProvinceDataJob: File ditemukan dan valid: $filePath");
return true;
}
/**
* Membuat file temporary untuk pemrosesan
*
* @param mixed $disk Storage disk instance
* @param string $filePath Path file sumber
* @return string Path file temporary
*/
private function createTemporaryFile($disk, string $filePath): string
{
$tempFilePath = storage_path("app/temp_" . self::FILENAME);
file_put_contents($tempFilePath, $disk->get($filePath));
Log::info("ProcessProvinceDataJob: File temporary dibuat: $tempFilePath");
return $tempFilePath;
}
/**
* Memproses file CSV dan mengimpor data ke database
* Format CSV: id~date_time~province~province_name
*
* @param string $tempFilePath Path file temporary
* @param string $filePath Path file asli untuk logging
* @return void
*/
private function processFile(string $tempFilePath, string $filePath): void
{
$handle = fopen($tempFilePath, "r");
if ($handle === false) {
Log::error("ProcessProvinceDataJob: Tidak dapat membuka file: $filePath");
return;
}
Log::info("ProcessProvinceDataJob: Memulai pemrosesan file: $filePath");
$rowCount = 0;
$isFirstRow = true;
while (($row = fgetcsv($handle, 0, self::CSV_DELIMITER)) !== false) {
$rowCount++;
// Skip header row
if ($isFirstRow) {
$isFirstRow = false;
Log::info("ProcessProvinceDataJob: Melewati header row: " . implode(self::CSV_DELIMITER, $row));
continue;
}
$this->processRow($row, $rowCount, $filePath);
}
fclose($handle);
Log::info("ProcessProvinceDataJob: Selesai memproses $filePath. Total baris: $rowCount, Diproses: {$this->processedCount}, Error: {$this->errorCount}, Dilewati: {$this->skippedCount}");
}
/**
* Memproses satu baris data CSV
*
* @param array $row Data baris CSV
* @param int $rowCount Nomor baris untuk logging
* @param string $filePath Path file untuk logging
* @return void
*/
private function processRow(array $row, int $rowCount, string $filePath): void
{
// Validasi jumlah kolom (id~date_time~province~province_name = 4 kolom)
if (count($row) !== 4) {
Log::warning("ProcessProvinceDataJob: Baris $rowCount di $filePath memiliki jumlah kolom yang salah. Diharapkan: 4, Ditemukan: " . count($row));
$this->skippedCount++;
return;
}
// Map data sesuai format CSV
$data = [
'code' => trim($row[2]), // province code
'name' => trim($row[3]) // province_name
];
Log::debug("ProcessProvinceDataJob: Memproses baris $rowCount dengan data: " . json_encode($data));
$this->saveRecord($data, $rowCount, $filePath);
}
/**
* Menyimpan record provinsi ke database
* Menggunakan updateOrCreate untuk menghindari duplikasi
*
* @param array $data Data provinsi yang akan disimpan
* @param int $rowCount Nomor baris untuk logging
* @param string $filePath Path file untuk logging
* @return void
*/
private function saveRecord(array $data, int $rowCount, string $filePath): void
{
try {
// Validasi data wajib
if (empty($data['code']) || empty($data['name'])) {
Log::warning("ProcessProvinceDataJob: Baris $rowCount di $filePath memiliki data kosong. Code: '{$data['code']}', Name: '{$data['name']}'");
$this->skippedCount++;
return;
}
// Simpan atau update data provinsi
$province = ProvinceCore::updateOrCreate(
['code' => $data['code']], // Kondisi pencarian
['name' => $data['name']] // Data yang akan diupdate/insert
);
$this->processedCount++;
Log::debug("ProcessProvinceDataJob: Berhasil menyimpan provinsi ID: {$province->id}, Code: {$data['code']}, Name: {$data['name']}");
} catch (Exception $e) {
$this->errorCount++;
Log::error("ProcessProvinceDataJob: Error menyimpan data provinsi pada baris $rowCount di $filePath: " . $e->getMessage());
Log::error("ProcessProvinceDataJob: Data yang error: " . json_encode($data));
}
}
/**
* Membersihkan file temporary
*
* @param string $tempFilePath Path file temporary yang akan dihapus
* @return void
*/
private function cleanup(string $tempFilePath): void
{
if (file_exists($tempFilePath)) {
unlink($tempFilePath);
Log::info("ProcessProvinceDataJob: File temporary dihapus: $tempFilePath");
}
}
/**
* Logging hasil akhir pemrosesan job
*
* @return void
*/
private function logJobCompletion(): void
{
$message = "ProcessProvinceDataJob: Pemrosesan data provinsi selesai. " .
"Total diproses: {$this->processedCount}, " .
"Total error: {$this->errorCount}, " .
"Total dilewati: {$this->skippedCount}";
Log::info($message);
// Log summary untuk monitoring
if ($this->errorCount > 0) {
Log::warning("ProcessProvinceDataJob: Terdapat {$this->errorCount} error dalam pemrosesan");
}
if ($this->skippedCount > 0) {
Log::info("ProcessProvinceDataJob: Terdapat {$this->skippedCount} baris yang dilewati");
}
}
/**
* Handle job failure
*
* @param Exception $exception
* @return void
*/
public function failed(Exception $exception): void
{
Log::error('ProcessProvinceDataJob: Job gagal dijalankan: ' . $exception->getMessage());
Log::error('ProcessProvinceDataJob: Stack trace: ' . $exception->getTraceAsString());
}
}

View File

@@ -1,5 +1,4 @@
<?php
namespace Modules\Webstatement\Mail;
use Carbon\Carbon;
@@ -8,12 +7,13 @@
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Config;
use Log;
use Modules\Webstatement\Models\Account;
use Modules\Webstatement\Models\PrintStatementLog;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mime\Email;
use Illuminate\Support\Facades\Log;
class StatementEmail extends Mailable
{

View File

@@ -4,6 +4,7 @@ namespace Modules\Webstatement\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Modules\Basicdata\Models\Branch;
// use Modules\Webstatement\Database\Factories\AccountFactory;
class Account extends Model
@@ -34,7 +35,7 @@ class Account extends Model
{
return $this->belongsTo(Customer::class, 'customer_code', 'customer_code');
}
/**
* Get all balances for this account.
*/
@@ -42,10 +43,10 @@ class Account extends Model
{
return $this->hasMany(AccountBalance::class, 'account_number', 'account_number');
}
/**
* Get balance for a specific period.
*
*
* @param string $period Format: YYYY-MM
* @return AccountBalance|null
*/
@@ -53,4 +54,8 @@ class Account extends Model
{
return $this->balances()->where('period', $period)->first();
}
public function branch(){
return $this->belongsTo(Branch::class, 'branch_code','code');
}
}

View File

@@ -24,9 +24,13 @@ class Customer extends Model
'email',
'sector',
'customer_type',
'birth_incorp_date'
'birth_incorp_date',
'home_rt',
'home_rw',
'ktp_rt',
'ktp_rw',
'local_ref'
];
public function accounts(){
return $this->hasMany(Account::class, 'customer_code', 'customer_code');
}

View File

@@ -44,11 +44,15 @@ class PrintStatementLog extends Model
'remarks',
'email',
'email_sent_at',
'stmt_sent_type',
'is_generated',
'password', // Tambahan field password
];
protected $casts = [
'is_period_range' => 'boolean',
'is_available' => 'boolean',
'is_generated' => 'boolean',
'is_downloaded' => 'boolean',
'downloaded_at' => 'datetime',
'authorized_at' => 'datetime',
@@ -57,6 +61,10 @@ class PrintStatementLog extends Model
'target_accounts' => 'array',
];
protected $hidden = [
'password', // Hide password dari serialization
];
/**
* Get the formatted period display
*
@@ -285,4 +293,8 @@ class PrintStatementLog extends Model
{
return $query->where('request_type', 'single_account');
}
public function account(){
return $this->belongsTo(Account::class, 'account_number','account_number');
}
}

View File

@@ -16,6 +16,7 @@
'transaction_type',
'description',
'end_balance',
'actual_date'
'actual_date',
'recipt_no'
];
}

161
app/Models/ProvinceCore.php Normal file
View File

@@ -0,0 +1,161 @@
<?php
namespace Modules\Webstatement\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
class ProvinceCore extends Model
{
use HasFactory;
/**
* Nama tabel yang digunakan oleh model
*
* @var string
*/
protected $table = 'province_core';
/**
* Field yang dapat diisi secara mass assignment
*
* @var array
*/
protected $fillable = [
'code',
'name',
];
/**
* Field yang di-cast ke tipe data tertentu
*
* @var array
*/
protected $casts = [
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* Scope untuk mencari berdasarkan kode provinsi
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $code
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByCode($query, $code)
{
Log::info('ProvinceCore: Mencari provinsi dengan kode: ' . $code);
return $query->where('code', $code);
}
/**
* Scope untuk mencari berdasarkan nama provinsi
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $name
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByName($query, $name)
{
Log::info('ProvinceCore: Mencari provinsi dengan nama: ' . $name);
return $query->where('name', 'ILIKE', '%' . $name . '%');
}
/**
* Scope untuk mendapatkan semua provinsi yang aktif
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeActive($query)
{
Log::info('ProvinceCore: Mengambil semua provinsi aktif');
return $query->orderBy('name', 'asc');
}
/**
* Mendapatkan provinsi berdasarkan kode
*
* @param string $code
* @return ProvinceCore|null
*/
public static function getByCode($code)
{
try {
Log::info('ProvinceCore: Mengambil provinsi dengan kode: ' . $code);
return self::byCode($code)->first();
} catch (\Exception $e) {
Log::error('ProvinceCore: Error mengambil provinsi dengan kode ' . $code . ': ' . $e->getMessage());
return null;
}
}
/**
* Mendapatkan semua provinsi untuk dropdown/select
*
* @return \Illuminate\Database\Eloquent\Collection
*/
public static function getForDropdown()
{
try {
Log::info('ProvinceCore: Mengambil data provinsi untuk dropdown');
return self::active()->pluck('name', 'code');
} catch (\Exception $e) {
Log::error('ProvinceCore: Error mengambil data dropdown provinsi: ' . $e->getMessage());
return collect();
}
}
/**
* Validasi kode provinsi
*
* @param string $code
* @return bool
*/
public static function isValidCode($code)
{
try {
Log::info('ProvinceCore: Validasi kode provinsi: ' . $code);
return self::byCode($code)->exists();
} catch (\Exception $e) {
Log::error('ProvinceCore: Error validasi kode provinsi ' . $code . ': ' . $e->getMessage());
return false;
}
}
/**
* Boot method untuk model events
*
* @return void
*/
protected static function boot()
{
parent::boot();
static::creating(function ($model) {
Log::info('ProvinceCore: Membuat data provinsi baru dengan kode: ' . $model->code);
});
static::created(function ($model) {
Log::info('ProvinceCore: Data provinsi berhasil dibuat dengan ID: ' . $model->id);
});
static::updating(function ($model) {
Log::info('ProvinceCore: Mengupdate data provinsi dengan ID: ' . $model->id);
});
static::updated(function ($model) {
Log::info('ProvinceCore: Data provinsi berhasil diupdate dengan ID: ' . $model->id);
});
static::deleting(function ($model) {
Log::info('ProvinceCore: Menghapus data provinsi dengan ID: ' . $model->id);
});
static::deleted(function ($model) {
Log::info('ProvinceCore: Data provinsi berhasil dihapus dengan ID: ' . $model->id);
});
}
}

View File

@@ -14,6 +14,7 @@ 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\Jobs\UpdateAtmCardBranchCurrencyJob;
@@ -72,7 +73,8 @@ class WebstatementServiceProvider extends ServiceProvider
GenerateAtmTransactionReport::class,
SendStatementEmailCommand::class,
CheckEmailProgressCommand::class,
UpdateAllAtmCardsCommand::class
UpdateAllAtmCardsCommand::class,
AutoSendStatementEmailCommand::class
]);
}

View File

@@ -0,0 +1,301 @@
<?php
namespace Modules\Webstatement\Services;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Config;
/**
* Service untuk menangani pengiriman email menggunakan PHPMailer
* dengan dukungan autentikasi NTLM dan GSSAPI
*/
class PHPMailerService
{
protected $mailer;
/**
* Inisialisasi PHPMailer dengan konfigurasi NTLM/GSSAPI
*/
public function __construct()
{
$this->mailer = new PHPMailer(true);
$this->configureSMTP();
Log::info('PHPMailerService initialized with NTLM/GSSAPI support');
}
/**
* Konfigurasi SMTP dengan dukungan NTLM/GSSAPI dan fallback untuk development
*/
protected function configureSMTP(): void
{
try {
// Server settings
$this->mailer->isSMTP();
$this->mailer->Host = config('mail.mailers.phpmailer.host', env('MAIL_HOST'));
$this->mailer->Port = config('mail.mailers.phpmailer.port', env('MAIL_PORT', 587));
// Deteksi apakah perlu autentikasi berdasarkan username
$username = config('mail.mailers.phpmailer.username', env('MAIL_USERNAME'));
$password = config('mail.mailers.phpmailer.password', env('MAIL_PASSWORD'));
// Hanya aktifkan autentikasi jika username dan password tersedia
if (!empty($username) && $username !== 'null' && !empty($password) && $password !== 'null') {
$this->mailer->SMTPAuth = true;
$this->mailer->Username = $username;
$this->mailer->Password = $password;
Log::info('SMTP authentication enabled', [
'username' => $username,
'host' => $this->mailer->Host
]);
// Dukungan NTLM/GSSAPI untuk production
$authType = config('mail.mailers.phpmailer.auth_type', env('MAIL_AUTH_TYPE', 'NTLM'));
if (strtoupper($authType) === 'NTLM') {
$this->mailer->AuthType = 'NTLM';
$this->mailer->Realm = config('mail.mailers.phpmailer.realm', env('MAIL_REALM', ''));
$this->mailer->Workstation = config('mail.mailers.phpmailer.workstation', env('MAIL_WORKSTATION', ''));
Log::info('NTLM authentication configured', [
'realm' => $this->mailer->Realm,
'workstation' => $this->mailer->Workstation
]);
} elseif (strtoupper($authType) === 'GSSAPI') {
$this->mailer->AuthType = 'XOAUTH2';
Log::info('GSSAPI authentication configured');
}
} else {
// Untuk development server seperti Mailpit
$this->mailer->SMTPAuth = false;
Log::info('SMTP authentication disabled for development', [
'host' => $this->mailer->Host,
'port' => $this->mailer->Port
]);
}
// Encryption configuration
$encryption = config('mail.mailers.phpmailer.encryption', env('MAIL_ENCRYPTION'));
$port = $this->mailer->Port;
if (!empty($encryption) && $encryption !== 'null') {
if ($encryption === 'tls' && ($port == 587 || $port == 25)) {
$this->mailer->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
Log::info('Using STARTTLS encryption', ['port' => $port]);
} elseif ($encryption === 'ssl' && ($port == 465 || $port == 993)) {
$this->mailer->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
Log::info('Using SSL encryption', ['port' => $port]);
}
} else {
// Untuk development/testing server
$this->mailer->SMTPSecure = false;
$this->mailer->SMTPAutoTLS = false;
Log::info('Using no encryption (plain text)', ['port' => $port]);
}
// Tambahan konfigurasi untuk kompatibilitas
$this->mailer->SMTPOptions = array(
'ssl' => array(
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true
)
);
// --- TAMBAHKAN BAGIAN INI UNTUK MENGABAIKAN VALIDASI SERTIFIKAT ---
if (isset($config['ignore_certificate_errors']) && $config['ignore_certificate_errors']) {
$this->mailer->SMTPOptions = [
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
],
];
}
// --- AKHIR TAMBAHAN ---
// Debug mode
if (config('app.debug')) {
$this->mailer->SMTPDebug = SMTP::DEBUG_SERVER;
}
// Timeout settings
$this->mailer->Timeout = config('mail.mailers.phpmailer.timeout', 30);
$this->mailer->SMTPKeepAlive = true;
Log::info('SMTP configured successfully', [
'host' => $this->mailer->Host,
'port' => $this->mailer->Port,
'auth_enabled' => $this->mailer->SMTPAuth,
'encryption' => $encryption ?: 'none',
'smtp_secure' => $this->mailer->SMTPSecure ?: 'none'
]);
} catch (Exception $e) {
Log::error('Failed to configure SMTP', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
throw $e;
}
}
/**
* Kirim email dengan attachment
*
* @param string $to Email tujuan
* @param string $subject Subjek email
* @param string $body Body email (HTML)
* @param string|null $attachmentPath Path file attachment
* @param string|null $attachmentName Nama file attachment
* @param string|null $mimeType MIME type attachment
* @return bool
*/
/**
* Kirim email dengan handling khusus untuk development dan production
*
* @param string $to Email tujuan
* @param string $subject Subjek email
* @param string $body Body email (HTML)
* @param string|null $attachmentPath Path file attachment
* @param string|null $attachmentName Nama file attachment
* @param string|null $mimeType MIME type attachment
* @return bool
*/
public function sendEmail(
string $to,
string $subject,
string $body,
?string $attachmentPath = null,
?string $attachmentName = null,
?string $mimeType = 'application/pdf'
): bool {
try {
// Reset recipients dan attachments
$this->mailer->clearAddresses();
$this->mailer->clearAttachments();
// Set sender
$fromAddress = config('mail.from.address', env('MAIL_FROM_ADDRESS'));
$fromName = config('mail.from.name', env('MAIL_FROM_NAME'));
if (!empty($fromAddress)) {
$this->mailer->setFrom($fromAddress, $fromName);
} else {
// Fallback untuk development
$this->mailer->setFrom('noreply@localhost', 'Development Server');
}
// Add recipient
$this->mailer->addAddress($to);
// Content
$this->mailer->isHTML(true);
$this->mailer->Subject = $subject;
$this->mailer->Body = $body;
$this->mailer->AltBody = strip_tags($body);
// Attachment
if ($attachmentPath && file_exists($attachmentPath)) {
$this->mailer->addAttachment(
$attachmentPath,
$attachmentName ?: basename($attachmentPath),
'base64',
$mimeType
);
Log::info('Attachment added to email', [
'path' => $attachmentPath,
'name' => $attachmentName,
'mime_type' => $mimeType,
'file_size' => filesize($attachmentPath)
]);
}
// Attempt to send
$result = $this->mailer->send();
Log::info('Email sent successfully via PHPMailer', [
'to' => $to,
'subject' => $subject,
'has_attachment' => !is_null($attachmentPath),
'host' => $this->mailer->Host,
'port' => $this->mailer->Port,
'auth_enabled' => $this->mailer->SMTPAuth
]);
return $result;
} catch (Exception $e) {
Log::error('Failed to send email via PHPMailer', [
'to' => $to,
'subject' => $subject,
'host' => $this->mailer->Host,
'port' => $this->mailer->Port,
'auth_enabled' => $this->mailer->SMTPAuth,
'error' => $e->getMessage(),
'error_code' => $e->getCode(),
'trace' => $e->getTraceAsString()
]);
return false;
}
}
/**
* Test koneksi SMTP dengan fallback encryption
*
* @return bool
*/
public function testConnection(): bool
{
try {
// Coba koneksi dengan konfigurasi saat ini
$this->mailer->smtpConnect();
$this->mailer->smtpClose();
Log::info('SMTP connection test successful with current config');
return true;
} catch (Exception $e) {
Log::warning('SMTP connection failed, trying fallback', [
'error' => $e->getMessage()
]);
// Fallback: coba tanpa encryption
try {
$this->mailer->SMTPSecure = false;
$this->mailer->SMTPAutoTLS = false;
$this->mailer->smtpConnect();
$this->mailer->smtpClose();
Log::info('SMTP connection successful with fallback (no encryption)');
return true;
} catch (Exception $fallbackError) {
Log::error('SMTP connection test failed completely', [
'original_error' => $e->getMessage(),
'fallback_error' => $fallbackError->getMessage()
]);
return false;
}
}
}
/**
* Dapatkan instance PHPMailer
*
* @return PHPMailer
*/
public function getMailer(): PHPMailer
{
return $this->mailer;
}
}

View File

@@ -2,4 +2,7 @@
return [
'name' => 'Webstatement',
// ZIP file password configuration
'zip_password' => env('WEBSTATEMENT_ZIP_PASSWORD', 'statement123'),
];

View File

@@ -0,0 +1,30 @@
<?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::table('print_statement_logs', function (Blueprint $table) {
$table->string('stmt_sent_type')->after('status')->nullable();
$table->boolean('is_generated')->after('is_available')->nullable()->default(false);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('print_statement_logs', function (Blueprint $table) {
$table->dropColumn('stmt_sent_type');
$table->dropColumn('is_generated');
});
}
};

View File

@@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Menambahkan field tambahan ke tabel customers:
* - home_rt: RT alamat rumah
* - home_rw: RW alamat rumah
* - ktp_rt: RT alamat KTP
* - ktp_rw: RW alamat KTP
* - local_ref: Referensi lokal dengan data panjang
*/
public function up(): void
{
Schema::table('customers', function (Blueprint $table) {
// Field RT dan RW untuk alamat rumah
$table->string('home_rt', 10)->nullable()->comment('RT alamat rumah');
$table->string('home_rw', 10)->nullable()->comment('RW alamat rumah');
// Field RT dan RW untuk alamat KTP
$table->string('ktp_rt', 10)->nullable()->comment('RT alamat KTP');
$table->string('ktp_rw', 10)->nullable()->comment('RW alamat KTP');
// Field untuk referensi lokal dengan tipe data TEXT untuk menampung data panjang
$table->text('local_ref')->nullable()->comment('Referensi lokal dengan data panjang');
// Menambahkan index untuk performa query jika diperlukan
$table->index(['home_rt', 'home_rw'], 'idx_customers_home_rt_rw');
$table->index(['ktp_rt', 'ktp_rw'], 'idx_customers_ktp_rt_rw');
});
}
/**
* Reverse the migrations.
*
* Menghapus field yang ditambahkan jika migration di-rollback
*/
public function down(): void
{
Schema::table('customers', function (Blueprint $table) {
// Hapus index terlebih dahulu
$table->dropIndex('idx_customers_home_rt_rw');
$table->dropIndex('idx_customers_ktp_rt_rw');
// Hapus kolom yang ditambahkan
$table->dropColumn([
'home_rt',
'home_rw',
'ktp_rt',
'ktp_rw',
'local_ref'
]);
});
}
};

View File

@@ -0,0 +1,76 @@
<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Menjalankan migrasi untuk menambahkan support multi_account
* Menggunakan constraint check sebagai alternatif enum
*
* @return void
*/
public function up(): void
{
DB::beginTransaction();
try {
// Hapus constraint enum yang lama jika ada
DB::statement("ALTER TABLE print_statement_logs DROP CONSTRAINT IF EXISTS print_statement_logs_request_type_check");
// Ubah kolom menjadi varchar
Schema::table('print_statement_logs', function (Blueprint $table) {
$table->string('request_type', 50)->change();
});
// Tambahkan constraint check baru dengan multi_account
DB::statement("
ALTER TABLE print_statement_logs
ADD CONSTRAINT print_statement_logs_request_type_check
CHECK (request_type IN ('single_account', 'branch', 'all_branches', 'multi_account'))
");
DB::commit();
Log::info('Migration berhasil: request_type sekarang mendukung multi_account');
} catch (\Exception $e) {
DB::rollback();
Log::error('Migration gagal: ' . $e->getMessage());
throw $e;
}
}
/**
* Membalikkan migrasi
*
* @return void
*/
public function down(): void
{
DB::beginTransaction();
try {
// Hapus constraint yang baru
DB::statement("ALTER TABLE print_statement_logs DROP CONSTRAINT IF EXISTS print_statement_logs_request_type_check");
// Kembalikan constraint lama tanpa multi_account
DB::statement("
ALTER TABLE print_statement_logs
ADD CONSTRAINT print_statement_logs_request_type_check
CHECK (request_type IN ('single_account', 'branch', 'all_branches'))
");
DB::commit();
Log::info('Migration rollback berhasil: multi_account dihapus dari request_type');
} catch (\Exception $e) {
DB::rollback();
Log::error('Migration rollback gagal: ' . $e->getMessage());
throw $e;
}
}
};

View File

@@ -0,0 +1,64 @@
<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Menjalankan migrasi untuk membuat tabel province_core
* Tabel ini menyimpan data master provinsi dengan kode dan nama
*
* @return void
*/
public function up(): void
{
DB::beginTransaction();
try {
Schema::create('province_core', function (Blueprint $table) {
$table->id();
$table->string('code', 10)->unique()->comment('Kode provinsi unik');
$table->string('name', 255)->comment('Nama provinsi');
$table->timestamps();
// Index untuk performa pencarian
$table->index(['code']);
$table->index(['name']);
});
DB::commit();
Log::info('Migration province_core table berhasil dibuat');
} catch (\Exception $e) {
DB::rollback();
Log::error('Migration province_core table gagal: ' . $e->getMessage());
throw $e;
}
}
/**
* Membalikkan migrasi dengan menghapus tabel province_core
*
* @return void
*/
public function down(): void
{
DB::beginTransaction();
try {
Schema::dropIfExists('province_core');
DB::commit();
Log::info('Migration rollback province_core table berhasil');
} catch (\Exception $e) {
DB::rollback();
Log::error('Migration rollback province_core table gagal: ' . $e->getMessage());
throw $e;
}
}
};

View File

@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Menjalankan migrasi untuk menambahkan kolom password ke tabel print_statement_logs
*
* @return void
*/
public function up(): void
{
Schema::table('print_statement_logs', function (Blueprint $table) {
// Menambahkan kolom password setelah kolom stmt_sent_type
$table->string('password', 255)->nullable()->after('stmt_sent_type')
->comment('Password untuk proteksi PDF statement');
// Menambahkan index untuk performa query jika diperlukan
$table->index(['password'], 'idx_print_statement_logs_password');
});
}
/**
* Membalikkan migrasi dengan menghapus kolom password
*
* @return void
*/
public function down(): void
{
Schema::table('print_statement_logs', function (Blueprint $table) {
// Hapus index terlebih dahulu
$table->dropIndex('idx_print_statement_logs_password');
// Hapus kolom password
$table->dropColumn('password');
});
}
};

View File

@@ -0,0 +1,32 @@
<?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::table('processed_statements', function (Blueprint $table) {
$table->string('recipt_no')->nullable()->after('reference_number');
// Menambahkan index untuk field no_receipt jika diperlukan untuk pencarian
$table->index('recipt_no');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('processed_statements', function (Blueprint $table) {
$table->dropIndex(['recipt_no']);
$table->dropColumn('recipt_no');
});
}
};

View File

@@ -8,7 +8,9 @@
"providers": [
"Modules\\Webstatement\\Providers\\WebstatementServiceProvider"
],
"files": [],
"files": [
"app/Helpers/helpers.php"
],
"menu": {
"main": [
{

View File

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

View File

@@ -0,0 +1,575 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rekening Tabungan</title>
<style>
@page {
size: A4;
margin: 0;
}
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #fff;
width: 210mm;
height: 297mm;
display: flex;
flex-direction: column;
}
.container {
width: 190mm;
min-height: 277mm;
margin: 10mm auto;
background: #ffffff;
border: 1px solid #ccc;
padding: 10mm;
box-sizing: border-box;
display: flex;
flex-direction: column;
position: relative;
}
.watermark {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 1;
z-index: 0;
pointer-events: none;
width: 100%;
height: auto;
}
.watermark img {
margin: 0px 50px;
width: 85%;
height: auto;
}
.content-wrapper {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
z-index: 1;
/* Ensure content is above watermark */
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-end;
padding-bottom: 10px;
height: 45px;
}
.header .title {
text-align: left;
font-size: 12px;
color: #0056b3;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.header .title h1 {
padding: 0;
margin: 0;
}
.header .logo {
text-align: center;
display: flex;
align-items: flex-end;
justify-content: center;
}
.header .logo img {
max-height: 50px;
margin-right: 10px
}
.info-section {
text-transform: uppercase;
padding: 10px 0;
font-size: 10px;
font-weight: bold;
}
.info-section .column {
width: 48%;
display: inline-block;
vertical-align: top;
}
.info-section .column p {
margin: 5px 0;
}
.table-section {
padding-top: 15px;
flex: 1;
/* Allow table section to grow */
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
table th,
table td {
padding: 5px;
text-align: left;
font-size: 10px;
}
table th {
text-transform: uppercase;
font-weight: bold;
color: white;
}
table td.text-right {
text-align: right !important;
}
table td.text-center {
text-align: center !important;
}
table td.text-left {
text-align: left !important;
}
.footer {
font-size: 10px;
color: #666;
margin-top: auto;
position: relative;
bottom: 0;
width: 100%;
z-index: 1;
/* Ensure footer is above watermark */
}
.footer p {
margin: 0px;
}
.footer .highlight {
font-weight: bold;
}
.left-25 {
margin-left: 25px !important;
}
.same-size {
display: inline-block;
width: 100px;
}
.sponsor {
border-top: 1.5px solid black;
padding: 10px;
text-align: center;
}
.highlight {
margin: 10px 0px 0px 0px;
padding: 0;
width: 100%;
}
tbody td {
border-bottom: none;
border-top: none;
vertical-align: top;
}
/* Menghilangkan padding dan margin untuk baris narrative tambahan */
tbody tr td {
padding: 5px;
}
/* Khusus untuk baris narrative tambahan - tanpa padding/margin */
tbody tr.narrative-line td {
padding: 2px 5px;
margin: 0;
line-height: 1;
}
/* Column width classes */
.col-date {
width: 10%;
text-align: center;
}
.col-desc {
width: 25%;
min-width: 150px;
max-width: 150px;
}
.col-valuta {
width: 10%;
text-align: center;
}
.col-referensi {
width: 15%;
}
.col-debet,
.col-kredit {
width: 12.5%;
text-align: right;
}
.col-saldo {
width: 15%;
text-align: right;
}
.page-number {
position: absolute;
bottom: 5mm;
right: 10mm;
font-size: 10px;
color: #666;
}
@media print {
body {
width: 210mm;
height: 297mm;
}
.container {
margin: 0;
border: initial;
border-radius: initial;
width: initial;
min-height: initial;
box-shadow: initial;
background: initial;
page-break-after: always;
}
.container:last-of-type {
page-break-after: auto;
}
}
</style>
</head>
<body>
@php
$saldo = $saldoAwalBulan->actual_balance ?? 0;
$totalDebit = 0;
$totalKredit = 0;
$line = 1;
$linePerPage = 26;
@endphp
@php
// Hitung tanggal periode berdasarkan $period
$periodDates = calculatePeriodDates($period);
$startDate = $periodDates['start'];
$endDate = $periodDates['end'];
// Log hasil perhitungan
\Log::info('Period dates calculated', [
'period' => $period,
'start_date' => $startDate->format('d/m/Y'),
'end_date' => $endDate->format('d/m/Y'),
]);
@endphp
@php
// Calculate total pages based on actual line count
$totalLines = 0;
foreach ($stmtEntries as $entry) {
// Split narrative into multiple lines of approximately 35 characters, breaking at word boundaries
$narrative = $entry->description ?? '';
$words = explode(' ', $narrative);
$narrativeLineCount = 0;
$currentLine = '';
foreach ($words as $word) {
if (strlen($currentLine . ' ' . $word) > 30) {
$narrativeLineCount++;
$currentLine = $word;
} else {
$currentLine .= ($currentLine ? ' ' : '') . $word;
}
}
if ($currentLine) {
$narrativeLineCount++;
}
// Each entry takes at least one line for the main data + narrative lines + gap row
$totalLines += $narrativeLineCount; // +1 for gap row
}
// Add 1 for the "Saldo Awal Bulan" row
$totalLines += 1;
// Calculate total pages ($linePerPage lines per page)
$totalPages = ceil($totalLines / $linePerPage);
$pageNumber = 0;
$footerContent =
'
<div class="footer">
<p class="sponsor">Belanja puas di Electronic City! Dapatkan cashback hingga 250 ribu dan nikmati makan enak dengan cashback hingga 25 ribu. Bayar pakai QRIS AGI. S&amp;K berlaku. Info lengkap: www.arthagraha.com</p>
<p class="sponsor">Waspada dalam bertransaksi QRIS! Periksa kembali identitas penjual & nominal pembayaran sebelum melanjutkan transaksi. Info terkait Bank Artha Graha Internasional, kunjungi website www.arthagraha.com</p>
<div class="highlight">
<img src="' .
public_path('assets/media/images/banner-footer.png') .
'" alt="Logo Bank" style="width: 100%; height: auto;">
</div>
</div>
';
@endphp
<div class="container">
<div class="watermark">
<img src="{{ public_path('assets/media/images/watermark.png') }}" alt="Watermark">
</div>
<div class="content-wrapper">
<!-- Header Section -->
<div class="header">
<div class="logo">
<img src="{{ public_path('assets/media/images/logo-arthagraha.png') }}" alt="Logo Bank">
<img src="{{ public_path('assets/media/images/logo-agi.png') }}" alt="Logo Bank">
</div>
</div>
<!-- Bank Information Section -->
<div class="info-section">
<div class="column">
<p>{{ $branch->name }}</p>
<p style="text-transform: capitalize">Kepada</p>
<p>{{ $account->customer->name }}</p>
<p>{{ $account->customer->address }}</p>
<p>{{ $account->customer->district }}
{{ ($account->customer->ktp_rt ?: $account->customer->home_rt) ? 'RT ' . ($account->customer->ktp_rt ?: $account->customer->home_rt) : '' }}
{{ ($account->customer->ktp_rw ?: $account->customer->home_rw) ? 'RW ' . ($account->customer->ktp_rw ?: $account->customer->home_rw) : '' }}
</p>
<p>{{ trim($account->customer->city . ' ' . ($account->customer->province ? getProvinceCoreName($account->customer->province) . ' ' : '') . ($account->customer->postal_code ?? '')) }}
</p>
</div>
<div style="text-transform: capitalize;" class="column">
<p style="padding-left:50px"><span class="same-size">Periode Statement </span>:
{{ dateFormat($startDate) }} <span style="text-transform:lowercase !important">s/d</span>
{{ dateFormat($endDate) }}</p>
<p style="padding-left:50px"><span class="same-size">Nomor Rekening</span>:
{{ $account->account_number }}</p>
</div>
</div>
<!-- Table Section -->
<div class="table-section">
<table>
<thead>
<tr
style="@if ($headerTableBg) background-image: url('data:image/png;base64,{{ $headerTableBg }}'); background-repeat: no-repeat; background-size: cover; background-position: center; @else background-color: #0056b3; @endif height: 30px;">
<th class="col-date">Tanggal</th>
<th class="col-valuta">Tanggal<br>Valuta</th>
<th class="text-left col-desc">Keterangan</th>
<th class="text-left col-referensi">Referensi</th>
<th class="col-debet">Debet</th>
<th class="col-kredit">Kredit</th>
<th class="col-saldo">Saldo</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"></td>
<td class="text-center">&nbsp;</td>
<td><strong>Saldo Awal Bulan</strong></td>
<td class="text-center">&nbsp;</td>
<td class="text-right">&nbsp;</td>
<td class="text-right">&nbsp;</td>
<td class="text-right">
<strong>{{ number_format($saldoAwalBulan->actual_balance, 2, ',', '.') }}</strong>
</td>
</tr>
@foreach ($stmtEntries as $row)
@php
$debit = $row->transaction_amount < 0 ? abs($row->transaction_amount) : 0;
$kredit = $row->transaction_amount > 0 ? $row->transaction_amount : 0;
$saldo += $kredit - $debit;
$totalDebit += $debit;
$totalKredit += $kredit;
// Split narrative into multiple lines of approximately 35 characters, breaking at word boundaries
$narrative = $row->description ?? '';
$words = explode(' ', $narrative);
$narrativeLines = [];
$currentLine = '';
foreach ($words as $word) {
if (strlen($currentLine . ' ' . $word) > 30) {
$narrativeLines[] = trim($currentLine);
$currentLine = $word;
} else {
$currentLine .= ($currentLine ? ' ' : '') . $word;
}
}
if ($currentLine) {
$narrativeLines[] = trim($currentLine);
}
@endphp
<tr>
<td class="text-center">{{ date('d/m/Y', strtotime($row->transaction_date)) }}</td>
<td class="text-center">{{ substr($row->actual_date, 0, 10) }}</td>
<td>{{ str_replace(['[', ']'], ' ', $narrativeLines[0] ?? '') }}</td>
<td>{{ $row->reference_number }}</td>
<td class="text-right">{{ $debit > 0 ? number_format($debit, 2, ',', '.') : '' }}</td>
<td class="text-right">{{ $kredit > 0 ? number_format($kredit, 2, ',', '.') : '' }}
</td>
<td class="text-right">{{ number_format($saldo, 2, ',', '.') }}</td>
</tr>
@for ($i = 1; $i < count($narrativeLines); $i++)
<tr class="narrative-line">
<td class="text-center"></td>
<td class="text-center"></td>
<td>{{ str_replace(['[', ']'], ' ', $narrativeLines[$i] ?? '') }}</td>
<td class="text-center"></td>
<td class="text-right"></td>
<td class="text-right"></td>
<td class="text-right"></td>
</tr>
@endfor
@php $line += count($narrativeLines); @endphp
<!-- Add a gap row -->
<tr class="gap-row">
<td class="text-center"></td>
<td class="text-center"></td>
<td class="text-center"></td>
<td></td>
<td class="text-right"></td>
<td class="text-right"></td>
<td class="text-right"></td>
</tr>
@if ($line >= $linePerPage && !$loop->last)
@php
$line = 0;
$pageNumber++;
@endphp
<tr>
<td class="text-center"></td>
<td class="text-center"></td>
<td><strong>Pindah ke Halaman Berikutnya</strong></td>
<td class="text-center"></td>
<td class="text-right">&nbsp;</td>
<td class="text-right">&nbsp;</td>
<td class="text-right"></td>
</tr>
</tbody>
</table>
</div>
{!! $footerContent !!}
</div>
<div class="page-number">Halaman {{ $pageNumber }} dari {{ $totalPages }}</div>
</div>
<div class="container">
<div class="watermark">
<img src="{{ public_path('assets/media/images/watermark.png') }}" alt="Watermark">
</div>
<div class="content-wrapper">
<!-- Header Section for continuation page -->
<div class="header">
<div class="logo">
<img src="{{ public_path('assets/media/images/logo-arthagraha.png') }}" alt="Logo Bank">
<img src="{{ public_path('assets/media/images/logo-agi.png') }}" alt="Logo Bank">
</div>
</div>
<!-- Bank Information Section for continuation page -->
<div class="info-section">
<div class="column">
<p>{{ $branch->name }}</p>
<p style="text-transform: capitalize">Kepada</p>
<p>{{ $account->customer->name }}</p>
<p>{{ $account->customer->address }}</p>
<p>{{ $account->customer->district }}
{{ ($account->customer->ktp_rt ?: $account->customer->home_rt) ? 'RT ' . ($account->customer->ktp_rt ?: $account->customer->home_rt) : '' }}
{{ ($account->customer->ktp_rw ?: $account->customer->home_rw) ? 'RW ' . ($account->customer->ktp_rw ?: $account->customer->home_rw) : '' }}
</p>
<p>{{ trim($account->customer->city . ' ' . ($account->customer->province ? getProvinceCoreName($account->customer->province) . ' ' : '') . ($account->customer->postal_code ?? '')) }}
</p>
</div>
<div style="text-transform: capitalize;" class="column">
<p style="padding-left:50px"><span class="same-size">Periode Statement </span>:
{{ dateFormat($startDate) }} <span style="text-transform:lowercase !important">s/d</span>
{{ dateFormat($endDate) }}</p>
<p style="padding-left:50px"><span class="same-size">Nomor Rekening</span>:
{{ $account->account_number }}</p>
</div>
</div>
<div class="table-section">
<table>
<thead>
<tr
style="@if ($headerTableBg) background-image: url('data:image/png;base64,{{ $headerTableBg }}'); background-repeat: no-repeat; background-size: cover; background-position: center; @else background-color: #0056b3; @endif height: 30px;">
<th class="col-date">Tanggal</th>
<th class="col-valuta">Tanggal<br>Valuta</th>
<th class="text-left col-desc">Keterangan</th>
<th class="col-referensi">Referensi</th>
<th class="col-debet">Debet</th>
<th class="col-kredit">Kredit</th>
<th class="col-saldo">Saldo</th>
</tr>
</thead>
<tbody>
@endif
@endforeach
@for ($i = 0; $i < $linePerPage - $line; $i++)
<tr>
<td class="text-center"></td>
<td class="text-center"></td>
<td></td>
<td class="text-center"></td>
<td class="text-right"></td>
<td class="text-right"></td>
<td class="text-right"></td>
</tr>
@endfor
<tr>
<td class="text-center"></td>
<td class="text-center"></td>
<td><strong>Total Akhir</strong></td>
<td class="text-center"></td>
<td class="text-right"><strong>{{ number_format($totalDebit, 2, ',', '.') }}</strong></td>
<td class="text-right"><strong>{{ number_format($totalKredit, 2, ',', '.') }}</strong>
</td>
<td class="text-right"><strong>{{ number_format($saldo, 2, ',', '.') }}</strong></td>
</tr>
</tbody>
</table>
</div>
<!-- Footer Section -->
{!! $footerContent !!}
</div>
<div class="page-number">Halaman {{ $pageNumber + 1 }} dari {{ $totalPages }}</div>
</div>
</body>
</html>

View File

@@ -91,7 +91,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 () {
@@ -112,13 +112,8 @@ Route::middleware(['auth'])->group(function () {
Route::resource('email-statement-logs', EmailStatementLogController::class)->only(['index', 'show']);
});
Route::get('migrasi', [MigrasiController::class, 'index'])->name('migrasi.index');
Route::get('biaya-kartu', [SyncLogsController::class, 'index'])->name('biaya-kartu.index');
Route::get('/stmt-entries/{accountNumber}', [MigrasiController::class, 'getStmtEntryByAccount']);
Route::get('/stmt-export-csv', [WebstatementController::class, 'index'])->name('webstatement.index');
Route::prefix('debug')->group(function () {
Route::get('/test-statement',[WebstatementController::class,'printStatementRekening'])->name('webstatement.test');
Route::post('/statement', [DebugStatementController::class, 'debugStatement'])->name('debug.statement');