Compare commits

..

35 Commits

Author SHA1 Message Date
Daeng Deni Mardaeni
4616137e0c feat(webstatement): tambahkan fitur konfirmasi email dan optimasi proses download statement
- **Penambahan Fitur Konfirmasi Email:**
  - Menambahkan event listener untuk form submit:
    - Menampilkan SweetAlert jika field email telah diisi.
    - Mengonfirmasi pengiriman statement ke alamat email yang diisi pengguna.
    - Submit form hanya setelah user mengonfirmasi.

- **Optimalisasi Proses Download Statement:**
  - Menangani logic download statement dalam rentang periode (period range):
    - Mencatat log keberadaan file untuk setiap periode.
    - Membuat file ZIP yang berisi semua file statement yang tersedia dalam rentang tersebut.
    - Mengelola file sementara untuk proses kompresi dengan pembersihan otomatis.
    - Menambahkan log error dan warning untuk file yang hilang dalam rentang periode.
    - Mendukung mekanisme download file tunggal untuk periode tertentu.
  - Menyesuaikan log dengan detail proses, seperti:
    - Informasi periode yang tersedia dan tidak.
    - Notifikasi penyelesaian atau kegagalan proses download ZIP.
  - Menambahkan logging trace pada exception untuk debugging lebih rinci.

- **Perubahan Validasi Logic:**
  - Validasi baru pada `PrintStatementRequest`:
    - Menentukan `is_period_range` hanya jika `period_to` berbeda dengan `period_from`.

- **Perbaikan dan Penyesuaian Pengiriman Email:**
  - Menambahkan pengecekan field email sebelum menjalankan fungsi kirim email di `PrintStatementController`.
  - Mengintegrasikan fungsi `sendEmail` jika terdapat email pada statement.

- **Penambahan Dokumentasi Kode:**
  - Menambahkan komentar inline di beberapa bagian:
    - Logika konfirmasi email.
    - Proses pembuatan ZIP dan penanganan download.
  - Menjelaskan tiap langkah operasional untuk mempermudah pemahaman dan debugging.

Perubahan ini mengintegrasikan fitur konfirmasi email yang lebih interaktif, meningkatkan proses download statement berjenjang, serta memperbaiki validasi dan logging pada tiap langkah proses.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-23 10:24:23 +07:00
Daeng Deni Mardaeni
19c962307e feat(webstatement): tambahkan fitur multi-branch dan perbaikan validasi form pada halaman statements
- **Penambahan Fitur Multi-Branch:**
  - Tambahkan dropdown pilihan cabang (branch) saat fitur multi-branch diaktifkan.
  - Secara otomatis mengisi informasi branch jika hanya tersedia satu branch yang terkait dengan user.

- **Perbaikan Validasi Form:**
  - Memastikan field `account_number` dan `branch_id` memiliki validasi yang lebih ketat.
  - Tambahkan validasi untuk `period_from` agar hanya menerima data periode yang tersedia (`is_available`).

- **Perubahan Tampilan:**
  - Menyesuaikan desain form:
    - Tambahkan kondisi dynamic display pada field branch berdasarkan status multi-branch.
    - Reformat struktur HTML untuk meningkatkan keterbacaan dengan indentasi lebih konsisten.
  - Perbaikan tampilan elemen tabel pada daftar request statement:
    - Mengoptimalkan style menggunakan properti CSS baru pada grid dan typography.

- **Optimasi Query dan Akses Data:**
  - Tambahkan filter berdasarkan `branch_code` agar data hanya terlihat untuk cabang yang relevan dengan user.
  - Optimalkan pengambilan data branch dengan hanya memuat cabang yang aktif.

- **Peningkatan Logging:**
  - Tambahkan log pada pengolahan query untuk mendeteksi masalah akses branch saat user tidak memiliki akses multi-branch.

- **Refaktor Backend:**
  - Tambahkan variable `multiBranch` pada controller untuk mengatur logika UI secara dinamis.
  - Refaktor pencarian branch di server-side untuk mengantisipasi session `MULTI_BRANCH`.

Perubahan ini mendukung fleksibilitas akses cabang untuk user dengan mode multi-branch serta meningkatkan validasi dan pengalaman UI form.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-22 20:57:24 +07:00
Daeng Deni Mardaeni
a79b1bd99e feat(webstatement): perbarui penamaan file PDF dan tambahkan log debug pada PrintStatementController
- **Perubahan Penamaan File PDF:**
  - Mengubah format nama file dari `{account_number}.pdf` menjadi `{account_number}_{period_from}.pdf`.
  - Penyesuaian pada semua lokasi logika penentuan path file di SFTP:
    - Path file period single.
    - Path file pada mode period range.
    - Path file saat kompresi ke dalam ZIP.

- **Penambahan Logging untuk Debugging:**
  - Menambahkan **Log::info** untuk mencatat informasi terkait path file, termasuk:
    - Path relatif file berdasarkan periode dan kode cabang.
    - Root path konfigurasi SFTP.
    - Path final lengkap pada SFTP.

- **Penyesuaian Logika Path:**
  - Memastikan format nama file konsisten di semua fungsi handling periode tunggal dan periode range.
  - Menambahkan logging sebelum proses pengecekan eksistensi file pada SFTP.

- **Peningkatan Monitoring:**
  - Memastikan struktur file dan path dapat dipantau dengan logging untuk mendukung debugging lebih baik.
  - Memberikan konteks tambahan pada setiap log yang relevan untuk memudahkan tracking.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-22 16:50:37 +07:00
daengdeni
fd5b8e1dad feat(webstatement): tambahkan filter data berdasarkan peran pengguna
### Perubahan Utama
- Menambahkan filter data pada `PrintStatementLog` untuk pengguna non-administrator.
- Membatasi query hanya untuk data yang sesuai dengan `user_id` pengguna yang sedang login jika bukan administrator.

### Detail Perubahan
1. **Update Logika Query**:
   - Menambahkan kondisi pengecekan untuk peran pengguna menggunakan `auth()->user()->hasRole('administrator')`.
   - Jika pengguna bukan administrator, query akan otomatis difilter berdasarkan `user_id` dari pengguna yang sedang login dengan fungsi `Auth::id()`.

2. **Peningkatan Keamanan Data**:
   - Membatasi akses data supaya hanya pengguna yang berhak dapat melihat data milik mereka.
   - Memastikan administrator tetap memiliki akses penuh ke semua data tanpa pembatasan.
2025-06-20 14:33:56 +07:00
daengdeni
8fb16028d9 feat(webstatement): tambah validasi cabang rekening dan update logika penyimpanan statement
### Perubahan Utama
- Tambah validasi untuk memverifikasi bahwa nomor rekening sesuai dengan cabang pengguna.
- Cegah transaksi untuk rekening yang terdaftar di cabang khusus (`ID0019999`).
- Perbaikan sistem untuk menangani kasus rekening yang tidak ditemukan di database.

### Detail Perubahan
1. **Validasi Cabang Rekening**:
   - Tambah pengecekan untuk memastikan rekening yang dimasukkan adalah milik cabang pengguna (non-multi-branch).
   - Blokir transaksi jika rekening terdaftar pada cabang khusus (`ID0019999`) dengan menampilkan pesan error yang relevan.
   - Tambahkan pesan error jika nomor rekening tidak ditemukan dalam sistem.

2. **Update Logika Penyimpanan**:
   - Tambahkan validasi untuk mengisi kolom `branch_code` secara otomatis berdasarkan informasi rekening terkait.
   - Otomatis atur nilai awal `authorization_status` menjadi `approved`.

3. **Penghapusan Atribut Tidak Digunakan**:
   - Hapus form `branch_code` dari view terkait (`index.blade.php`) karena sekarang diisi secara otomatis berdasarkan data rekening.

4. **Perbaikan View dan Logika Terkait Status Otorisasi**:
   - Hapus logic dan elemen UI terkait `authorization_status` di halaman statement (`index.blade.php` dan `show.blade.php`).
   - Simplifikasi tampilan untuk hanya menampilkan informasi yang tersedia dan relevan.

5. **Optimasi Query Data Cabang**:
   - Update query untuk memfilter cabang berdasarkan kondisi `customer_company` dan mengecualikan kode cabang khusus.

6. **Penyesuaian Struktur Request**:
   - Hapus validasi terkait `branch_code` di `PrintStatementRequest` karena tidak lagi relevan.

7. **Log Aktivitas dan Kesalahan**:
   - Tambahkan log untuk mencatat aktivitas seperti validasi rekening dan penyimpanan batch data.
   - Penanganan lebih baik untuk logging jika terjadi error saat validasi nomor rekening atau penyimpanan statement.

### Manfaat Perubahan
- Meningkatkan akurasi data cabang dan validasi rekening sebelum penyimpanan.
- Menyederhanakan antarmuka pengguna dengan menghapus field input redundant.
- Memastikan proses menjadi lebih transparan dengan penanganan error yang lebih baik.

Langkah ini diterapkan untuk meningkatkan keamanan dan keandalan sistem dalam memverifikasi dan memproses pemintaan statement.
2025-06-20 13:59:58 +07:00
Daeng Deni Mardaeni
6035c61cc4 feat(webstatement): tingkatkan validasi dan logging pada ProcessStmtEntryDataJob
- **Validasi Data:**
  - Menambahkan validasi untuk memastikan bahwa setiap `entryData` adalah array dan memiliki properti `stmt_entry_id`.
  - Log peringatan ditambahkan untuk mendeteksi struktur data yang tidak valid.

- **Perbaikan Logging:**
  - Logging ditingkatkan untuk mencatat data invalid yang ditemukan selama proses.
  - Menambahkan log peringatan dengan struktur data detail saat validasi gagal.

- **Penghapusan Nested Loop:**
  - Memperbaiki logika iterasi dengan menghapus nested loop dan langsung memproses tiap elemen `entryBatch`.

- **Penghitungan Kesalahan:**
  - Menambahkan penghitungan `errorCount` untuk melacak jumlah data yang mengalami validasi gagal.

Perubahan ini meningkatkan keandalan proses dengan validasi tambahan, mencegah error akibat struktur data tidak valid, serta memberikan informasi log yang lebih rinci.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-17 09:54:26 +07:00
Daeng Deni Mardaeni
2c8f49af20 feat(webstatement): optimalkan saveBatch pada ProcessStmtEntryDataJob
- **Perubahan Mekanisme Simpan Data:**
  - Mengganti pendekatan `delete` dan `insert` dengan `updateOrCreate` untuk mencegah duplicate key error.
  - Menambahkan transaksi database (`DB::beginTransaction()` dan `DB::commit()`) untuk memastikan konsistensi data.
  - Menambahkan logging pada awal dan akhir proses untuk memantau jumlah record yang berhasil diproses.

- **Penanganan Error:**
  - Menambahkan rollback transaksi (`DB::rollback()`) pada exception untuk menghindari data korup.
  - Logging eror ditingkatkan dengan menampilkan pesan dan trace exception secara rinci.

- **Optimasi Loop:**
  - Refinement looping pada `entryBatch` dengan menerapkan chunking untuk efisiensi memori.
  - Proses setiap record menggunakan `updateOrCreate` guna mengurangi overhead penghapusan data secara manual.

- **Peningkatan Logging:**
  - Menambahkan informasi log yang mencakup:
    - Proses awal dan akhir dari `saveBatch`.
    - Jumlah record yang diproses secara sukses.
    - Error yang terjadi selama proses berlangsung.

- **Dokumentasi dan Komentar:**
  - Menambahkan penjelasan detil pada method `saveBatch` untuk memperjelas logika baru.
  - Penyempurnaan komentar agar mencerminkan proses terkini dengan jelas.

Perubahan ini meningkatkan efisiensi dan keandalan proses penyimpanan data batch dengan mengurangi risiko konflik pada database serta memastikan rollback pada situasi error.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-17 09:45:41 +07:00
Daeng Deni Mardaeni
4bfd937490 feat(webstatement): tambahkan pengelolaan kartu ATM dengan fitur batch processing dan CSV tunggal
- **Penambahan Fitur**:
  - Menambahkan metode baru `generateSingleAtmCardCsv` untuk membuat file CSV tunggal tanpa pemisahan cabang:
    - Mencakup seluruh data kartu ATM yang memenuhi syarat.
    - File diunggah ke SFTP tanpa direktori spesifik cabang.
  - Implementasi command `UpdateAllAtmCardsCommand` untuk batch update:
    - Dukungan konfigurasi parameter seperti batch size, ID log sinkronisasi, queue, filter, dan dry-run.

- **Optimasi Logging**:
  - Logging rinci ditambahkan pada semua proses, termasuk:
    - Generasi CSV tunggal.
    - Proses upload CSV ke SFTP.
    - Pembaruan atau pembuatan `KartuSyncLog` dalam batch processing.
    - Progress dan status tiap batch.
    - Error handling dengan detail informasi pada setiap exception.

- **Perbaikan dan Penyesuaian Job**:
  - Penambahan `UpdateAllAtmCardsBatchJob` yang mengatur proses batch update:
    - Mendukung operasi batch dengan pengaturan ukuran dan parameter filtering kartu.
    - Pencatatan log progres secara dinamis dengan kalkulasi batch dan persentase.
    - Menyusun delay antar job untuk performa yang lebih baik.
  - Menyertakan validasi untuk sinkronisasi dan pembaruan data kartu ATM.

- **Refaktor Provider**:
  - Pendaftaran command baru:
    - `UpdateAllAtmCardsCommand` untuk batch update seluruh kartu ATM.
    - Command disertakan dalam provider `WebstatementServiceProvider`.

- **Error Handling**:
  - Peningkatan mekanisme rollback pada database saat error.
  - Menambahkan notifikasi log `failure` apabila job gagal dijalankan.

- **Dokumentasi dan Komentar**:
  - Menambahkan komentar mendetail pada setiap fungsi baru untuk penjelasan lebih baik.
  - Mendokumentasikan seluruh proses dan perubahan pada job serta command baru terkait kartu ATM.

  Perubahan ini meningkatkan efisiensi pengelolaan data kartu ATM, termasuk generasi CSV, proses batch, dan pengunggahan data ke SFTP.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-16 22:51:26 +07:00
Daeng Deni Mardaeni
7b32cb8d39 feat(webstatement): tambah filter product_code dan branch pada GenerateBiayaKartuCsvJob
- Menambahkan filter baru:
  - Memastikan `product_code` tidak termasuk dalam daftar `6002`, `6004`, `6042`, dan `6031`.
  - Menyaring data dengan kondisi branch tidak sama dengan `ID0019999`.

- Optimasi query:
  - Filter tambahan bertujuan untuk mempersempit data hasil pengambilan sehingga lebih relevan dan efisien dalam pembuatan file CSV.

- Peningkatan validasi:
  - Memastikan data yang diekspor sesuai dengan ketentuan baru guna meningkatkan akurasi laporan biaya kartu.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-13 15:11:25 +07:00
Daeng Deni Mardaeni
4b889da5a5 feat(webstatement): tambahkan pengelolaan product_code pada ATM Card
- **Penambahan Field Baru:**
  - Menambahkan field baru `product_code` pada tabel `atmcards` melalui migrasi database.
  - Field bersifat nullable dan memiliki komentar deskriptif untuk dokumentasi skema database.

- **Refaktor Logika pada UpdateAtmCardBranchCurrencyJob:**
  - Menambahkan assignment data `product_code` untuk update kartu ATM berdasarkan informasi account.
  - Mengoptimalkan proses query dengan memperbaiki penggunaan namespace model `Account`.

- **Peningkatan Model Atmcard:**
  - Menambahkan relasi baru `biaya` untuk mendapatkan informasi terkait jenis kartu (`JenisKartu`).
  - Menambah **scope** baru:
    - `active` untuk memfilter kartu ATM yang aktif.
    - `byProductCode` untuk memfilter berdasarkan kode produk (`product_code`).
  - Memperkenalkan accessor dan mutator untuk memastikan format `product_code` konsisten (uppercase, trimmed).
  - Menambahkan logging pada setiap akses relasi atau perubahan terkait field `product_code`.

- **Penyesuaian Logging:**
  - Memperbanyak log untuk monitoring aktivitas, termasuk:
    - Akses dan perubahan data `product_code`.
    - Scope query pada model `Atmcard`.

- **Migrasi Database:**
  - Menambahkan proses safe migration dengan transaksi pada operasi `up` dan `down`.
  - Mencatat log saat migrasi berhasil atau rollback diperlukan jika terjadi kesalahan.

- **Optimisasi dan Perbaikan Format:**
  - Mengorganisasi ulang import pada file `UpdateAtmCardBranchCurrencyJob` sesuai standar PSR-12.
  - Membenahi key output response dari `openCategory` menjadi `acctType` untuk dukungan data baru `product_code`.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-13 15:06:37 +07:00
Daeng Deni Mardaeni
dbdeceb4c0 feat(webstatement): optimalkan pengambilan informasi account untuk UpdateAtmCardBranchCurrencyJob
- **Penambahan Logika Pengambilan Data:**
  - Menambahkan proses pengambilan data account dari model `Account` sebelum memanggil API Fiorano.
  - Melakukan pencarian data berdasarkan nomor rekening (`account_number`) melalui query pada model.
  - Jika data ditemukan, mengembalikan informasi account berupa response format yang menyerupai hasil dari API.

- **Optimisasi Response:**
  - Menyusun data response lengkap dari model `Account`, seperti kode cabang (`branch_code`), mata uang (`currency`), kategori pembukaan (`open_category`), dan properti lain yang relevan.
  - Field response menyertakan nilai default atau diisi dengan data lain yang ada dalam model.

- **Fallback API Fiorano:**
  - Jika data dari database tidak ditemukan, tetap menggunakan mekanisme existing untuk melakukan request ke API Fiorano.
  - Tidak ada perubahan lain pada struktur permintaan atau penanganan response Fiorano.

- **Komentar dan Dokumentasi:**
  - Memperbarui komentar pada fungsi `getAccountInfo` untuk mencerminkan logika terbaru.
  - Menjelaskan fallback ke API jika data model tidak tersedia melalui komentar inline agar lebih mudah dipahami.

- **Peningkatan Efisiensi:**
  - Mengurangi frekuensi panggilan API Fiorano dengan memanfaatkan data lokal terlebih dahulu, sehingga mempercepat proses eksekusi job.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-13 14:33:47 +07:00
Daeng Deni Mardaeni
f7a92a5336 refactor(webstatement): sesuaikan format list pada template email statement
- **Perubahan Format List:**
  - Mengganti properti `class="dashed-list"` dengan `style="list-style-type: none;"` untuk meningkatkan estetika dan konsistensi tampilan.
  - Menambahkan simbol `-` pada setiap item list untuk memperjelas poin dalam deskripsi password.

- **Peningkatan Konsistensi:**
  - Perbaikan dilakukan pada bagian deskripsi password dalam bahasa Indonesia dan Inggris untuk menjaga keseragaman format.
  - Memastikan semua elemen list menggunakan format yang seragam.

- **Penyesuaian Detail:**
  - Menonjolkan elemen list dengan properti HTML agar lebih mudah dipahami oleh penerima email.
  - Perbaikan minor pada struktur dan whitespace untuk meningkatkan keterbacaan kode.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-12 12:15:56 +07:00
Daeng Deni Mardaeni
b717749450 refactor(webstatement): perbaikan tampilan dan penyesuaian template email pada statement
- **Penyesuaian Layout dan Tampilan:**
  - Mengubah properti CSS pada `.container`:
    - `max-width` diubah dari `90%` menjadi `100%`.
    - `margin` diubah dari `20px auto` menjadi `0px auto`.
  - Mengurangi padding pada elemen `.content` dari `30px` menjadi `5px` untuk meningkatkan efisiensi ruang tampilan.

- **Perbaikan Format Signature:**
  - Menggabungkan pemisah signature menjadi satu baris panjang untuk meningkatkan estetika dan keterbacaan.
  - Menghapus elemen `<wbr>` yang berlebihan.

- **Penghapusan Footer:**
  - Menghilangkan bagian footer yang terdapat elemen copyright dan informasi customer service.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-12 09:19:07 +07:00
Daeng Deni Mardaeni
e5b8dfc7c4 feat(webstatement): tambahkan pengiriman email dengan fallback konfigurasi
- **Implementasi Custom Email Sender:**
  - Menambahkan metode `send()` dengan EsmtpTransport untuk mendukung pengiriman email menggunakan fallback konfigurasi yang lebih fleksibel.
  - Penanganan port `STARTTLS` dengan koneksi manual serta pengaturan SSL.

- **Refaktor Method Build:**
  - Memperbaiki tampilan struktur email:
    - Menyertakan template email, attachment, dan penempatan properti email secara lebih dinamis.
    - Mendukung file PDF atau ZIP sebagai lampiran.

- **Implementasi Konversi Laravel to Symfony Mailer:**
  - Metode `toSymfonyEmail()` diimplementasikan untuk mengonversi email Laravel ke Symfony Mailer.
  - Penanganan dari email `from`, `to`, `subject`, hingga body HTML secara langsung.
  - Penambahan mekanisme attachment dengan validasi eksistensi file.

- **Peningkatan Logging:**
  - Menambahkan logging detail untuk setiap tahap proses pengiriman, termasuk metode fallback yang berhasil atau gagal.
  - Log peringatan dan error ditambahkan saat terjadi kegagalan di setiap metode yang dicoba.

- **Penanganan Error:**
  - Menangani pengecualian dan pencatatan error terakhir dari koneksi email manual serta fallback ke Laravel mailer jika cara custom gagal.

- **Konsistensi Format:**
  - Melakukan perapihan atribut class serta alignment pada kode untuk meningkatkan keterbacaan dan konsistensi.

- **Optimalisasi Email Statement:**
  - Memastikan format periode (bulanan/range) tampil dinamis pada subjek email.
  - Menambahkan validasi serta pengelolaan untuk lampiran file sebelum pengiriman.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-12 09:18:42 +07:00
Daeng Deni Mardaeni
d5482fb824 refactor(webstatement): restrukturisasi kode pada SendStatementEmailJob
- **Perbaikan Struktural:**
  - Melakukan perapihan kode dengan konsistensi indentasi dan penyusunan namespace seluruh file.
  - Menambahkan dan mengimpor namespace baru seperti `Throwable`, `InvalidArgumentException`, dan `Exception`.

- **Peningkatan Readability:**
  - Menambahkan format dan penyesuaian pada komentar, khususnya penjelasan method dan atribut.
  - Menggunakan alignment untuk parameter pada log dan constructor untuk meningkatkan keterbacaan.

- **Pengelolaan Job:**
  - Menambahkan logging detail saat memulai, menjalankan, hingga menyelesaikan job.
  - Menambahkan penanganan proses tiap akun dalam batch, termasuk logging sukses/gagal dan pembaruan status log.

- **Penanganan Error:**
  - Menambahkan rollback database jika terjadi exception pada saat proses pengiriman email.
  - Melakukan logging error dengan detail tambahan termasuk pesan dan trace.

- **Penambahan Utility:**
  - Menambahkan metode reusable seperti `updateLogStatus` untuk update status log dengan parameter dinamis.
  - Menambahkan validasi seperti pengecekan eksistensi file PDF dan email terkait sebelum pengiriman.

- **Peningkatan Proses Batch:**
  - Menambahkan pengelolaan batch berbasis properti `batchId` untuk tracking.
  - Memperbaiki handle retries dan status akhir batch secara komprehensif (completed, failed).
  - Menambahkan logging agregat untuk jumlah akun yang diproses dan tingkat keberhasilan.

- **Peningkatan Validasi Email:**
  - Menambahkan skenario untuk pengambilan email dari `stmt_email` atau fallback ke data customer jika tersedia.
  - Menambahkan peringatan saat akun tertentu tidak memiliki email yang valid.

- **Pemeliharaan File PDF:**
  - Mengecek keberadaan file PDF terkait sebelum proses pengiriman.
  - Menampilkan log error jika file tidak ditemukan.

- **Peningkatan Retry dan Logging Gagal:**
  - Implementasi metode `failed()` untuk logging pada job yang gagal permanen.
  - Menangani detail error dan rollback pada level tiap akun dan batch.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-12 09:18:16 +07:00
Daeng Deni Mardaeni
f6df453ddc refactor(webstatement): perbaiki pembentukan logika, struktur kode, dan validasi parameter pada SendStatementEmailCommand
- **Perbaikan Struktur Kode:**
  - Melakukan perapihan kode dengan konsistensi indentasi dan penyusunan namespace.
  - Memisahkan logika kompleks dan mengorganisasi ulang kode untuk meningkatkan keterbacaan.
  - Menambahkan namespace `InvalidArgumentException`.

- **Peningkatan Validasi:**
  - Menambahkan validasi komprehensif untuk parameter `period`, `type`, `--account`, dan `--branch`.
  - Validasi lebih spesifik untuk memastikan account atau branch terkait sesuai kebutuhan.
  - Memberikan pesan error informatif ketika validasi gagal.

- **Peningkatan Metode Utility:**
  - Menambahkan metode `validateParameters` untuk menangani berbagai skenario validasi input.
  - Menambahkan metode `determineRequestTypeAndTarget` untuk memisahkan logika penentuan tipe request.
  - Memperbarui metode `createLogEntry` untuk menyesuaikan atribut log dengan lebih baik berdasarkan request type.

- **Perbaikan Feedback Pengguna:**
  - Menampilkan informasi yang lebih rinci terkait status pengiriman email, seperti parameter validasi, log ID, dan batch ID.
  - Memberikan panduan untuk monitoring queue melalui command.

- **Penanganan Error dan Logging:**
  - Menambahkan logging detail untuk error yang terjadi dalam proses pengiriman email.
  - Memastikan rollback jika terjadi kegagalan selama proses dispatch job.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-12 09:15:38 +07:00
Daeng Deni Mardaeni
9199a4d748 feat(webstatement): tambahkan fitur monitoring dan peningkatan pengiriman email statement
- **Perbaikan dan Penambahan Komando:**
  - Memberikan komando baru `webstatement:check-progress` untuk memantau progres pengiriman email statement.
    - Menampilkan informasi seperti `Log ID`, `Batch ID`, `Request Type`, status, hingga persentase progress.
    - Menangani secara detail jumlah akun yang diproses, sukses, gagal, dan kalkulasi tingkat keberhasilan.
    - Menyediakan penanganan error jika log tidak ditemukan atau terjadi kegagalan lainnya.
  - Memperluas komando `webstatement:send-email`:
    - Mendukung pengiriman berdasarkan `single account`, `branch`, atau `all branches`.
    - Menambahkan validasi parameter `type` (`single`, `branch`, `all`) dan input spesifik seperti `--account` atau `--branch` untuk mode tertentu.
    - Melakukan pencatatan log awal dengan metadata lengkap seperti `request_type`, `batch_id`, dan status.

- **Peningkatan Logika Proses Backend:**
  - Menambahkan fungsi `createLogEntry` untuk mencatat log pengiriman email statement secara dinamis berdasarkan tipe request.
  - Menyediakan reusable method seperti `validateParameters` dan `determineRequestTypeAndTarget` untuk mempermudah pengelolaan parameter pengiriman.
  - Memberikan feedback dan panduan kepada pengguna mengenai ID log dan komando monitoring (`webstatement:check-progress`).

- **Penambahan Controller dan Fitur UI:**
  - Menambahkan controller baru `EmailStatementLogController`:
    - Mendukung pengelolaan log seperti list, detail, dan retry untuk pengiriman ulang email statement.
    - Menyediakan fitur pencarian, filter, dan halaman data log yang responsif menggunakan datatable.
    - Menambahkan kemampuan resend email untuk log dengan status `completed` atau `failed`.
  - Mengimplementasikan UI untuk log pengiriman:
    - Halaman daftar monitoring dengan filter berdasarkan branch, account number, request type, status, dan tanggal.
    - Menampilkan kemajuan, tingkat keberhasilan, serta tombol aksi seperti detail dan pengiriman ulang.

- **Peningkatan Model dan Validasi:**
  - Menyesuaikan model `PrintStatementLog` untuk mendukung lebih banyak atribut seperti `processed_accounts`, `success_count`, `failed_count`, `request_type`, serta metode utilitas seperti `getProgressPercentage()` dan `getSuccessRate()`.
  - Memvalidasi parameter input lebih mendalam agar kesalahan dapat diminimalisasi di awal proses.

- **Peningkatan pada View dan Feedback Pengguna:**
  - Menambah daftar command berguna untuk user di interface log:
    - Status antrian dengan `php artisan queue:work`.
    - Monitoring menggunakan komando custom yang baru ditambahkan.

- **Perbaikan Logging dan Error Handling:**
  - Menambahkan logging komprehensif pada semua proses, termasuk batch pengiriman ulang.
  - Memastikan rollback pada database jika terjadi error melalui transaksi pada critical path.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-11 09:57:09 +07:00
Daeng Deni Mardaeni
f3c649572b feat(webstatement): tambahkan fitur pengiriman email statement PDF
- Menambahkan Command `SendStatementEmailCommand` untuk mengirim email statement PDF:
  - Mendukung parameter input seperti periode laporan (`YYYY-MM`), nomor rekening, ID batch, queue, dan delay waktu.
  - Menjalankan validasi parameter input, mencatat log eksekusi, dan mendispatch job pengiriman email.
  - Menyediakan feedback status eksekusi serta informasi job kepada user.

- Menambahkan Job `SendStatementEmailJob` untuk pengiriman statement dalam latar belakang:
  - Memfilter account yang memiliki email terkait, baik dari `stmt_email` atau email dari data customer.
  - Melakukan pengiriman email dengan attachment file PDF statement.
  - Mencatat log sukses atau kegagalan pengiriman untuk setiap account.

- Memperbarui Model dan Template Email:
  - Mengubah template email untuk mendukung pengisian nama rekening secara dinamis berdasarkan customer account.
  - Menambahkan pengisian dinamis untuk tahun copyright di footer.

- Memperbarui Provider `WebstatementServiceProvider`:
  - Mendaftarkan Command baru `SendStatementEmailCommand` ke dalam aplikasi.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-10 13:50:00 +07:00
Daeng Deni Mardaeni
55313fb0b0 feat(webstatement): tambahkan fitur retry dengan handling timeout pada laporan transaksi ATM
- Memperbarui logika retry pada `AtmTransactionReportController`:
  - Memperbolehkan retry jika status laporan adalah `failed`, `pending`, atau laporan dengan status `processing` yang telah melebihi batas waktu (1 jam).
  - Menambahkan atribut baru seperti `processing_hours` dan `is_processing_timeout` pada data untuk menampilkan informasi durasi proses dan flag timeout.
  - Mengubah status laporan menjadi `failed` jika melebihi batas waktu sebelum dilakukan retry.
  - Memperbarui error message untuk mencatat alasan timeout.

- Menambahkan metode baru `canRetry` pada controller:
  - Mengembalikan boolean jika laporan dapat di-retry berdasarkan status dan kondisi laporan.

- Memperbarui tampilan untuk bagian daftar laporan (`atm-reports/index.blade.php`):
  - Menambahkan tombol retry dengan warna yang disesuaikan (kuning/oranye untuk `failed`/`pending`, merah untuk timeout).
  - Memperbarui tampilan status laporan menjadi lebih informatif, termasuk keterangan durasi proses jika timeout.

- Memperbarui tampilan detail laporan (`atm-reports/show.blade.php`):
  - Menambahkan tombol retry dengan label tambahan "Retry (Timeout)" jika melebihi batas waktu proses.
  - Menampilkan informasi tambahan seperti waktu proses jika laporan dalam status `processing`.

- Menyediakan fungsi JavaScript baru `retryReport` untuk handling retry via AJAX:
  - Menyertakan konfirmasi sebelum retry.
  - Memperbarui tombol retry agar lebih reaktif terhadap perubahan status laporan.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-10 11:17:17 +07:00
Daeng Deni Mardaeni
8a7d4f351c feat(webstatement): tambahkan fitur retry laporan transaksi ATM
- Menambahkan method baru `retry` pada `AtmTransactionReportController` untuk memproses ulang laporan transaksi ATM:
  - Mengizinkan retry untuk laporan dengan status `failed` atau `pending`.
  - Mereset status laporan menjadi `processing` dan membersihkan informasi error sebelumnya.
  - Dispatch ulang job `GenerateAtmTransactionReportJob`.
  - Menambahkan mekanisme error handling dengan memperbarui status laporan jika terjadi kegagalan.

- Memperbarui view `atm-reports/index.blade.php`:
  - Menambahkan tombol `Retry Job` pada baris laporan dengan status `failed` atau `pending`.
  - Menyediakan fungsi JavaScript untuk memproses retry dengan AJAX:
    - Menampilkan konfirmasi sebelum retry.
    - Reload halaman setelah retry selesai.

- Memperbarui view `atm-reports/show.blade.php`:
  - Menambahkan tombol `Retry Job` untuk laporan dengan status `failed`, `pending`, atau `completed` yang kehilangan file.
  - Menampilkan form retry dalam pesan error jika file laporan tidak tersedia.

- Memperbarui routing pada `web.php`:
  - Menambahkan route baru `atm-reports.retry` untuk endpoint retry dengan HTTP POST.

- Mendaftarkan ulang command `GenerateAtmTransactionReport` pada provider:
  - Memastikan job untuk retry sudah terdaftar pada sistem.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-09 01:53:20 +07:00
Daeng Deni Mardaeni
f800c97a40 feat(webstatement): tambahkan halaman detail laporan transaksi ATM
- Menambahkan view `atm-reports/show.blade.php` untuk menampilkan detail laporan transaksi ATM:
  - Menampilkan informasi laporan seperti periode, tanggal laporan, status, dan status otorisasi.
  - Menyediakan informasi file laporan jika status selesai, seperti path, ukuran file, dan jumlah data.
  - Menampilkan pesan error jika status laporan gagal, termasuk pesan kesalahan detail.
  - Menampilkan status unduhan laporan beserta waktu unduhan jika sudah diunduh.

- Menambahkan informasi pengguna terkait:
  - Pihak yang membuat, memodifikasi, dan memberikan otorisasi laporan.
  - Metadata tambahan seperti IP address dan user agent.

- Menambahkan form otorisasi untuk laporan dengan status `pending authorization`:
  - Menyediakan opsi untuk `approve` atau `reject` laporan.
  - Menyertakan field remarks sebagai catatan keputusan otorisasi.

- Memasukkan elemen navigasi:
  - Tombol kembali ke daftar laporan.
  - Tombol unduh file laporan (jika tersedia).

- Menyertakan scripting tambahan untuk inisialisasi dinamika halaman menggunakan JavaScript.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-09 01:46:18 +07:00
Daeng Deni Mardaeni
8fa4b2ea9e feat(webstatement): tambahkan menu dan halaman untuk laporan transaksi ATM
- Menambahkan menu baru "Laporan Transaksi ATM" di file `module.json`:
  - Pengaturan path: `atm-reports`.
  - Ikon: `ki-filled ki-printer text-lg text-primary`.
  - Role akses hanya untuk `administrator`.

- Menambahkan breadcrumb untuk laporan transaksi ATM di file `breadcrumbs.php`:
  - Nama breadcrumb: `atm-reports.index`.
  - Nama tampilan: `Laporan Transaksi ATM`.
  - Mengarah ke route `atm-reports.index`.

- Menambahkan view halaman `atm-reports/index.blade.php`:
  - Form permintaan laporan transaksi ATM:
    - Input tanggal laporan.
    - Tombol submit laporan.
  - Tabel daftar laporan yang mendukung:
    - Pagination, filter, dan search.
    - Status laporan (`completed`, `processing`, `pending`, `failed`).
    - Status otorisasi (`approved`, `rejected`, `pending`).
    - Aksi: Lihat detail, unduh (jika selesai), dan hapus laporan (jika pending/failed).

- Menambahkan script handling untuk:
  - Delete laporan transaksi dengan modal konfirmasi menggunakan Swal.
  - Konfigurasi data datatable dengan sorting, pagination, dan API.

- Tujuan pembaruan:
  - Memberikan akses pengguna untuk mengelola laporan transaksi ATM melalui UI.
  - Memungkinkan permintaan, pelacakan, dan penghapusan laporan transaksi ATM secara mudah.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-08 23:45:05 +07:00
Daeng Deni Mardaeni
1f4d37370e feat(webstatement): tambahkan AtmTransactionReportController untuk pengelolaan laporan transaksi ATM
- Menambahkan controller `AtmTransactionReportController` untuk mengelola laporan transaksi ATM.
- Fungsi utama yang disediakan:
  1. **index**: Menampilkan daftar laporan transaksi ATM.
  2. **create**: Menampilkan form untuk permintaan laporan baru.
  3. **store**: Menghandle penyimpanan permintaan laporan baru, termasuk validasi input, pembuatan log laporan, dan dispatching job.
  4. **show**: Menampilkan detail laporan transaksi ATM berdasarkan log laporan.
  5. **download**: Melakukan unduhan file laporan jika telah selesai diproses.
  6. **authorize**: Menghandle otorisasi permintaan laporan, termasuk validasi status `approved` atau `rejected`.
  7. **dataForDatatables**: Memberikan data laporan untuk tabular dengan filter, sorting, dan pagination.
  8. **destroy**: Menghapus laporan transaksi ATM, termasuk file terkait jika ada.
  9. **sendEmail**: Mengirim laporan ke email jika laporan dan alamat email tersedia.

- Fitur tambahan:
  - Memastikan validasi input untuk keamanan data pengguna.
  - Menambahkan updating log laporan seperti status, error, hingga metadata unduhan.
  - Mendukung pencarian dan filtering data untuk pengelolaan laporan berjumlah besar.

- Dispatch job `GenerateAtmTransactionReportJob` untuk menghasilkan laporan transaksi secara background:
  - Menambahkan log detail jika terjadi kegagalan saat dispatch.

- Tujuan pembaruan:
  - Mempermudah pengelolaan dan pelacakan laporan transaksi ATM.
  - Menyediakan antarmuka user-friendly untuk pengguna, termasuk validasi dan feedback status laporan.
  - Meningkatkan fleksibilitas dan efisiensi pengelolaan laporan dengan mendukung filter, sorting, dan job asynchronous.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-08 23:42:25 +07:00
Daeng Deni Mardaeni
49f90eef43 feat(webstatement): tambahkan model AtmTransactionReportLog
- Menambahkan model `AtmTransactionReportLog` untuk pengelolaan log laporan transaksi ATM.
- Memperkenalkan atribut berikut:
  - `period`, `report_date`, `status`, `authorization_status`, `file_path`, `file_size`, `record_count`, `error_message`, `is_downloaded`, `downloaded_at`, `user_id`, `created_by`, `updated_by`, `authorized_by`, `authorized_at`, `ip_address`, `user_agent`.
- Menambahkan pengaturan casting untuk tipe data seperti `date`, `datetime`, `boolean`, dan `integer`.
- Menambahkan relasi `belongsTo` ke model `User` untuk atribut:
  - `user_id` (pembuat permintaan laporan).
  - `created_by` (pencipta entri log).
  - `authorized_by` (pemberi otorisasi laporan)

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-08 23:41:56 +07:00
Daeng Deni Mardaeni
6eef6e89bf feat(webstatement): tambahkan migrasi tabel log laporan transaksi ATM
- Menambahkan file migrasi baru `create_atm_transaction_report_logs_table`:
  - Membuat tabel `atm_transaction_report_logs` untuk menyimpan log laporan transaksi ATM.
  - Struktur tabel meliputi:
    - Field `period` (format Ymd) untuk menyimpan periode laporan.
    - Status laporan `status` dengan opsi: `pending`, `processing`, `completed`, dan `failed`.
    - Status otorisasi `authorization_status` dengan opsi: `pending`, `approved`, dan `rejected`.
    - Informasi file laporan seperti `file_path`, `file_size`, dan `record_count`.
    - Informasi kesalahan dengan `error_message`.
    - Status unduhan laporan dengan `is_downloaded` dan `downloaded_at`.
    - Informasi user terkait (`user_id`, `created_by`, `updated_by`, dan `authorized_by`).
    - Metadata lain seperti `ip_address`, `user_agent`, serta timestamps.

- Menambahkan beberapa indeks untuk optimasi:
  - Indeks untuk kolom `period`, `status`, `authorization_status`, dan `created_at`.

- Menambahkan fungsi rollback untuk menghapus tabel jika migrasi dibatalkan.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-08 23:41:32 +07:00
Daeng Deni Mardaeni
8d4accffaf feat(webstatement): tambahkan command generate laporan transaksi ATM
- Menambahkan command baru `webstatement:generate-atm-report`:
  - Membuat laporan transaksi ATM berdasarkan periode yang ditentukan melalui opsi `--period` dengan format `Ymd` (contoh: 20250512).
  - Melakukan validasi format periode untuk memastikan input sesuai.
  - Menampilkan pesan error jika format periode tidak valid atau tidak disertakan.

- Mengintegrasikan dengan job `GenerateAtmTransactionReportJob`:
  - Mendukung antrian jalur background untuk pengelolaan laporan besar.
  - Menyediakan informasi log tambahan, seperti periode laporan dan status pembuatan laporan di background.

- Menangani error saat proses:
  - Menampilkan pesan error detail jika terjadi exception saat dispatch job.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-08 23:41:10 +07:00
Daeng Deni Mardaeni
903cbd1725 feat(webstatement): tambahkan job untuk menghasilkan laporan transaksi ATM
- Menambahkan class baru `GenerateAtmTransactionReportJob` untuk menghasilkan laporan transaksi ATM dalam format CSV.
  - Memproses transaksi ATM berdasarkan periode tertentu dengan pagination menggunakan `chunk`.
  - Mendukung inisialisasi log laporan menggunakan model `AtmTransactionReportLog`.
  - Menyertakan fitur untuk menulis header CSV dan data transaksi, termasuk handling escape karakter pada nilai CSV.

- Memperkenalkan direktori penyimpanan baru:
  - Path: `reports/atm_transactions/<period>.csv`.
  - Membuat direktori jika belum ada sebelum menyimpan laporan.

- Menambahkan logging:
  - Menyertakan informasi waktu mulai, lokasi file laporan CSV, dan jumlah transaksi yang diproses.
  - Menangani error dengan logging error message dan memperbarui status log laporan.

- Menambahkan logika pembaruan log laporan:
  - Field `status`, `file_path`, `file_size`, dan `record_count` akan diperbarui setelah proses selesai.
  - Handling error pada log laporan jika proses gagal.

- Fitur tambahan:
  - Handling escape untuk nilai CSV guna memastikan format tetap valid.
  - Menangani error dengan throwing exception jika terjadi masalah selama proses.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-08 23:40:52 +07:00
Daeng Deni Mardaeni
3720a24690 feat(webstatement): dukung multiple file r23 pada proses combine PDF
- Memperbarui `CombinePdfController`:
  - Menambahkan logika untuk mendukung multiple file r23, baik dari `local` maupun `sftp`.
  - Mengimplementasikan pencarian file secara dinamis menggunakan pola glob untuk file lokal (`local`).
  - Menambahkan proses pengunduhan file r23 secara bertahap berdasarkan urutan dari SFTP.
  - Menyortir file r23 berdasarkan urutan numerik untuk memastikan urutan yang benar dalam penggabungan PDF.
  - Memindahkan semua file r23 ke direktori sementara sebelum proses gabungan.

- Memungkinkan konfigurasi sumber file r23:
  - Jika `local`, sistem akan mencari semua file dengan nama akun dan variasi urutan di folder penyimpanan lokal.
  - Jika `sftp`, sistem akan mengunduh semua file r23 dengan pola tertentu dari SFTP dan menyimpannya secara temporer.

- Memperbarui log:
  - Menambahkan informasi jumlah file r23 yang ditemukan untuk setiap akun.
  - Menambahkan log error detail saat terjadi kegagalan pengunduhan file dari SFTP.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-08 10:33:27 +07:00
Daeng Deni Mardaeni
58a5db7303 refactor(webstatement): sederhanakan logika pemrosesan data transfer dana
- Menghapus penggunaan batching data (`transferBatch`) untuk penyederhanaan logika:
  - Menghapus konstanta `CHUNK_SIZE`.
  - Menghapus variabel dan metode terkait batching, seperti `transferBatch`, `addToBatch`, dan `saveBatch`.
  - Memproses dan menyimpan setiap baris data langsung melalui metode `saveRecord`.

- Memperbarui metode `processRow`:
  - Menggantikan logika penambahan batch dengan langsung memanggil `saveRecord`.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-06 21:00:12 +07:00
Daeng Deni Mardaeni
47bf23302f refactor(webstatement): ganti namespace trait Userstamps untuk kompatibilitas
- Mengganti pemanggilan trait `Userstamps` dari `Wildside\Userstamps\Userstamps` menjadi `Mattiverse\Userstamps\Traits\Userstamps`:
  - Update dilakukan pada file `Modules/Webstatement/app/Models/Base.php`.
  - Perubahan ini memastikan penggunaan namespace yang sesuai dan kompatibel dengan library yang terbaru.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-05 16:50:18 +07:00
Daeng Deni Mardaeni
d85954bdf2 feat(webstatement): tambahkan pengaturan tujuan output dan unggah ke SFTP untuk combine PDF
- Memperbarui `CombinePdfJob`:
  - Menambahkan parameter baru `outputDestination`, `branchCode`, dan `period` pada constructor untuk pengaturan tujuan output.
  - Menambahkan opsi tujuan output ke `local` atau `sftp` pada proses combine PDF.
  - Menambahkan metode baru `uploadToSftp` untuk mengunggah file PDF hasil gabungan ke SFTP.
  - Mengatur jalur unggahan SFTP ke `combine/{period}/{branchCode}/{filename}`.
  - Menambahkan log informasi terkait jalur dan status unggahan file PDF ke SFTP.

- Memperbarui `CombinePdfController`:
  - Menambahkan konfigurasi `output_destination` untuk menentukan tujuan output (`local` atau `sftp`).
  - Memperbarui pemanggilan `CombinePdfJob::dispatch` dengan parameter baru untuk konfigurasi output dan SFTP.
  - Menyesuaikan log dan respons untuk mencerminkan tujuan output yang disetel.

- Tujuan pembaruan ini:
  - Memungkinkan pengaturan flexibel tujuan penyimpanan file PDF ke lokal atau SFTP.
  - Menyediakan log yang lebih informatif terkait proses combine PDF dan unggahan SFTP.
  - Mempermudah integrasi dan pengelolaan file PDF dengan pengaturan jalur dan periodisasi yang jelas.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-05 11:37:09 +07:00
Daeng Deni Mardaeni
db99465690 feat(webstatement): tambahkan konfigurasi sumber file r23 dan optimalkan log combine PDF
- Memperbarui fungsi `combinePdfs` di `CombinePdfController`:
  - Menambahkan konfigurasi sumber file `r23` dengan opsi `local` atau `sftp`.
  - Memindahkan logika pencarian file `r23` ke dalam pengaturan berbasis konfigurasi:
    - Jika menggunakan `local`, sistem mencari file `r23` dalam penyimpanan lokal.
    - Jika menggunakan `sftp`, sistem mengunduh file `r23` dari SFTP dan menyimpannya ke direktori sementara.
  - Menambahkan log informasi untuk menentukan sumber file `r23` yang digunakan (`local` atau `sftp`).
  - Merubah pencatatan log yang sebelumnya statis menjadi dinamis berdasarkan konfigurasi.

- Menyesuaikan pengelolaan jalur file `r23`:
  - Menambahkan jalur sementara untuk penyimpanan file hasil unduhan SFTP.
  - Memisahkan logika jalur file lokal dan file unduhan berdasarkan konfigurasi.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-05 11:31:31 +07:00
Daeng Deni Mardaeni
df5d0c420b feat(webstatement): optimalkan logika combine PDF dan pengaturan password dinamis
- Memperbarui fungsi `combinePdfs` di `CombinePdfController`:
  - Menambahkan dukungan pengunduhan file `r23` dari SFTP dan penyimpanan sementara ke direktori lokal.
  - Mengubah filter `branch_code` dari `ID0010001` menjadi `ID0010052` untuk pemrosesan akun.
  - Menambahkan log peringatan dan error untuk mengelola skenario file yang tidak ditemukan atau error saat unduhan dari SFTP.

- Memperkenalkan logika baru untuk pengaturan password dinamis pada file PDF:
  - Menambahkan metode `generatePassword` untuk menghasilkan password berdasarkan data customers.
  - Format password: kombinasi `ddMmmyyyyXX` (contoh: 05Oct202585), menggunakan tanggal yang relevan dan 2 digit terakhir nomor rekening.
  - Handling fallback ke nomor rekening jika tidak ada data tanggal yang tersedia.
  - Menambahkan validasi parsing tanggal untuk menghindari error format.

- Tujuan pembaruan ini:
  - Memastikan proses combine PDF lebih fleksibel dengan pengunduhan file dari SFTP.
  - Meningkatkan keamanan PDF dengan pengaturan password dinamis berdasarkan data customers.
  - Mempermudah troubleshooting dengan penambahan log yang lebih informatif terkait proses file dan password.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-05 11:19:25 +07:00
Daeng Deni Mardaeni
836cdfc49d fix(webstatement): perbaiki log display jumlah job pada command ExportPeriodStatements
- Mengubah tampilan informasi pada log command `ExportPeriodStatements`:
  - Mengganti variabel yang ditampilkan di log dari `jobCount` menjadi `accountNumber`.
  - Memastikan log lebih relevan dengan konteks data yang diproses.

- Perubahan ini bertujuan untuk:
  - Menyediakan informasi log yang lebih jelas dan sesuai dengan proses yang dijalankan.
  - Meningkatkan keakuratan komunikasi informasi kepada pengguna command.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-05 10:46:25 +07:00
Daeng Deni Mardaeni
dcb6d43026 feat(webstatement): perbaiki logika filter atribut fillable di Sector
- Memperbarui logika untuk memfilter atribut array `fillable` pada model `Sector`:
  - Menambahkan filter untuk mengabaikan atribut `id` agar tidak terikut dalam proses.
  - Mengoptimalkan proses pengambilan header CSV dengan hanya menggunakan atribut yang relevan.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-05 10:44:47 +07:00
38 changed files with 5218 additions and 728 deletions

View File

@@ -0,0 +1,54 @@
<?php
namespace Modules\Webstatement\Console;
use Illuminate\Console\Command;
use Modules\Webstatement\Models\PrintStatementLog;
class CheckEmailProgressCommand extends Command
{
protected $signature = 'webstatement:check-progress {log-id : ID log untuk dicek progressnya}';
protected $description = 'Cek progress pengiriman email statement';
public function handle()
{
$logId = $this->argument('log-id');
try {
$log = PrintStatementLog::findOrFail($logId);
$this->info("📊 Progress Pengiriman Email Statement");
$this->line("Log ID: {$log->id}");
$this->line("Batch ID: {$log->batch_id}");
$this->line("Request Type: {$log->request_type}");
$this->line("Status: {$log->status}");
if ($log->total_accounts) {
$this->line("Total Accounts: {$log->total_accounts}");
$this->line("Processed: {$log->processed_accounts}");
$this->line("Success: {$log->success_count}");
$this->line("Failed: {$log->failed_count}");
$this->line("Progress: {$log->getProgressPercentage()}%");
$this->line("Success Rate: {$log->getSuccessRate()}%");
}
if ($log->started_at) {
$this->line("Started: {$log->started_at}");
}
if ($log->completed_at) {
$this->line("Completed: {$log->completed_at}");
}
if ($log->error_message) {
$this->error("Error: {$log->error_message}");
}
} catch (\Exception $e) {
$this->error("Log dengan ID {$logId} tidak ditemukan.");
return Command::FAILURE;
}
return Command::SUCCESS;
}
}

View File

@@ -46,7 +46,7 @@
// Display summary of jobs queued
$jobCount = count($responseData['jobs'] ?? []);
$this->info("Successfully queued {$jobCount} statement export jobs");
$this->info("Successfully queued {$accountNumber} statement export jobs");
return Command::SUCCESS;
} catch (Exception $e) {

View File

@@ -0,0 +1,59 @@
<?php
namespace Modules\Webstatement\Console;
use Exception;
use Illuminate\Console\Command;
use Modules\Webstatement\Jobs\GenerateAtmTransactionReportJob;
class GenerateAtmTransactionReport extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'webstatement:generate-atm-report {--period= : Period to generate report format Ymd, contoh: 20250512}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate ATM Transaction report for specified period';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$this->info('Starting ATM Transaction report generation...');
$period = $this->option('period');
if (!$period) {
$this->error('Period parameter is required. Format: Ymd (example: 20250512)');
return Command::FAILURE;
}
// Validate period format
if (!preg_match('/^\d{8}$/', $period)) {
$this->error('Invalid period format. Use Ymd format (example: 20250512)');
return Command::FAILURE;
}
try {
// Dispatch the job
GenerateAtmTransactionReportJob::dispatch($period);
$this->info("ATM Transaction report generation job queued for period: {$period}");
$this->info('The report will be generated in the background.');
return Command::SUCCESS;
} catch (Exception $e) {
$this->error('Error queuing ATM Transaction report job: ' . $e->getMessage());
return Command::FAILURE;
}
}
}

View File

@@ -0,0 +1,249 @@
<?php
namespace Modules\Webstatement\Console;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\Basicdata\Models\Branch;
use Modules\Webstatement\Jobs\SendStatementEmailJob;
use Modules\Webstatement\Models\Account;
use Modules\Webstatement\Models\PrintStatementLog;
use InvalidArgumentException;
/**
* Command untuk mengirim email statement PDF ke nasabah
* Mendukung pengiriman per rekening, per cabang, atau seluruh cabang
*/
class SendStatementEmailCommand extends Command
{
protected $signature = 'webstatement:send-email
{period : Format periode YYYYMM (contoh: 202401)}
{--type=single : Tipe pengiriman: single, branch, all}
{--account= : Nomor rekening (untuk type=single)}
{--branch= : Kode cabang (untuk type=branch)}
{--batch-id= : ID batch untuk tracking (opsional)}
{--queue=emails : Nama queue untuk job (default: emails)}
{--delay=0 : Delay dalam menit sebelum job dijalankan}';
protected $description = 'Mengirim email statement PDF ke nasabah (per rekening, per cabang, atau seluruh cabang)';
public function handle()
{
$this->info('🚀 Memulai proses pengiriman email statement...');
try {
$period = $this->argument('period');
$type = $this->option('type');
$accountNumber = $this->option('account');
$branchCode = $this->option('branch');
$batchId = $this->option('batch-id');
$queueName = $this->option('queue');
$delay = (int) $this->option('delay');
// Validasi parameter
if (!$this->validateParameters($period, $type, $accountNumber, $branchCode)) {
return Command::FAILURE;
}
// Tentukan request type dan target value
[$requestType, $targetValue] = $this->determineRequestTypeAndTarget($type, $accountNumber, $branchCode);
// Buat log entry
$log = $this->createLogEntry($period, $requestType, $targetValue, $batchId);
// Dispatch job
$job = SendStatementEmailJob::dispatch($period, $requestType, $targetValue, $batchId, $log->id)
->onQueue($queueName);
if ($delay > 0) {
$job->delay(now()->addMinutes($delay));
$this->info("⏰ Job dijadwalkan untuk dijalankan dalam {$delay} menit");
}
$this->displayJobInfo($period, $requestType, $targetValue, $queueName, $log);
$this->info('✅ Job pengiriman email statement berhasil didispatch!');
$this->info('📊 Gunakan command berikut untuk monitoring:');
$this->line(" php artisan queue:work {$queueName}");
$this->line(' php artisan webstatement:check-progress ' . $log->id);
return Command::SUCCESS;
} catch (Exception $e) {
$this->error('❌ Error saat mendispatch job: ' . $e->getMessage());
Log::error('SendStatementEmailCommand failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return Command::FAILURE;
}
}
private function validateParameters($period, $type, $accountNumber, $branchCode)
{
// Validasi format periode
if (!preg_match('/^\d{6}$/', $period)) {
$this->error('❌ Format periode tidak valid. Gunakan format YYYYMM (contoh: 202401)');
return false;
}
// Validasi type
if (!in_array($type, ['single', 'branch', 'all'])) {
$this->error('❌ Type tidak valid. Gunakan: single, branch, atau all');
return false;
}
// Validasi parameter berdasarkan type
switch ($type) {
case 'single':
if (!$accountNumber) {
$this->error('❌ Parameter --account diperlukan untuk type=single');
return false;
}
$account = Account::with('customer')
->where('account_number', $accountNumber)
->first();
if (!$account) {
$this->error("❌ Account {$accountNumber} tidak ditemukan");
return false;
}
$hasEmail = !empty($account->stmt_email) ||
($account->customer && !empty($account->customer->email));
if (!$hasEmail) {
$this->error("❌ Account {$accountNumber} tidak memiliki email");
return false;
}
$this->info("✅ Account {$accountNumber} ditemukan dengan email");
break;
case 'branch':
if (!$branchCode) {
$this->error('❌ Parameter --branch diperlukan untuk type=branch');
return false;
}
$branch = Branch::where('code', $branchCode)->first();
if (!$branch) {
$this->error("❌ Branch {$branchCode} tidak ditemukan");
return false;
}
$accountCount = Account::with('customer')
->where('branch_code', $branchCode)
->where('stmt_sent_type', 'BY.EMAIL')
->get()
->filter(function ($account) {
return !empty($account->stmt_email) ||
($account->customer && !empty($account->customer->email));
})
->count();
if ($accountCount === 0) {
$this->error("❌ Tidak ada account dengan email di branch {$branchCode}");
return false;
}
$this->info("✅ Ditemukan {$accountCount} account dengan email di branch {$branch->name}");
break;
case 'all':
$accountCount = Account::with('customer')
->where('stmt_sent_type', 'BY.EMAIL')
->get()
->filter(function ($account) {
return !empty($account->stmt_email) ||
($account->customer && !empty($account->customer->email));
})
->count();
if ($accountCount === 0) {
$this->error('❌ Tidak ada account dengan email ditemukan');
return false;
}
$this->info("✅ Ditemukan {$accountCount} account dengan email di seluruh cabang");
break;
}
return true;
}
private function determineRequestTypeAndTarget($type, $accountNumber, $branchCode)
{
switch ($type) {
case 'single':
return ['single_account', $accountNumber];
case 'branch':
return ['branch', $branchCode];
case 'all':
return ['all_branches', null];
default:
throw new InvalidArgumentException("Invalid type: {$type}");
}
}
private function createLogEntry($period, $requestType, $targetValue, $batchId)
{
$logData = [
'user_id' => null, // Command line execution
'period_from' => $period,
'period_to' => $period,
'is_period_range' => false,
'request_type' => $requestType,
'batch_id' => $batchId ?? uniqid('cmd_'),
'status' => 'pending',
'authorization_status' => 'approved', // Auto-approved untuk command line
'created_by' => null,
'ip_address' => '127.0.0.1',
'user_agent' => 'Command Line'
];
// Set branch_code dan account_number berdasarkan request type
switch ($requestType) {
case 'single_account':
$account = Account::where('account_number', $targetValue)->first();
$logData['branch_code'] = $account->branch_code;
$logData['account_number'] = $targetValue;
break;
case 'branch':
$logData['branch_code'] = $targetValue;
$logData['account_number'] = null;
break;
case 'all_branches':
$logData['branch_code'] = 'ALL';
$logData['account_number'] = null;
break;
}
return PrintStatementLog::create($logData);
}
private function displayJobInfo($period, $requestType, $targetValue, $queueName, $log)
{
$this->info('📋 Detail Job:');
$this->line(" Log ID: {$log->id}");
$this->line(" Periode: {$period}");
$this->line(" Request Type: {$requestType}");
switch ($requestType) {
case 'single_account':
$this->line(" Account: {$targetValue}");
break;
case 'branch':
$branch = Branch::where('code', $targetValue)->first();
$this->line(" Branch: {$targetValue} ({$branch->name})");
break;
case 'all_branches':
$this->line(" Target: Seluruh cabang");
break;
}
$this->line(" Batch ID: {$log->batch_id}");
$this->line(" Queue: {$queueName}");
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace Modules\Webstatement\Console;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\Webstatement\Jobs\UpdateAllAtmCardsBatchJob;
class UpdateAllAtmCardsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'atmcard:update-all
{--sync-log-id= : ID sync log yang akan digunakan}
{--batch-size=100 : Ukuran batch untuk processing}
{--queue=atmcard-update : Nama queue untuk job}
{--filters= : Filter JSON untuk kondisi kartu}
{--dry-run : Preview tanpa eksekusi aktual}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Jalankan job untuk update seluruh kartu ATM secara batch';
/**
* Execute the console command.
*
* @return int
*/
public function handle(): int
{
Log::info('Memulai command update seluruh kartu ATM');
try {
$syncLogId = $this->option('sync-log-id');
$batchSize = (int) $this->option('batch-size');
$queueName = $this->option('queue');
$filtersJson = $this->option('filters');
$isDryRun = $this->option('dry-run');
// Parse filters jika ada
$filters = [];
if ($filtersJson) {
$filters = json_decode($filtersJson, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->error('Format JSON filters tidak valid');
return Command::FAILURE;
}
}
// Validasi input
if ($batchSize <= 0) {
$this->error('Batch size harus lebih besar dari 0');
return Command::FAILURE;
}
$this->info('Konfigurasi job:');
$this->info("- Sync Log ID: " . ($syncLogId ?: 'Akan dibuat baru'));
$this->info("- Batch Size: {$batchSize}");
$this->info("- Queue: {$queueName}");
$this->info("- Filters: " . ($filtersJson ?: 'Tidak ada'));
$this->info("- Dry Run: " . ($isDryRun ? 'Ya' : 'Tidak'));
if ($isDryRun) {
$this->warn('Mode DRY RUN - Job tidak akan dijalankan');
return Command::SUCCESS;
}
// Konfirmasi sebelum menjalankan
if (!$this->confirm('Apakah Anda yakin ingin menjalankan job update seluruh kartu ATM?')) {
$this->info('Operasi dibatalkan');
return Command::SUCCESS;
}
// Dispatch job
$job = new UpdateAllAtmCardsBatchJob($syncLogId, $batchSize, $filters);
$job->onQueue($queueName);
dispatch($job);
$this->info('Job berhasil dijadwalkan!');
$this->info("Queue: {$queueName}");
$this->info('Gunakan command berikut untuk memonitor:');
$this->info('php artisan queue:work --queue=' . $queueName);
Log::info('Command update seluruh kartu ATM selesai', [
'sync_log_id' => $syncLogId,
'batch_size' => $batchSize,
'queue' => $queueName,
'filters' => $filters
]);
return Command::SUCCESS;
} catch (Exception $e) {
$this->error('Terjadi error: ' . $e->getMessage());
Log::error('Error dalam command update seluruh kartu ATM: ' . $e->getMessage(), [
'file' => $e->getFile(),
'line' => $e->getLine()
]);
return Command::FAILURE;
}
}
}

View File

@@ -0,0 +1,366 @@
<?php
namespace Modules\Webstatement\Http\Controllers;
use App\Http\Controllers\Controller;
use Carbon\Carbon;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Log;
use Modules\Webstatement\Jobs\GenerateAtmTransactionReportJob;
use Modules\Webstatement\Models\AtmTransactionReportLog;
class AtmTransactionReportController extends Controller
{
/**
* Display a listing of the ATM transaction reports.
*/
public function index(Request $request)
{
return view('webstatement::atm-reports.index');
}
/**
* Store a newly created ATM transaction report request.
*/
public function store(Request $request)
{
$validated = $request->validate([
'report_date' => ['required', 'date_format:Y-m-d'],
]);
// Convert date to Ymd format for period
$period = Carbon::createFromFormat('Y-m-d', $validated['report_date'])->format('Ymd');
// Add user tracking data
$reportData = [
'period' => $period,
'report_date' => $validated['report_date'],
'user_id' => Auth::id(),
'created_by' => Auth::id(),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'status' => 'pending',
];
// Create the report request log
$reportRequest = AtmTransactionReportLog::create($reportData);
// Dispatch the job to generate the report
try {
GenerateAtmTransactionReportJob::dispatch($period, $reportRequest->id);
$reportRequest->update([
'status' => 'processing',
'updated_by' => Auth::id()
]);
} catch (Exception $e) {
$reportRequest->update([
'status' => 'failed',
'error_message' => $e->getMessage(),
'updated_by' => Auth::id()
]);
}
return redirect()->route('atm-reports.index')
->with('success', 'ATM Transaction report request has been created successfully.');
}
/**
* Show the form for creating a new report request.
*/
public function create()
{
return view('webstatement::atm-reports.create', compact('branches'));
}
/**
* Display the specified report request.
*/
public function show(AtmTransactionReportLog $atmReport)
{
$atmReport->load(['user', 'creator', 'authorizer']);
return view('webstatement::atm-reports.show', compact('atmReport'));
}
/**
* Download the report if available.
*/
public function download(AtmTransactionReportLog $atmReport)
{
// Check if report is available
if ($atmReport->status !== 'completed' || !$atmReport->file_path) {
return back()->with('error', 'Report is not available for download.');
}
// Update download status
$atmReport->update([
'is_downloaded' => true,
'downloaded_at' => now(),
'updated_by' => Auth::id()
]);
// Download the file
$filePath = $atmReport->file_path;
if (Storage::exists($filePath)) {
$fileName = "atm_transaction_report_{$atmReport->period}.csv";
return Storage::download($filePath, $fileName);
}
return back()->with('error', 'Report file not found.');
}
/**
* Authorize a report request.
*/
public function authorize(Request $request, AtmTransactionReportLog $atmReport)
{
$request->validate([
'authorization_status' => ['required', Rule::in(['approved', 'rejected'])],
'remarks' => ['nullable', 'string', 'max:255'],
]);
// Update authorization status
$atmReport->update([
'authorization_status' => $request->authorization_status,
'authorized_by' => Auth::id(),
'authorized_at' => now(),
'remarks' => $request->remarks,
'updated_by' => Auth::id()
]);
$statusText = $request->authorization_status === 'approved' ? 'approved' : 'rejected';
return redirect()->route('atm-reports.show', $atmReport->id)
->with('success', "ATM Transaction report request has been {$statusText} successfully.");
}
/**
* Provide data for datatables.
*/
public function dataForDatatables(Request $request)
{
// Retrieve data from the database
$query = AtmTransactionReportLog::query();
// Apply search filter if provided
if ($request->has('search') && !empty($request->get('search'))) {
$search = $request->get('search');
$query->where(function ($q) use ($search) {
$q->where('period', 'LIKE', "%$search%")
->orWhere('status', 'LIKE', "%$search%")
->orWhere('authorization_status', 'LIKE', "%$search%");
});
}
// Apply column filters if provided
if ($request->has('filters') && !empty($request->get('filters'))) {
$filters = json_decode($request->get('filters'), true);
foreach ($filters as $filter) {
if (!empty($filter['value'])) {
if ($filter['column'] === 'status') {
$query->where('status', $filter['value']);
} else if ($filter['column'] === 'authorization_status') {
$query->where('authorization_status', $filter['value']);
}
}
}
}
// Apply sorting if provided
if ($request->has('sortOrder') && !empty($request->get('sortOrder'))) {
$order = $request->get('sortOrder');
$column = $request->get('sortField');
// Map frontend column names to database column names if needed
$columnMap = [
'period' => 'period',
'status' => 'status',
];
$dbColumn = $columnMap[$column] ?? $column;
$query->orderBy($dbColumn, $order);
} else {
// Default sorting
$query->latest('created_at');
}
// Get the total count of records
$totalRecords = $query->count();
// Apply pagination if provided
if ($request->has('page') && $request->has('size')) {
$page = $request->get('page');
$size = $request->get('size');
$offset = ($page - 1) * $size;
$query->skip($offset)->take($size);
}
// Get the filtered count of records
$filteredRecords = $query->count();
// Eager load relationships (remove branch since it's not used anymore)
$query->with(['user', 'authorizer']);
// Get the data for the current page
$data = $query->get()->map(function ($item) {
$processingHours = $item->status === 'processing' ? $item->updated_at->diffInHours(now()) : 0;
$isProcessingTimeout = $item->status === 'processing' && $processingHours >= 1;
return [
'id' => $item->id,
'period' => $item->period,
'report_date' => Carbon::createFromFormat('Ymd', $item->period)->format('Y-m-d'),
'status' => $item->status,
'status_display' => $item->status . ($isProcessingTimeout ? ' (Timeout)' : ''),
'processing_hours' => $processingHours,
'is_processing_timeout' => $isProcessingTimeout,
'authorization_status' => $item->authorization_status,
'is_downloaded' => $item->is_downloaded,
'created_at' => dateFormat($item->created_at, 1, 1),
'created_by' => $item->user->name ?? 'N/A',
'authorized_by' => $item->authorizer ? $item->authorizer->name : null,
'authorized_at' => $item->authorized_at ? $item->authorized_at->format('Y-m-d H:i:s') : null,
'file_path' => $item->file_path,
'can_retry' => in_array($item->status, ['failed', 'pending']) || $isProcessingTimeout || ($item->status === 'completed' && !$item->file_path),
];
});
// Calculate the page count
$pageCount = ceil($filteredRecords / ($request->get('size') ?: 1));
$currentPage = $request->get('page') ?: 1;
return response()->json([
'draw' => $request->get('draw'),
'recordsTotal' => $totalRecords,
'recordsFiltered' => $filteredRecords,
'pageCount' => $pageCount,
'page' => $currentPage,
'totalCount' => $totalRecords,
'data' => $data,
]);
}
/**
* Delete a report request.
*/
public function destroy(AtmTransactionReportLog $atmReport)
{
// Delete the file if exists
if ($atmReport->file_path && Storage::exists($atmReport->file_path)) {
Storage::delete($atmReport->file_path);
}
// Delete the report request
$atmReport->delete();
return response()->json([
'message' => 'ATM Transaction report deleted successfully.',
]);
}
/**
* Send report to email
*/
public function sendEmail($id)
{
$atmReport = AtmTransactionReportLog::findOrFail($id);
// Check if report has email
if (empty($atmReport->email)) {
return redirect()->back()->with('error', 'No email address provided for this report.');
}
// Check if report is available
if ($atmReport->status !== 'completed' || !$atmReport->file_path) {
return redirect()->back()->with('error', 'Report is not available for sending.');
}
try {
// Send email with report attachment
// Implementation depends on your email system
// Mail::to($atmReport->email)->send(new AtmTransactionReportEmail($atmReport));
$atmReport->update([
'email_sent' => true,
'email_sent_at' => now(),
'updated_by' => Auth::id()
]);
return redirect()->back()->with('success', 'ATM Transaction report sent to email successfully.');
} catch (Exception $e) {
Log::error('Failed to send ATM Transaction report email: ' . $e->getMessage());
return redirect()->back()->with('error', 'Failed to send email: ' . $e->getMessage());
}
}
/**
* Retry generating the ATM transaction report
*/
public function retry(AtmTransactionReportLog $atmReport)
{
// Check if retry is allowed (failed, pending, or processing for more than 1 hour)
$allowedStatuses = ['failed', 'pending'];
$isProcessingTooLong = $atmReport->status === 'processing' &&
$atmReport->updated_at->diffInHours(now()) >= 1;
if (!in_array($atmReport->status, $allowedStatuses) && !$isProcessingTooLong) {
return back()->with('error', 'Report can only be retried if status is failed, pending, or processing for more than 1 hour.');
}
try {
// If it was processing for too long, mark it as failed first
if ($isProcessingTooLong) {
$atmReport->update([
'status' => 'failed',
'error_message' => 'Processing timeout - exceeded 1 hour limit',
'updated_by' => Auth::id()
]);
}
// Reset the report status and clear previous data
$atmReport->update([
'status' => 'processing',
'error_message' => null,
'file_path' => null,
'file_size' => null,
'record_count' => null,
'updated_by' => Auth::id()
]);
// Dispatch the job again
GenerateAtmTransactionReportJob::dispatch($atmReport->period, $atmReport->id);
return back()->with('success', 'ATM Transaction report job has been retried successfully.');
} catch (Exception $e) {
$atmReport->update([
'status' => 'failed',
'error_message' => $e->getMessage(),
'updated_by' => Auth::id()
]);
return back()->with('error', 'Failed to retry report generation: ' . $e->getMessage());
}
}
/**
* Check if report can be retried
*/
public function canRetry(AtmTransactionReportLog $atmReport)
{
$allowedStatuses = ['failed', 'pending'];
$isProcessingTooLong = $atmReport->status === 'processing' &&
$atmReport->updated_at->diffInHours(now()) >= 1;
return in_array($atmReport->status, $allowedStatuses) ||
$isProcessingTooLong ||
($atmReport->status === 'completed' && !$atmReport->file_path);
}
}

View File

@@ -5,9 +5,11 @@ namespace Modules\Webstatement\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
use Modules\Webstatement\Jobs\CombinePdfJob;
use Modules\Webstatement\Models\Account;
use Carbon\Carbon;
class CombinePdfController extends Controller
{
@@ -27,11 +29,17 @@ class CombinePdfController extends Controller
*/
public function combinePdfs($period)
{
// Configuration: Set r23 file source - 'local' or 'sftp'
$file_r23 = 'local'; // Change this to 'sftp' to use SFTP for r23 files
// Configuration: Set output destination - 'local' or 'sftp'
$output_destination = 'local'; // Change this to 'sftp' to upload combined PDFs to SFTP
// Get period from request or use current period
$period = $period ?? date('Ym');
// Get all accounts
$accounts = Account::where('branch_code','ID0010001')->get();
// Get all accounts with customer relation
$accounts = Account::where('branch_code','ID0010052')->get();
$processedCount = 0;
$skippedCount = 0;
$errorCount = 0;
@@ -41,53 +49,167 @@ class CombinePdfController extends Controller
$accountNumber = $account->account_number;
// Define file paths
$r14Path = storage_path("app/STMT/r14/{$period}/{$branchCode}/{$accountNumber}_{$period}.pdf");
$r23Path = storage_path("app/STMT/r23/{$period}/{$branchCode}/{$accountNumber}_{$period}.pdf");
$r14Path = storage_path("app/r14/{$accountNumber}_{$period}.pdf");
// Define temporary path for r23 files downloaded from SFTP
$tempDir = storage_path("app/temp/{$period}");
if (!File::exists($tempDir)) {
File::makeDirectory($tempDir, 0755, true);
}
$outputDir = storage_path("app/combine/{$period}/{$branchCode}");
$outputFilename = "{$accountNumber}_{$period}.pdf";
// Check if files exist
// Check if r14 file exists locally
$r14Exists = File::exists($r14Path);
$r23Exists = File::exists($r23Path);
// Check for multiple r23 files based on configuration
$r23Files = [];
$r23Exists = false;
if ($file_r23 === 'local') {
// Use local r23 files - check for multiple files
$r23Pattern = storage_path("app/r23/{$accountNumber}.*.pdf");
$foundR23Files = glob($r23Pattern);
if (!empty($foundR23Files)) {
// Sort files numerically by their sequence number
usort($foundR23Files, function($a, $b) {
preg_match('/\.(\d+)\.pdf$/', $a, $matchesA);
preg_match('/\.(\d+)\.pdf$/', $b, $matchesB);
return (int)$matchesA[1] - (int)$matchesB[1];
});
$r23Files = $foundR23Files;
$r23Exists = true;
Log::info("Found " . count($r23Files) . " r23 files locally for account {$accountNumber}");
}
} elseif ($file_r23 === 'sftp') {
// Use SFTP r23 files - check for multiple files
try {
$sftpFiles = Storage::disk('sftpStatement')->files('r23');
$accountR23Files = array_filter($sftpFiles, function($file) use ($accountNumber) {
return preg_match("/r23\/{$accountNumber}\.(\d+)\.pdf$/", $file);
});
if (!empty($accountR23Files)) {
// Sort files numerically by their sequence number
usort($accountR23Files, function($a, $b) {
preg_match('/\.(\d+)\.pdf$/', $a, $matchesA);
preg_match('/\.(\d+)\.pdf$/', $b, $matchesB);
return (int)$matchesA[1] - (int)$matchesB[1];
});
// Download all r23 files
foreach ($accountR23Files as $index => $sftpFile) {
$r23Content = Storage::disk('sftpStatement')->get($sftpFile);
$tempFileName = "{$tempDir}/{$accountNumber}_r23_" . ($index + 1) . ".pdf";
File::put($tempFileName, $r23Content);
$r23Files[] = $tempFileName;
}
$r23Exists = true;
Log::info("Downloaded " . count($r23Files) . " r23 files for account {$accountNumber} from SFTP");
}
} catch (\Exception $e) {
Log::error("Error downloading r23 files from SFTP for account {$accountNumber}: {$e->getMessage()}");
}
}
// Skip if neither file exists
if (!$r14Exists && !$r23Exists) {
Log::warning("No PDF files found for account {$accountNumber}");
//Log::warning("No PDF files found for account {$accountNumber}");
$skippedCount++;
continue;
}
// If both files exist, combine them
if ($r14Exists && $r23Exists) {
Log::info("Combining PDFs for account {$accountNumber}");
$pdfFiles = [$r14Path, $r23Path];
// Prepare file list for processing
$pdfFiles = [];
if ($r14Exists) {
$pdfFiles[] = $r14Path;
}
// If only one file exists, just apply password protection
else {
Log::info("Applying password protection to single PDF for account {$accountNumber}");
$pdfFile = $r14Exists ? $r14Path : $r23Path;
$pdfFiles = [$pdfFile];
if ($r23Exists) {
// Add all r23 files to the list
$pdfFiles = array_merge($pdfFiles, $r23Files);
}
try {
// Use the account number as password
$password = $accountNumber;
// Generate password based on customer relation data
$password = $this->generatePassword($account);
// Dispatch job to combine PDFs or apply password protection
CombinePdfJob::dispatch($pdfFiles, $outputDir, $outputFilename, $password);
CombinePdfJob::dispatch($pdfFiles, $outputDir, $outputFilename, $password, $output_destination, $branchCode, $period);
$processedCount++;
Log::info("Queued PDF processing for account {$accountNumber} - r14: local, r23: {$file_r23}, output: {$output_destination}, password: {$password}");
} catch (\Exception $e) {
Log::error("Error processing PDF for account {$accountNumber}: {$e->getMessage()}");
$errorCount++;
}
}
Log::info("Processed {$processedCount} accounts, skipped {$skippedCount} accounts, and encountered {$errorCount} errors.");
return response()->json([
'message' => 'PDF combination process has been queued',
'message' => "PDF combination process has been queued (r14: local, r23: {$file_r23}, output: {$output_destination})",
'processed' => $processedCount,
'skipped' => $skippedCount,
'errors' => $errorCount,
'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

@@ -0,0 +1,297 @@
<?php
namespace Modules\Webstatement\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\Webstatement\Models\PrintStatementLog;
use Modules\Basicdata\Models\Branch;
use Modules\Webstatement\Jobs\SendStatementEmailJob;
/**
* Controller untuk mengelola log pengiriman statement email
* Mendukung log untuk pengiriman per rekening, per cabang, atau seluruh cabang
*/
class EmailStatementLogController extends Controller
{
public function index(Request $request)
{
Log::info('Accessing email statement log index page', [
'user_id' => auth()->id(),
'ip_address' => $request->ip()
]);
try {
$branches = Branch::orderBy('name')->get();
return view('webstatement::email-statement-logs.index', compact('branches'));
} catch (\Exception $e) {
Log::error('Failed to load email statement log index page', [
'error' => $e->getMessage(),
'user_id' => auth()->id()
]);
return back()->with('error', 'Gagal memuat halaman log pengiriman email statement.');
}
}
public function dataForDatatables(Request $request)
{
Log::info('Fetching email statement log data for datatables', [
'user_id' => auth()->id(),
'filters' => $request->only(['branch_code', 'account_number', 'period_from', 'period_to', 'request_type', 'status'])
]);
DB::beginTransaction();
try {
$query = PrintStatementLog::query()
->with(['user', 'branch'])
->select([
'id',
'user_id',
'branch_code',
'account_number',
'request_type',
'batch_id',
'total_accounts',
'processed_accounts',
'success_count',
'failed_count',
'status',
'period_from',
'period_to',
'email',
'email_sent_at',
'is_available',
'authorization_status',
'started_at',
'completed_at',
'created_at',
'updated_at'
]);
// Filter berdasarkan branch
if ($request->filled('branch_code')) {
$query->where('branch_code', $request->branch_code);
}
// Filter berdasarkan account number (hanya untuk single account)
if ($request->filled('account_number')) {
$query->where('account_number', 'like', '%' . $request->account_number . '%');
}
// Filter berdasarkan request type
if ($request->filled('request_type')) {
$query->where('request_type', $request->request_type);
}
// Filter berdasarkan status
if ($request->filled('status')) {
$query->where('status', $request->status);
}
// Filter berdasarkan periode
if ($request->filled('period_from')) {
$query->where('period_from', '>=', $request->period_from);
}
if ($request->filled('period_to')) {
$query->where('period_to', '<=', $request->period_to);
}
// Filter berdasarkan tanggal
if ($request->filled('date_from')) {
$query->whereDate('created_at', '>=', $request->date_from);
}
if ($request->filled('date_to')) {
$query->whereDate('created_at', '<=', $request->date_to);
}
$query->orderBy('created_at', 'desc');
$totalRecords = $query->count();
if ($request->filled('start')) {
$query->skip($request->start);
}
if ($request->filled('length') && $request->length != -1) {
$query->take($request->length);
}
$logs = $query->get();
$data = $logs->map(function ($log) {
return [
'id' => $log->id,
'request_type' => $this->formatRequestType($log->request_type),
'branch_code' => $log->branch_code,
'branch_name' => $log->branch->name ?? 'N/A',
'account_number' => $log->account_number ?? '-',
'period_display' => $log->period_display,
'batch_id' => $log->batch_id,
'total_accounts' => $log->total_accounts ?? 1,
'processed_accounts' => $log->processed_accounts ?? 0,
'success_count' => $log->success_count ?? 0,
'failed_count' => $log->failed_count ?? 0,
'progress_percentage' => $log->getProgressPercentage(),
'success_rate' => $log->getSuccessRate(),
'status' => $this->formatStatus($log->status),
'email' => $log->email,
'email_status' => $log->email_sent_at ? 'Terkirim' : 'Pending',
'email_sent_at' => $log->email_sent_at ?? '-',
'authorization_status' => ucfirst($log->authorization_status),
'user_name' => $log->user->name ?? 'System',
'started_at' => $log->started_at ? $log->started_at->format('d/m/Y H:i:s') : '-',
'completed_at' => $log->completed_at ? $log->completed_at->format('d/m/Y H:i:s') : '-',
'created_at' => $log->created_at->format('d/m/Y H:i:s'),
'actions' => $this->generateActionButtons($log)
];
});
DB::commit();
return response()->json([
'draw' => intval($request->draw),
'recordsTotal' => $totalRecords,
'recordsFiltered' => $totalRecords,
'data' => $data
]);
} catch (\Exception $e) {
DB::rollBack();
Log::error('Failed to fetch email statement log data', [
'error' => $e->getMessage(),
'user_id' => auth()->id()
]);
return response()->json([
'draw' => intval($request->draw),
'recordsTotal' => 0,
'recordsFiltered' => 0,
'data' => [],
'error' => 'Gagal memuat data log pengiriman email statement.'
]);
}
}
public function show($id)
{
try {
$log = PrintStatementLog::with(['user', 'branch'])->findOrFail($id);
return view('webstatement::email-statement-logs.show', compact('log'));
} catch (\Exception $e) {
Log::error('Failed to load email statement log detail', [
'log_id' => $id,
'error' => $e->getMessage(),
'user_id' => auth()->id()
]);
return back()->with('error', 'Log pengiriman email statement tidak ditemukan.');
}
}
/**
* Mengirim ulang email statement untuk batch atau single account
*/
public function resendEmail(Request $request, $id)
{
Log::info('Attempting to resend statement email', [
'log_id' => $id,
'user_id' => auth()->id()
]);
DB::beginTransaction();
try {
$log = PrintStatementLog::findOrFail($id);
// Buat batch ID baru untuk resend
$newBatchId = 'resend_' . time() . '_' . $log->id;
// Dispatch job dengan parameter yang sama
SendStatementEmailJob::dispatch(
$log->period_from,
$log->request_type,
$log->request_type === 'single_account' ? $log->account_number :
($log->request_type === 'branch' ? $log->branch_code : null),
$newBatchId,
$log->id
);
// Reset status untuk tracking ulang
$log->update([
'status' => 'pending',
'batch_id' => $newBatchId,
'processed_accounts' => 0,
'success_count' => 0,
'failed_count' => 0,
'started_at' => null,
'completed_at' => null,
'error_message' => null
]);
DB::commit();
Log::info('Statement email resend job dispatched successfully', [
'log_id' => $id,
'new_batch_id' => $newBatchId,
'request_type' => $log->request_type
]);
return back()->with('success', 'Email statement berhasil dijadwalkan untuk dikirim ulang.');
} catch (\Exception $e) {
DB::rollBack();
Log::error('Failed to resend statement email', [
'log_id' => $id,
'error' => $e->getMessage(),
'user_id' => auth()->id()
]);
return back()->with('error', 'Gagal mengirim ulang email statement.');
}
}
private function formatRequestType($requestType)
{
$types = [
'single_account' => 'Single Account',
'branch' => 'Per Cabang',
'all_branches' => 'Seluruh Cabang'
];
return $types[$requestType] ?? $requestType;
}
private function formatStatus($status)
{
$statuses = [
'pending' => '<span class="badge badge-warning">Pending</span>',
'processing' => '<span class="badge badge-info">Processing</span>',
'completed' => '<span class="badge badge-success">Completed</span>',
'failed' => '<span class="badge badge-danger">Failed</span>'
];
return $statuses[$status] ?? $status;
}
private function generateActionButtons(PrintStatementLog $log)
{
$buttons = [];
// Tombol view detail
$buttons[] = '<a href="' . route('email-statement-logs.show', $log->id) . '" class="btn btn-sm btn-icon btn-clear btn-light" title="Lihat Detail">' .
'<i class="text-base text-gray-500 ki-filled ki-eye"></i>' .
'</a>';
// Tombol resend email
if (in_array($log->status, ['completed', 'failed']) && $log->authorization_status === 'approved') {
$buttons[] = '<button onclick="resendEmail(' . $log->id . ')" class="btn btn-sm btn-icon btn-clear btn-light" title="Kirim Ulang Email">' .
'<i class="text-base ki-filled ki-message-text-2 text-primary"></i>' .
'</button>';
}
return implode(' ', $buttons);
}
}

View File

@@ -7,12 +7,14 @@
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Log;
use Modules\Basicdata\Models\Branch;
use Modules\Webstatement\Http\Requests\PrintStatementRequest;
use Modules\Webstatement\Mail\StatementEmail;
use Modules\Webstatement\Models\Account;
use Modules\Webstatement\Models\PrintStatementLog;
use ZipArchive;
@@ -23,32 +25,108 @@
*/
public function index(Request $request)
{
$branches = Branch::orderBy('name')->get();
$branches = Branch::whereNotNull('customer_company')
->where('code', '!=', 'ID0019999')
->orderBy('name')
->get();
return view('webstatement::statements.index', compact('branches'));
$branch = Branch::find(Auth::user()->branch_id);
$multiBranch = session('MULTI_BRANCH') ?? false;
return view('webstatement::statements.index', compact('branches', 'branch', 'multiBranch'));
}
/**
* Store a newly created statement request.
* Menangani pembuatan request statement baru dengan logging dan transaksi database
*/
public function store(PrintStatementRequest $request)
{
$validated = $request->validated();
// Add account verification before storing
$accountNumber = $request->input('account_number'); // Assuming this is the field name for account number
// First, check if the account exists and get branch information
$account = Account::where('account_number', $accountNumber)->first();
if ($account) {
$branch_code = $account->branch_code;
$userBranchId = session('branch_id'); // Assuming branch ID is stored in session
$multiBranch = session('MULTI_BRANCH');
// Add user tracking data
$validated['user_id'] = Auth::id();
$validated['created_by'] = Auth::id();
$validated['ip_address'] = $request->ip();
$validated['user_agent'] = $request->userAgent();
if (!$multiBranch) {
// Check if account branch matches user's branch
if ($account->branch_id !== $userBranchId) {
return redirect()->route('statements.index')
->with('error', 'Nomor rekening tidak sesuai dengan cabang Anda. Transaksi tidak dapat dilanjutkan.');
}
}
// Create the statement log
$statement = PrintStatementLog::create($validated);
// Check if account belongs to restricted branch ID0019999
if ($account->branch_id === 'ID0019999') {
return redirect()->route('statements.index')
->with('error', 'Nomor rekening terdaftar pada cabang khusus. Silakan hubungi bagian HC untuk informasi lebih lanjut.');
}
// Process statement availability check (this would be implemented based on your system)
$this->checkStatementAvailability($statement);
// If all checks pass, proceed with storing data
// Your existing store logic here
return redirect()->route('statements.index')
->with('success', 'Statement request has been created successfully.');
} else {
// Account not found
return redirect()->route('statements.index')
->with('error', 'Nomor rekening tidak ditemukan dalam sistem.');
}
DB::beginTransaction();
try {
$validated = $request->validated();
// Add user tracking data dan field baru untuk single account request
$validated['user_id'] = Auth::id();
$validated['created_by'] = Auth::id();
$validated['ip_address'] = $request->ip();
$validated['user_agent'] = $request->userAgent();
$validated['request_type'] = 'single_account'; // Default untuk request manual
$validated['status'] = 'pending'; // Status awal
$validated['authorization_status'] = 'approved'; // Status otorisasi awal
$validated['total_accounts'] = 1; // Untuk single account
$validated['processed_accounts'] = 0;
$validated['success_count'] = 0;
$validated['failed_count'] = 0;
$validated['branch_code'] = $branch_code; // Awal tidak tersedia
// Create the statement log
$statement = PrintStatementLog::create($validated);
// Log aktivitas
Log::info('Statement request created', [
'statement_id' => $statement->id,
'user_id' => Auth::id(),
'account_number' => $statement->account_number,
'request_type' => $statement->request_type
]);
// Process statement availability check
$this->checkStatementAvailability($statement);
$statement = PrintStatementLog::find($statement->id);
if($statement->email){
$this->sendEmail($statement->id);
}
DB::commit();
return redirect()->route('statements.index')
->with('success', 'Statement request has been created successfully.');
} catch (Exception $e) {
DB::rollBack();
Log::error('Failed to create statement request', [
'error' => $e->getMessage(),
'user_id' => Auth::id()
]);
return redirect()->back()
->withInput()
->with('error', 'Failed to create statement request: ' . $e->getMessage());
}
}
/**
@@ -62,68 +140,113 @@
/**
* Check if the statement is available in the system.
* This is a placeholder method - implement according to your system.
* Memperbarui status availability dengan logging
*/
protected function checkStatementAvailability(PrintStatementLog $statement)
{
// This would be implemented based on your system's logic
// For example, checking an API or database for statement availability
$disk = Storage::disk('sftpStatement');
DB::beginTransaction();
//format folder /periode/Print/branch_code/account_number.pdf
$filePath = "{$statement->period_from}/Print/{$statement->branch_code}/{$statement->account_number}.pdf";
try {
$disk = Storage::disk('sftpStatement');
$filePath = "{$statement->period_from}/{$statement->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf";
// Check if the statement exists in the storage
if ($statement->is_period_range && $statement->period_to) {
$periodFrom = Carbon::createFromFormat('Ym', $statement->period_from);
$periodTo = Carbon::createFromFormat('Ym', $statement->period_to);
// Loop through each month in the range
$missingPeriods = [];
$availablePeriods = [];
for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) {
$periodFormatted = $period->format('Ym');
$periodPath = $periodFormatted . "/Print/{$statement->branch_code}/{$statement->account_number}.pdf";
if ($disk->exists($periodPath)) {
$availablePeriods[] = $periodFormatted;
} else {
$missingPeriods[] = $periodFormatted;
}
}
// If any period is missing, the statement is not available
if (count($missingPeriods) > 0) {
$notes = "Missing periods: " . implode(', ', $missingPeriods);
$statement->update([
'is_available' => false,
'remarks' => $notes,
'updated_by' => Auth::id()
]);
return;
} else {
// All periods are available
$statement->update([
'is_available' => true,
'updated_by' => Auth::id()
]);
return;
}
} else if ($disk->exists($filePath)) {
$statement->update([
'is_available' => true,
'updated_by' => Auth::id()
// Log untuk debugging
Log::info('Checking SFTP file path', [
'file_path' => $filePath,
'sftp_root' => config('filesystems.disks.sftpStatement.root'),
'full_path' => config('filesystems.disks.sftpStatement.root') . '/' . $filePath
]);
return;
}
$statement->update([
'is_available' => false,
'updated_by' => Auth::id()
]);
return;
if ($statement->is_period_range && $statement->period_to) {
$periodFrom = Carbon::createFromFormat('Ym', $statement->period_from);
$periodTo = Carbon::createFromFormat('Ym', $statement->period_to);
$missingPeriods = [];
$availablePeriods = [];
for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) {
$periodFormatted = $period->format('Ym');
$periodPath = $periodFormatted . "/{$statement->branch_code}/{$statement->account_number}_{$periodFormatted}.pdf";
if ($disk->exists($periodPath)) {
$availablePeriods[] = $periodFormatted;
} else {
$missingPeriods[] = $periodFormatted;
}
}
if (count($missingPeriods) > 0) {
$notes = "Missing periods: " . implode(', ', $missingPeriods);
$statement->update([
'is_available' => false,
'remarks' => $notes,
'updated_by' => Auth::id(),
'status' => 'failed'
]);
Log::warning('Statement not available - missing periods', [
'statement_id' => $statement->id,
'missing_periods' => $missingPeriods
]);
} else {
$statement->update([
'is_available' => true,
'updated_by' => Auth::id(),
'status' => 'completed',
'processed_accounts' => 1,
'success_count' => 1
]);
Log::info('Statement available - all periods found', [
'statement_id' => $statement->id,
'available_periods' => $availablePeriods
]);
}
} else if ($disk->exists($filePath)) {
$statement->update([
'is_available' => true,
'updated_by' => Auth::id(),
'status' => 'completed',
'processed_accounts' => 1,
'success_count' => 1
]);
Log::info('Statement available', [
'statement_id' => $statement->id,
'file_path' => $filePath
]);
} else {
$statement->update([
'is_available' => false,
'updated_by' => Auth::id(),
'status' => 'failed',
'processed_accounts' => 1,
'failed_count' => 1,
'error_message' => 'Statement file not found'
]);
Log::warning('Statement not available', [
'statement_id' => $statement->id,
'file_path' => $filePath
]);
}
DB::commit();
} catch (Exception $e) {
DB::rollBack();
Log::error('Error checking statement availability', [
'statement_id' => $statement->id,
'error' => $e->getMessage()
]);
$statement->update([
'is_available' => false,
'status' => 'failed',
'error_message' => $e->getMessage(),
'updated_by' => Auth::id()
]);
}
}
/**
@@ -137,94 +260,179 @@
/**
* Download the statement if available and authorized.
* Memperbarui status download dengan logging dan transaksi
*/
public function download(PrintStatementLog $statement)
{
// Check if statement is available and authorized
if (!$statement->is_available) {
return back()->with('error', 'Statement is not available for download.');
}
/* if ($statement->authorization_status !== 'approved') {
return back()->with('error', 'Statement download requires authorization.');
}*/
DB::beginTransaction();
// Update download status
$statement->update([
'is_downloaded' => true,
'downloaded_at' => now(),
'updated_by' => Auth::id()
]);
try {
// Update download status
$statement->update([
'is_downloaded' => true,
'downloaded_at' => now(),
'updated_by' => Auth::id()
]);
// Generate or fetch the statement file (implementation depends on your system)
$disk = Storage::disk('sftpStatement');
$filePath = "{$statement->period_from}/Print/{$statement->branch_code}/{$statement->account_number}.pdf";
Log::info('Statement downloaded', [
'statement_id' => $statement->id,
'user_id' => Auth::id(),
'account_number' => $statement->account_number
]);
if ($statement->is_period_range && $statement->period_to) {
$periodFrom = Carbon::createFromFormat('Ym', $statement->period_from);
$periodTo = Carbon::createFromFormat('Ym', $statement->period_to);
DB::commit();
// Loop through each month in the range
$missingPeriods = [];
$availablePeriods = [];
// Generate or fetch the statement file
$disk = Storage::disk('sftpStatement');
$filePath = "{$statement->period_from}/{$statement->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf";
for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) {
$periodFormatted = $period->format('Ym');
$periodPath = $periodFormatted . "/Print/{$statement->branch_code}/{$statement->account_number}.pdf";
if ($statement->is_period_range && $statement->period_to) {
// Log: Memulai proses download period range
Log::info('Starting period range download', [
'statement_id' => $statement->id,
'period_from' => $statement->period_from,
'period_to' => $statement->period_to
]);
if ($disk->exists($periodPath)) {
$availablePeriods[] = $periodFormatted;
} else {
$missingPeriods[] = $periodFormatted;
}
}
/**
* Handle period range download dengan membuat zip file
* yang berisi semua statement dalam rentang periode
*/
$periodFrom = Carbon::createFromFormat('Ym', $statement->period_from);
$periodTo = Carbon::createFromFormat('Ym', $statement->period_to);
// If any period is missing, the statement is not available
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}");
// Loop through each month in the range
$missingPeriods = [];
$availablePeriods = [];
// Ensure the temp directory exists
if (!file_exists(storage_path('app/temp'))) {
mkdir(storage_path('app/temp'), 0755, true);
for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) {
$periodFormatted = $period->format('Ym');
$periodPath = $periodFormatted . "/{$statement->branch_code}/{$statement->account_number}_{$periodFormatted}.pdf";
if ($disk->exists($periodPath)) {
$availablePeriods[] = $periodFormatted;
Log::info('Period available for download', [
'period' => $periodFormatted,
'path' => $periodPath
]);
} else {
$missingPeriods[] = $periodFormatted;
Log::warning('Period not available for download', [
'period' => $periodFormatted,
'path' => $periodPath
]);
}
}
// 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) {
$filePath = "{$period}/Print/{$statement->branch_code}/{$statement->account_number}.pdf";
$localFilePath = storage_path("app/temp/{$statement->account_number}_{$period}.pdf");
// If any period is available, create a zip and download it
if (count($availablePeriods) > 0) {
/**
* Membuat zip file temporary untuk download
* dengan semua statement yang tersedia dalam periode
*/
$zipFileName = "{$statement->account_number}_{$statement->period_from}_to_{$statement->period_to}.zip";
$zipFilePath = storage_path("app/temp/{$zipFileName}");
// Download the file from SFTP to local storage temporarily
file_put_contents($localFilePath, $disk->get($filePath));
// Add the file to the zip
$zip->addFile($localFilePath, "{$statement->account_number}_{$period}.pdf");
// Ensure the temp directory exists
if (!file_exists(storage_path('app/temp'))) {
mkdir(storage_path('app/temp'), 0755, true);
Log::info('Created temp directory for zip files');
}
$zip->close();
// Create a new zip archive
$zip = new ZipArchive();
if ($zip->open($zipFilePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
Log::info('Zip archive created successfully', ['zip_path' => $zipFilePath]);
// Clean up temporary files
foreach ($availablePeriods as $period) {
$localFilePath = storage_path("app/temp/{$statement->account_number}_{$period}.pdf");
if (file_exists($localFilePath)) {
unlink($localFilePath);
// Add each available statement to the zip
foreach ($availablePeriods as $period) {
$periodFilePath = "{$period}/{$statement->branch_code}/{$statement->account_number}_{$period}.pdf";
$localFilePath = storage_path("app/temp/{$statement->account_number}_{$period}.pdf");
try {
// Download the file from SFTP to local storage temporarily
file_put_contents($localFilePath, $disk->get($periodFilePath));
// Add the file to the zip
$zip->addFile($localFilePath, "{$statement->account_number}_{$period}.pdf");
Log::info('Added file to zip', [
'period' => $period,
'local_path' => $localFilePath
]);
} catch (Exception $e) {
Log::error('Failed to add file to zip', [
'period' => $period,
'error' => $e->getMessage()
]);
}
}
}
// Return the zip file for download
return response()->download($zipFilePath, $zipFileName)->deleteFileAfterSend(true);
$zip->close();
Log::info('Zip archive closed successfully');
// Return the zip file for download
$response = response()->download($zipFilePath, $zipFileName)->deleteFileAfterSend(true);
// Clean up temporary PDF files
foreach ($availablePeriods as $period) {
$localFilePath = storage_path("app/temp/{$statement->account_number}_{$period}.pdf");
if (file_exists($localFilePath)) {
unlink($localFilePath);
Log::info('Cleaned up temporary file', ['file' => $localFilePath]);
}
}
Log::info('Period range download completed successfully', [
'statement_id' => $statement->id,
'available_periods' => count($availablePeriods),
'missing_periods' => count($missingPeriods)
]);
return $response;
} else {
Log::error('Failed to create zip archive', ['zip_path' => $zipFilePath]);
return back()->with('error', 'Failed to create zip archive for download.');
}
} else {
return back()->with('error', 'Failed to create zip archive.');
Log::warning('No statements available for download in period range', [
'statement_id' => $statement->id,
'missing_periods' => $missingPeriods
]);
return back()->with('error', 'No statements available for download in the specified period range.');
}
} else if ($disk->exists($filePath)) {
/**
* Handle single period download
* Download file PDF tunggal untuk periode tertentu
*/
Log::info('Single period download', [
'statement_id' => $statement->id,
'file_path' => $filePath
]);
return $disk->download($filePath, "{$statement->account_number}_{$statement->period_from}.pdf");
} else {
return back()->with('error', 'No statements available for download.');
Log::warning('Statement file not found', [
'statement_id' => $statement->id,
'file_path' => $filePath
]);
return back()->with('error', 'Statement file not found.');
}
} else if ($disk->exists($filePath)) {
return $disk->download($filePath, "{$statement->account_number}_{$statement->period_from}.pdf");
} catch (Exception $e) {
DB::rollBack();
Log::error('Failed to download statement', [
'statement_id' => $statement->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return back()->with('error', 'Failed to download statement: ' . $e->getMessage());
}
}
@@ -267,6 +475,13 @@
// Retrieve data from the database
$query = PrintStatementLog::query();
if (!auth()->user()->hasRole('administrator')) {
$query->where(function($q) {
$q->where('user_id', Auth::id())
->orWhere('branch_code', Auth::user()->branch->code);
});
}
// Apply search filter if provided
if ($request->has('search') && !empty($request->get('search'))) {
$search = $request->get('search');
@@ -275,7 +490,9 @@
->orWhere('branch_code', 'LIKE', "%$search%")
->orWhere('period_from', 'LIKE', "%$search%")
->orWhere('period_to', 'LIKE', "%$search%")
->orWhere('authorization_status', 'LIKE', "%$search%");
->orWhere('authorization_status', 'LIKE', "%$search%")
->orWhere('request_type', 'LIKE', "%$search%")
->orWhere('status', 'LIKE', "%$search%");
});
}
@@ -289,6 +506,10 @@
$query->where('branch_code', $filter['value']);
} else if ($filter['column'] === 'authorization_status') {
$query->where('authorization_status', $filter['value']);
} else if ($filter['column'] === 'request_type') {
$query->where('request_type', $filter['value']);
} else if ($filter['column'] === 'status') {
$query->where('status', $filter['value']);
} else if ($filter['column'] === 'is_downloaded') {
$query->where('is_downloaded', filter_var($filter['value'], FILTER_VALIDATE_BOOLEAN));
}
@@ -303,12 +524,13 @@
// Map frontend column names to database column names if needed
$columnMap = [
'branch' => 'branch_code',
'account' => 'account_number',
'period' => 'period_from',
'status' => 'authorization_status',
'remarks' => 'remarks',
// Add more mappings as needed
'branch' => 'branch_code',
'account' => 'account_number',
'period' => 'period_from',
'auth_status' => 'authorization_status',
'request_type' => 'request_type',
'status' => 'status',
'remarks' => 'remarks',
];
$dbColumn = $columnMap[$column] ?? $column;
@@ -391,6 +613,7 @@
public function sendEmail($id)
{
$statement = PrintStatementLog::findOrFail($id);
// Check if statement has email
if (empty($statement->email)) {
return redirect()->back()->with('error', 'No email address provided for this statement.');
@@ -403,7 +626,7 @@
try {
$disk = Storage::disk('sftpStatement');
$filePath = "{$statement->period_from}/Print/{$statement->branch_code}/{$statement->account_number}.pdf";
$filePath = "{$statement->period_from}/{$statement->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf";
if ($statement->is_period_range && $statement->period_to) {
$periodFrom = Carbon::createFromFormat('Ym', $statement->period_from);
@@ -415,7 +638,7 @@
for ($period = clone $periodFrom; $period->lte($periodTo); $period->addMonth()) {
$periodFormatted = $period->format('Ym');
$periodPath = $periodFormatted . "/Print/{$statement->branch_code}/{$statement->account_number}.pdf";
$periodPath = $periodFormatted . "/{$statement->branch_code}/{$statement->account_number}_{$periodFormatted}.pdf";
if ($disk->exists($periodPath)) {
$availablePeriods[] = $periodFormatted;
@@ -440,7 +663,7 @@
if ($zip->open($zipFilePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
// Add each available statement to the zip
foreach ($availablePeriods as $period) {
$filePath = "{$period}/Print/{$statement->branch_code}/{$statement->account_number}.pdf";
$filePath = "{$period}/{$statement->branch_code}/{$statement->account_number}_{$statement->period_from}.pdf";
$localFilePath = storage_path("app/temp/{$statement->account_number}_{$period}.pdf");
// Download the file from SFTP to local storage temporarily
@@ -504,6 +727,14 @@
'updated_by' => Auth::id()
]);
Log::info('Statement email sent successfully', [
'statement_id' => $statement->id,
'email' => $statement->email,
'user_id' => Auth::id()
]);
DB::commit();
return redirect()->back()->with('success', 'Statement has been sent to ' . $statement->email);
} catch (Exception $e) {
// Log the error

View File

@@ -1,125 +1,123 @@
<?php
namespace Modules\Webstatement\Http\Requests;
namespace Modules\Webstatement\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Modules\Webstatement\Models\PrintStatementLog as Statement;
use Illuminate\Foundation\Http\FormRequest;
use Modules\Webstatement\Models\PrintStatementLog as Statement;
class PrintStatementRequest extends FormRequest
class PrintStatementRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize()
: bool
{
return true;
}
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules()
: array
{
$rules = [
'branch_code' => ['required', 'string', 'exists:branches,code'],
'account_number' => ['required', 'string'],
'is_period_range' => ['sometimes', 'boolean'],
'email' => ['nullable', 'email'],
'email_sent_at' => ['nullable', 'timestamp'],
'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('period_from', $value);
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
$rules = [
'account_number' => ['required', 'string'],
'is_period_range' => ['sometimes', 'boolean'],
'email' => ['nullable', 'email'],
'email_sent_at' => ['nullable', 'timestamp'],
'request_type' => ['sometimes', 'string', 'in:single_account,branch,all_branches'],
'batch_id' => ['nullable', 'string'],
'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);
// 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 ($query->exists()) {
$fail('A statement request with this account number and period already exists.');
}
// If this is an update request, exclude the current record
if ($this->route('statement')) {
$query->where('id', '!=', $this->route('statement'));
}
],
];
// If it's a period range, require period_to
if ($this->input('period_to')) {
$rules['period_to'] = [
'required',
'string',
'regex:/^\d{6}$/', // YYYYMM format
'gte:period_from' // period_to must be greater than or equal to period_from
];
}
// 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);
});
}
return $rules;
}
if ($query->exists()) {
$fail('A statement request with this account number and period already exists.');
}
}
],
];
/**
* Get custom messages for validator errors.
*
* @return array
*/
public function messages()
: array
{
return [
'branch_code.required' => 'Branch code is required',
'branch_code.exists' => 'Selected branch does not exist',
'account_number.required' => 'Account number is required',
'period_from.required' => 'Period is required',
'period_from.regex' => 'Period must be in YYYYMM format',
'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',
// If it's a period range, require period_to
if ($this->input('period_to')) {
$rules['period_to'] = [
'required',
'string',
'regex:/^\d{6}$/', // YYYYMM format
'gte:period_from' // period_to must be greater than or equal to period_from
];
}
/**
* Prepare the data for validation.
*
* @return void
*/
protected function prepareForValidation()
: void
{
if ($this->has('period_from')) {
//conver to YYYYMM format
$this->merge([
'period_from' => substr($this->period_from, 0, 4) . substr($this->period_from, 5, 2),
]);
}
return $rules;
}
if ($this->has('period_to')) {
//conver to YYYYMM format
$this->merge([
'period_to' => substr($this->period_to, 0, 4) . substr($this->period_to, 5, 2),
]);
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'account_number.required' => 'Account number is required',
'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',
];
}
// Convert is_period_range to boolean if it exists
if ($this->has('period_to')) {
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
if ($this->has('period_from')) {
// Convert to YYYYMM format
$this->merge([
'period_from' => substr($this->period_from, 0, 4) . substr($this->period_from, 5, 2),
]);
}
if ($this->has('period_to')) {
// Convert to YYYYMM format
$this->merge([
'period_to' => substr($this->period_to, 0, 4) . substr($this->period_to, 5, 2),
]);
// Only set is_period_range to true if period_to is different from period_from
if ($this->period_to !== $this->period_from) {
$this->merge([
'is_period_range' => true,
]);
}
}
// Set default request_type if not provided
if (!$this->has('request_type')) {
$this->merge([
'request_type' => 'single_account',
]);
}
}
}

View File

@@ -10,6 +10,7 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\File;
use Owenoj\PDFPasswordProtect\Facade\PDFPasswordProtect;
use Webklex\PDFMerger\Facades\PDFMergerFacade as PDFMerger;
@@ -21,6 +22,9 @@ class CombinePdfJob implements ShouldQueue
protected $outputPath;
protected $outputFilename;
protected $password;
protected $outputDestination;
protected $branchCode;
protected $period;
/**
* Create a new job instance.
@@ -29,13 +33,19 @@ class CombinePdfJob implements ShouldQueue
* @param string $outputPath Directory where the combined PDF will be saved
* @param string $outputFilename Filename for the combined PDF
* @param string $password Password to protect the PDF
* @param string $outputDestination Output destination: 'local' or 'sftp'
* @param string $branchCode Branch code for SFTP path
* @param string $period Period for SFTP path
*/
public function __construct(array $pdfFiles, string $outputPath, string $outputFilename, string $password)
public function __construct(array $pdfFiles, string $outputPath, string $outputFilename, string $password, string $outputDestination = 'local', string $branchCode = '', string $period = '')
{
$this->pdfFiles = $pdfFiles;
$this->outputPath = $outputPath;
$this->outputFilename = $outputFilename;
$this->password = $password;
$this->outputDestination = $outputDestination;
$this->branchCode = $branchCode;
$this->period = $period;
}
/**
@@ -86,10 +96,43 @@ class CombinePdfJob implements ShouldQueue
Log::info("PDF password protection applied successfully.");
}
Log::info("PDFs combined successfully. Output file: {$fullPath}");
// Handle output destination
if ($this->outputDestination === 'sftp') {
$this->uploadToSftp($fullPath);
}
Log::info("PDFs combined successfully. Output file: {$fullPath}, Destination: {$this->outputDestination}");
} catch (Exception $e) {
Log::error("Error combining PDFs: " . $e->getMessage());
throw $e;
}
}
/**
* Upload the combined PDF to SFTP server
*
* @param string $localFilePath
*/
private function uploadToSftp(string $localFilePath): void
{
try {
// Define SFTP path: combine/{period}/{branchCode}/{filename}
$sftpPath = "combine/{$this->period}/{$this->branchCode}/{$this->outputFilename}";
// Read the local file content
$fileContent = File::get($localFilePath);
// Upload to SFTP
Storage::disk('sftpStatement')->put($sftpPath, $fileContent);
Log::info("Combined PDF uploaded to SFTP: {$sftpPath}");
// Optionally, remove the local file after successful upload
// File::delete($localFilePath);
} catch (\Exception $e) {
Log::error("Error uploading combined PDF to SFTP: {$e->getMessage()}");
throw $e;
}
}
}

View File

@@ -0,0 +1,156 @@
<?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\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Models\AtmTransaction;
use Modules\Webstatement\Models\AtmTransactionReportLog;
class GenerateAtmTransactionReportJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private string $period;
private const CHUNK_SIZE = 1000;
private const CSV_DELIMITER = ',';
private ?int $reportLogId;
public function __construct(string $period, ?int $reportLogId = null)
{
$this->period = $period;
$this->reportLogId = $reportLogId;
}
public function handle(): void
{
$reportLog = null;
if ($this->reportLogId) {
$reportLog = AtmTransactionReportLog::find($this->reportLogId);
}
try {
Log::info("Starting ATM Transaction report generation for period: {$this->period} and LogId: {$this->reportLogId}");
$filename = "atm_transaction_report_{$this->period}.csv";
$filePath = "reports/atm_transactions/{$filename}";
// Create directory if not exists
Storage::makeDirectory('reports/atm_transactions');
// Initialize CSV file with headers
$headers = [
'reff_no',
'pan',
'atm_id_terminal_id',
'amount',
'channel',
'account_no',
'internal_account',
'transaction_type',
'trans_ref',
'posting_date',
'stan',
'trans_status'
];
$csvContent = implode(self::CSV_DELIMITER, $headers) . "\n";
Storage::put($filePath, $csvContent);
$totalRecords = 0;
// Process data in chunks
AtmTransaction::select(
'retrieval_ref_no as reff_no',
'pan_number as pan',
'card_acc_id as atm_id_terminal_id',
'txn_amount as amount',
'merchant_id as channel',
'debit_acct_no as account_no',
'credit_acct_no as internal_account',
'txn_type as transaction_type',
'trans_ref',
'booking_date as posting_date',
'stan_no as stan',
'trans_status'
)
->where('booking_date', $this->period)
->chunk(self::CHUNK_SIZE, function ($transactions) use ($filePath, &$totalRecords) {
$csvRows = [];
foreach ($transactions as $transaction) {
$csvRows[] = implode(self::CSV_DELIMITER, [
$this->escapeCsvValue($transaction->reff_no),
$this->escapeCsvValue($transaction->pan),
$this->escapeCsvValue($transaction->atm_id_terminal_id),
$this->escapeCsvValue($transaction->amount),
$this->escapeCsvValue($transaction->channel),
$this->escapeCsvValue($transaction->account_no),
$this->escapeCsvValue($transaction->internal_account),
$this->escapeCsvValue($transaction->transaction_type),
$this->escapeCsvValue($transaction->trans_ref),
$this->escapeCsvValue($transaction->posting_date),
$this->escapeCsvValue($transaction->stan),
$this->escapeCsvValue($transaction->trans_status)
]);
$totalRecords++;
}
if (!empty($csvRows)) {
Storage::append($filePath, implode("\n", $csvRows));
}
});
// Update report log if exists
if ($reportLog) {
$reportLog->update([
'status' => 'completed',
'file_path' => $filePath,
'file_size' => Storage::size($filePath),
'record_count' => $totalRecords,
]);
}
Log::info("ATM Transaction report generated successfully. File: {$filePath}, Total records: {$totalRecords}");
} catch (Exception $e) {
if ($reportLog) {
$reportLog->update([
'status' => 'failed',
'error_message' => $e->getMessage(),
]);
}
Log::error("Error generating ATM Transaction report for period {$this->period}: " . $e->getMessage());
throw $e;
}
}
/**
* Escape CSV values to handle commas and quotes
*/
private function escapeCsvValue($value): string
{
if ($value === null) {
return '';
}
$value = (string) $value;
// If value contains comma, quote, or newline, wrap in quotes and escape internal quotes
if (strpos($value, self::CSV_DELIMITER) !== false ||
strpos($value, '"') !== false ||
strpos($value, "\n") !== false) {
$value = '"' . str_replace('"', '""', $value) . '"';
}
return $value;
}
}

View File

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

View File

@@ -20,12 +20,10 @@
private const MAX_EXECUTION_TIME = 86400; // 24 hours in seconds
private const FILENAME = 'ST.FUNDS.TRANSFER.csv';
private const DISK_NAME = 'sftpStatement';
private const CHUNK_SIZE = 1000; // Process data in chunks to reduce memory usage
private string $period = '';
private int $processedCount = 0;
private int $errorCount = 0;
private array $transferBatch = [];
/**
* Create a new job instance.
@@ -63,7 +61,6 @@
set_time_limit(self::MAX_EXECUTION_TIME);
$this->processedCount = 0;
$this->errorCount = 0;
$this->transferBatch = [];
}
private function processPeriod()
@@ -114,23 +111,10 @@
$headers = (new TempFundsTransfer())->getFillable();
$rowCount = 0;
$chunkCount = 0;
while (($row = fgetcsv($handle, 0, self::CSV_DELIMITER)) !== false) {
$rowCount++;
$this->processRow($row, $headers, $rowCount, $filePath);
// Process in chunks to avoid memory issues
if (count($this->transferBatch) >= self::CHUNK_SIZE) {
$this->saveBatch();
$chunkCount++;
Log::info("Processed chunk $chunkCount ({$this->processedCount} records so far)");
}
}
// Process any remaining records
if (!empty($this->transferBatch)) {
$this->saveBatch();
}
fclose($handle);
@@ -152,24 +136,18 @@
}
$data = array_combine($headers, $row);
$this->addToBatch($data, $rowCount, $filePath);
$this->saveRecord($data, $rowCount, $filePath);
}
/**
* Add record to batch instead of saving immediately
*/
private function addToBatch(array $data, int $rowCount, string $filePath)
private function saveRecord(array $data, int $rowCount, string $filePath)
: void
{
try {
if (isset($data['_id']) && $data['_id'] !== '_id') {
// Add timestamp fields
$now = now();
$data['created_at'] = $now;
$data['updated_at'] = $now;
// Add to transfer batch
$this->transferBatch[] = $data;
TempFundsTransfer::updateOrCreate(
['_id' => $data['_id']],
$data
);
$this->processedCount++;
}
} catch (Exception $e) {
@@ -178,37 +156,6 @@
}
}
/**
* Save batched records to the database
*/
private function saveBatch()
: void
{
try {
if (!empty($this->transferBatch)) {
// Process in smaller chunks for better memory management
foreach ($this->transferBatch as $entry) {
// Extract all stmt_entry_ids from the current chunk
$entryIds = array_column($entry, '_id');
// Delete existing records with these IDs to avoid conflicts
TempFundsTransfer::whereIn('_id', $entryIds)->delete();
// Insert all records in the chunk at once
TempFundsTransfer::insert($entry);
}
// Reset entry batch after processing
$this->transferBatch = [];
}
} catch (Exception $e) {
Log::error("Error in saveBatch: " . $e->getMessage());
$this->errorCount += count($this->transferBatch);
// Reset batch even if there's an error to prevent reprocessing the same failed records
$this->transferBatch = [];
}
}
private function cleanup(string $tempFilePath)
: void
{

View File

@@ -108,7 +108,9 @@ class ProcessSectorDataJob implements ShouldQueue
return;
}
$headers = (new Sector())->getFillable();
$headers = array_filter((new Sector())->getFillable(), function($field) {
return $field !== 'id';
});
$rowCount = 0;
while (($row = fgetcsv($handle, 0, self::CSV_DELIMITER)) !== false) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,18 @@
namespace Modules\Webstatement\Mail;
use Carbon\Carbon;
use Exception;
use Illuminate\Bus\Queueable;
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;
class StatementEmail extends Mailable
{
@@ -14,9 +22,11 @@
protected $statement;
protected $filePath;
protected $isZip;
protected $message;
/**
* Create a new message instance.
* Membuat instance email baru untuk pengiriman statement
*
* @param PrintStatementLog $statement
* @param string $filePath
@@ -29,43 +39,164 @@
$this->isZip = $isZip;
}
/**
* Override the send method to use EsmtpTransport directly
* Using the working configuration from Python script with multiple fallback methods
*/
public function send($mailer)
{
// Get mail configuration
$host = Config::get('mail.mailers.smtp.host');
$port = Config::get('mail.mailers.smtp.port');
$username = Config::get('mail.mailers.smtp.username');
$password = Config::get('mail.mailers.smtp.password');
Log::info('StatementEmail: Attempting to send email with multiple fallback methods');
// Define connection methods like in Python script
$method =
// Method 3: STARTTLS with original port
[
'port' => $port,
'ssl' => false,
'name' => 'STARTTLS (Port $port)'
];
$lastException = null;
// Try each connection method until one succeeds
try {
Log::info('StatementEmail: Trying ' . $method['name']);
// Create EsmtpTransport with current method
$transport = new EsmtpTransport($host, $method['port'], $method['ssl']);
// Set username and password
if ($username) {
$transport->setUsername($username);
}
if ($password) {
$transport->setPassword($password);
}
// Disable SSL verification for development
$streamOptions = [
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true
]
];
$transport->getStream()->setStreamOptions($streamOptions);
// Build the email content
$this->build();
// Start transport connection
$transport->start();
// Create Symfony mailer
$symfonyMailer = new Mailer($transport);
// Convert Laravel message to Symfony Email
$email = $this->toSymfonyEmail();
// Send the email
$symfonyMailer->send($email);
// Close connection
$transport->stop();
Log::info('StatementEmail: Successfully sent email using ' . $method['name']);
return $this;
} catch (Exception $e) {
$lastException = $e;
Log::warning('StatementEmail: Failed to send with ' . $method['name'] . ': ' . $e->getMessage());
// Continue to next method
}
try {
return parent::send($mailer);
} catch (Exception $e) {
Log::error('StatementEmail: Laravel mailer also failed: ' . $e->getMessage());
// If we got here, throw the last exception from our custom methods
throw $lastException;
}
}
/**
* Build the message.
* Membangun struktur email dengan attachment statement
*
* @return $this
*/
public function build()
{
$subject = 'Your Account Statement';
$subject = 'Statement Rekening Bank Artha Graha Internasional';
if ($this->statement->is_period_range) {
$subject .= " - {$this->statement->period_from} to {$this->statement->period_to}";
} else {
$subject .= " - {$this->statement->period_from}";
$subject .= " - " . Carbon::createFromFormat('Ym', $this->statement->period_from)
->locale('id')
->isoFormat('MMMM Y');
}
$email = $this->subject($subject)
->view('webstatement::statements.email')
->with([
'statement' => $this->statement,
'accountNumber' => $this->statement->account_number,
'periodFrom' => $this->statement->period_from,
'periodTo' => $this->statement->period_to,
'isRange' => $this->statement->is_period_range,
]);
$email = $this->subject($subject);
if ($this->isZip) {
$fileName = "{$this->statement->account_number}_{$this->statement->period_from}_to_{$this->statement->period_to}.zip";
$email->attach($this->filePath, [
'as' => $fileName,
'mime' => 'application/zip',
]);
} else {
$fileName = "{$this->statement->account_number}_{$this->statement->period_from}.pdf";
$email->attach($this->filePath, [
'as' => $fileName,
'mime' => 'application/pdf',
]);
// Store the email in the message property for later use in toSymfonyEmail()
$this->message = $email;
return $email;
}
/**
* Convert Laravel message to Symfony Email
*/
protected function toSymfonyEmail()
{
// Build the message if it hasn't been built yet
$this->build();
// Create a new Symfony Email
$email = new Email();
// Set from address using config values instead of trying to call getFrom()
$fromAddress = Config::get('mail.from.address');
$fromName = Config::get('mail.from.name');
$email->from($fromName ? "$fromName <$fromAddress>" : $fromAddress);
// Set to addresses - use the to addresses from the mailer instead of trying to call getTo()
// We'll get the to addresses from the Mail facade when the email is sent
// For now, we'll just add a placeholder recipient that will be overridden by the Mail facade
$email->to($this->message->to[0]['address']);
$email->subject($this->message->subject);
// Set body - use a simple HTML content instead of trying to call getHtmlBody()
// In a real implementation, we would need to find a way to access the rendered HTML content
$email->html(view('webstatement::statements.email', [
'statement' => $this->statement,
'accountNumber' => $this->statement->account_number,
'periodFrom' => $this->statement->period_from,
'periodTo' => $this->statement->period_to,
'isRange' => $this->statement->is_period_range,
'requestType' => $this->statement->request_type,
'batchId' => $this->statement->batch_id,
'accounts' => Account::where('account_number', $this->statement->account_number)->first()
])->render());
//$email->text($this->message->getTextBody());
// Add attachments - use the file path directly instead of trying to call getAttachments()
if ($this->filePath && file_exists($this->filePath)) {
if ($this->isZip) {
$fileName = "{$this->statement->account_number}_{$this->statement->period_from}_to_{$this->statement->period_to}.zip";
$contentType = 'application/zip';
} else {
$fileName = "{$this->statement->account_number}_{$this->statement->period_from}.pdf";
$contentType = 'application/pdf';
}
$email->attachFromPath($this->filePath, $fileName, $contentType);
}
return $email;

View File

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

View File

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

View File

@@ -6,7 +6,7 @@
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
use Wildside\Userstamps\Userstamps;
use Mattiverse\Userstamps\Traits\Userstamps;
/**

View File

@@ -1,186 +1,288 @@
<?php
namespace Modules\Webstatement\Models;
namespace Modules\Webstatement\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Modules\Basicdata\Models\Branch;
use Modules\Usermanagement\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Modules\Basicdata\Models\Branch;
use Modules\Usermanagement\Models\User;
class PrintStatementLog extends Model
class PrintStatementLog extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'user_id',
'branch_code',
'account_number',
'request_type',
'batch_id',
'target_accounts',
'total_accounts',
'processed_accounts',
'success_count',
'failed_count',
'status',
'started_at',
'completed_at',
'error_message',
'period_from',
'period_to',
'is_period_range',
'is_available',
'is_downloaded',
'ip_address',
'user_agent',
'downloaded_at',
'authorization_status',
'created_by',
'updated_by',
'deleted_by',
'authorized_by',
'authorized_at',
'remarks',
'email',
'email_sent_at',
];
protected $casts = [
'is_period_range' => 'boolean',
'is_available' => 'boolean',
'is_downloaded' => 'boolean',
'downloaded_at' => 'datetime',
'authorized_at' => 'datetime',
'started_at' => 'datetime',
'completed_at' => 'datetime',
'target_accounts' => 'array',
];
/**
* Get the formatted period display
*
* @return string
*/
public function getPeriodDisplayAttribute()
{
use HasFactory, SoftDeletes;
protected $fillable = [
'user_id',
'branch_code',
'account_number',
'period_from',
'period_to',
'is_period_range',
'is_available',
'is_downloaded',
'ip_address',
'user_agent',
'downloaded_at',
'authorization_status',
'created_by',
'updated_by',
'deleted_by',
'authorized_by',
'authorized_at',
'remarks',
'email',
'email_sent_at',
];
protected $casts = [
'is_period_range' => 'boolean',
'is_available' => 'boolean',
'is_downloaded' => 'boolean',
'downloaded_at' => 'datetime',
'authorized_at' => 'datetime',
];
/**
* Get the formatted period display
*
* @return string
*/
public function getPeriodDisplayAttribute()
{
if ($this->is_period_range) {
return $this->formatPeriod($this->period_from) . ' - ' . $this->formatPeriod($this->period_to);
}
return $this->formatPeriod($this->period_from);
if ($this->is_period_range) {
return $this->formatPeriod($this->period_from) . ' - ' . $this->formatPeriod($this->period_to);
}
/**
* Format period from YYYYMM to Month Year
*
* @param string $period
*
* @return string
*/
protected function formatPeriod($period)
{
if (strlen($period) !== 6) {
return $period;
}
$year = substr($period, 0, 4);
$month = substr($period, 4, 2);
return date('F Y', mktime(0, 0, 0, (int) $month, 1, (int) $year));
}
/**
* Get the user who requested the statement
*/
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* Get the user who created the record
*/
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* Get the user who updated the record
*/
public function updater()
{
return $this->belongsTo(User::class, 'updated_by');
}
/**
* Get the user who authorized the record
*/
public function authorizer()
{
return $this->belongsTo(User::class, 'authorized_by');
}
/**
* Scope a query to only include pending authorization records
*/
public function scopePending($query)
{
return $query->where('authorization_status', 'pending');
}
/**
* Scope a query to only include approved records
*/
public function scopeApproved($query)
{
return $query->where('authorization_status', 'approved');
}
/**
* Scope a query to only include rejected records
*/
public function scopeRejected($query)
{
return $query->where('authorization_status', 'rejected');
}
/**
* Scope a query to only include downloaded records
*/
public function scopeDownloaded($query)
{
return $query->where('is_downloaded', true);
}
/**
* Scope a query to only include available records
*/
public function scopeAvailable($query)
{
return $query->where('is_available', true);
}
/**
* Check if the statement is for a single period
*/
public function isSinglePeriod()
{
return !$this->is_period_range;
}
/**
* Check if the statement is authorized
*/
public function isAuthorized()
{
return $this->authorization_status === 'approved';
}
/**
* Check if the statement is rejected
*/
public function isRejected()
{
return $this->authorization_status === 'rejected';
}
/**
* Check if the statement is pending authorization
*/
public function isPending()
{
return $this->authorization_status === 'pending';
}
public function branch(){
return $this->belongsTo(Branch::class, 'branch_code','code');
}
return $this->formatPeriod($this->period_from);
}
/**
* Format period from YYYYMM to Month Year
*
* @param string $period
*
* @return string
*/
protected function formatPeriod($period)
{
if (strlen($period) !== 6) {
return $period;
}
$year = substr($period, 0, 4);
$month = substr($period, 4, 2);
return date('F Y', mktime(0, 0, 0, (int) $month, 1, (int) $year));
}
/**
* Get the user who requested the statement
*/
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* Get the user who created the record
*/
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* Get the user who updated the record
*/
public function updater()
{
return $this->belongsTo(User::class, 'updated_by');
}
/**
* Get the user who authorized the record
*/
public function authorizer()
{
return $this->belongsTo(User::class, 'authorized_by');
}
/**
* Scope a query to only include pending authorization records
*/
public function scopePending($query)
{
return $query->where('authorization_status', 'pending');
}
/**
* Scope a query to only include approved records
*/
public function scopeApproved($query)
{
return $query->where('authorization_status', 'approved');
}
/**
* Scope a query to only include rejected records
*/
public function scopeRejected($query)
{
return $query->where('authorization_status', 'rejected');
}
/**
* Scope a query to only include downloaded records
*/
public function scopeDownloaded($query)
{
return $query->where('is_downloaded', true);
}
/**
* Scope a query to only include available records
*/
public function scopeAvailable($query)
{
return $query->where('is_available', true);
}
/**
* Check if the statement is for a single period
*/
public function isSinglePeriod()
{
return !$this->is_period_range;
}
/**
* Check if the statement is authorized
*/
public function isAuthorized()
{
return $this->authorization_status === 'approved';
}
/**
* Check if the statement is rejected
*/
public function isRejected()
{
return $this->authorization_status === 'rejected';
}
/**
* Check if the statement is pending authorization
*/
public function isPending()
{
return $this->authorization_status === 'pending';
}
public function branch(){
return $this->belongsTo(Branch::class, 'branch_code','code');
}
/**
* Check if this is a single account request
*/
public function isSingleAccountRequest()
{
return $this->request_type === 'single_account';
}
/**
* Check if this is a branch request
*/
public function isBranchRequest()
{
return $this->request_type === 'branch';
}
/**
* Check if this is an all branches request
*/
public function isAllBranchesRequest()
{
return $this->request_type === 'all_branches';
}
/**
* Check if processing is completed
*/
public function isCompleted()
{
return $this->status === 'completed';
}
/**
* Check if processing is in progress
*/
public function isProcessing()
{
return $this->status === 'processing';
}
/**
* Check if processing failed
*/
public function isFailed()
{
return $this->status === 'failed';
}
/**
* Get progress percentage
*/
public function getProgressPercentage()
{
if (!$this->total_accounts || $this->total_accounts == 0) {
return 0;
}
return round(($this->processed_accounts / $this->total_accounts) * 100, 2);
}
/**
* Get success rate percentage
*/
public function getSuccessRate()
{
if (!$this->processed_accounts || $this->processed_accounts == 0) {
return 0;
}
return round(($this->success_count / $this->processed_accounts) * 100, 2);
}
/**
* Scope for batch requests
*/
public function scopeBatch($query)
{
return $query->whereIn('request_type', ['branch', 'all_branches']);
}
/**
* Scope for single account requests
*/
public function scopeSingleAccount($query)
{
return $query->where('request_type', 'single_account');
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,95 @@
<?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) {
// Field untuk mendukung pengiriman dinamis
$table->enum('request_type', ['single_account', 'branch', 'all_branches'])
->default('single_account')
->after('account_number')
->comment('Type of statement request');
$table->string('batch_id')->nullable()
->after('request_type')
->comment('Batch ID for bulk operations');
$table->json('target_accounts')->nullable()
->after('batch_id')
->comment('JSON array of target account numbers for batch processing');
$table->integer('total_accounts')->nullable()
->after('target_accounts')
->comment('Total number of accounts in batch');
$table->integer('processed_accounts')->default(0)
->after('total_accounts')
->comment('Number of accounts processed');
$table->integer('success_count')->default(0)
->after('processed_accounts')
->comment('Number of successful email sends');
$table->integer('failed_count')->default(0)
->after('success_count')
->comment('Number of failed email sends');
$table->enum('status', ['pending', 'processing', 'completed', 'failed'])
->default('pending')
->after('failed_count')
->comment('Overall status of the request');
$table->timestamp('started_at')->nullable()
->after('status')
->comment('When processing started');
$table->timestamp('completed_at')->nullable()
->after('started_at')
->comment('When processing completed');
$table->text('error_message')->nullable()
->after('completed_at')
->comment('Error message if processing failed');
// Ubah account_number menjadi nullable untuk request batch
$table->string('account_number')->nullable()->change();
// Index untuk performa
$table->index(['request_type', 'status']);
$table->index(['batch_id']);
$table->index(['branch_code', 'request_type']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('print_statement_logs', function (Blueprint $table) {
$table->dropColumn([
'request_type',
'batch_id',
'target_accounts',
'total_accounts',
'processed_accounts',
'success_count',
'failed_count',
'status',
'started_at',
'completed_at',
'error_message'
]);
$table->string('account_number')->nullable(false)->change();
});
}
};

View File

@@ -30,7 +30,8 @@
"attributes": [],
"permission": "",
"roles": [
"administrator"
"administrator",
"customer_service"
]
},
{
@@ -63,6 +64,19 @@
"roles": []
}
],
"laporan": [
{
"title": "Laporan Transaksi ATM",
"path": "atm-reports",
"icon": "ki-filled ki-printer text-lg text-primary",
"classes": "",
"attributes": [],
"permission": "",
"roles": [
"administrator"
]
}
],
"master": [
{
"title": "Basic Data",
@@ -121,6 +135,17 @@
"roles": [
"administrator"
]
},
{
"title": "Log Email Statement",
"path": "email-statement-logs",
"icon": "ki-filled ki-message-text-2 text-lg text-primary",
"classes": "",
"attributes": [],
"permission": "",
"roles": [
"administrator"
]
}
]
}

View File

@@ -0,0 +1,322 @@
@extends('layouts.main')
@section('title', 'ATM Transaction Reports')
@section('breadcrumbs')
{{ Breadcrumbs::render(request()->route()->getName()) }}
@endsection
@section('content')
<div class="grid grid-cols-8 gap-5">
<div class="col-span-2 card">
<div class="card-header">
<h3 class="card-title">Request ATM Transaction Report</h3>
</div>
<div class="card-body">
<form action="{{ route('atm-reports.store') }}" method="POST">
@csrf
<div class="grid grid-cols-1 gap-5">
<div class="form-group">
<label class="form-label required" for="report_date">Report Date</label>
<input type="date" class="input form-control @error('report_date') is-invalid @enderror"
id="report_date" name="report_date" value="{{ old('report_date') }}" required>
@error('report_date')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<button type="submit" class="w-full btn btn-primary">
<i class="ki-filled ki-plus"></i>
Generate Report
</button>
</div>
</div>
</form>
</div>
</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="atm-reports-table"
data-api-url="{{ route('atm-reports.datatables') }}">
<div class="flex-wrap py-5 card-header">
<h3 class="card-title">
ATM Transaction Reports
</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 Reports" 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="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="report_date">
<span class="sort"> <span class="sort-label"> Report Date </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="status">
<span class="sort"> <span class="sort-label"> Status </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="authorization_status">
<span class="sort"> <span class="sort-label"> Authorization </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[180px]" data-datatable-column="created_at">
<span class="sort"> <span class="sort-label"> Created At </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[50px] text-center" data-datatable-column="actions">Action</th>
</tr>
</thead>
</table>
</div>
<div
class="flex-col gap-3 justify-center font-medium text-gray-600 card-footer md:justify-between md:flex-row text-2sm">
<div class="flex gap-2 items-center">
Show
<select class="w-16 select select-sm" data-datatable-size="true" name="perpage"> </select> per
page
</div>
<div class="flex gap-4 items-center">
<span data-datatable-info="true"> </span>
<div class="pagination" data-datatable-pagination="true">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script type="text/javascript">
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(`atm-reports/${data}`, {
type: 'DELETE'
}).then((response) => {
swal.fire('Deleted!', 'ATM Transaction report 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');
});
}
})
}
// Add the missing retryReport function
function retryReport(id) {
Swal.fire({
title: 'Are you sure?',
text: 'This will reset the current job and start a new one.',
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Yes, retry it!'
}).then((result) => {
if (result.isConfirmed) {
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
});
$.ajax(`atm-reports/${id}/retry`, {
type: 'POST'
}).then((response) => {
Swal.fire('Success!', 'Report retry initiated successfully.', 'success').then(
() => {
window.location.reload();
});
}).catch((error) => {
console.error('Error:', error);
Swal.fire('Error!', 'Failed to retry report: ' + (error.responseJSON?.message ||
'Unknown error'), 'error');
});
}
})
}
</script>
<script type="module">
const element = document.querySelector('#atm-reports-table');
const searchInput = document.getElementById('search');
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',
},
period: {
title: 'Period',
render: (item, data) => {
return data.period || '';
},
},
report_date: {
title: 'Report Date',
render: (item, data) => {
return data.report_date || '';
},
},
status: {
title: 'Status',
render: (item, data) => {
let statusClass = 'badge badge-light-primary';
let statusText = data.status;
if (data.status === 'completed') {
statusClass = 'badge badge-light-success';
} else if (data.status === 'failed') {
statusClass = 'badge badge-light-danger';
} else if (data.status === 'processing') {
if (data.is_processing_timeout) {
statusClass = 'badge badge-light-danger';
statusText += ` (${data.processing_hours}h)`;
} else {
statusClass = 'badge badge-light-warning';
}
} else if (data.status === 'pending') {
statusClass = 'badge badge-light-info';
}
return `<span class="${statusClass}">${statusText}</span>`;
},
},
authorization_status: {
title: 'Authorization',
render: (item, data) => {
let statusClass = 'badge badge-light-primary';
if (data.authorization_status === 'approved') {
statusClass = 'badge badge-light-success';
} else if (data.authorization_status === 'rejected') {
statusClass = 'badge badge-light-danger';
} else if (data.authorization_status === 'pending') {
statusClass = 'badge badge-light-warning';
}
return `<span class="${statusClass}">${data.authorization_status || 'pending'}</span>`;
},
},
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="atm-reports/${data.id}">
<i class="ki-outline ki-eye"></i>
</a>`;
// Show download button if report is completed
if (data.status === 'completed' && data.file_path) {
buttons += `<a class="btn btn-sm btn-icon btn-clear btn-success" href="atm-reports/${data.id}/download">
<i class="ki-outline ki-cloud-download"></i>
</a>`;
}
// Retry button
if (data.can_retry) {
let retryClass = 'btn-warning';
if (data.is_processing_timeout) {
retryClass = 'btn-danger';
}
buttons += `<button class="btn btn-sm btn-icon btn-clear ${retryClass}" onclick="retryReport(${data.id})">
<i class="ki-outline ki-arrows-circle"></i>
</button>`;
}
// Only show delete button if status is pending or failed
if (data.status === 'pending' || data.status === 'failed') {
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;
},
}
},
};
let dataTable = new KTDataTable(element, dataTableOptions);
// Custom search functionality
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"]');
rowCheckboxes.forEach(checkbox => {
checkbox.checked = isChecked;
});
});
}
</script>
@endpush

View File

@@ -0,0 +1,291 @@
@extends('layouts.main')
@section('content')
<div class="card">
<div class="card-header">
<h3 class="card-title">ATM Transaction Report Details</h3>
<div class="card-toolbar">
<a href="{{ route('atm-reports.index') }}" class="btn btn-sm btn-info me-2">
<i class="ki-duotone ki-arrow-left fs-2"></i>Back to List
</a>
@if ($atmReport->status === 'completed' && $atmReport->file_path)
<a href="{{ route('atm-reports.download', $atmReport->id) }}" class="btn btn-sm btn-primary">
<i class="ki-duotone ki-document fs-2"></i>Download Report
</a>
@endif
@php
$canRetry = in_array($atmReport->status, ['failed', 'pending']) ||
($atmReport->status === 'processing' && $atmReport->updated_at->diffInHours(now()) >= 1) ||
($atmReport->status === 'completed' && !$atmReport->file_path);
@endphp
@if ($canRetry)
<form action="{{ route('atm-reports.retry', $atmReport->id) }}" method="POST" class="d-inline" onsubmit="return confirm('Are you sure you want to retry generating this report?')">
@csrf
<button type="submit" class="btn btn-sm btn-warning me-2">
<i class="ki-duotone ki-arrows-circle fs-2"></i>
@if($atmReport->status === 'processing' && $atmReport->updated_at->diffInHours(now()) >= 1)
Retry (Timeout)
@else
Retry Job
@endif
</button>
</form>
@endif
</div>
</div>
<div class="card-body">
@if (session('success'))
<div class="alert alert-success">
{{ session('success') }}
</div>
@endif
@if (session('error'))
<div class="alert alert-danger">
{{ session('error') }}
</div>
@endif
<div class="grid grid-cols-2 gap-5 g-5">
<!-- Left Column - Report Information -->
<div class="shadow-sm card card-flush h-xl-100">
<div class="card-header">
<div class="card-title">
<h2>Report Information</h2>
</div>
</div>
<div class="py-5 card-body">
<div class="gap-5 d-flex flex-column">
<div class="d-flex flex-column">
<div class="text-gray-500 fw-semibold">Period</div>
<div class="fw-bold fs-5">
@php
// Convert format YYYYMMDD to readable date
$date = \Carbon\Carbon::createFromFormat('Ymd', $atmReport->period);
$periodText = $date->format('d F Y');
@endphp
{{ $periodText }}
</div>
</div>
<div class="d-flex flex-column">
<div class="text-gray-500 fw-semibold">Report Date</div>
<div class="fw-bold fs-5">{{ $atmReport->report_date }}</div>
</div>
<div class="d-flex flex-column">
<div class="text-gray-500 fw-semibold">Status</div>
<div>
@if ($atmReport->status === 'pending')
<span class="badge badge-info">Pending</span>
@elseif($atmReport->status === 'processing')
@php
$processingHours = $atmReport->updated_at->diffInHours(now());
@endphp
<span class="badge {{ $processingHours >= 1 ? 'badge-danger' : 'badge-warning' }}">
Processing
@if($processingHours >= 1)
({{ $processingHours }}h - Timeout)
@endif
</span>
@if($processingHours >= 1)
<div class="mt-1 text-danger small">
Processing for more than 1 hour. You can retry this job.
</div>
@endif
@elseif($atmReport->status === 'completed')
<span class="badge badge-success">Completed</span>
@elseif($atmReport->status === 'failed')
<span class="badge badge-danger">Failed</span>
@endif
</div>
</div>
<div class="d-flex flex-column">
<div class="text-gray-500 fw-semibold">Authorization Status</div>
<div>
@if ($atmReport->authorization_status === 'pending')
<span class="badge badge-warning">Pending Authorization</span>
@elseif($atmReport->authorization_status === 'approved')
<span class="badge badge-success">Approved</span>
@elseif($atmReport->authorization_status === 'rejected')
<span class="badge badge-danger">Rejected</span>
@endif
</div>
</div>
@if ($atmReport->status === 'completed')
<div class="d-flex flex-column">
<div class="text-gray-500 fw-semibold">File Information</div>
<div class="fw-bold fs-6">
@if ($atmReport->file_path)
<div>Path: {{ $atmReport->file_path }}</div>
@else
<div class="text-warning">File not available -
<form action="{{ route('atm-reports.retry', $atmReport->id) }}" method="POST" class="d-inline">
@csrf
<button type="submit" class="p-0 btn btn-link text-warning" onclick="return confirm('File is missing. Retry generating the report?')">
Click here to retry
</button>
</form>
</div>
@endif
@if ($atmReport->file_size)
<div>Size: {{ number_format($atmReport->file_size / 1024, 2) }} KB</div>
@endif
@if ($atmReport->record_count)
<div>Records: {{ number_format($atmReport->record_count) }}</div>
@endif
</div>
</div>
@endif
@if ($atmReport->status === 'failed' && $atmReport->error_message)
<div class="d-flex flex-column">
<div class="text-gray-500 fw-semibold">Error Message</div>
<div class="text-danger fw-bold fs-6">{{ $atmReport->error_message }}</div>
</div>
@endif
<div class="d-flex flex-column">
<div class="text-gray-500 fw-semibold">Downloaded</div>
<div>
@if ($atmReport->is_downloaded)
<span class="badge badge-success">Yes</span>
<div class="mt-1 text-muted">
Downloaded at:
{{ $atmReport->downloaded_at ? $atmReport->downloaded_at->format('d M Y H:i:s') : 'N/A' }}
</div>
@else
<span class="badge badge-light-primary">No</span>
@endif
</div>
</div>
</div>
</div>
</div>
<!-- Right Column - Request Information -->
<div class="shadow-sm card card-flush h-xl-100">
<div class="card-header">
<div class="card-title">
<h2>Request Information</h2>
</div>
</div>
<div class="py-5 card-body">
<div class="gap-5 d-flex flex-column">
<div class="d-flex flex-column">
<div class="text-gray-500 fw-semibold">Requested By</div>
<div class="fw-bold fs-5">{{ $atmReport->user->name ?? 'N/A' }}</div>
</div>
<div class="d-flex flex-column">
<div class="text-gray-500 fw-semibold">Requested At</div>
<div class="fw-bold fs-5">{{ dateFormat($atmReport->created_at, 1, 1) }}</div>
</div>
<div class="d-flex flex-column">
<div class="text-gray-500 fw-semibold">IP Address</div>
<div class="fw-bold fs-5">{{ $atmReport->ip_address }}</div>
</div>
<div class="d-flex flex-column">
<div class="text-gray-500 fw-semibold">User Agent</div>
<div class="text-muted small">{{ $atmReport->user_agent }}</div>
</div>
@if ($atmReport->authorization_status !== 'pending')
<div class="d-flex flex-column">
<div class="text-gray-500 fw-semibold">Authorized By</div>
<div class="fw-bold fs-5">{{ $atmReport->authorizer->name ?? 'N/A' }}</div>
</div>
<div class="d-flex flex-column">
<div class="text-gray-500 fw-semibold">Authorized At</div>
<div class="fw-bold fs-5">
{{ $atmReport->authorized_at ? $atmReport->authorized_at->format('d M Y H:i:s') : 'N/A' }}
</div>
</div>
@endif
@if ($atmReport->created_by && $atmReport->created_by !== $atmReport->user_id)
<div class="d-flex flex-column">
<div class="text-gray-500 fw-semibold">Created By</div>
<div class="fw-bold fs-5">{{ $atmReport->creator->name ?? 'N/A' }}</div>
</div>
@endif
@if ($atmReport->updated_by)
<div class="d-flex flex-column">
<div class="text-gray-500 fw-semibold">Last Updated By</div>
<div class="fw-bold fs-5">{{ $atmReport->updater->name ?? 'N/A' }}</div>
</div>
<div class="d-flex flex-column">
<div class="text-gray-500 fw-semibold">Last Updated At</div>
<div class="fw-bold fs-5">{{ dateFormat($atmReport->updated_at, 1, 1) }}</div>
</div>
@endif
</div>
</div>
</div>
</div>
@if ($atmReport->authorization_status === 'pending' && auth()->user()->can('authorize_atm_reports'))
<div class="mt-7 shadow-sm card">
<div class="card-header">
<h3 class="card-title">Authorization</h3>
</div>
<div class="card-body">
<form action="{{ route('atm-reports.authorize', $atmReport->id) }}" method="POST">
@csrf
<div class="mb-5">
<label class="form-label required">Authorization Decision</label>
<div class="d-flex">
<div class="form-check form-check-custom form-check-solid me-5">
<input class="form-check-input" type="radio" name="authorization_status"
value="approved" id="status_approved" required />
<label class="form-check-label" for="status_approved">
Approve
</label>
</div>
<div class="form-check form-check-custom form-check-solid">
<input class="form-check-input" type="radio" name="authorization_status"
value="rejected" id="status_rejected" required />
<label class="form-check-label" for="status_rejected">
Reject
</label>
</div>
</div>
</div>
<div class="mb-5">
<label class="form-label">Remarks</label>
<textarea class="form-control" name="remarks" rows="3"
placeholder="Enter any remarks or reasons for your decision"></textarea>
</div>
<div class="text-end">
<button type="submit" class="btn btn-primary">Submit Authorization</button>
</div>
</form>
</div>
</div>
@endif
</div>
</div>
@endsection
@push('scripts')
<script>
// Any additional JavaScript for this page
document.addEventListener('DOMContentLoaded', function() {
// Initialize any components if needed
});
</script>
@endpush

View File

@@ -0,0 +1,393 @@
@extends('layouts.main')
@section('breadcrumbs')
{{ Breadcrumbs::render(request()->route()->getName()) }}
@endsection
@section('content')
<div class="grid">
<div class="min-w-full card card-grid" data-datatable="false" data-datatable-page-size="10"
data-datatable-state-save="false" id="email-statement-logs-table"
data-api-url="{{ route('email-statement-logs.datatables') }}">
<div class="flex-wrap py-5 card-header">
<h3 class="card-title">
Log Pengiriman Email Statement
</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="Cari Log Email Statement" id="search" type="text" value="">
</label>
</div>
<div class="flex">
<select class="select select-sm" id="filter-branch">
<option value="">Cabang (Semua)</option>
@foreach ($branches as $branch)
<option value="{{ $branch }}">{{ $branch }}</option>
@endforeach
</select>
</div>
<div class="flex">
<select class="select select-sm" id="filter-email-status">
<option value="">Status Email (Semua)</option>
<option value="sent">Terkirim</option>
<option value="failed">Gagal</option>
<option value="pending">Pending</option>
</select>
</div>
<div class="flex">
<select class="select select-sm" id="filter-email-source">
<option value="">Sumber Email (Semua)</option>
<option value="account">Email Akun</option>
<option value="customer">Email Nasabah</option>
</select>
</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="min-w-[120px]" data-datatable-column="branch_code">
<span class="sort"> <span class="sort-label">Cabang</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">No. Rekening</span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[120px]" data-datatable-column="period_from">
<span class="sort"> <span class="sort-label">Periode Dari</span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[120px]" data-datatable-column="period_to">
<span class="sort"> <span class="sort-label">Periode Sampai</span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[200px]" data-datatable-column="email_address">
<span class="sort"> <span class="sort-label">Email</span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[120px]" data-datatable-column="email_source">
<span class="sort"> <span class="sort-label">Sumber Email</span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[120px]" data-datatable-column="email_status">
<span class="sort"> <span class="sort-label">Status Email</span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="email_sent_at">
<span class="sort"> <span class="sort-label">Waktu Kirim</span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[100px]" data-datatable-column="actions">
<span>Aksi</span>
</th>
</tr>
</thead>
</table>
</div>
<div
class="flex-col gap-3 justify-center font-medium text-gray-600 card-footer md:justify-between md:flex-row text-2sm">
<div class="flex gap-2 items-center">
Show
<select class="w-16 select select-sm" data-datatable-size="true" name="perpage"> </select> per page
</div>
<div class="flex gap-4 items-center">
<span data-datatable-info="true"> </span>
<div class="pagination" data-datatable-pagination="true">
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal for detail view -->
<div class="modal modal-open:!flex" data-modal="true" id="detail-modal">
<div class="overflow-hidden px-5 w-full modal-content pt-7.5 container-fixed">
<div class="modal-header">
<h2 class="modal-title">Detail Log Email Statement</h2>
<button class="btn btn-sm btn-icon btn-active-color-danger" data-modal-dismiss="true">
<i class="ki-outline ki-cross fs-1"></i>
</button>
</div>
<div class="modal-body">
<div class="mb-5">
<h3 class="mb-2 font-bold">Informasi Umum</h3>
<div class="grid grid-cols-2 gap-2">
<div>Cabang:</div>
<div id="detail-branch-code"></div>
<div>No. Rekening:</div>
<div id="detail-account-number"></div>
<div>Periode:</div>
<div id="detail-period"></div>
<div>Email:</div>
<div id="detail-email-address"></div>
<div>Sumber Email:</div>
<div id="detail-email-source"></div>
</div>
</div>
<div class="mb-5">
<h3 class="mb-2 font-bold">Status Pengiriman Email</h3>
<div class="grid grid-cols-2 gap-2">
<div>Status:</div>
<div id="detail-email-status"></div>
<div>Waktu Kirim:</div>
<div id="detail-email-sent-at"></div>
<div>Error Message:</div>
<div id="detail-error-message"></div>
</div>
</div>
<div class="mb-5">
<h3 class="mb-2 font-bold">Informasi Tambahan</h3>
<div class="grid grid-cols-2 gap-2">
<div>Dibuat Oleh:</div>
<div id="detail-user-id"></div>
<div>Waktu Dibuat:</div>
<div id="detail-created-at"></div>
<div>Waktu Update:</div>
<div id="detail-updated-at"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" id="btn-resend-email" class="btn btn-primary btn-sm me-3">
<i class="ki-outline ki-send me-1"></i> Kirim Ulang Email
</button>
<button type="button" class="btn btn-light" data-modal-dismiss="true">Tutup</button>
</div>
</div>
</div>
@endsection
@push('scripts')
<script type="module">
const element = document.querySelector('#email-statement-logs-table');
const searchInput = document.getElementById('search');
const filterBranch = document.getElementById('filter-branch');
const filterEmailStatus = document.getElementById('filter-email-status');
const filterEmailSource = document.getElementById('filter-email-source');
const detailModal = document.getElementById('detail-modal');
const btnResendEmail = document.getElementById('btn-resend-email');
const apiUrl = element.getAttribute('data-api-url');
const dataTableOptions = {
apiEndpoint: apiUrl,
pageSize: 10,
columns: {
branch_code: {
title: 'Cabang',
},
account_number: {
title: 'No. Rekening',
},
period_from: {
title: 'Periode Dari',
render: (item, data) => {
return data.period_from ? new Date(data.period_from).toLocaleDateString('id-ID') : '-';
},
},
period_to: {
title: 'Periode Sampai',
render: (item, data) => {
return data.period_to ? new Date(data.period_to).toLocaleDateString('id-ID') : '-';
},
},
email_address: {
title: 'Email',
},
email_source: {
title: 'Sumber Email',
render: (item, data) => {
if (data.email_source === 'account') {
return '<span class="badge badge-info">Email Akun</span>';
} else if (data.email_source === 'customer') {
return '<span class="badge badge-primary">Email Nasabah</span>';
}
return '-';
},
},
email_status: {
title: 'Status Email',
render: (item, data) => {
if (data.email_status === 'sent') {
return '<span class="badge badge-success">Terkirim</span>';
} else if (data.email_status === 'failed') {
return '<span class="badge badge-danger">Gagal</span>';
} else if (data.email_status === 'pending') {
return '<span class="badge badge-warning">Pending</span>';
}
return '<span class="badge badge-secondary">Unknown</span>';
},
},
email_sent_at: {
title: 'Waktu Kirim',
render: (item, data) => {
return data.email_sent_at ? new Date(data.email_sent_at).toLocaleString('id-ID') : '-';
},
},
actions: {
title: 'Aksi',
render: (item, data) => {
let actions = `<div class="flex gap-2">`;
// Detail button
actions += `<button class="btn btn-sm btn-icon btn-light btn-detail" data-id="${data.id}">
<i class="ki-outline ki-eye fs-3"></i>
</button>`;
// Resend button for failed emails
if (data.email_status === 'failed') {
actions += `<button class="btn btn-sm btn-icon btn-warning btn-resend" data-id="${data.id}" title="Kirim Ulang Email">
<i class="ki-outline ki-send fs-3"></i>
</button>`;
}
actions += `</div>`;
return actions;
},
}
},
};
let dataTable = new KTDataTable(element, dataTableOptions);
// Custom search functionality
searchInput.addEventListener('change', function() {
const searchValue = this.value.trim();
dataTable.goPage(1);
dataTable.search(searchValue, true);
});
// Filter functionality
const applyFilters = () => {
const branchValue = filterBranch.value;
const emailStatusValue = filterEmailStatus.value;
const emailSourceValue = filterEmailSource.value;
const params = {};
if (searchInput.value) {
params.search = searchInput.value;
}
if (branchValue !== '') params.branch_code = branchValue;
if (emailStatusValue !== '') params.email_status = emailStatusValue;
if (emailSourceValue !== '') params.email_source = emailSourceValue;
dataTable.goPage(1);
dataTable.search(params);
dataTable.reload();
};
filterBranch.addEventListener('change', applyFilters);
filterEmailStatus.addEventListener('change', applyFilters);
filterEmailSource.addEventListener('change', applyFilters);
// Detail modal functionality
document.addEventListener('click', function(e) {
if (e.target.closest('.btn-detail')) {
const id = e.target.closest('.btn-detail').getAttribute('data-id');
// Fetch log details by ID
fetch(`{{ url('email-statement-logs') }}/${id}`)
.then(response => response.json())
.then(data => {
// Store current log ID for resend functionality
btnResendEmail.setAttribute('data-id', data.id);
// Show/hide resend button based on email status
if (data.email_status === 'failed') {
btnResendEmail.classList.remove('hidden');
} else {
btnResendEmail.classList.add('hidden');
}
// Populate modal with data
document.getElementById('detail-branch-code').textContent = data.branch_code;
document.getElementById('detail-account-number').textContent = data.account_number;
document.getElementById('detail-period').textContent =
`${new Date(data.period_from).toLocaleDateString('id-ID')} - ${new Date(data.period_to).toLocaleDateString('id-ID')}`;
document.getElementById('detail-email-address').textContent = data.email_address;
const emailSourceText = data.email_source === 'account' ? 'Email Akun' : (data
.email_source === 'customer' ? 'Email Nasabah' : '-');
document.getElementById('detail-email-source').textContent = emailSourceText;
let emailStatusBadge = '';
if (data.email_status === 'sent') {
emailStatusBadge = '<span class="badge badge-success">Terkirim</span>';
} else if (data.email_status === 'failed') {
emailStatusBadge = '<span class="badge badge-danger">Gagal</span>';
} else if (data.email_status === 'pending') {
emailStatusBadge = '<span class="badge badge-warning">Pending</span>';
}
document.getElementById('detail-email-status').innerHTML = emailStatusBadge;
document.getElementById('detail-email-sent-at').textContent = data.email_sent_at ?
new Date(data.email_sent_at).toLocaleString('id-ID') :
'-';
document.getElementById('detail-error-message').textContent = data.error_message || '-';
document.getElementById('detail-user-id').textContent = data.user_id || '-';
document.getElementById('detail-created-at').textContent = data.created_at ?
new Date(data.created_at).toLocaleString('id-ID') :
'-';
document.getElementById('detail-updated-at').textContent = data.updated_at ?
new Date(data.updated_at).toLocaleString('id-ID') :
'-';
// Show modal
const modalEl = KTDom.getElement('#detail-modal');
const modal = KTModal.getInstance(modalEl);
modal.show();
})
.catch(error => {
console.error('Error fetching log details:', error);
alert('Gagal mengambil detail log');
});
}
// Resend email functionality
if (e.target.closest('.btn-resend') || e.target.closest('#btn-resend-email')) {
const id = e.target.closest('.btn-resend, #btn-resend-email').getAttribute('data-id');
if (confirm('Apakah Anda yakin ingin mengirim ulang email ini?')) {
fetch(`{{ url('email-statement-logs') }}/${id}/resend`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content')
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Email berhasil dikirim ulang');
dataTable.reload();
// Close modal if open
const modalEl = KTDom.getElement('#detail-modal');
const modal = KTModal.getInstance(modalEl);
if (modal) {
modal.hide();
}
} else {
alert('Gagal mengirim ulang email: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
console.error('Error resending email:', error);
alert('Gagal mengirim ulang email');
});
}
}
});
window.dataTable = dataTable;
</script>
@endpush

View File

@@ -0,0 +1,176 @@
@extends('layouts.main')
@section('title', 'Detail Log Pengiriman Email Statement')
@section('breadcrumbs')
{{ Breadcrumbs::render(request()->route()->getName(), $log) }}
@endsection
@section('content')
<div class="container-fluid">
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3 class="card-title">Detail Log Pengiriman Email Statement</h3>
<div class="card-toolbar">
<a href="{{ route('email-statement-logs.index') }}" class="btn btn-sm btn-light">
<i class="text-base ki-filled ki-black-left"></i>
Kembali
</a>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<table class="table table-borderless">
<tr>
<td class="fw-bold">ID Log:</td>
<td>{{ $log->id }}</td>
</tr>
<tr>
<td class="fw-bold">Branch:</td>
<td>{{ $log->branch->name ?? 'N/A' }} ({{ $log->branch_code }})</td>
</tr>
<tr>
<td class="fw-bold">No. Rekening:</td>
<td>{{ $log->account_number }}</td>
</tr>
<tr>
<td class="fw-bold">Periode:</td>
<td>{{ $log->period_display }}</td>
</tr>
<tr>
<td class="fw-bold">Email Tujuan:</td>
<td>{{ $log->email }}</td>
</tr>
<tr>
<td class="fw-bold">Status Email:</td>
<td>
@if ($log->email_sent_at)
<span class="badge badge-success">Terkirim</span>
@else
<span class="badge badge-warning">Pending</span>
@endif
</td>
</tr>
</table>
</div>
<div class="col-md-6">
<table class="table table-borderless">
<tr>
<td class="fw-bold">Tanggal Kirim:</td>
<td>{{ $log->email_sent_at ? $log->email_sent_at->format('d/m/Y H:i:s') : '-' }}
</td>
</tr>
<tr>
<td class="fw-bold">Status Otorisasi:</td>
<td>
@php
$badgeClass = 'badge-secondary';
if ($log->authorization_status === 'approved') {
$badgeClass = 'badge-success';
} elseif ($log->authorization_status === 'rejected') {
$badgeClass = 'badge-danger';
} elseif ($log->authorization_status === 'pending') {
$badgeClass = 'badge-warning';
}
@endphp
<span
class="badge {{ $badgeClass }}">{{ ucfirst($log->authorization_status) }}</span>
</td>
</tr>
<tr>
<td class="fw-bold">Statement Tersedia:</td>
<td>
@if ($log->is_available)
<span class="badge badge-success">Ya</span>
@else
<span class="badge badge-danger">Tidak</span>
@endif
</td>
</tr>
<tr>
<td class="fw-bold">User Pembuat:</td>
<td>{{ $log->user->name ?? 'N/A' }}</td>
</tr>
<tr>
<td class="fw-bold">Tanggal Dibuat:</td>
<td>{{ $log->created_at->format('d/m/Y H:i:s') }}</td>
</tr>
<tr>
<td class="fw-bold">Terakhir Update:</td>
<td>{{ $log->updated_at->format('d/m/Y H:i:s') }}</td>
</tr>
</table>
</div>
</div>
@if ($log->remarks)
<div class="mt-4 row">
<div class="col-12">
<h5>Catatan:</h5>
<div class="alert alert-info">
{{ $log->remarks }}
</div>
</div>
</div>
@endif
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h3 class="card-title">Aksi</h3>
</div>
<div class="card-body">
@if ($log->is_available && $log->authorization_status === 'approved')
<button onclick="resendEmail({{ $log->id }})" class="mb-3 btn btn-primary w-100">
<i class="text-base ki-filled ki-message-text-2"></i>
Kirim Ulang Email
</button>
@endif
@if ($log->is_available)
<a href="{{ route('statements.download', $log->id) }}" class="mb-3 btn btn-success w-100">
<i class="text-base ki-filled ki-file-down"></i>
Download Statement
</a>
@endif
<a href="{{ route('statements.show', $log->id) }}" class="btn btn-info w-100">
<i class="text-base ki-filled ki-eye"></i>
Lihat Statement Log
</a>
</div>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
// Function untuk resend email
function resendEmail(logId) {
if (confirm('Apakah Anda yakin ingin mengirim ulang email statement ini?')) {
$.ajax({
url: '{{ route('email-statement-logs.resend-email', ':id') }}'.replace(':id', logId),
type: 'POST',
data: {
_token: '{{ csrf_token() }}'
},
success: function(response) {
alert('Email statement berhasil dijadwalkan untuk dikirim ulang.');
location.reload();
},
error: function(xhr) {
alert('Gagal mengirim ulang email statement.');
}
});
}
}
</script>
@endpush

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -15,8 +16,8 @@
}
.container {
max-width: 90%;
margin: 20px auto;
max-width: 100%;
margin: 0px auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
@@ -36,7 +37,7 @@
}
.content {
padding: 30px;
padding: 5px;
font-size: 14px;
}
@@ -59,85 +60,84 @@
}
ul.dashed-list {
list-style-type: none; /* Remove default bullet */
padding-left: 1em; /* Add some left padding for spacing */
list-style-type: none;
/* Remove default bullet */
padding-left: 1em;
/* Add some left padding for spacing */
}
ul.dashed-list li::before {
content: " "; /* Use an en dash (U+2013) or a hyphen "-" */
display: inline-block; /* Ensure proper spacing */
width: 1em; /* Adjust width as needed */
margin-left: 0.5em; /* Align the dash properly */
content: " ";
/* Use an en dash (U+2013) or a hyphen "-" */
display: inline-block;
/* Ensure proper spacing */
width: 1em;
/* Adjust width as needed */
margin-left: 0.5em;
/* Align the dash properly */
}
</style>
</head>
<body>
<div class="container">
<div class="content">
<div>
Yang Terhormat <strong>Bapak/Ibu Daeng Deni Mardaeni</strong>,<br><br>
<div class="container">
<div class="content">
<div>
Yang Terhormat <strong>Bapak/Ibu {{ $accounts->customer->name }}</strong>,<br><br>
Terlampir adalah Electronic Statement Rekening Anda.<br>
Silahkan gunakan password Electronic Statement Anda untuk membukanya.<br><br>
Password standar Elektronic Statement ini adalah <strong>ddMonyyyyxx</strong> (contoh: 01Aug1970xx) dimana :
<ul class="dashed-list">
<li>dd : <strong>2 digit</strong> tanggal lahir anda, contoh: 01</li>
<li>Mon :
<strong>3 huruf pertama</strong> bulan lahir anda dalam bahasa Ingris. Huruf pertama adalah huruf besar dan selanjutnya huruf kecil, contoh : Aug
</li>
<li>yyyy : <strong>4 digit</strong> tahun kelahiran anda, contoh : 1970</li>
<li>xx : <strong>2 digit terakhir</strong> dari nomer rekening anda, contoh : 12</li>
</ul>
<br>
Terlampir adalah Electronic Statement Rekening Anda.<br>
Silahkan gunakan password Electronic Statement Anda untuk membukanya.<br><br>
Password standar Elektronic Statement ini adalah <strong>ddMonyyyyxx</strong> (contoh: 01Aug1970xx)
dimana :
<ul style="list-style-type: none;">
<li>- dd : <strong>2 digit</strong> tanggal lahir anda, contoh: 01</li>
<li>- Mon :
<strong>3 huruf pertama</strong> bulan lahir anda dalam bahasa Ingris. Huruf pertama adalah
huruf besar dan selanjutnya huruf kecil, contoh : Aug
</li>
<li>- yyyy : <strong>4 digit</strong> tahun kelahiran anda, contoh : 1970</li>
<li>- xx : <strong>2 digit terakhir</strong> dari nomer rekening anda, contoh : 12</li>
</ul>
<br>
Terima Kasih,<br><br>
Terima Kasih,<br><br>
<strong>Bank Artha Graha Internasional</strong><br>
------------------------------
<wbr>
------------------------------
<wbr>
--------<br>
Kami sangat menghargai masukan dan saran Anda untuk meningkatkan layanan dan produk kami.<br>
Untuk memberikan masukan, silakan hubungi <strong>GrahaCall 24 Jam</strong> kami di
<strong>0-800-191-8880</strong>.<br><br><br>
<strong>Bank Artha Graha Internasional</strong><br>
------------------------------------------------------------<br>
Kami sangat menghargai masukan dan saran Anda untuk meningkatkan layanan dan produk kami.<br>
Untuk memberikan masukan, silakan hubungi <strong>GrahaCall 24 Jam</strong> kami di
<strong>0-800-191-8880</strong>.<br><br><br>
Dear <strong>Mr/Mrs/Ms Daeng Deni Mardaeni</strong>,<br><br>
Dear <strong>Mr/Mrs/Ms {{ $accounts->customer->name }}</strong>,<br><br>
Attached is your Electronic Account Statement.<br>
Please use your Electronic Statement password to open it.<br><br>
Attached is your Electronic Account Statement.<br>
Please use your Electronic Statement password to open it.<br><br>
The Electronic Statement standard password is <strong>ddMonyyyyxx</strong> (example: 01Aug1970xx) where:
<ul class="dashed-list">
<li>dd : <strong>The first 2 digits</strong> of your birthdate, example: 01</li>
<li>Mon :
<strong>The first 3 letters</strong> of your birth month in English. The first letter is uppercase and the rest are lowercase, example: Aug
</li>
<li>yyyy : <strong>4 digit</strong> of your birth year, example: 1970</li>
<li>xx : <strong>The last 2 digits</strong> of your account number, example: 12.</li>
</ul>
<br>
The Electronic Statement standard password is <strong>ddMonyyyyxx</strong> (example: 01Aug1970xx) where:
<ul style="list-style-type: none;">
<li>- dd : <strong>The first 2 digits</strong> of your birthdate, example: 01</li>
<li>- Mon :
<strong>The first 3 letters</strong> of your birth month in English. The first letter is
uppercase and the rest are lowercase, example: Aug
</li>
<li>- yyyy : <strong>4 digit</strong> of your birth year, example: 1970</li>
<li>- xx : <strong>The last 2 digits</strong> of your account number, example: 12.</li>
</ul>
<br>
Regards,<br><br>
Regards,<br><br>
<strong>Bank Artha Graha Internasional</strong><br>
------------------------------
<wbr>
------------------------------
<wbr>
--------<br>
We welcome any feedback or suggestions to improve our product and services.<br>
If you have any feedback, please contact our <strong>GrahaCall 24 Hours</strong> at
<strong>0-800-191-8880</strong>.
<div class="yj6qo"></div>
<div class="adL"><br>
<strong>Bank Artha Graha Internasional</strong><br>
------------------------------------------------------------<br>
We welcome any feedback or suggestions to improve our product and services.<br>
If you have any feedback, please contact our <strong>GrahaCall 24 Hours</strong> at
<strong>0-800-191-8880</strong>.
<div class="yj6qo"></div>
<div class="adL"><br>
</div>
</div>
</div>
</div>
<div class="footer">
<p>© 2023 Bank Artha Graha Internasional. All rights reserved.</p>
<p>Jika Anda memiliki pertanyaan, silakan hubungi customer service kami.</p>
</div>
</div>
</body>
</html>

View File

@@ -11,41 +11,59 @@
<h3 class="card-title">Request Print Stetement</h3>
</div>
<div class="card-body">
<form action="{{ isset($statement) ? route('statements.update', $statement->id) : route('statements.store') }}" method="POST">
<form
action="{{ isset($statement) ? route('statements.update', $statement->id) : route('statements.store') }}"
method="POST">
@csrf
@if(isset($statement))
@if (isset($statement))
@method('PUT')
@endif
<div class="grid grid-cols-1 gap-5">
<div class="form-group">
<label class="form-label required" for="branch_code">Branch</label>
<select class="select tomselect @error('branch_code') is-invalid @enderror" id="branch_code" name="branch_code" required>
<option value="">Select Branch</option>
@foreach($branches as $branch)
<option value="{{ $branch->code }}" {{ (old('branch_code', $statement->branch_code ?? '') == $branch->code) ? 'selected' : '' }}>
{{ $branch->name }}
</option>
@endforeach
</select>
@error('branch_code')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
@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>
<option value="">Pilih Branch/Cabang</option>
@foreach ($branches as $branchOption)
<option value="{{ $branchOption->code }}"
{{ old('branch_id', $statement->branch_id ?? ($branch->code ?? '')) == $branchOption->code ? 'selected' : '' }}>
{{ $branchOption->code }} - {{ $branchOption->name }}
</option>
@endforeach
</select>
@error('branch_id')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
@else
<div class="form-group">
<label class="form-label" for="branch_display">Branch/Cabang</label>
<input type="text" class="input form-control" id="branch_display"
value="{{ $branch->code ?? '' }} - {{ $branch->name ?? '' }}" readonly>
<input type="hidden" name="branch_id" value="{{ $branch->code ?? '' }}">
</div>
@endif
<div class="form-group">
<label class="form-label required" for="account_number">Account Number</label>
<input type="text" class="input form-control @error('account_number') is-invalid @enderror" id="account_number" name="account_number" value="{{ old('account_number', $statement->account_number ?? '') }}" required>
<input type="text" class="input form-control @error('account_number') is-invalid @enderror"
id="account_number" name="account_number"
value="{{ old('account_number', $statement->account_number ?? '') }}" required>
@error('account_number')
<div class="invalid-feedback">{{ $message }}</div>
<div class="invalid-feedback">{{ $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" id="email" name="email" value="{{ old('email', $statement->email ?? '') }}" placeholder="Optional email for send statement">
<input type="email" class="input form-control @error('email') is-invalid @enderror"
id="email" name="email" value="{{ old('email', $statement->email ?? '') }}"
placeholder="Optional email for send statement">
@error('email')
<div class="invalid-feedback">{{ $message }}</div>
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
@@ -53,24 +71,21 @@
<label class="form-label required" for="start_date">Start Date</label>
<input class="input @error('period_from') border-danger bg-danger-light @enderror"
type="month"
name="period_from"
value="{{ $statement->period_from ?? old('period_from') }}"
max="{{ date('Y-m', strtotime('-1 month')) }}">
type="month" name="period_from"
value="{{ $statement->period_from ?? old('period_from') }}"
max="{{ date('Y-m', strtotime('-1 month')) }}">
@error('period_from')
<em class="alert text-danger text-sm">{{ $message }}</em>
<em class="text-sm alert text-danger">{{ $message }}</em>
@enderror
</div>
<div class="form-group">
<label class="form-label required" for="end_date">End Date</label>
<input class="input @error('period_to') border-danger bg-danger-light @enderror"
type="month"
name="period_to"
value="{{ $statement->period_to ?? old('period_to') }}"
max="{{ date('Y-m', strtotime('-1 month')) }}">
<input class="input @error('period_to') border-danger bg-danger-light @enderror" type="month"
name="period_to" value="{{ $statement->period_to ?? old('period_to') }}"
max="{{ date('Y-m', strtotime('-1 month')) }}">
@error('period_to')
<em class="alert text-danger text-sm">{{ $message }}</em>
<em class="text-sm alert text-danger">{{ $message }}</em>
@enderror
</div>
</div>
@@ -85,8 +100,9 @@
</div>
</div>
<div class="col-span-6">
<div class="card card-grid min-w-full" data-datatable="false" data-datatable-page-size="10" data-datatable-state-save="false" id="statement-table" data-api-url="{{ route('statements.datatables') }}">
<div class="card-header py-5 flex-wrap">
<div class="min-w-full card card-grid" data-datatable="false" data-datatable-page-size="10"
data-datatable-state-save="false" id="statement-table" data-api-url="{{ route('statements.datatables') }}">
<div class="flex-wrap py-5 card-header">
<h3 class="card-title">
Daftar Statement Request
</h3>
@@ -100,55 +116,54 @@
</div>
<div class="card-body">
<div class="scrollable-x-auto">
<table class="table table-auto table-border align-middle text-gray-700 font-medium text-sm" data-datatable-table="true">
<table class="table text-sm font-medium text-gray-700 align-middle table-auto table-border"
data-datatable-table="true">
<thead>
<tr>
<th class="w-14">
<input class="checkbox checkbox-sm" data-datatable-check="true" type="checkbox"/>
</th>
<th class="min-w-[100px]" data-datatable-column="id">
<span class="sort"> <span class="sort-label"> ID </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="branch_name">
<span class="sort"> <span class="sort-label"> Branch </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="account_number">
<span class="sort"> <span class="sort-label"> Account Number </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="period">
<span class="sort"> <span class="sort-label"> Period </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="authorization_status">
<span class="sort"> <span class="sort-label"> Status </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="is_available">
<span class="sort"> <span class="sort-label"> Available </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[150px]" data-datatable-column="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>
<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="card-footer justify-center md:justify-between flex-col md:flex-row gap-3 text-gray-600 text-2sm font-medium">
<div class="flex items-center gap-2">
<div
class="flex-col gap-3 justify-center font-medium text-gray-600 card-footer md:justify-between md:flex-row text-2sm">
<div class="flex gap-2 items-center">
Show
<select class="select select-sm w-16" data-datatable-size="true" name="perpage"> </select> per page
<select class="w-16 select select-sm" data-datatable-size="true" name="perpage"> </select>
per page
</div>
<div class="flex items-center gap-4">
<div class="flex gap-4 items-center">
<span data-datatable-info="true"> </span>
<div class="pagination" data-datatable-pagination="true">
</div>
@@ -162,6 +177,10 @@
@push('scripts')
<script type="text/javascript">
/**
* Fungsi untuk menghapus data statement
* @param {number} data - ID statement yang akan dihapus
*/
function deleteData(data) {
Swal.fire({
title: 'Are you sure?',
@@ -175,7 +194,7 @@
if (result.isConfirmed) {
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
});
@@ -192,6 +211,56 @@
}
})
}
/**
* Konfirmasi email sebelum submit form
* Menampilkan SweetAlert jika email diisi untuk konfirmasi pengiriman
*/
document.addEventListener('DOMContentLoaded', function() {
const form = document.querySelector('form');
const emailInput = document.getElementById('email');
// Log: Inisialisasi event listener untuk konfirmasi email
console.log('Email confirmation listener initialized');
form.addEventListener('submit', function(e) {
const emailValue = emailInput.value.trim();
// Jika email diisi, tampilkan konfirmasi
if (emailValue) {
e.preventDefault(); // Hentikan submit form sementara
// Log: Email terdeteksi, menampilkan konfirmasi
console.log('Email detected:', emailValue);
Swal.fire({
title: 'Konfirmasi Pengiriman Email',
text: `Apakah Anda yakin ingin mengirimkan statement ke email: ${emailValue}?`,
icon: 'question',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Ya, Kirim Email',
cancelButtonText: 'Batal',
reverseButtons: true
}).then((result) => {
if (result.isConfirmed) {
// Log: User konfirmasi pengiriman email
console.log('User confirmed email sending');
// Submit form setelah konfirmasi
form.submit();
} else {
// Log: User membatalkan pengiriman email
console.log('User cancelled email sending');
}
});
} else {
// Log: Tidak ada email, submit form normal
console.log('No email provided, submitting form normally');
}
});
});
</script>
<script type="module">
@@ -243,31 +312,16 @@
return fromPeriod + toPeriod;
},
},
authorization_status: {
title: 'Status',
render: (item, data) => {
let statusClass = 'badge badge-light-primary';
if (data.authorization_status === 'approved') {
statusClass = 'badge badge-light-success';
} else if (data.authorization_status === 'rejected') {
statusClass = 'badge badge-light-danger';
} else if (data.authorization_status === 'pending') {
statusClass = 'badge badge-light-warning';
}
return `<span class="${statusClass}">${data.authorization_status}</span>`;
},
},
is_available: {
title: 'Available',
render: (item, data) => {
let statusClass = data.is_available ? 'badge badge-light-success' : 'badge badge-light-danger';
let statusClass = data.is_available ? 'badge badge-light-success' :
'badge badge-light-danger';
let statusText = data.is_available ? 'Yes' : 'No';
return `<span class="${statusClass}">${statusText}</span>`;
},
},
remarks : {
remarks: {
title: 'Notes',
},
created_at: {
@@ -315,7 +369,7 @@
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);
});

View File

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

View File

@@ -114,3 +114,14 @@
$trail->parent('home');
$trail->push('Print Stetement', route('statements.index'));
});
Breadcrumbs::for('atm-reports.index', function (BreadcrumbTrail $trail) {
$trail->parent('home');
$trail->push('Laporan Transaksi ATM', route('atm-reports.index'));
});
Breadcrumbs::for('email-statement-logs.index', function (BreadcrumbTrail $trail) {
$trail->parent('home');
$trail->push('Statement Email Logs', route('email-statement-logs.index'));
});

View File

@@ -8,9 +8,11 @@ use Illuminate\Support\Facades\Route;
use Modules\Webstatement\Http\Controllers\KartuAtmController;
use Modules\Webstatement\Http\Controllers\MigrasiController;
use Modules\Webstatement\Http\Controllers\CustomerController;
use Modules\Webstatement\Http\Controllers\DebugStatementController;
use Modules\Webstatement\Http\Controllers\EmailBlastController;
use Modules\Webstatement\Http\Controllers\WebstatementController;
use Modules\Webstatement\Http\Controllers\DebugStatementController;
use Modules\Webstatement\Http\Controllers\EmailStatementLogController;
use Modules\Webstatement\Http\Controllers\AtmTransactionReportController;
@@ -89,6 +91,25 @@ 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 () {
Route::get('/datatables', [AtmTransactionReportController::class, 'dataForDatatables'])->name('datatables');
Route::get('/{atmReport}/download', [AtmTransactionReportController::class, 'download'])->name('download');
Route::post('/{atmReport}/authorize', [AtmTransactionReportController::class, 'authorize'])->name('authorize');
Route::get('/{atmReport}/send-email', [AtmTransactionReportController::class, 'sendEmail'])->name('send-email');
Route::post('/{atmReport}/retry', [AtmTransactionReportController::class, 'retry'])->name('retry');
});
Route::resource('atm-reports', AtmTransactionReportController::class);
// Email Statement Log Routes
Route::group(['prefix' => 'email-statement-logs', 'as' => 'email-statement-logs.', 'middleware' => ['auth']], function () {
Route::get('/datatables', [EmailStatementLogController::class, 'dataForDatatables'])->name('datatables');
Route::post('/{id}/resend-email', [EmailStatementLogController::class, 'resendEmail'])->name('resend-email');
});
Route::resource('email-statement-logs', EmailStatementLogController::class)->only(['index', 'show']);
});
Route::get('migrasi', [MigrasiController::class, 'index'])->name('migrasi.index');